diff --git a/project.godot b/project.godot index 3b69d42..8a49214 100644 --- a/project.godot +++ b/project.godot @@ -30,8 +30,8 @@ enabled=PackedStringArray("res://addons/netfishing_shoreline_baker/plugin.cfg") [gui] -theme/custom="res://ui/game_theme.tres" timers/tooltip_delay_sec=0.0 +theme/custom="res://ui/game_theme.tres" [input] diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index bf856d3..70d2095 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -130,7 +130,28 @@ func _validate_primary_menu_navigation() -> void: and not (title.get_node("%DeleteSaveButton") as Control).visible, "Legacy New/Delete title bubbles are still visible.", ) - _assert_directionally_reachable(title_controls.front(), title_controls) + _assert_neighbor( + title.get_node("%JoinGameButton") as Control, + &"focus_neighbor_right", + play_button, + ) + _assert_neighbor( + title.get_node("%SettingsButton") as Control, + &"focus_neighbor_left", + play_button, + ) + _assert_neighbor( + title.get_node("%CreditsButton") as Control, + &"focus_neighbor_right", + play_button, + ) + _assert_neighbor( + title.get_node("%QuitButton") as Control, + &"focus_neighbor_left", + play_button, + ) + for title_control: Control in title_controls: + _assert_directionally_reachable(title_control, title_controls) title.queue_free() await process_frame @@ -195,6 +216,13 @@ func _validate_save_slots_navigation() -> void: page.call("_select_page", &"saves", false) await process_frame var import_button := page.get_node("%ImportSlotButton") as Button + var actions := page.get_node("%Actions") as VBoxContainer + var secondary_actions := page.get_node("%SecondaryActions") as GridContainer + var play_slot := page.get_node("%PlaySlotButton") as Button + var rename_slot := page.get_node("%RenameSlotButton") as Button + var duplicate_slot := page.get_node("%DuplicateSlotButton") as Button + var export_slot := page.get_node("%ExportSlotButton") as Button + var delete_slot := page.get_node("%DeleteSlotButton") as Button _expect( saves_tab.find_valid_focus_neighbor(SIDE_BOTTOM) == import_button, "An empty Save Slots page does not lead from its tab to Import Save.", @@ -204,6 +232,29 @@ func _validate_save_slots_navigation() -> void: == page.get_node("%BackButton"), "An empty Save Slots page does not lead from Import Save to Back.", ) + _expect( + is_equal_approx(play_slot.size.x, actions.size.x), + "Save-slot Play does not span the full action width.", + ) + _expect( + secondary_actions.columns == 2 + and is_equal_approx(rename_slot.size.x, duplicate_slot.size.x) + and is_equal_approx(export_slot.size.x, delete_slot.size.x) + and is_equal_approx(rename_slot.position.y, duplicate_slot.position.y) + and is_equal_approx(export_slot.position.y, delete_slot.position.y) + and export_slot.position.y > rename_slot.position.y, + "Save-slot secondary actions are not arranged as a 2 by 2 grid.", + ) + var play_style := play_slot.get_theme_stylebox("normal") as StyleBoxFlat + _expect( + play_style != null and play_style.bg_color == UtilityPageStyle.GREEN, + "Save-slot Play does not use the green primary-action style.", + ) + _assert_neighbor(play_slot, &"focus_neighbor_bottom", rename_slot) + _assert_neighbor(rename_slot, &"focus_neighbor_right", duplicate_slot) + _assert_neighbor(rename_slot, &"focus_neighbor_bottom", export_slot) + _assert_neighbor(duplicate_slot, &"focus_neighbor_bottom", delete_slot) + _assert_neighbor(export_slot, &"focus_neighbor_right", delete_slot) page.queue_free() await process_frame @@ -222,6 +273,9 @@ func _validate_join_game_navigation() -> void: var address := page.get_node("%Address") as LineEdit var name_edit := page.get_node("%NameEdit") as LineEdit var server_list := page.get_node("%ServerList") as ItemList + var presence := page.get_node("%PresenceButton") as Button + var room_open := page.get_node("%RoomOpenButton") as Button + var room_listing := page.get_node("%RoomListingButton") as Button var refresh := page.get_node("%RefreshButton") as Button var join := page.get_node("%JoinButton") as Button var save := page.get_node("%SaveButton") as Button @@ -233,6 +287,7 @@ func _validate_join_game_navigation() -> void: var direct_content := page.get_node("%DirectContent") as Control var list_content := page.get_node("%ListContent") as Control var content_panel := page.get_node("%ContentPanel") as PanelContainer + var actions := page.get_node("%Actions") as HBoxContainer var modes: Array[Control] = [discover, friends, direct, saved, recent] var tab_overlap: float = ( discover.get_global_rect().end.y @@ -265,9 +320,36 @@ func _validate_join_game_navigation() -> void: _assert_neighbor(refresh, &"focus_neighbor_right", join) _assert_neighbor(join, &"focus_neighbor_bottom", back) _assert_neighbor(back, &"focus_neighbor_top", join) + _expect( + is_equal_approx( + join.get_global_rect().end.x, + actions.get_global_rect().end.x, + ), + "Discover Refresh and Join are not right-justified.", + ) var discover_controls: Array[Control] = modes.duplicate() discover_controls.append_array([server_list, refresh, join, back]) _assert_directionally_reachable(discover, discover_controls) + room_open.show() + room_listing.show() + _set_button_state(room_open, true) + _set_button_state(room_listing, true) + page.call("_configure_controller_navigation") + await process_frame + _assert_neighbor(room_open, &"focus_neighbor_right", room_listing) + _assert_neighbor(room_open, &"focus_neighbor_left", room_open) + _assert_neighbor(room_listing, &"focus_neighbor_left", room_open) + _assert_neighbor(room_listing, &"focus_neighbor_bottom", server_list) + var hosted_discover_controls: Array[Control] = modes.duplicate() + hosted_discover_controls.append_array([ + room_open, + room_listing, + server_list, + refresh, + join, + back, + ]) + _assert_directionally_reachable(discover, hosted_discover_controls) var rooms: Array[Dictionary] = [ { "room_id": "controller-default-room", @@ -293,16 +375,36 @@ func _validate_join_game_navigation() -> void: ) page.set("_mode", JoinGamePage.Mode.FRIENDS) + (page.get_node("%OnlineControls") as Control).hide() + presence.show() page.call("_configure_controller_navigation") await process_frame _assert_neighbor(friends, &"focus_neighbor_bottom", server_list) _assert_neighbor(server_list, &"focus_neighbor_top", friends) + _assert_neighbor(server_list, &"focus_neighbor_bottom", presence) + _assert_neighbor(presence, &"focus_neighbor_right", refresh) + _assert_neighbor(refresh, &"focus_neighbor_right", join) + _expect( + is_equal_approx( + presence.get_global_rect().position.x, + actions.get_global_rect().position.x, + ), + "Friend presence is not left-justified in the action row.", + ) + _expect( + is_equal_approx( + join.get_global_rect().end.x, + actions.get_global_rect().end.x, + ), + "Friends Refresh and Join are not right-justified.", + ) var friend_controls: Array[Control] = modes.duplicate() - friend_controls.append_array([server_list, refresh, join, back]) + friend_controls.append_array([server_list, presence, refresh, join, back]) _assert_directionally_reachable(friends, friend_controls) list_content.hide() direct_content.show() + presence.hide() address.show() address.editable = true server_list.hide() diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index 72bf19e..93ba793 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -32,6 +32,10 @@ const DIRECT_WORKFLOW_HELP: String = ( @onready var _direct_content: Control = %DirectContent @onready var _list_content: Control = %ListContent @onready var _list_title: Label = %ListTitle +@onready var _online_controls: Control = %OnlineControls +@onready var _presence_button: Button = %PresenceButton +@onready var _room_open_button: Button = %RoomOpenButton +@onready var _room_listing_button: Button = %RoomListingButton @onready var _details_panel: PanelContainer = %DetailsPanel @onready var _address: LineEdit = %Address @onready var _address_label: Label = %AddressLabel @@ -89,6 +93,9 @@ func _ready() -> void: _direct_button.pressed.connect(_set_mode.bind(Mode.DIRECT)) _saved_button.pressed.connect(_set_mode.bind(Mode.SAVED)) _recent_button.pressed.connect(_set_mode.bind(Mode.RECENT)) + _presence_button.pressed.connect(_on_presence_pressed) + _room_open_button.pressed.connect(_on_room_open_pressed) + _room_listing_button.pressed.connect(_on_room_listing_pressed) _refresh_button.pressed.connect(_request_discovery_refresh) _join_button.pressed.connect(_request_join) _save_button.pressed.connect(_on_save_pressed) @@ -238,6 +245,10 @@ func setup( _on_peer_count_changed ): _network_session.peer_count_changed.connect(_on_peer_count_changed) + if not _network_session.host_openness_changed.is_connected( + _on_host_openness_changed + ): + _network_session.host_openness_changed.connect(_on_host_openness_changed) if ( _saved_servers != null and not _saved_servers.data_changed.is_connected(_on_store_changed) @@ -278,6 +289,22 @@ func setup( _discovery.social_status_changed.connect( _on_social_status_changed ) + if not _discovery.host_settings_changed.is_connected( + _on_host_settings_changed + ): + _discovery.host_settings_changed.connect( + _on_host_settings_changed + ) + if not _discovery.host_status_changed.is_connected( + _on_host_status_changed + ): + _discovery.host_status_changed.connect(_on_host_status_changed) + if not _discovery.presence_sharing_changed.is_connected( + _on_presence_sharing_changed + ): + _discovery.presence_sharing_changed.connect( + _on_presence_sharing_changed + ) _friend_entries = _discovery.get_friend_presence() _refresh() @@ -792,6 +819,7 @@ func _refresh() -> void: _direct_content.visible = direct_content_visible _list_content.visible = list_content_visible _list_title.text = _current_list_title() + _refresh_online_controls(discovery_mode, friends_mode, connecting) _address.visible = direct or _name_entry_active _address_label.visible = _address.visible _address_helper.visible = _address.visible @@ -916,6 +944,66 @@ func _current_list_title() -> String: return "public rooms" +func _refresh_online_controls( + discovery_mode: bool, + friends_mode: bool, + connecting: bool, +) -> void: + _online_controls.visible = discovery_mode and not _name_entry_active + _presence_button.visible = friends_mode and not _name_entry_active + var presence_enabled: bool = ( + _discovery != null and _discovery.is_presence_sharing() + ) + _presence_button.text = ( + "friends: online" if presence_enabled else "friends: offline" + ) + _presence_button.disabled = ( + connecting or _discovery == null or not _discovery.is_configured() + ) + _presence_button.tooltip_text = ( + "Stay visible to friends until you switch this off. " + + "Your current room is shared only while it is publicly joinable." + if presence_enabled + else "Show as online to friends until you switch this off." + ) + if not _online_controls.visible: + return + var local_host: bool = _network_session.is_host() + _room_open_button.visible = local_host + _room_listing_button.visible = local_host + if not local_host: + return + var room_open: bool = _network_session.is_open_host() + var room_listed: bool = ( + _discovery != null and _discovery.is_discoverable() + ) + _room_open_button.text = "room: open" if room_open else "room: closed" + _room_open_button.disabled = connecting + _room_open_button.tooltip_text = ( + "Close this room to new connections." + if room_open + else "Open this room so other players can connect." + ) + _room_listing_button.text = ( + "listing: on" if room_listed else "listing: off" + ) + _room_listing_button.disabled = ( + connecting + or _discovery == null + or not _discovery.is_configured() + or not room_open + ) + _room_listing_button.tooltip_text = ( + "Remove this room from the public room browser." + if room_listed + else "List this open room in the public room browser." + if room_open and _discovery != null and _discovery.is_configured() + else "Open the room before enabling its public listing." + if _discovery != null and _discovery.is_configured() + else "Room discovery is not configured in this build." + ) + + func _configure_controller_navigation() -> void: var mode_buttons: Array[Control] = [ _discover_button, @@ -924,12 +1012,24 @@ func _configure_controller_navigation() -> void: _saved_button, _recent_button, ] + var online_controls: Array[Control] = [] + for control: Control in [ + _room_open_button, + _room_listing_button, + ]: + if _controller_focus_eligible(control): + online_controls.append(control) var content_controls: Array[Control] = [] - for control: Control in [_address, _name_edit, _server_list]: + for control: Control in [ + _address, + _name_edit, + _server_list, + ]: if _controller_focus_eligible(control): content_controls.append(control) var action_controls: Array[Control] = [] for control: Control in [ + _presence_button, _refresh_button, _join_button, _save_button, @@ -942,12 +1042,16 @@ func _configure_controller_navigation() -> void: action_controls.append(control) var all_controls: Array[Control] = [] all_controls.append_array(mode_buttons) + all_controls.append_array(online_controls) all_controls.append_array(content_controls) all_controls.append_array(action_controls) all_controls.append(_back_button) for control: Control in all_controls: control.focus_mode = Control.FOCUS_ALL for control: Control in [ + _presence_button, + _room_open_button, + _room_listing_button, _address, _name_edit, _server_list, @@ -963,7 +1067,9 @@ func _configure_controller_navigation() -> void: control.focus_mode = Control.FOCUS_NONE _back_button.focus_mode = Control.FOCUS_ALL var primary_content: Control = ( - content_controls.front() + online_controls.front() + if not online_controls.is_empty() + else content_controls.front() if not content_controls.is_empty() else action_controls.front() if not action_controls.is_empty() @@ -978,11 +1084,29 @@ func _configure_controller_navigation() -> void: mode_button, primary_content, ) + for index: int in online_controls.size(): + var online_control: Control = online_controls[index] + var below: Control = ( + content_controls.front() + if not content_controls.is_empty() + else action_controls.front() + if not action_controls.is_empty() + else _back_button + ) + _set_controller_neighbors( + online_control, + online_controls[maxi(index - 1, 0)], + online_controls[mini(index + 1, online_controls.size() - 1)], + mode_buttons[int(_mode)], + below, + ) for index: int in content_controls.size(): var content: Control = content_controls[index] var above: Control = ( content_controls[index - 1] if index > 0 + else online_controls.front() + if not online_controls.is_empty() else mode_buttons[int(_mode)] ) var below: Control = ( @@ -1001,6 +1125,8 @@ func _configure_controller_navigation() -> void: action_controls[mini(index + 1, action_controls.size() - 1)], content_controls.back() if not content_controls.is_empty() + else online_controls.back() + if not online_controls.is_empty() else mode_buttons[int(_mode)], _back_button, ) @@ -1009,6 +1135,8 @@ func _configure_controller_navigation() -> void: if not action_controls.is_empty() else content_controls.back() if not content_controls.is_empty() + else online_controls.back() + if not online_controls.is_empty() else mode_buttons[int(_mode)] ) _set_controller_neighbors( @@ -1270,6 +1398,76 @@ func _on_social_status_changed(message: String, is_error: bool) -> void: _set_status(message, is_error) +func _on_presence_pressed() -> void: + if _discovery == null: + _set_status("Friend presence is not available.", true) + return + var enabling: bool = not _discovery.is_presence_sharing() + if not _discovery.set_presence_sharing(enabling): + return + _set_status( + "You will remain online to friends until you switch this off." + if enabling + else "You now appear offline to friends." + ) + _refresh() + + +func _on_room_open_pressed() -> void: + if _network_session == null or not _network_session.is_host(): + return + var opening: bool = not _network_session.is_open_host() + if not _network_session.set_host_open(opening): + _set_status("The room could not be updated.", true) + return + _set_status( + "The room is open to new connections." + if opening + else "The room is closed to new connections." + ) + _refresh() + + +func _on_room_listing_pressed() -> void: + if _discovery == null: + return + var enabling: bool = not _discovery.is_discoverable() + if not _discovery.set_discoverable(enabling): + return + _set_status( + "The room is being listed publicly." + if enabling + else "The room is no longer listed publicly." + ) + _refresh() + + +func _on_host_openness_changed(_is_open: bool) -> void: + _refresh() + + +func _on_host_settings_changed( + _room_name: String, + _discoverable: bool, +) -> void: + _refresh() + + +func _on_host_status_changed(message: String, is_error: bool) -> void: + if ( + _mode == Mode.DISCOVER + and is_visible_in_tree() + and _network_session != null + and _network_session.is_host() + ): + _set_status(message, is_error) + _refresh() + + +func _on_presence_sharing_changed(_enabled: bool) -> void: + _refresh() + + func _format_result_code(result_code: String) -> String: match result_code.strip_edges().to_upper(): "SUCCESS": diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn index 54f354d..9374a85 100644 --- a/ui/network/join_game_page.tscn +++ b/ui/network/join_game_page.tscn @@ -138,9 +138,35 @@ layout_mode = 2 theme_override_font_sizes/font_size = 20 text = "public rooms" +[node name="OnlineControls" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="OnlineControlsSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RoomOpenButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(160, 42) +layout_mode = 2 +focus_mode = 2 +text = "room: closed" + +[node name="RoomListingButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(180, 42) +layout_mode = 2 +focus_mode = 2 +text = "listing: off" + [node name="ServerList" type="ItemList" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] unique_name_in_owner = true -custom_minimum_size = Vector2(0, 180) +custom_minimum_size = Vector2(0, 140) layout_mode = 2 size_flags_vertical = 3 theme_override_font_sizes/font_size = 17 @@ -235,7 +261,18 @@ unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 theme_override_constants/separation = 10 -alignment = 1 + +[node name="PresenceButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(210, 46) +layout_mode = 2 +focus_mode = 2 +text = "friends: offline" + +[node name="ActionsSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] +layout_mode = 2 +size_flags_horizontal = 3 [node name="RefreshButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true diff --git a/ui/players_page.gd b/ui/players_page.gd index 27c142c..68f25c6 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -44,6 +44,7 @@ var _online_state_label: Label var _discoverable_toggle: Button var _discoverable_state_label: Label var _host_discovery_status: Label +var _reset_artwork_button: Button var _current_tab := 0 var _active: bool = false var _interactive: bool = false @@ -295,6 +296,9 @@ func _build_host_settings(root: VBoxContainer) -> void: "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) access_row.add_child(_host_discovery_status) + _reset_artwork_button = Button.new() + _configure_session_artwork_reset_button(_reset_artwork_button) + access_row.add_child(_reset_artwork_button) func _build_host_toggle(label_text: String, row: HBoxContainer) -> Button: @@ -389,7 +393,10 @@ func _refresh_host_settings() -> void: return var host_visible: bool = _service.is_local_host() and _current_tab == 0 _host_settings_panel.visible = host_visible - if not host_visible or _discovery == null: + if not host_visible: + return + _update_session_artwork_reset_button(_reset_artwork_button) + if _discovery == null: return if not _room_name_edit.has_focus(): _room_name_edit.text = _discovery.get_room_name() @@ -481,7 +488,9 @@ func _on_host_status_changed(message: String, is_error: bool) -> void: "Open the game before enabling discovery.", ] _host_discovery_status.text = "" if tooltip_only else message - _host_discovery_status.visible = not _host_discovery_status.text.is_empty() + # Keep this expanding label in the row even when there is no status text so + # the session reset action remains pinned to the far-right edge. + _host_discovery_status.visible = true _host_discovery_status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_DANGER @@ -491,7 +500,7 @@ func _on_host_status_changed(message: String, is_error: bool) -> void: func _build_active_rows() -> void: - if _service.is_local_moderator(): + if _service.is_local_moderator() and not _service.is_local_host(): _build_session_artwork_controls() var entries := _service.get_entries() if entries.is_empty(): @@ -502,35 +511,29 @@ func _build_active_rows() -> void: func _build_active_player_row(entry: PlayerListEntry) -> void: - var row := _make_active_player_row() - var identity_row := HBoxContainer.new() - identity_row.add_theme_constant_override("separation", 8) - row.add_child(identity_row) - + var row := _make_row() var identity := Label.new() identity.clip_text = true identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var markers: Array[String] = [] - if entry.is_host: - markers.append("host") + identity.text = entry.display_name + var roles: Array[String] = ["host" if entry.is_host else "player"] if entry.is_operator: - markers.append("operator") + roles.append("operator") if entry.is_local_player: - markers.append("You") - identity.text = "%s%s · %s\n%s" % [ - entry.display_name, - " [%s]" % ", ".join(markers) if not markers.is_empty() else "", - entry.compact_fingerprint, + roles.append("you") + _configure_identity_tooltip( + identity, + "identity fingerprint:\n%s\nrole: %s\nidentity status: %s" % [ + NetworkIdentityCrypto.format_fingerprint(entry.full_fingerprint), + ", ".join(roles), entry.continuity_state, - ] - identity.tooltip_text = "Full identity fingerprint:\n%s" % ( - entry.full_fingerprint + ], ) identity.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) - identity_row.add_child(identity) + row.add_child(identity) var ping := Label.new() ping.custom_minimum_size.x = 72 ping.text = ( @@ -542,7 +545,7 @@ func _build_active_player_row(entry: PlayerListEntry) -> void: "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) ping.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - identity_row.add_child(ping) + row.add_child(ping) var actions := HBoxContainer.new() actions.alignment = BoxContainer.ALIGNMENT_END @@ -634,31 +637,38 @@ func _configure_moderation_button( func _build_session_artwork_controls() -> void: - var counts: Vector2i = _service.get_session_artwork_counts() var row := _make_row() - var label := Label.new() - label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - label.clip_text = true - label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - label.text = "session artwork · %d layers · %d painted pixels" % [ - counts.x, counts.y, - ] - label.add_theme_color_override( - "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY - ) - row.add_child(label) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(spacer) var reset := Button.new() - reset.text = "reset paint" - reset.disabled = counts.x == 0 - reset.tooltip_text = "Clears all shared artwork from this session." - reset.pressed.connect(func() -> void: + _configure_session_artwork_reset_button(reset) + _update_session_artwork_reset_button(reset) + row.add_child(reset) + + +func _configure_session_artwork_reset_button(button: Button) -> void: + button.text = "reset paint" + button.tooltip_text = "Clears all shared artwork from this session." + button.pressed.connect(func() -> void: _confirm( "Clear all shared paint from this session?\nThis cannot be undone.", _service.reset_session_artwork, ) ) - UtilityPageStyle.apply_compact_ocean_button(reset) - row.add_child(reset) + UtilityPageStyle.apply_compact_ocean_button(button) + + +func _update_session_artwork_reset_button(button: Button) -> void: + if button == null or _service == null: + return + button.disabled = _service.get_session_artwork_counts().x == 0 + + +func _configure_identity_tooltip(label: Label, text: String) -> void: + label.mouse_filter = Control.MOUSE_FILTER_STOP + label.tooltip_text = text func _build_relationship_rows() -> void: @@ -673,12 +683,16 @@ func _build_relationship_rows() -> void: label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS var fingerprint := str(record["fingerprint"]) - label.text = "%s · %s %s" % [ + label.text = "%s %s" % [ str(record.get("last_known_display_name", "Player")), - NetworkIdentityCrypto.compact_suffix(fingerprint), "Blocked" if bool(record.get("blocked", false)) else "Muted", ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -724,16 +738,20 @@ func _build_friend_rows() -> void: label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - label.text = "%s · %s %s" % [ + label.text = "%s %s" % [ display_name, - NetworkIdentityCrypto.compact_suffix(fingerprint), "playing in %s" % str(room.get("room_name", "a public room")) if not room.is_empty() else "Online" if bool(friend.get("online", false)) else "Offline", ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -811,12 +829,16 @@ func _build_ban_rows() -> void: label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - label.text = "%s · %s banned %s" % [ + label.text = "%s banned %s" % [ str(record.get("last_known_display_name", "Player")), - NetworkIdentityCrypto.compact_suffix(fingerprint), Time.get_date_string_from_unix_time(int(record.get("banned_unix", 0))), ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -841,20 +863,6 @@ func _make_row() -> HBoxContainer: return row -func _make_active_player_row() -> VBoxContainer: - var panel := PanelContainer.new() - panel.clip_contents = true - panel.add_theme_stylebox_override( - "panel", UtilityPageStyle.row_style(false) - ) - _list.add_child(panel) - var row := VBoxContainer.new() - row.custom_minimum_size.y = 88 - row.add_theme_constant_override("separation", 4) - panel.add_child(row) - return row - - func _add_empty(text: String) -> void: var label := Label.new() label.text = text diff --git a/ui/save_slots_page.gd b/ui/save_slots_page.gd index e50c8ee..ba142f7 100644 --- a/ui/save_slots_page.gd +++ b/ui/save_slots_page.gd @@ -124,6 +124,12 @@ func _configure_style() -> void: if node is OrganizerTab: continue UtilityPageStyle.apply_ocean_button(node as BaseButton) + _play_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.GREEN, + ), + ) _delete_button.add_theme_stylebox_override( "normal", UtilityPageStyle.ocean_button_style( @@ -639,11 +645,11 @@ func _configure_controller_focus() -> void: _set_neighbors(button, button, _slot_name_edit, top, bottom) _set_neighbors(_import_button, _import_button, _slot_name_edit, last, _back_button) _set_neighbors(_slot_name_edit, first, _slot_name_edit, _saves_tab, _play_button) - _set_neighbors(_play_button, _import_button, _rename_button, _slot_name_edit, _duplicate_button) - _set_neighbors(_rename_button, _play_button, _rename_button, _slot_name_edit, _export_button) - _set_neighbors(_duplicate_button, _import_button, _export_button, _play_button, _delete_button) - _set_neighbors(_export_button, _duplicate_button, _export_button, _rename_button, _delete_button) - _set_neighbors(_delete_button, _import_button, _delete_button, _duplicate_button, _back_button) + _set_neighbors(_play_button, _import_button, _play_button, _slot_name_edit, _rename_button) + _set_neighbors(_rename_button, _import_button, _duplicate_button, _play_button, _export_button) + _set_neighbors(_duplicate_button, _rename_button, _duplicate_button, _play_button, _delete_button) + _set_neighbors(_export_button, _import_button, _delete_button, _rename_button, _back_button) + _set_neighbors(_delete_button, _export_button, _delete_button, _duplicate_button, _back_button) _set_neighbors(_back_button, _back_button, _back_button, _import_button, _back_button) return _saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(_new_slot_name) diff --git a/ui/save_slots_page.tscn b/ui/save_slots_page.tscn index 5f47c8e..87d5f93 100644 --- a/ui/save_slots_page.tscn +++ b/ui/save_slots_page.tscn @@ -165,11 +165,10 @@ scroll_active = false autowrap_mode = 2 vertical_alignment = 1 -[node name="Actions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +[node name="Actions" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +unique_name_in_owner = true layout_mode = 2 -theme_override_constants/h_separation = 10 -theme_override_constants/v_separation = 10 -columns = 2 +theme_override_constants/separation = 10 [node name="PlaySlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] unique_name_in_owner = true @@ -179,7 +178,14 @@ size_flags_horizontal = 3 focus_mode = 2 text = "play" -[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="SecondaryActions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 10 +columns = 2 + +[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -187,7 +193,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "rename" -[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -195,7 +201,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "duplicate" -[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -203,7 +209,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "export" -[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 6274f7d..07a61fd 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -464,6 +464,7 @@ func _update_title_layout() -> void: Vector2(field_width, field_height), compact_layout ) + _configure_title_controller_navigation() if not _title_settings_transition_active: call_deferred("_capture_title_bubble_rest_position") if _awaiting_start_input and not _title_entry_transition_active: @@ -1483,9 +1484,28 @@ func _set_title_bubbles_interactive(interactive: bool) -> void: else Control.MOUSE_FILTER_IGNORE ) if interactive: - ControllerFocusNavigationType.configure_spatial_neighbors( - _get_title_buttons() - ) + _configure_title_controller_navigation() + + +func _configure_title_controller_navigation() -> void: + ControllerFocusNavigationType.configure_spatial_neighbors( + _get_title_buttons() + ) + # The large Play bubble overlaps the inward edge of every surrounding + # bubble. Center-only spatial scoring otherwise links the four outside + # bubbles into a ring with no route back into Play. + _join_game_button.focus_neighbor_right = ( + _join_game_button.get_path_to(_play_button) + ) + _settings_button.focus_neighbor_left = ( + _settings_button.get_path_to(_play_button) + ) + _credits_button.focus_neighbor_right = ( + _credits_button.get_path_to(_play_button) + ) + _quit_button.focus_neighbor_left = ( + _quit_button.get_path_to(_play_button) + ) func _capture_title_bubble_rest_position() -> void: