From 4f9c85989b4c5033189b18e8dbcc694e7c25e5d8 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 15 Aug 2026 19:20:42 -0400 Subject: [PATCH] feat: overhaul controller menu accessibility --- main/main.gd | 68 ++- main/main.tscn | 5 + project.godot | 4 +- scripts/run_validations.sh | 9 + settings/controller_mapping_manager.gd | 7 +- settings/keyboard_mouse_mapping_manager.gd | 448 ++++++++++++++ .../keyboard_mouse_mapping_manager.gd.uid | 1 + ...ontroller_focus_presentation_validation.gd | 28 + tests/controller_mapping_validation.gd | 49 ++ ...ontroller_menu_accessibility_validation.gd | 457 ++++++++++++++ ...oller_menu_accessibility_validation.gd.uid | 1 + tests/controller_ui_navigation_validation.gd | 113 +++- ...dialog_controller_navigation_validation.gd | 183 ++++++ ...og_controller_navigation_validation.gd.uid | 1 + tests/keyboard_mouse_mapping_validation.gd | 160 +++++ .../keyboard_mouse_mapping_validation.gd.uid | 1 + tests/logbook_validation.gd | 53 ++ ui/chat_ui.gd | 92 ++- .../bubble_menu/bubble_confirmation_page.gd | 14 +- .../bubble_menu/notepad_ink_choice.gd | 6 +- ui/components/organizer_tab.gd | 1 + ui/controller_focus_navigation.gd | 88 ++- ui/controller_focus_presentation.gd | 24 +- ui/controller_mapping_panel.gd | 71 ++- ui/file_dialog_controller_navigation.gd | 124 ++++ ui/file_dialog_controller_navigation.gd.uid | 1 + ui/fishing_shop.gd | 81 +++ ui/game_ui.gd | 50 +- ui/hotbar.gd | 8 +- ui/interface_font_controller.gd | 64 ++ ui/keyboard_mouse_mapping_panel.gd | 400 +++++++++++++ ui/keyboard_mouse_mapping_panel.gd.uid | 1 + ui/logbook_page.gd | 341 ++++++++++- ui/mail_page.gd | 330 +++++++++- ui/network/join_game_page.gd | 146 ++++- ui/on_screen_keyboard.gd | 73 ++- ui/pause_menu.gd | 2 +- ui/player_menu.gd | 566 ++++++++++-------- ui/players_page.gd | 127 +++- ui/settings/settings_bubble_page.gd | 16 + ui/settings_panel.gd | 79 ++- ui/settings_panel.tscn | 32 +- ui/the_net_page.gd | 128 +++- ui/title_confirmation_bubble_page.gd | 14 +- ui/title_screen.gd | 17 +- ui/ui_pixelation_presenter.gd | 3 +- 46 files changed, 4091 insertions(+), 396 deletions(-) create mode 100644 settings/keyboard_mouse_mapping_manager.gd create mode 100644 settings/keyboard_mouse_mapping_manager.gd.uid create mode 100644 tests/controller_menu_accessibility_validation.gd create mode 100644 tests/controller_menu_accessibility_validation.gd.uid create mode 100644 tests/file_dialog_controller_navigation_validation.gd create mode 100644 tests/file_dialog_controller_navigation_validation.gd.uid create mode 100644 tests/keyboard_mouse_mapping_validation.gd create mode 100644 tests/keyboard_mouse_mapping_validation.gd.uid create mode 100644 ui/file_dialog_controller_navigation.gd create mode 100644 ui/file_dialog_controller_navigation.gd.uid create mode 100644 ui/keyboard_mouse_mapping_panel.gd create mode 100644 ui/keyboard_mouse_mapping_panel.gd.uid diff --git a/main/main.gd b/main/main.gd index f51d0f7..5d9268e 100644 --- a/main/main.gd +++ b/main/main.gd @@ -19,6 +19,9 @@ const PlayerSettingsType = preload("res://settings/player_settings.gd") const ControllerMappingManagerType = preload( "res://settings/controller_mapping_manager.gd" ) +const KeyboardMouseMappingManagerType = preload( + "res://settings/keyboard_mouse_mapping_manager.gd" +) const TitleScreenType = preload("res://ui/title_screen.gd") const PauseMenuType = preload("res://ui/pause_menu.gd") const ItemCatalogType = preload("res://items/item_catalog.gd") @@ -139,6 +142,9 @@ const SHOP_PATTERN_SCALE: float = 1.75 @onready var _controller_mapping_manager: ControllerMappingManagerType = ( %ControllerMappingManager ) +@onready var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType = ( + %KeyboardMouseMappingManager +) @onready var _interface_fonts: InterfaceFontController = %InterfaceFontController @onready var _title_music: AudioStreamPlayer = %TitleMusic @onready var _dusk_music: AudioStreamPlayer = %DuskMusic @@ -279,6 +285,10 @@ func _ready() -> void: _settings_manager.settings_changed.connect(_apply_runtime_settings) _settings_manager.load_settings() _interface_fonts.enforce_standard_font() + _interface_fonts.set_controller_text_entry_request( + Callable(_game_ui, "request_controller_text_entry_for"), + Callable(_game_ui, "is_controller_text_entry_open"), + ) if _data_root.resolve(): _configure_portable_stores() _initialize_after_data_root() @@ -760,6 +770,7 @@ func _initialize_application(dedicated: bool) -> void: _interface_fonts, ) _game_ui.setup_controller_mapping(_controller_mapping_manager) + _game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager) _data_root.conflict_detected.connect(_on_portable_conflict) _data_root.status_changed.connect(_on_data_root_status) if ( @@ -919,13 +930,13 @@ func _focus_data_setup_actions() -> void: actions.append(_data_setup_dialog.get_cancel_button()) for index: int in actions.size(): var action: Button = actions[index] - var previous: Button = actions[posmod(index - 1, actions.size())] - var next: Button = actions[(index + 1) % actions.size()] + var previous: Button = actions[maxi(index - 1, 0)] + var next: Button = actions[mini(index + 1, actions.size() - 1)] action.focus_mode = Control.FOCUS_ALL action.focus_neighbor_left = action.get_path_to(previous) - action.focus_neighbor_top = action.focus_neighbor_left action.focus_neighbor_right = action.get_path_to(next) - action.focus_neighbor_bottom = action.focus_neighbor_right + action.focus_neighbor_top = action.get_path_to(action) + action.focus_neighbor_bottom = action.get_path_to(action) _data_setup_dialog.get_ok_button().grab_focus() @@ -1072,12 +1083,16 @@ func _show_existing_root_choice(path: String) -> void: _interface_fonts.apply_utility_theme(dialog) add_child(dialog) dialog.popup_centered(Vector2i(680, 360)) + _configure_popup_dialog.call_deferred( + dialog, dialog.get_cancel_button() + ) func _show_data_error(message: String) -> void: if _data_setup_dialog != null: _data_setup_dialog.dialog_text = message _data_setup_dialog.popup_centered(Vector2i(640, 300)) + _focus_data_setup_actions.call_deferred() func _on_portable_conflict(message: String, _path: String) -> void: @@ -1096,6 +1111,7 @@ func _on_portable_conflict(message: String, _path: String) -> void: dialog.confirmed.connect(dialog.queue_free) _game_ui.add_child(dialog) dialog.popup_centered(Vector2i(560, 300)) + _configure_popup_dialog.call_deferred(dialog, dialog.get_ok_button()) func _on_data_root_status(message: String) -> void: @@ -1113,6 +1129,7 @@ func _on_data_root_status(message: String) -> void: dialog.confirmed.connect(dialog.queue_free) _game_ui.add_child(dialog) dialog.popup_centered(Vector2i(560, 260)) + _configure_popup_dialog.call_deferred(dialog, dialog.get_ok_button()) func _on_server_trust_required( @@ -1153,6 +1170,10 @@ func _on_server_trust_required( ) _server_trust_dialog.ok_button_text = "Trust & Connect" _server_trust_dialog.popup_centered(Vector2i(560, 360)) + _configure_popup_dialog.call_deferred( + _server_trust_dialog, + _server_trust_dialog.get_cancel_button(), + ) func _confirm_server_trust() -> void: @@ -1164,6 +1185,10 @@ func _confirm_server_trust() -> void: ) _server_trust_dialog.ok_button_text = "Replace Pin" _server_trust_dialog.popup_centered(Vector2i(520, 260)) + _configure_popup_dialog.call_deferred( + _server_trust_dialog, + _server_trust_dialog.get_cancel_button(), + ) return if _server_trust_dialog != null: _server_trust_dialog.hide() @@ -1182,13 +1207,27 @@ func _on_peer_identity_observed(_peer_id: int, status: String) -> void: + "This may be a different player using the same display name." ) _identity_notice_dialog.popup_centered(Vector2i(480, 240)) + _configure_popup_dialog.call_deferred( + _identity_notice_dialog, + _identity_notice_dialog.get_ok_button(), + ) + + +func _configure_popup_dialog( + dialog: Window, + preferred_control: Control = null, +) -> void: + if dialog != null and is_instance_valid(dialog) and dialog.visible: + FileDialogControllerNavigation.configure_scope( + dialog, preferred_control + ) func _input(event: InputEvent) -> void: if _handle_data_root_controller_input(event): get_viewport().set_input_as_handled() return - if _game_ui.is_controller_mapping_capturing(): + if _game_ui.is_input_mapping_capturing(): return if ( not ( @@ -1227,6 +1266,8 @@ func _input(event: InputEvent) -> void: func _handle_data_root_controller_input(event: InputEvent) -> bool: + if _game_ui.is_controller_text_entry_open(): + return false var button_event := event as InputEventJoypadButton if button_event == null or not button_event.pressed: return false @@ -1238,6 +1279,11 @@ func _handle_data_root_controller_input(event: InputEvent) -> bool: ) if not setup_visible and not picker_visible: return false + var picker_scope: Window = ( + FileDialogControllerNavigation.active_scope(_data_folder_dialog) + if picker_visible + else null + ) var use_mapping: bool = _controller_mapping_manager != null var accept_pressed: bool = ( _controller_mapping_manager.event_matches_role( @@ -1263,14 +1309,17 @@ func _handle_data_root_controller_input(event: InputEvent) -> bool: ) if cancel_pressed: if picker_visible: - _on_data_folder_picker_canceled() + if picker_scope != null and picker_scope != _data_folder_dialog: + picker_scope.hide() + else: + _on_data_folder_picker_canceled() else: _data_setup_dialog.get_cancel_button().pressed.emit() return true if not accept_pressed: return false var focused: Control = ( - _data_folder_dialog.gui_get_focus_owner() + picker_scope.gui_get_focus_owner() if picker_visible else _data_setup_dialog.gui_get_focus_owner() ) @@ -1278,6 +1327,11 @@ func _handle_data_root_controller_input(event: InputEvent) -> bool: if focused_button != null and not focused_button.disabled: focused_button.pressed.emit() return true + if ( + (focused is LineEdit or focused is TextEdit) + and _game_ui.request_controller_text_entry_for(focused) + ): + return true if setup_visible: _data_setup_dialog.get_ok_button().pressed.emit() return true diff --git a/main/main.tscn b/main/main.tscn index 9a88c42..ee846d6 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -55,6 +55,7 @@ [ext_resource type="Script" uid="uid://b0c3o2vy76fg3" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"] [ext_resource type="AudioStream" uid="uid://dck3tf8qkadea" path="res://audio/ambience/waves.wav" id="55_waves"] [ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"] +[ext_resource type="Script" path="res://settings/keyboard_mouse_mapping_manager.gd" id="keyboard_mouse_mapping"] [ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"] [ext_resource type="AudioStream" uid="uid://c4y8puv0n6xk8" path="res://audio/music/world/craft.mp3" id="dusk_music"] [ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"] @@ -391,6 +392,10 @@ script = ExtResource("10_settings") unique_name_in_owner = true script = ExtResource("56_controller_mapping") +[node name="KeyboardMouseMappingManager" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("keyboard_mouse_mapping") + [node name="TitleMusic" type="AudioStreamPlayer" parent="." unique_id=1292939956] unique_name_in_owner = true stream = ExtResource("13_title_music") diff --git a/project.godot b/project.godot index 3af8375..0a39a8e 100644 --- a/project.godot +++ b/project.godot @@ -87,6 +87,7 @@ jump={ sneak={ "deadzone": 0.2, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":8,"pressure":0.0,"pressed":false,"script":null) ] } slow_walk={ @@ -205,7 +206,8 @@ open_quick_actions={ } open_chat={ "deadzone": 0.2, -"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":4,"pressure":0.0,"pressed":false,"script":null) +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":84,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":4,"pressure":0.0,"pressed":false,"script":null) ] } focus_gameplay={ diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 5992197..722c97b 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -10,15 +10,23 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)" readonly -a QUICK_TESTS=( "tests/android_readiness_validation.gd" + "tests/controller_focus_presentation_validation.gd" + "tests/controller_focus_recovery_validation.gd" + "tests/controller_menu_accessibility_validation.gd" + "tests/controller_mapping_validation.gd" + "tests/controller_ui_navigation_validation.gd" "tests/dedicated_server_config_validation.gd" "tests/exported_decal_hotfix_validation.gd" + "tests/file_dialog_controller_navigation_validation.gd" "tests/fish_catalog_content_validation.gd" "tests/fish_quality_validation.gd" "tests/fishing_audio_validation.gd" "tests/fishing_surface_validation.gd" "tests/fur_pattern_validation.gd" + "tests/keyboard_mouse_mapping_validation.gd" "tests/logbook_validation.gd" "tests/network_player_animation_protocol_validation.gd" + "tests/on_screen_keyboard_validation.gd" "tests/player_experience_validation.gd" "tests/shoreline_ambience_validation.gd" "tests/surface_drawing_validation.gd" @@ -56,6 +64,7 @@ readonly -a NETWORK_TESTS=( "tests/job_multiplayer_validation.gd" "tests/movement_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd" + "tests/profile_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd" "tests/world_spawn_multiplayer_validation.gd" diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd index 1690963..7c54777 100644 --- a/settings/controller_mapping_manager.gd +++ b/settings/controller_mapping_manager.gd @@ -88,7 +88,7 @@ const ROLE_LABELS: Dictionary = { ROLE_SELECT: "chat", ROLE_START: "pause", ROLE_LEFT_STICK_CLICK: "sprint", - ROLE_RIGHT_STICK_CLICK: "unassigned stick click", + ROLE_RIGHT_STICK_CLICK: "sneak", ROLE_DPAD_UP: "emote wheel", ROLE_DPAD_DOWN: "quick menu", ROLE_DPAD_LEFT: "previous hotbar slot", @@ -106,8 +106,8 @@ const ROLE_PROMPTS: Dictionary = { ROLE_Y: "press the y button", ROLE_LB: "press the left bumper", ROLE_RB: "press the right bumper", - ROLE_POINTER_MODIFIER: "press or squeeze the virtual mouse modifier", - ROLE_CAMERA_ZOOM: "press or squeeze the camera zoom control", + ROLE_POINTER_MODIFIER: "press or squeeze LT", + ROLE_CAMERA_ZOOM: "press or squeeze RT", ROLE_SELECT: "press select / back", ROLE_START: "press start", ROLE_LEFT_STICK_CLICK: "click the left stick", @@ -144,6 +144,7 @@ const BUTTON_ACTION_ROLES: Dictionary = { &"open_chat": ROLE_SELECT, &"open_system_menu": ROLE_START, &"sprint": ROLE_LEFT_STICK_CLICK, + &"sneak": ROLE_RIGHT_STICK_CLICK, &"open_emotes": ROLE_DPAD_UP, &"open_quick_actions": ROLE_DPAD_DOWN, &"hotbar_previous": ROLE_DPAD_LEFT, diff --git a/settings/keyboard_mouse_mapping_manager.gd b/settings/keyboard_mouse_mapping_manager.gd new file mode 100644 index 0000000..a8d4f09 --- /dev/null +++ b/settings/keyboard_mouse_mapping_manager.gd @@ -0,0 +1,448 @@ +class_name KeyboardMouseMappingManager +extends Node + +signal mapping_changed + +const FORMAT_VERSION: int = 1 +const MAPPING_PATH: String = "user://keyboard_mouse_bindings.json" +const MAPPING_TEMP_PATH: String = "user://keyboard_mouse_bindings.json.tmp" +const MAPPING_BACKUP_PATH: String = ( + "user://keyboard_mouse_bindings.json.backup" +) +const MAX_MAPPING_BYTES: int = 256 * 1024 + +const ROLE_MOVE_FORWARD: StringName = &"move_forward" +const ROLE_MOVE_BACKWARD: StringName = &"move_backward" +const ROLE_MOVE_LEFT: StringName = &"move_left" +const ROLE_MOVE_RIGHT: StringName = &"move_right" +const ROLE_SPRINT: StringName = &"sprint" +const ROLE_JUMP: StringName = &"jump" +const ROLE_SNEAK: StringName = &"sneak" +const ROLE_SLOW_WALK: StringName = &"slow_walk" +const ROLE_INTERACT: StringName = &"interact" +const ROLE_PRIMARY_ACTION: StringName = &"fish_primary" +const ROLE_CAMERA_DRAG: StringName = &"camera_drag" +const ROLE_CAMERA_ZOOM_IN: StringName = &"camera_zoom_in" +const ROLE_CAMERA_ZOOM_OUT: StringName = &"camera_zoom_out" +const ROLE_PLAYER_MENU: StringName = &"open_backpack" +const ROLE_TACKLE_BOX: StringName = &"open_tacklebox" +const ROLE_PROP_BOOK: StringName = &"open_props" +const ROLE_EMOTE_WHEEL: StringName = &"open_emotes" +const ROLE_CHARACTER_CALL: StringName = &"character_call" +const ROLE_CHAT: StringName = &"open_chat" +const ROLE_FREE_CAMERA: StringName = &"toggle_free_camera" +const ROLE_HIDE_HUD: StringName = &"toggle_hud" +const ROLE_MENU_ACCEPT: StringName = &"ui_accept" +const ROLE_MENU_BACK: StringName = &"ui_cancel" + +const ROLE_ORDER: Array[StringName] = [ + ROLE_MOVE_FORWARD, + ROLE_MOVE_BACKWARD, + ROLE_MOVE_LEFT, + ROLE_MOVE_RIGHT, + ROLE_SPRINT, + ROLE_JUMP, + ROLE_SNEAK, + ROLE_SLOW_WALK, + ROLE_INTERACT, + ROLE_PRIMARY_ACTION, + ROLE_CAMERA_DRAG, + ROLE_CAMERA_ZOOM_IN, + ROLE_CAMERA_ZOOM_OUT, + ROLE_PLAYER_MENU, + ROLE_TACKLE_BOX, + ROLE_PROP_BOOK, + ROLE_EMOTE_WHEEL, + ROLE_CHARACTER_CALL, + ROLE_CHAT, + ROLE_FREE_CAMERA, + ROLE_HIDE_HUD, + &"hotbar_1", + &"hotbar_2", + &"hotbar_3", + &"hotbar_4", + &"hotbar_5", + &"hotbar_6", + &"hotbar_7", + &"hotbar_8", + &"hotbar_9", + ROLE_MENU_ACCEPT, + ROLE_MENU_BACK, +] + +const ROLE_LABELS: Dictionary = { + ROLE_MOVE_FORWARD: "move forward", + ROLE_MOVE_BACKWARD: "move backward", + ROLE_MOVE_LEFT: "move left", + ROLE_MOVE_RIGHT: "move right", + ROLE_SPRINT: "sprint", + ROLE_JUMP: "jump", + ROLE_SNEAK: "sneak", + ROLE_SLOW_WALK: "slow walk", + ROLE_INTERACT: "interact", + ROLE_PRIMARY_ACTION: "primary action", + ROLE_CAMERA_DRAG: "rotate camera", + ROLE_CAMERA_ZOOM_IN: "camera zoom in", + ROLE_CAMERA_ZOOM_OUT: "camera zoom out", + ROLE_PLAYER_MENU: "player menu", + ROLE_TACKLE_BOX: "tackle box", + ROLE_PROP_BOOK: "prop book", + ROLE_EMOTE_WHEEL: "emote wheel", + ROLE_CHARACTER_CALL: "character call", + ROLE_CHAT: "chat", + ROLE_FREE_CAMERA: "free camera", + ROLE_HIDE_HUD: "hide hud", + &"hotbar_1": "hotbar 1", + &"hotbar_2": "hotbar 2", + &"hotbar_3": "hotbar 3", + &"hotbar_4": "hotbar 4", + &"hotbar_5": "hotbar 5", + &"hotbar_6": "hotbar 6", + &"hotbar_7": "hotbar 7", + &"hotbar_8": "hotbar 8", + &"hotbar_9": "hotbar 9", + ROLE_MENU_ACCEPT: "menu accept", + ROLE_MENU_BACK: "menu back / pause", +} + +var _default_events: Dictionary = {} +var _default_bindings: Dictionary = {} +var _bindings: Dictionary = {} + + +func _ready() -> void: + _capture_project_defaults() + load_mapping() + _apply_active_mapping() + + +func load_mapping() -> bool: + _recover_interrupted_write() + if not FileAccess.file_exists(MAPPING_PATH): + _bindings = {} + return true + var file := FileAccess.open(MAPPING_PATH, FileAccess.READ) + if file == null: + _bindings = {} + return false + if file.get_length() > MAX_MAPPING_BYTES: + file.close() + push_warning("Keyboard bindings are too large; using defaults.") + _bindings = {} + return false + var json := JSON.new() + var error: Error = json.parse(file.get_as_text()) + file.close() + if error != OK or typeof(json.data) != TYPE_DICTIONARY: + push_warning("Keyboard bindings are malformed; using defaults.") + _bindings = {} + return false + var data := json.data as Dictionary + if int(data.get("format_version", -1)) != FORMAT_VERSION: + push_warning("Keyboard binding version is unsupported; using defaults.") + _bindings = {} + return false + var raw_bindings: Variant = data.get("bindings", {}) + if ( + typeof(raw_bindings) != TYPE_DICTIONARY + or not _validate_complete_bindings(raw_bindings as Dictionary) + ): + push_warning("Keyboard bindings are incomplete; using defaults.") + _bindings = {} + return false + _bindings = (raw_bindings as Dictionary).duplicate(true) + return true + + +func has_custom_mapping() -> bool: + return not _bindings.is_empty() + + +func get_active_bindings() -> Dictionary: + if has_custom_mapping(): + return _bindings.duplicate(true) + return _default_bindings.duplicate(true) + + +func get_role_label(role: StringName) -> String: + return str(ROLE_LABELS.get(role, str(role).replace("_", " "))) + + +func binding_from_event(event: InputEvent) -> Dictionary: + var key_event := event as InputEventKey + if key_event != null: + if not key_event.pressed or key_event.echo: + return {} + if key_event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META]: + return {} + if key_event.keycode == KEY_NONE and ( + key_event.physical_keycode == KEY_NONE + ): + return {} + return { + "kind": "key", + "keycode": int(key_event.keycode), + "physical_keycode": int(key_event.physical_keycode), + "alt": key_event.alt_pressed, + "shift": key_event.shift_pressed, + "ctrl": key_event.ctrl_pressed, + "meta": key_event.meta_pressed, + "location": int(key_event.location), + } + var mouse_event := event as InputEventMouseButton + if mouse_event == null or not mouse_event.pressed: + return {} + return { + "kind": "mouse_button", + "button": int(mouse_event.button_index), + } + + +func validate_binding(binding: Dictionary) -> bool: + var kind: String = str(binding.get("kind", "")) + if kind == "mouse_button": + var button: int = int(binding.get("button", 0)) + return ( + button >= int(MOUSE_BUTTON_LEFT) + and button <= int(MOUSE_BUTTON_XBUTTON2) + ) + if kind != "key": + return false + var keycode: int = int(binding.get("keycode", int(KEY_NONE))) + var physical_keycode: int = int( + binding.get("physical_keycode", int(KEY_NONE)) + ) + if keycode == int(KEY_NONE) and physical_keycode == int(KEY_NONE): + return false + for modifier: String in ["alt", "shift", "ctrl", "meta"]: + if typeof(binding.get(modifier, false)) != TYPE_BOOL: + return false + return true + + +func set_binding(role: StringName, binding: Dictionary) -> bool: + if role not in ROLE_ORDER or not validate_binding(binding): + return false + var updated: Dictionary = get_active_bindings() + updated[str(role)] = binding.duplicate(true) + return replace_active_bindings(updated) + + +func replace_active_bindings(bindings: Dictionary) -> bool: + if not _validate_complete_bindings(bindings): + return false + var previous: Dictionary = _bindings.duplicate(true) + _bindings = bindings.duplicate(true) + if not _save_mapping(): + _bindings = previous + return false + _apply_active_mapping() + mapping_changed.emit() + return true + + +func reset_mapping() -> bool: + var previous: Dictionary = _bindings.duplicate(true) + _bindings = {} + if not _remove_if_present(MAPPING_PATH): + _bindings = previous + return false + _remove_if_present(MAPPING_TEMP_PATH) + _remove_if_present(MAPPING_BACKUP_PATH) + _restore_project_defaults() + mapping_changed.emit() + return true + + +func binding_label(binding: Dictionary) -> String: + if str(binding.get("kind", "")) == "mouse_button": + return _mouse_button_label(int(binding.get("button", 0))) + if str(binding.get("kind", "")) != "key": + return "unmapped" + var event := _event_from_binding(binding) as InputEventKey + if event == null: + return "unmapped" + var label: String = event.as_text_physical_keycode() + if label.is_empty(): + label = event.as_text_keycode() + return label.to_lower() if not label.is_empty() else "unknown key" + + +static func bindings_conflict(first: Dictionary, second: Dictionary) -> bool: + if str(first.get("kind", "")) != str(second.get("kind", "")): + return false + if str(first.get("kind", "")) == "mouse_button": + return int(first.get("button", 0)) == int(second.get("button", 0)) + if str(first.get("kind", "")) != "key": + return false + return ( + int(first.get("keycode", 0)) == int(second.get("keycode", 0)) + and int(first.get("physical_keycode", 0)) + == int(second.get("physical_keycode", 0)) + and bool(first.get("alt", false)) == bool(second.get("alt", false)) + and bool(first.get("shift", false)) == bool(second.get("shift", false)) + and bool(first.get("ctrl", false)) == bool(second.get("ctrl", false)) + and bool(first.get("meta", false)) == bool(second.get("meta", false)) + ) + + +func _capture_project_defaults() -> void: + _default_events.clear() + _default_bindings.clear() + for role: StringName in ROLE_ORDER: + var action: StringName = role + var events: Array[InputEvent] = [] + if InputMap.has_action(action): + for event: InputEvent in InputMap.action_get_events(action): + if not _is_keyboard_mouse_event(event): + continue + events.append(event.duplicate()) + if not _default_bindings.has(str(role)): + var binding: Dictionary = binding_from_event( + _pressed_copy(event) + ) + if not binding.is_empty(): + _default_bindings[str(role)] = binding + _default_events[action] = events + + +func _validate_complete_bindings(bindings: Dictionary) -> bool: + for role: StringName in ROLE_ORDER: + var raw_binding: Variant = bindings.get(str(role), {}) + if ( + typeof(raw_binding) != TYPE_DICTIONARY + or not validate_binding(raw_binding as Dictionary) + ): + return false + return true + + +func _apply_active_mapping() -> void: + if not has_custom_mapping(): + _restore_project_defaults() + return + _remove_managed_keyboard_mouse_events() + for role: StringName in ROLE_ORDER: + var binding: Variant = _bindings.get(str(role), {}) + if typeof(binding) != TYPE_DICTIONARY: + continue + var event: InputEvent = _event_from_binding(binding as Dictionary) + if event != null: + InputMap.action_add_event(role, event) + + +func _restore_project_defaults() -> void: + _remove_managed_keyboard_mouse_events() + for action_value: Variant in _default_events: + var action: StringName = StringName(action_value) + for event: InputEvent in _default_events[action]: + InputMap.action_add_event(action, event.duplicate()) + + +func _remove_managed_keyboard_mouse_events() -> void: + for action: StringName in ROLE_ORDER: + if not InputMap.has_action(action): + continue + for event: InputEvent in InputMap.action_get_events(action): + if _is_keyboard_mouse_event(event): + InputMap.action_erase_event(action, event) + + +func _event_from_binding(binding: Dictionary) -> InputEvent: + if not validate_binding(binding): + return null + if str(binding.get("kind", "")) == "mouse_button": + var mouse_event := InputEventMouseButton.new() + mouse_event.button_index = int(binding.get("button", 0)) as MouseButton + return mouse_event + var key_event := InputEventKey.new() + key_event.keycode = int(binding.get("keycode", 0)) as Key + key_event.physical_keycode = int( + binding.get("physical_keycode", 0) + ) as Key + key_event.alt_pressed = bool(binding.get("alt", false)) + key_event.shift_pressed = bool(binding.get("shift", false)) + key_event.ctrl_pressed = bool(binding.get("ctrl", false)) + key_event.meta_pressed = bool(binding.get("meta", false)) + key_event.location = int(binding.get("location", 0)) as KeyLocation + return key_event + + +static func _pressed_copy(event: InputEvent) -> InputEvent: + var result: InputEvent = event.duplicate() + if result is InputEventKey: + (result as InputEventKey).pressed = true + elif result is InputEventMouseButton: + (result as InputEventMouseButton).pressed = true + return result + + +static func _is_keyboard_mouse_event(event: InputEvent) -> bool: + return event is InputEventKey or event is InputEventMouseButton + + +static func _mouse_button_label(button: int) -> String: + var labels: Dictionary = { + int(MOUSE_BUTTON_LEFT): "mouse left", + int(MOUSE_BUTTON_RIGHT): "mouse right", + int(MOUSE_BUTTON_MIDDLE): "mouse middle", + int(MOUSE_BUTTON_WHEEL_UP): "wheel up", + int(MOUSE_BUTTON_WHEEL_DOWN): "wheel down", + int(MOUSE_BUTTON_WHEEL_LEFT): "wheel left", + int(MOUSE_BUTTON_WHEEL_RIGHT): "wheel right", + int(MOUSE_BUTTON_XBUTTON1): "mouse 4", + int(MOUSE_BUTTON_XBUTTON2): "mouse 5", + } + return str(labels.get(button, "mouse %d" % button)) + + +func _save_mapping() -> bool: + var file := FileAccess.open(MAPPING_TEMP_PATH, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify({ + "format_version": FORMAT_VERSION, + "bindings": _bindings, + }, "\t")) + file.flush() + var write_error: Error = file.get_error() + file.close() + if write_error != OK: + _remove_if_present(MAPPING_TEMP_PATH) + return false + _remove_if_present(MAPPING_BACKUP_PATH) + var had_primary: bool = FileAccess.file_exists(MAPPING_PATH) + if had_primary and not _rename_file(MAPPING_PATH, MAPPING_BACKUP_PATH): + _remove_if_present(MAPPING_TEMP_PATH) + return false + if not _rename_file(MAPPING_TEMP_PATH, MAPPING_PATH): + if had_primary: + _rename_file(MAPPING_BACKUP_PATH, MAPPING_PATH) + return false + _remove_if_present(MAPPING_BACKUP_PATH) + return true + + +func _recover_interrupted_write() -> void: + if FileAccess.file_exists(MAPPING_PATH): + _remove_if_present(MAPPING_TEMP_PATH) + _remove_if_present(MAPPING_BACKUP_PATH) + return + if FileAccess.file_exists(MAPPING_BACKUP_PATH): + _rename_file(MAPPING_BACKUP_PATH, MAPPING_PATH) + _remove_if_present(MAPPING_TEMP_PATH) + + +static func _rename_file(from_path: String, to_path: String) -> bool: + return DirAccess.rename_absolute( + ProjectSettings.globalize_path(from_path), + ProjectSettings.globalize_path(to_path), + ) == OK + + +static func _remove_if_present(path: String) -> bool: + if not FileAccess.file_exists(path): + return true + return DirAccess.remove_absolute( + ProjectSettings.globalize_path(path) + ) == OK diff --git a/settings/keyboard_mouse_mapping_manager.gd.uid b/settings/keyboard_mouse_mapping_manager.gd.uid new file mode 100644 index 0000000..7c0679c --- /dev/null +++ b/settings/keyboard_mouse_mapping_manager.gd.uid @@ -0,0 +1 @@ +uid://b7ih6m4nte705 diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd index f0d0355..e349a1e 100644 --- a/tests/controller_focus_presentation_validation.gd +++ b/tests/controller_focus_presentation_validation.gd @@ -69,6 +69,34 @@ func _run() -> void: "inversion clears when a focused control is defocused", ) + var scroll := ScrollContainer.new() + scroll.position = Vector2(0.0, 80.0) + scroll.size = Vector2(180.0, 90.0) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + stage.add_child(scroll) + var list := VBoxContainer.new() + list.custom_minimum_size.x = 160.0 + scroll.add_child(list) + var last_scroll_button: Button + for index: int in 8: + var scroll_button := Button.new() + scroll_button.text = "scroll option %d" % index + scroll_button.custom_minimum_size = Vector2(160.0, 40.0) + list.add_child(scroll_button) + last_scroll_button = scroll_button + await process_frame + last_scroll_button.grab_focus() + await process_frame + await process_frame + _expect( + scroll.scroll_vertical > 0, + "controller focus scrolls an off-screen selection into view", + ) + _expect( + root.gui_get_focus_owner() == last_scroll_button, + "scroll following preserves the selected controller control", + ) + stage.queue_free() if _failures.is_empty(): print("Controller focus presentation validation: PASS") diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index 82bce26..78e0ee8 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -94,6 +94,16 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String: ControllerMappingManagerType.ROLE_ORDER.size(), str(defaults.keys()), ] + if ControllerMappingManagerType.ROLE_LABELS.get( + ControllerMappingManagerType.ROLE_RIGHT_STICK_CLICK, "" + ) != "sneak": + return "right-stick click is not presented as sneak" + if ControllerMappingManagerType.BUTTON_ACTION_ROLES.get( + &"sneak", &"" + ) != ControllerMappingManagerType.ROLE_RIGHT_STICK_CLICK: + return "right-stick click is not assigned to the sneak action" + if not _has_joy_button(&"sneak", JOY_BUTTON_RIGHT_STICK): + return "sneak does not default to right-stick click" var trigger_button := InputEventJoypadButton.new() trigger_button.device = manager.get_active_device_id() trigger_button.button_index = JOY_BUTTON_MISC1 @@ -226,10 +236,41 @@ func _validate_portmaster_launcher() -> String: func _validate_auto_map(manager: ControllerMappingManagerType) -> String: + if manager.get_role_prompt(ControllerMappingManagerType.ROLE_LT) != ( + "press or squeeze LT" + ): + return "auto-map does not identify the LT control" + if manager.get_role_prompt(ControllerMappingManagerType.ROLE_RT) != ( + "press or squeeze RT" + ): + return "auto-map does not identify the RT control" var panel := ControllerMappingPanelType.new() root.add_child(panel) panel.setup(manager) panel.open_panel() + var mapping_scrolls: Array[Node] = panel.find_children( + "", "ScrollContainer", true, false + ) + if mapping_scrolls.is_empty(): + return "controller mapper does not contain its mapping list" + if not (mapping_scrolls[0] as ScrollContainer).follow_focus: + return "controller mapper does not scroll to follow controller focus" + var first_binding := panel._binding_buttons.get( + ControllerMappingManagerType.ROLE_ORDER.front() + ) as Button + var last_binding := panel._binding_buttons.get( + ControllerMappingManagerType.ROLE_ORDER.back() + ) as Button + if first_binding.get_node(first_binding.focus_neighbor_top) != first_binding: + return "controller mapper loses focus above its first row" + if last_binding.get_node(last_binding.focus_neighbor_bottom) != ( + panel._close_button + ): + return "controller mapper cannot reach Done from its last row" + if panel._close_button.get_node( + panel._close_button.focus_neighbor_top + ) != last_binding: + return "controller mapper cannot return from Done to its last row" panel._begin_auto_map() for role: StringName in ControllerMappingManagerType.ROLE_ORDER: panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS) @@ -391,3 +432,11 @@ func _keyboard_event_count(action: StringName) -> int: if event is InputEventKey or event is InputEventMouseButton: count += 1 return count + + +func _has_joy_button(action: StringName, button_index: JoyButton) -> bool: + for event: InputEvent in InputMap.action_get_events(action): + var button := event as InputEventJoypadButton + if button != null and button.button_index == button_index: + return true + return false diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd new file mode 100644 index 0000000..fca6c46 --- /dev/null +++ b/tests/controller_menu_accessibility_validation.gd @@ -0,0 +1,457 @@ +extends SceneTree + +const JoinGamePageScene = preload( + "res://ui/network/join_game_page.tscn" +) +const SettingsPanelScene = preload("res://ui/settings_panel.tscn") +const BubbleConfirmationScene = preload( + "res://ui/components/bubble_menu/bubble_confirmation_page.tscn" +) +const TitleConfirmationScene = preload( + "res://ui/title_confirmation_bubble_page.tscn" +) +const MailPageType = preload("res://ui/mail_page.gd") +const ProfilePageType = preload("res://ui/profile_page.gd") +const DialogControllerNavigationType = preload( + "res://ui/file_dialog_controller_navigation.gd" +) + +var _failures: Array[String] = [] + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1280, 720) + await _validate_join_game_navigation() + await _validate_data_settings_navigation() + await _validate_mail_navigation() + await _validate_profile_confirmation_focus() + await _validate_confirmation_dialog_navigation() + await _validate_bubble_confirmation_navigation() + _validate_mapping_capture_contract() + if _failures.is_empty(): + print("Controller menu accessibility validation: PASS") + quit(0) + return + for failure: String in _failures: + push_error(failure) + quit(1) + + +func _validate_join_game_navigation() -> void: + var page := JoinGamePageScene.instantiate() as Control + root.add_child(page) + await process_frame + page.show() + var discover := page.get_node("%DiscoverButton") as Button + var direct := page.get_node("%DirectButton") as Button + var saved := page.get_node("%SavedButton") as Button + var recent := page.get_node("%RecentButton") as Button + 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 refresh := page.get_node("%RefreshButton") as Button + var join := page.get_node("%JoinButton") as Button + var save := page.get_node("%SaveButton") as Button + var edit := page.get_node("%EditButton") as Button + var favorite := page.get_node("%FavoriteButton") as Button + var delete := page.get_node("%DeleteButton") as Button + var cancel := page.get_node("%CancelButton") as Button + var back := page.get_node("%BackButton") as Button + var modes: Array[Control] = [discover, direct, saved, recent] + + address.hide() + name_edit.hide() + server_list.show() + _set_button_state(refresh, true) + _set_button_state(join, true) + _set_button_state(save, false) + _set_button_state(edit, false) + _set_button_state(favorite, false) + _set_button_state(delete, false) + _set_button_state(cancel, false) + _set_button_state(back, true) + page.set("_mode", 0) + page.call("_configure_controller_navigation") + await process_frame + _assert_neighbor(discover, &"focus_neighbor_bottom", server_list) + _assert_neighbor(server_list, &"focus_neighbor_top", discover) + _assert_neighbor(server_list, &"focus_neighbor_bottom", refresh) + _assert_neighbor(refresh, &"focus_neighbor_right", join) + _assert_neighbor(back, &"focus_neighbor_left", join) + var discover_controls: Array[Control] = modes.duplicate() + discover_controls.append_array([server_list, refresh, join, back]) + _assert_directionally_reachable(discover, discover_controls) + + address.show() + address.editable = true + server_list.hide() + _set_button_state(refresh, false) + _set_button_state(join, true) + _set_button_state(save, true) + page.set("_mode", 1) + page.call("_configure_controller_navigation") + await process_frame + _assert_neighbor(direct, &"focus_neighbor_bottom", address) + _assert_neighbor(address, &"focus_neighbor_top", direct) + _assert_neighbor(address, &"focus_neighbor_bottom", join) + var direct_controls: Array[Control] = modes.duplicate() + direct_controls.append_array([address, join, save, back]) + _assert_directionally_reachable(direct, direct_controls) + + name_edit.show() + name_edit.editable = true + page.set("_name_entry_active", true) + page.call("_configure_controller_navigation") + await process_frame + _assert_neighbor(address, &"focus_neighbor_bottom", name_edit) + _assert_neighbor(name_edit, &"focus_neighbor_top", address) + page.call("request_back") + _expect( + not bool(page.get("_name_entry_active")), + "Join-game Back should leave the server-name edit substate.", + ) + _expect( + page.visible, + "Join-game Back should not close the browser from an edit substate.", + ) + await process_frame + + page.queue_free() + await process_frame + + +func _validate_data_settings_navigation() -> void: + var panel := SettingsPanelScene.instantiate() as Control + root.add_child(panel) + await process_frame + panel.show() + var data_page := panel.get_node("%DataPage") as SettingsBubblePage + data_page.show_page(false) + for _frame: int in 2: + await process_frame + var controls: Array[Control] = [ + panel.get_node("%OpenDataFolder") as Control, + panel.get_node("%ChangeDataFolder") as Control, + panel.get_node("%CopyPlayerFingerprint") as Control, + panel.get_node("%ExportPlayerIdentity") as Control, + panel.get_node("%ImportPlayerIdentity") as Control, + panel.get_node("%ExportHostIdentity") as Control, + panel.get_node("%ImportHostIdentity") as Control, + panel.get_node("%DataBackButton") as Control, + ] + for control: Control in controls: + _expect( + control.focus_mode == Control.FOCUS_ALL, + "Data & Identity control %s is not controller-focusable." + % control.name, + ) + _assert_directionally_reachable(controls.front(), controls) + panel.queue_free() + await process_frame + + +func _validate_mail_navigation() -> void: + var page := MailPageType.new() as Control + root.add_child(page) + await process_frame + page.call("activate") + page.call("set_interactive", true) + var inbox_list := page.get("_inbox_list") as VBoxContainer + var entry := Button.new() + entry.text = "test letter" + entry.custom_minimum_size = Vector2(1008.0, 54.0) + inbox_list.add_child(entry) + await process_frame + page.call("_refresh_controller_navigation") + var archive_view := page.get("_archive_view_button") as Button + var send_mail := page.get("_send_mail_button") as Button + _assert_neighbor( + archive_view, &"focus_neighbor_right", send_mail + ) + _assert_neighbor(archive_view, &"focus_neighbor_bottom", entry) + _assert_neighbor(send_mail, &"focus_neighbor_bottom", entry) + _assert_neighbor(entry, &"focus_neighbor_top", archive_view) + _assert_directionally_reachable( + archive_view, [archive_view, send_mail, entry] + ) + + var inbox := page.get("_inbox") as Control + var compose := page.get("_compose") as Control + var letter := page.get("_letter") as Control + inbox.hide() + compose.hide() + letter.show() + var accept := page.get("_accept") as Button + var decline := page.get("_decline") as Button + var close := page.get("_letter_close") as Button + var archive := page.get("_archive") as Button + var delete := page.get("_delete") as Button + accept.show() + decline.show() + delete.disabled = false + page.call("_refresh_controller_navigation") + _assert_neighbor(accept, &"focus_neighbor_bottom", decline) + _assert_neighbor(decline, &"focus_neighbor_bottom", delete) + _assert_neighbor(delete, &"focus_neighbor_left", archive) + _assert_neighbor(archive, &"focus_neighbor_left", close) + _assert_neighbor(close, &"focus_neighbor_top", close) + _assert_directionally_reachable( + accept, [accept, decline, close, archive, delete] + ) + page.queue_free() + await process_frame + + +func _validate_profile_confirmation_focus() -> void: + var page := ProfilePageType.new() as Control + root.add_child(page) + await process_frame + page.call("activate") + page.call("set_interactive", true) + page.call("reset_controller_zone") + var suggestions := page.get("_suggestions") as HBoxContainer + var suggestion := Button.new() + suggestion.text = "alternate name" + suggestions.add_child(suggestion) + page.call("_apply_controller_zone_focus") + var account_controls: Array[Control] = [] + for item: Variant in page.call("_account_controller_controls"): + account_controls.append(item as Control) + var preview := page.get("_preview") as Control + var reset_view := page.get("_reset_view_button") as Button + _expect( + suggestion in account_controls, + "Profile name-conflict choices are outside the account zone.", + ) + _expect( + suggestion.focus_mode == Control.FOCUS_ALL, + "Profile name-conflict choices are not controller-focusable.", + ) + _expect( + preview.focus_mode == Control.FOCUS_NONE, + "The profile preview must not take controller focus.", + ) + _expect( + reset_view.focus_mode == Control.FOCUS_NONE, + "The profile reset-view button must not take controller focus.", + ) + page.call("_show_confirmation", "defaults") + var confirmation := page.get("_discard_confirmation") as Control + var confirm := page.get("_confirmation_confirm") as Button + var keep_editing := page.get("_keep_editing_button") as Button + _expect(confirmation.visible, "The profile confirmation did not open.") + _expect( + confirm.focus_mode == Control.FOCUS_ALL, + "The profile confirmation action is not controller-focusable.", + ) + _expect( + keep_editing.focus_mode == Control.FOCUS_ALL, + "The profile confirmation cancel action is not controller-focusable.", + ) + for control: Control in account_controls: + _expect( + control.focus_mode == Control.FOCUS_NONE, + "Profile confirmation leaked focus to %s." % control.name, + ) + var cancel_event := InputEventAction.new() + cancel_event.action = &"ui_cancel" + cancel_event.pressed = true + _expect( + bool(page.call("handle_controller_input", cancel_event)), + "Profile confirmation did not consume controller Back.", + ) + _expect( + not confirmation.visible, + "Controller Back did not close the profile confirmation.", + ) + for control: Control in account_controls: + _expect( + control.focus_mode == Control.FOCUS_ALL, + "Profile account focus was not restored after confirmation.", + ) + page.queue_free() + await process_frame + + +func _validate_mapping_capture_contract() -> void: + var title_source: String = FileAccess.get_file_as_string( + "res://ui/title_screen.gd" + ) + var game_ui_source: String = FileAccess.get_file_as_string( + "res://ui/game_ui.gd" + ) + var chat_source: String = FileAccess.get_file_as_string( + "res://ui/chat_ui.gd" + ) + var main_source: String = FileAccess.get_file_as_string( + "res://main/main.gd" + ) + _expect( + title_source.contains( + "_settings_panel.is_input_mapping_capturing()" + ), + "Title input does not respect every active binding capture.", + ) + _expect( + game_ui_source.contains("or is_input_mapping_capturing()"), + "Controller menu scrolling does not respect every binding capture.", + ) + _expect( + chat_source.contains("func _configure_controller_focus()"), + "Chat does not author controller routes for its exterior controls.", + ) + _expect( + chat_source.contains( + "Control.FOCUS_ALL if _opened else Control.FOCUS_NONE" + ), + "Passive chat controls can steal world controller focus.", + ) + _expect( + main_source.contains("func _configure_popup_dialog("), + "Runtime popup dialogs do not receive authored controller routes.", + ) + + +func _validate_confirmation_dialog_navigation() -> void: + var dialog := ConfirmationDialog.new() + dialog.ok_button_text = "continue" + var fields := VBoxContainer.new() + var first := LineEdit.new() + first.placeholder_text = "passphrase" + first.custom_minimum_size = Vector2(420.0, 42.0) + fields.add_child(first) + var second := LineEdit.new() + second.placeholder_text = "confirm passphrase" + second.custom_minimum_size = Vector2(420.0, 42.0) + fields.add_child(second) + dialog.add_child(fields) + root.add_child(dialog) + dialog.popup_centered(Vector2i(560, 300)) + for _frame: int in 2: + await process_frame + DialogControllerNavigationType.configure_scope(dialog, first) + await process_frame + var controls: Array[Control] = ( + DialogControllerNavigationType.interactive_controls(dialog) + ) + _expect( + controls.has(first) and controls.has(second), + "Confirmation-dialog text fields are not controller reachable.", + ) + _expect( + controls.has(dialog.get_ok_button()) + and controls.has(dialog.get_cancel_button()), + "Confirmation-dialog actions are not controller reachable.", + ) + _assert_directionally_reachable(first, controls) + _expect( + dialog.gui_get_focus_owner() == first, + "Confirmation dialog did not focus its requested entry control.", + ) + dialog.hide() + await process_frame + root.gui_release_focus() + dialog.popup_centered(Vector2i(560, 300)) + await process_frame + DialogControllerNavigationType.configure_scope(dialog, first) + await process_frame + _expect( + dialog.gui_get_focus_owner() == first, + "A reopened dialog did not restore controller focus.", + ) + dialog.queue_free() + await process_frame + + +func _validate_bubble_confirmation_navigation() -> void: + for scene: PackedScene in [ + BubbleConfirmationScene, + TitleConfirmationScene, + ]: + var page := scene.instantiate() as Control + root.add_child(page) + await process_frame + page.show() + page.call("_set_interactive", true) + var confirm := page.get_node("BubbleCluster/ConfirmButton") as Button + var cancel := page.get_node("BubbleCluster/CancelButton") as Button + _assert_neighbor(confirm, &"focus_neighbor_left", cancel) + _assert_neighbor(confirm, &"focus_neighbor_right", cancel) + _assert_neighbor(confirm, &"focus_neighbor_top", confirm) + _assert_neighbor(confirm, &"focus_neighbor_bottom", confirm) + _assert_neighbor(cancel, &"focus_neighbor_left", confirm) + _assert_neighbor(cancel, &"focus_neighbor_right", confirm) + _assert_neighbor(cancel, &"focus_neighbor_top", cancel) + _assert_neighbor(cancel, &"focus_neighbor_bottom", cancel) + page.queue_free() + await process_frame +func _set_button_state(button: Button, shown: bool) -> void: + button.visible = shown + button.disabled = false + + +func _assert_neighbor( + origin: Control, + property: StringName, + expected: Control, +) -> void: + var path: NodePath = origin.get(property) + _expect( + not path.is_empty(), + "%s has no %s neighbor." % [origin.name, property], + ) + if path.is_empty(): + return + var actual := origin.get_node_or_null(path) as Control + _expect( + actual == expected, + "%s points %s to %s instead of %s." + % [ + origin.name, + property, + actual.name if actual != null else "nothing", + expected.name, + ], + ) + + +func _assert_directionally_reachable( + start: Control, + controls: Array[Control], +) -> void: + var expected: Dictionary[int, bool] = {} + for control: Control in controls: + expected[control.get_instance_id()] = true + var visited: Dictionary[int, bool] = {start.get_instance_id(): true} + var pending: Array[Control] = [start] + while not pending.is_empty(): + var current: Control = pending.pop_front() + for path: NodePath in [ + current.focus_neighbor_left, + current.focus_neighbor_right, + current.focus_neighbor_top, + current.focus_neighbor_bottom, + ]: + var neighbor := current.get_node_or_null(path) as Control + if ( + neighbor == null + or not expected.has(neighbor.get_instance_id()) + or visited.has(neighbor.get_instance_id()) + ): + continue + visited[neighbor.get_instance_id()] = true + pending.append(neighbor) + _expect( + visited.size() == expected.size(), + "Only %d of %d controls are directionally reachable from %s." + % [visited.size(), expected.size(), start.name], + ) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/controller_menu_accessibility_validation.gd.uid b/tests/controller_menu_accessibility_validation.gd.uid new file mode 100644 index 0000000..a8b5c11 --- /dev/null +++ b/tests/controller_menu_accessibility_validation.gd.uid @@ -0,0 +1 @@ +uid://cdvy3ln6d3b1v diff --git a/tests/controller_ui_navigation_validation.gd b/tests/controller_ui_navigation_validation.gd index 6bfe7f7..30871eb 100644 --- a/tests/controller_ui_navigation_validation.gd +++ b/tests/controller_ui_navigation_validation.gd @@ -14,7 +14,12 @@ func _initialize() -> void: func _run() -> void: _validate_spatial_navigation() + _validate_strict_directional_navigation() + _validate_disabled_control_exclusion() + _validate_traversal_cycle() _validate_controller_hierarchy_contract() + _validate_player_page_zone_contracts() + _validate_shop_navigation_contract() _validate_four_by_three_centering() _validate_low_end_profile_contract() print("Controller UI navigation validation: PASS") @@ -38,16 +43,118 @@ func _validate_spatial_navigation() -> void: host.queue_free() +func _validate_strict_directional_navigation() -> void: + var host := Control.new() + root.add_child(host) + var origin := _make_button(host, "origin", Vector2(200.0, 200.0)) + var mostly_below := _make_button( + host, "mostly_below", Vector2(250.0, 400.0) + ) + var controls: Array[Control] = [origin, mostly_below] + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + assert(origin.get_node(origin.focus_neighbor_right) == origin) + assert(origin.get_node(origin.focus_neighbor_top) == origin) + assert(origin.get_node(origin.focus_neighbor_bottom) == mostly_below) + host.queue_free() + + +func _validate_disabled_control_exclusion() -> void: + var host := Control.new() + root.add_child(host) + var left := _make_button(host, "left", Vector2(50.0, 100.0)) + var disabled := _make_button(host, "disabled", Vector2(200.0, 100.0)) + var right := _make_button(host, "right", Vector2(350.0, 100.0)) + disabled.disabled = true + disabled.focus_next = disabled.get_path_to(right) + ControllerFocusNavigationType.configure_spatial_neighbors( + [left, disabled, right] + ) + assert(left.get_node(left.focus_neighbor_right) == right) + assert(right.get_node(right.focus_neighbor_left) == left) + assert(disabled.focus_next.is_empty()) + host.queue_free() + + +func _validate_traversal_cycle() -> void: + var host := Control.new() + root.add_child(host) + var first := _make_button(host, "first", Vector2(50.0, 50.0)) + var second := _make_button(host, "second", Vector2(200.0, 50.0)) + var third := _make_button(host, "third", Vector2(50.0, 150.0)) + var controls: Array[Control] = [third, first, second] + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + assert(first.get_node(first.focus_next) == second) + assert(second.get_node(second.focus_next) == third) + assert(third.get_node(third.focus_next) == first) + assert(first.get_node(first.focus_previous) == third) + host.queue_free() + + func _validate_controller_hierarchy_contract() -> void: var source: String = FileAccess.get_file_as_string( "res://ui/player_menu.gd" ) - assert(source.contains("ROLE_POINTER_MODIFIER")) - assert(source.contains("ROLE_CAMERA_ZOOM")) - assert(source.contains("_handle_controller_secondary_switch")) assert(source.contains("ROLE_LB")) assert(source.contains("ROLE_RB")) + assert(not source.contains("_handle_controller_secondary_switch")) + assert(source.contains("ControllerOwnership.INVENTORY_TABS")) + assert(source.contains("ControllerOwnership.SORT_FILTER")) + assert(source.contains("_reset_controller_zone_for_section")) + assert(source.contains("preserve_tab_zone")) + assert(source.contains("active_tab.call_deferred(\"grab_focus\")")) + assert(source.contains("CONTROLLER_PICKUP_HOLD_SECONDS")) assert(source.contains("_reserve_main_navigation_for_page_switching")) + assert(source.contains("configure_spatial_neighbors(candidates)")) + + +func _validate_player_page_zone_contracts() -> void: + for path: String in [ + "res://ui/logbook_page.gd", + "res://ui/the_net_page.gd", + "res://ui/mail_page.gd", + "res://ui/profile_page.gd", + "res://ui/players_page.gd", + ]: + var source: String = FileAccess.get_file_as_string(path) + assert(source.contains("func reset_controller_zone()")) + assert(source.contains("func handle_controller_input(event: InputEvent)")) + var profile_source: String = FileAccess.get_file_as_string( + "res://ui/profile_page.gd" + ) + assert(profile_source.contains("ControllerZone.COLOR_PICKER")) + assert(profile_source.contains("_adjust_controller_color_gamut")) + assert(profile_source.contains("_customize_button.text = \"customize\"")) + assert(profile_source.contains("_enter_controller_customization")) + assert(not profile_source.contains( + "if event.is_action_pressed(\"ui_down\"):\n" + + "\t\t\t_controller_zone = ControllerZone.CATEGORIES" + )) + assert(profile_source.contains( + "_reset_view_button.focus_mode = Control.FOCUS_NONE" + )) + var preview_source: String = FileAccess.get_file_as_string( + "res://ui/profile_preview.gd" + ) + assert(preview_source.contains("focus_mode = Control.FOCUS_NONE")) + assert(not preview_source.contains("grab_focus()")) + var mail_source: String = FileAccess.get_file_as_string( + "res://ui/mail_page.gd" + ) + assert(mail_source.contains("_configure_compose_controller_navigation")) + assert(mail_source.contains("_set_compose_neighbors")) + var net_source: String = FileAccess.get_file_as_string( + "res://ui/the_net_page.gd" + ) + assert(net_source.contains("ROLE_RIGHT_STICK_Y")) + + +func _validate_shop_navigation_contract() -> void: + var source: String = FileAccess.get_file_as_string( + "res://ui/fishing_shop.gd" + ) + assert(source.contains("ROLE_LB")) + assert(source.contains("ROLE_RB")) + assert(source.contains("_configure_controller_focus")) func _validate_four_by_three_centering() -> void: diff --git a/tests/file_dialog_controller_navigation_validation.gd b/tests/file_dialog_controller_navigation_validation.gd new file mode 100644 index 0000000..a31e56e --- /dev/null +++ b/tests/file_dialog_controller_navigation_validation.gd @@ -0,0 +1,183 @@ +extends SceneTree + +const FileDialogControllerNavigationType = preload( + "res://ui/file_dialog_controller_navigation.gd" +) +const InterfaceFontControllerType = preload( + "res://ui/interface_font_controller.gd" +) +const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var dialog := FileDialog.new() + dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR + dialog.access = FileDialog.ACCESS_FILESYSTEM + dialog.use_native_dialog = false + root.add_child(dialog) + dialog.current_dir = "/tmp" + dialog.popup_centered(Vector2i(900, 540)) + for _frame: int in 3: + await process_frame + FileDialogControllerNavigationType.configure(dialog) + await process_frame + + var root_scope: Window = ( + FileDialogControllerNavigationType.active_scope(dialog) + ) + assert(root_scope == dialog) + var root_controls: Array[Control] = ( + FileDialogControllerNavigationType.interactive_controls(root_scope) + ) + var directory_list: ItemList + var create_folder_button: Button + var path_edit: LineEdit + var has_menu_button: bool = false + var has_cancel: bool = false + var has_select: bool = false + for control: Control in root_controls: + if ( + control is ItemList + and control.accessibility_name == "Directories & Files:" + ): + directory_list = control as ItemList + if control is LineEdit and control.accessibility_name == "Path:": + path_edit = control as LineEdit + var button := control as Button + if button != null: + create_folder_button = ( + button + if button.tooltip_text == "Create a new folder." + else create_folder_button + ) + has_cancel = has_cancel or button.text == "Cancel" + has_select = has_select or button.text == "Select Current Folder" + has_menu_button = has_menu_button or control is MenuButton + assert(directory_list != null) + assert(path_edit != null) + assert(create_folder_button != null) + assert(has_menu_button) + assert(has_cancel) + assert(has_select) + assert(dialog.gui_get_focus_owner() == directory_list) + _assert_directionally_reachable(directory_list, root_controls) + + create_folder_button.pressed.emit() + for _frame: int in 3: + await process_frame + FileDialogControllerNavigationType.configure(dialog) + await process_frame + var folder_scope: Window = ( + FileDialogControllerNavigationType.active_scope(dialog) + ) + assert(folder_scope != null and folder_scope != dialog) + var folder_controls: Array[Control] = ( + FileDialogControllerNavigationType.interactive_controls(folder_scope) + ) + var folder_name_edit: LineEdit + var folder_cancel: Button + var folder_ok: Button + for control: Control in folder_controls: + if control is LineEdit: + folder_name_edit = control as LineEdit + var button := control as Button + if button != null and button.text == "Cancel": + folder_cancel = button + if button != null and button.text == "OK": + folder_ok = button + assert(folder_name_edit != null) + assert(folder_cancel != null) + assert(folder_ok != null) + assert(folder_scope.gui_get_focus_owner() == folder_name_edit) + _assert_directionally_reachable(folder_name_edit, folder_controls) + + var keyboard := OnScreenKeyboardType.new() + root.add_child(keyboard) + keyboard.set_enabled(true) + folder_name_edit.grab_focus() + assert(keyboard.request_for_control(folder_name_edit)) + assert(keyboard.is_open()) + assert(keyboard.get_parent() == folder_scope) + keyboard.call("_close_keyboard", true) + + var main_source: String = FileAccess.get_file_as_string( + "res://main/main.gd" + ) + assert(main_source.contains("picker_scope != _data_folder_dialog")) + assert(main_source.contains("picker_scope.hide()")) + assert(main_source.contains("is_controller_text_entry_open()")) + + dialog.queue_free() + keyboard.queue_free() + await process_frame + await _validate_compact_dialog() + print("File dialog controller navigation validation: PASS") + quit() + + +func _validate_compact_dialog() -> void: + root.size = Vector2i(640, 480) + var font_controller := InterfaceFontControllerType.new() + root.add_child(font_controller) + await process_frame + var dialog := FileDialog.new() + dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR + dialog.access = FileDialog.ACCESS_FILESYSTEM + dialog.use_native_dialog = false + root.add_child(dialog) + dialog.current_dir = "/tmp" + font_controller.popup_file_dialog(dialog) + for _frame: int in 4: + await process_frame + assert(dialog.size.x <= 616 and dialog.size.y <= 456) + var scope: Window = FileDialogControllerNavigationType.active_scope(dialog) + var controls: Array[Control] = ( + FileDialogControllerNavigationType.interactive_controls(scope) + ) + var directory_list: ItemList + for control: Control in controls: + if ( + control is ItemList + and control.accessibility_name == "Directories & Files:" + ): + directory_list = control as ItemList + break + assert(directory_list != null) + _assert_directionally_reachable(directory_list, controls) + dialog.queue_free() + font_controller.queue_free() + await process_frame + + +func _assert_directionally_reachable( + start: Control, + controls: Array[Control], +) -> void: + var expected: Dictionary[int, bool] = {} + for control: Control in controls: + expected[control.get_instance_id()] = true + var visited: Dictionary[int, bool] = {start.get_instance_id(): true} + var pending: Array[Control] = [start] + while not pending.is_empty(): + var current: Control = pending.pop_front() + for path: NodePath in [ + current.focus_neighbor_left, + current.focus_neighbor_right, + current.focus_neighbor_top, + current.focus_neighbor_bottom, + ]: + var neighbor := current.get_node_or_null(path) as Control + if ( + neighbor == null + or not expected.has(neighbor.get_instance_id()) + or visited.has(neighbor.get_instance_id()) + ): + continue + visited[neighbor.get_instance_id()] = true + pending.append(neighbor) + assert(visited.size() == expected.size()) diff --git a/tests/file_dialog_controller_navigation_validation.gd.uid b/tests/file_dialog_controller_navigation_validation.gd.uid new file mode 100644 index 0000000..b2156bf --- /dev/null +++ b/tests/file_dialog_controller_navigation_validation.gd.uid @@ -0,0 +1 @@ +uid://bgnku8bs7oxx7 diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd new file mode 100644 index 0000000..b532f4b --- /dev/null +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -0,0 +1,160 @@ +extends SceneTree + +const KeyboardMouseMappingManagerType = preload( + "res://settings/keyboard_mouse_mapping_manager.gd" +) +const KeyboardMouseMappingPanelType = preload( + "res://ui/keyboard_mouse_mapping_panel.gd" +) +const SettingsPanelScene = preload("res://ui/settings_panel.tscn") + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var manager := KeyboardMouseMappingManagerType.new() + root.add_child(manager) + await process_frame + var defaults: Dictionary = manager.get_active_bindings() + assert( + defaults.size() + == KeyboardMouseMappingManagerType.ROLE_ORDER.size() + ) + assert( + str(defaults[ + str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) + ].get("kind", "")) == "mouse_button" + ) + assert( + str(defaults[ + str(KeyboardMouseMappingManagerType.ROLE_CHAT) + ].get("kind", "")) == "key" + ) + + var joypad_events_before: int = _event_count(&"jump", true) + var rebind_key := InputEventKey.new() + rebind_key.physical_keycode = KEY_R + rebind_key.pressed = true + var jump_binding: Dictionary = manager.binding_from_event(rebind_key) + assert(manager.validate_binding(jump_binding)) + assert(manager.set_binding( + KeyboardMouseMappingManagerType.ROLE_JUMP, + jump_binding, + )) + assert(manager.has_custom_mapping()) + assert(FileAccess.file_exists( + KeyboardMouseMappingManagerType.MAPPING_PATH + )) + assert(_has_physical_key(&"jump", KEY_R)) + assert(_event_count(&"jump", true) == joypad_events_before) + + var mouse_event := InputEventMouseButton.new() + mouse_event.button_index = MOUSE_BUTTON_XBUTTON1 + mouse_event.pressed = true + var mouse_binding: Dictionary = manager.binding_from_event(mouse_event) + assert(manager.set_binding( + KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION, + mouse_binding, + )) + assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1)) + assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) + + var joypad_event := InputEventJoypadButton.new() + joypad_event.button_index = JOY_BUTTON_A + joypad_event.pressed = true + assert(manager.binding_from_event(joypad_event).is_empty()) + + var panel := KeyboardMouseMappingPanelType.new() + root.add_child(panel) + panel.setup(manager) + panel.open_panel() + await process_frame + assert(_has_label_text(panel, "keyboard binds")) + var scrolls: Array[Node] = panel.find_children( + "", "ScrollContainer", true, false + ) + assert(not scrolls.is_empty()) + assert((scrolls.front() as ScrollContainer).follow_focus) + var first_binding := panel._binding_buttons.get( + KeyboardMouseMappingManagerType.ROLE_ORDER.front() + ) as Button + var last_binding := panel._binding_buttons.get( + KeyboardMouseMappingManagerType.ROLE_ORDER.back() + ) as Button + assert(first_binding.get_node( + first_binding.focus_neighbor_top + ) == first_binding) + assert(last_binding.get_node( + last_binding.focus_neighbor_bottom + ) == panel._close_button) + assert(panel._close_button.get_node( + panel._close_button.focus_neighbor_top + ) == last_binding) + panel._begin_capture(KeyboardMouseMappingManagerType.ROLE_INTERACT) + var interact_key := InputEventKey.new() + interact_key.physical_keycode = KEY_F + interact_key.pressed = true + panel._input(interact_key) + assert(_has_physical_key(&"interact", KEY_F)) + assert(not panel.is_capturing()) + + var settings_panel := SettingsPanelScene.instantiate() as SettingsPanel + root.add_child(settings_panel) + await process_frame + var controller_bubble := settings_panel.get_node( + "%ControllerMapping" + ) as Button + var keyboard_bubble := settings_panel.get_node("%KeyboardMapping") as Button + assert(controller_bubble.text == "controller\nbinds") + assert(keyboard_bubble.text == "keyboard\nbinds") + + assert(manager.reset_mapping()) + assert(not manager.has_custom_mapping()) + assert(not FileAccess.file_exists( + KeyboardMouseMappingManagerType.MAPPING_PATH + )) + assert(_has_physical_key(&"jump", KEY_SPACE)) + assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) + assert(_has_physical_key(&"open_chat", KEY_T)) + assert(_event_count(&"jump", true) == joypad_events_before) + + settings_panel.queue_free() + panel.queue_free() + manager.queue_free() + print("Keyboard and mouse mapping validation: PASS") + quit(0) + + +func _event_count(action: StringName, joypad: bool) -> int: + var result: int = 0 + for event: InputEvent in InputMap.action_get_events(action): + if joypad == ( + event is InputEventJoypadButton or event is InputEventJoypadMotion + ): + result += 1 + return result + + +func _has_physical_key(action: StringName, key: Key) -> bool: + for event: InputEvent in InputMap.action_get_events(action): + var key_event := event as InputEventKey + if key_event != null and key_event.physical_keycode == key: + return true + return false + + +func _has_mouse_button(action: StringName, button: MouseButton) -> bool: + for event: InputEvent in InputMap.action_get_events(action): + var mouse_event := event as InputEventMouseButton + if mouse_event != null and mouse_event.button_index == button: + return true + return false + + +func _has_label_text(parent: Node, expected: String) -> bool: + for node: Node in parent.find_children("", "Label", true, false): + if (node as Label).text == expected: + return true + return false diff --git a/tests/keyboard_mouse_mapping_validation.gd.uid b/tests/keyboard_mouse_mapping_validation.gd.uid new file mode 100644 index 0000000..4d21769 --- /dev/null +++ b/tests/keyboard_mouse_mapping_validation.gd.uid @@ -0,0 +1 @@ +uid://br6aeelrpa64k diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 3e152ac..f966370 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -249,6 +249,36 @@ func _validate_page() -> void: assert(_detail_text(page).contains(LogbookCatalog.facts_for(bluegill))) _validate_detail_field_fonts(page) _validate_handwritten_numeric_scale(page) + var detail_buttons: Array = page.get("_detail_buttons") as Array + assert(detail_buttons.size() == 4) + var portrait_detail := detail_buttons[0] as Button + var facts_detail := detail_buttons[1] as Button + var quality_detail := detail_buttons[2] as Button + var stats_detail := detail_buttons[3] as Button + assert( + portrait_detail.get_node(portrait_detail.focus_neighbor_right) + == facts_detail + ) + assert( + portrait_detail.get_node(portrait_detail.focus_neighbor_bottom) + == quality_detail + ) + assert( + facts_detail.get_node(facts_detail.focus_neighbor_left) + == portrait_detail + ) + assert( + facts_detail.get_node(facts_detail.focus_neighbor_bottom) + == quality_detail + ) + assert( + quality_detail.get_node(quality_detail.focus_neighbor_bottom) + == stats_detail + ) + assert( + stats_detail.get_node(stats_detail.focus_neighbor_top) + == quality_detail + ) var portrait_button := page.get("_detail_portrait_button") as Button assert(portrait_button != null) portrait_button.pressed.emit() @@ -266,6 +296,29 @@ func _validate_page() -> void: (page.get("_portrait_overlay_backdrop") as Button).pressed.emit() await process_frame assert(not portrait_overlay.visible) + facts_detail.pressed.emit() + await process_frame + var overlay_text := page.get("_portrait_overlay_text") as Label + assert(overlay_text.visible) + assert( + overlay_text.get_theme_font_size("font_size") + == LogbookPage.DETAIL_OVERLAY_TEXT_FONT_SIZE + ) + assert( + overlay_text.horizontal_alignment + == HORIZONTAL_ALIGNMENT_LEFT + ) + assert( + portrait_overlay.size.x + - overlay_text.size.x + <= float( + (LogbookPage.DETAIL_OVERLAY_EDGE_MARGIN + + LogbookPage.DETAIL_OVERLAY_CONTENT_MARGIN) * 2 + + 8 + ) + ) + (page.get("_portrait_overlay_backdrop") as Button).pressed.emit() + await process_frame inventory.remove_catch_by_id(fish_catch.catch_id) await process_frame diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 7c8ba5c..c4b5264 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -293,6 +293,7 @@ func open_chat() -> void: # prediction would let a remote host continue the last held movement. _session.submit_neutral_local_movement() _entry.show() + _configure_controller_focus() _entry.virtual_keyboard_enabled = false _hint.hide() _refresh_input_ownership() @@ -328,6 +329,7 @@ func close_chat() -> void: _entry.virtual_keyboard_enabled = false _entry.release_focus() _entry.hide() + _configure_controller_focus() if _input_lock_applied: _player.set_local_input_suppressed(INPUT_OWNER, false) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) @@ -421,9 +423,6 @@ func _unhandled_input(event: InputEvent) -> void: ): open_chat() get_viewport().set_input_as_handled() - elif event.keycode == KEY_T and not _opened and _available: - open_chat() - get_viewport().set_input_as_handled() elif ( event.unicode == 47 and not _opened @@ -539,7 +538,7 @@ func _build_ui() -> void: _collapse_button.name = "ChatCollapseButton" _collapse_button.size = HANDLE_SIZE _collapse_button.mouse_filter = Control.MOUSE_FILTER_STOP - _collapse_button.focus_mode = Control.FOCUS_ALL + _collapse_button.focus_mode = Control.FOCUS_NONE _collapse_button.z_index = 3 _collapse_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST _collapse_button.expand_icon = true @@ -574,7 +573,7 @@ func _build_ui() -> void: _height_button.name = "ChatHeightButton" _height_button.size = HANDLE_SIZE _height_button.mouse_filter = Control.MOUSE_FILTER_STOP - _height_button.focus_mode = Control.FOCUS_ALL + _height_button.focus_mode = Control.FOCUS_NONE _height_button.z_index = 3 _height_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST _height_button.expand_icon = true @@ -1475,6 +1474,7 @@ func _layout_presentation(animate: bool) -> void: _height_button.position = height_position _unread_indicator.position = unread_position _hint.position = hint_position + _configure_controller_focus() return _height_tween = create_tween().set_parallel(true) _height_tween.tween_property( @@ -1521,10 +1521,92 @@ func _layout_presentation(animate: bool) -> void: ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.finished.connect(func() -> void: _scroll_history_to_bottom() + _configure_controller_focus() ) _hint.position = hint_position +func _configure_controller_focus() -> void: + if _entry == null or _collapse_button == null or _height_button == null: + return + for button: Button in [_collapse_button, _height_button]: + button.focus_mode = ( + Control.FOCUS_ALL if _opened else Control.FOCUS_NONE + ) + if not _opened: + return + _entry.focus_mode = Control.FOCUS_ALL + if _mobile_mode: + _set_controller_neighbors( + _entry, _entry, _entry, _entry, _height_button + ) + _set_controller_neighbors( + _height_button, + _height_button, + _collapse_button, + _entry, + _height_button, + ) + _set_controller_neighbors( + _collapse_button, + _height_button, + _collapse_button, + _entry, + _collapse_button, + ) + return + var panel_side: Control = _entry + if _dock_right: + _set_controller_neighbors( + _entry, _collapse_button, _entry, _entry, _entry + ) + _set_controller_neighbors( + _collapse_button, + _collapse_button, + panel_side, + _height_button, + _collapse_button, + ) + _set_controller_neighbors( + _height_button, + _height_button, + panel_side, + _height_button, + _collapse_button, + ) + else: + _set_controller_neighbors( + _entry, _entry, _collapse_button, _entry, _entry + ) + _set_controller_neighbors( + _collapse_button, + panel_side, + _collapse_button, + _height_button, + _collapse_button, + ) + _set_controller_neighbors( + _height_button, + panel_side, + _height_button, + _height_button, + _collapse_button, + ) + + +func _set_controller_neighbors( + control: Control, + left: Control, + right: Control, + top: Control, + bottom: Control, +) -> void: + control.focus_neighbor_left = control.get_path_to(left) + control.focus_neighbor_right = control.get_path_to(right) + control.focus_neighbor_top = control.get_path_to(top) + control.focus_neighbor_bottom = control.get_path_to(bottom) + + func _scroll_history_to_bottom() -> void: if _history == null: return diff --git a/ui/components/bubble_menu/bubble_confirmation_page.gd b/ui/components/bubble_menu/bubble_confirmation_page.gd index fde0f34..80f17bb 100644 --- a/ui/components/bubble_menu/bubble_confirmation_page.gd +++ b/ui/components/bubble_menu/bubble_confirmation_page.gd @@ -186,12 +186,16 @@ func _configure_focus() -> void: ) _confirm_button.focus_neighbor_left = confirm_to_cancel _confirm_button.focus_neighbor_right = confirm_to_cancel - _confirm_button.focus_neighbor_top = confirm_to_cancel - _confirm_button.focus_neighbor_bottom = confirm_to_cancel + _confirm_button.focus_neighbor_top = _confirm_button.get_path_to( + _confirm_button + ) + _confirm_button.focus_neighbor_bottom = _confirm_button.focus_neighbor_top _cancel_button.focus_neighbor_left = cancel_to_confirm _cancel_button.focus_neighbor_right = cancel_to_confirm - _cancel_button.focus_neighbor_top = cancel_to_confirm - _cancel_button.focus_neighbor_bottom = cancel_to_confirm + _cancel_button.focus_neighbor_top = _cancel_button.get_path_to( + _cancel_button + ) + _cancel_button.focus_neighbor_bottom = _cancel_button.focus_neighbor_top func _set_interactive(interactive: bool) -> void: @@ -211,6 +215,8 @@ func _set_interactive(interactive: bool) -> void: if interactive else Control.MOUSE_FILTER_IGNORE ) + if interactive: + _configure_focus() func _finish_transition_in( diff --git a/ui/components/bubble_menu/notepad_ink_choice.gd b/ui/components/bubble_menu/notepad_ink_choice.gd index 1578587..80aefbd 100644 --- a/ui/components/bubble_menu/notepad_ink_choice.gd +++ b/ui/components/bubble_menu/notepad_ink_choice.gd @@ -169,10 +169,12 @@ func _on_choice_gui_input(event: InputEvent, index: int) -> void: close_choices() get_viewport().set_input_as_handled() elif event.is_action_pressed("ui_up"): - _choice_buttons[posmod(index - 1, _choice_buttons.size())].grab_focus() + _choice_buttons[maxi(index - 1, 0)].grab_focus() get_viewport().set_input_as_handled() elif event.is_action_pressed("ui_down"): - _choice_buttons[(index + 1) % _choice_buttons.size()].grab_focus() + _choice_buttons[mini( + index + 1, _choice_buttons.size() - 1 + )].grab_focus() get_viewport().set_input_as_handled() diff --git a/ui/components/organizer_tab.gd b/ui/components/organizer_tab.gd index 3625b9a..076d022 100644 --- a/ui/components/organizer_tab.gd +++ b/ui/components/organizer_tab.gd @@ -29,6 +29,7 @@ var _selected: bool = false func _ready() -> void: toggle_mode = true + set_meta(&"controller_focus_inversion_disabled", true) _selected = button_pressed add_theme_font_override("font", UtilityPageStyle.TuffyFont) add_theme_font_size_override("font_size", FONT_SIZE) diff --git a/ui/controller_focus_navigation.gd b/ui/controller_focus_navigation.gd index cd93796..a1a3b93 100644 --- a/ui/controller_focus_navigation.gd +++ b/ui/controller_focus_navigation.gd @@ -3,16 +3,22 @@ extends RefCounted const PERPENDICULAR_WEIGHT: float = 2.5 const MINIMUM_FORWARD_DISTANCE: float = 0.5 +const MAXIMUM_DIRECTION_SLOPE: float = 1.0 static func configure_spatial_neighbors(controls: Array) -> void: var candidates: Array[Control] = [] for item: Variant in controls: var control := item as Control - if control == null or not control.is_visible_in_tree(): + if not is_focusable(control): + if control != null: + _clear_neighbors(control) continue candidates.append(control) + var traversal_order: Array[Control] = candidates.duplicate() + traversal_order.sort_custom(_sort_by_position) for control: Control in candidates: + _enable_ancestor_scroll_follow(control) control.focus_neighbor_left = _neighbor_path( control, candidates, Vector2.LEFT ) @@ -25,6 +31,80 @@ static func configure_spatial_neighbors(controls: Array) -> void: control.focus_neighbor_bottom = _neighbor_path( control, candidates, Vector2.DOWN ) + var traversal_index: int = traversal_order.find(control) + control.focus_previous = control.get_path_to( + traversal_order[wrapi( + traversal_index - 1, + 0, + traversal_order.size(), + )] + ) + control.focus_next = control.get_path_to( + traversal_order[wrapi( + traversal_index + 1, + 0, + traversal_order.size(), + )] + ) + + +static func configure_traversal(controls: Array) -> void: + var candidates: Array[Control] = [] + for item: Variant in controls: + var control := item as Control + if is_focusable(control): + candidates.append(control) + elif control != null: + control.focus_previous = NodePath() + control.focus_next = NodePath() + if candidates.is_empty(): + return + for index: int in candidates.size(): + var control: Control = candidates[index] + _enable_ancestor_scroll_follow(control) + control.focus_previous = control.get_path_to( + candidates[wrapi(index - 1, 0, candidates.size())] + ) + control.focus_next = control.get_path_to( + candidates[wrapi(index + 1, 0, candidates.size())] + ) + + +static func is_focusable(control: Control) -> bool: + if ( + control == null + or not control.is_visible_in_tree() + or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL] + ): + return false + var button := control as BaseButton + return button == null or not button.disabled + + +static func _clear_neighbors(control: Control) -> void: + control.focus_neighbor_left = NodePath() + control.focus_neighbor_right = NodePath() + control.focus_neighbor_top = NodePath() + control.focus_neighbor_bottom = NodePath() + control.focus_previous = NodePath() + control.focus_next = NodePath() + + +static func _enable_ancestor_scroll_follow(control: Control) -> void: + var ancestor: Node = control.get_parent() + while ancestor != null: + var scroll := ancestor as ScrollContainer + if scroll != null: + scroll.follow_focus = true + ancestor = ancestor.get_parent() + + +static func _sort_by_position(first: Control, second: Control) -> bool: + var first_center: Vector2 = first.get_global_rect().get_center() + var second_center: Vector2 = second.get_global_rect().get_center() + if not is_equal_approx(first_center.y, second_center.y): + return first_center.y < second_center.y + return first_center.x < second_center.x static func _neighbor_path( @@ -36,7 +116,7 @@ static func _neighbor_path( var best: Control = null var best_score: float = INF for candidate: Control in candidates: - if candidate == origin or candidate.focus_mode == Control.FOCUS_NONE: + if candidate == origin or not is_focusable(candidate): continue var delta: Vector2 = ( candidate.get_global_rect().get_center() - origin_center @@ -45,6 +125,8 @@ static func _neighbor_path( if forward_distance <= MINIMUM_FORWARD_DISTANCE: continue var perpendicular_distance: float = absf(delta.cross(direction)) + if perpendicular_distance > forward_distance * MAXIMUM_DIRECTION_SLOPE: + continue var score: float = ( forward_distance + perpendicular_distance * PERPENDICULAR_WEIGHT @@ -53,5 +135,5 @@ static func _neighbor_path( best = candidate best_score = score if best == null: - return NodePath() + return origin.get_path_to(origin) return origin.get_path_to(best) diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index 7223ca7..fab4ce6 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -70,11 +70,33 @@ func _set_controller_active(active: bool) -> void: if _controller_active == active: return _controller_active = active - _apply_to_focus(get_viewport().gui_get_focus_owner()) + var focus_owner: Control = get_viewport().gui_get_focus_owner() + _apply_to_focus(focus_owner) + if _controller_active: + _queue_focus_visibility_update(focus_owner) func _on_focus_changed(control: Control) -> void: _apply_to_focus(control) + if _controller_active: + _queue_focus_visibility_update(control) + + +func _queue_focus_visibility_update(control: Control) -> void: + if control == null: + return + _ensure_focus_visible.call_deferred(control) + + +func _ensure_focus_visible(control: Control) -> void: + if not is_instance_valid(control) or not control.is_visible_in_tree(): + return + var ancestor: Node = control.get_parent() + while ancestor != null: + var scroll := ancestor as ScrollContainer + if scroll != null and scroll.is_visible_in_tree(): + scroll.ensure_control_visible(control) + ancestor = ancestor.get_parent() func _apply_to_focus(control: Control) -> void: diff --git a/ui/controller_mapping_panel.gd b/ui/controller_mapping_panel.gd index 42fdf92..1312019 100644 --- a/ui/controller_mapping_panel.gd +++ b/ui/controller_mapping_panel.gd @@ -15,6 +15,7 @@ var _binding_buttons: Dictionary = {} var _controller_label: Label var _instruction_label: Label var _progress_label: Label +var _mapping_scroll: ScrollContainer var _auto_map_button: Button var _reset_button: Button var _close_button: Button @@ -258,17 +259,18 @@ func _build_interface() -> void: ) page.add_child(_progress_label) - var scroll := ScrollContainer.new() - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - page.add_child(scroll) + _mapping_scroll = ScrollContainer.new() + _mapping_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _mapping_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _mapping_scroll.follow_focus = true + page.add_child(_mapping_scroll) var role_grid := GridContainer.new() role_grid.columns = 2 role_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL role_grid.add_theme_constant_override("h_separation", 12) role_grid.add_theme_constant_override("v_separation", 7) - scroll.add_child(role_grid) + _mapping_scroll.add_child(role_grid) for role: StringName in ControllerMappingManagerType.ROLE_ORDER: var role_label := Label.new() @@ -325,6 +327,65 @@ func _build_interface() -> void: UtilityPageStyleType.apply_ocean_button(_close_button) _close_button.pressed.connect(close_panel) footer.add_child(_close_button) + _configure_mapping_navigation() + + +func _configure_mapping_navigation() -> void: + var buttons: Array[Button] = [] + for role: StringName in ControllerMappingManagerType.ROLE_ORDER: + var binding_button := _binding_buttons.get(role) as Button + if binding_button != null: + buttons.append(binding_button) + if buttons.is_empty(): + return + for index: int in buttons.size(): + var button: Button = buttons[index] + var previous: Control = buttons[index - 1] if index > 0 else button + var next: Control = ( + buttons[index + 1] + if index < buttons.size() - 1 + else _close_button + ) + _set_focus_neighbors(button, button, button, previous, next) + button.focus_previous = button.get_path_to(previous) + button.focus_next = button.get_path_to(next) + var last_button: Button = buttons.back() + _set_focus_neighbors( + _auto_map_button, + _auto_map_button, + _reset_button, + last_button, + _auto_map_button, + ) + _set_focus_neighbors( + _reset_button, + _auto_map_button, + _close_button, + last_button, + _reset_button, + ) + _set_focus_neighbors( + _close_button, + _reset_button, + _close_button, + last_button, + _close_button, + ) + _close_button.focus_previous = _close_button.get_path_to(last_button) + _close_button.focus_next = _close_button.get_path_to(_auto_map_button) + + +func _set_focus_neighbors( + control: Control, + left: Control, + right: Control, + top: Control, + bottom: Control, +) -> void: + control.focus_neighbor_left = control.get_path_to(left) + control.focus_neighbor_right = control.get_path_to(right) + control.focus_neighbor_top = control.get_path_to(top) + control.focus_neighbor_bottom = control.get_path_to(bottom) func _begin_auto_map() -> void: diff --git a/ui/file_dialog_controller_navigation.gd b/ui/file_dialog_controller_navigation.gd new file mode 100644 index 0000000..0d49afc --- /dev/null +++ b/ui/file_dialog_controller_navigation.gd @@ -0,0 +1,124 @@ +class_name FileDialogControllerNavigation +extends RefCounted + +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) + + +static func configure(dialog: FileDialog) -> void: + if dialog == null or not dialog.visible or not dialog.is_inside_tree(): + return + var scope: Window = active_scope(dialog) + if scope == null: + return + configure_scope(scope) + + +static func configure_scope( + scope: Window, + preferred_control: Control = null, +) -> void: + if scope == null or not scope.visible or not scope.is_inside_tree(): + return + var controls: Array[Control] = [] + _collect_interactive_controls(scope, scope, controls) + if controls.is_empty(): + return + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + var focus_owner: Control = scope.gui_get_focus_owner() + if controls.has(preferred_control): + if focus_owner != preferred_control: + preferred_control.grab_focus() + return + if focus_owner != null and controls.has(focus_owner): + return + var preferred: Control = _preferred_initial_control(scope, controls) + if preferred != null: + preferred.grab_focus() + + +static func active_scope(dialog: FileDialog) -> Window: + if dialog == null or not dialog.visible: + return null + return _deepest_exclusive_window(dialog) + + +static func interactive_controls(scope: Window) -> Array[Control]: + var controls: Array[Control] = [] + if scope != null: + _collect_interactive_controls(scope, scope, controls) + return controls + + +static func _deepest_exclusive_window(window: Window) -> Window: + for child: Node in window.get_children(true): + var child_window := child as Window + if ( + child_window == null + or not child_window.visible + or not child_window.exclusive + ): + continue + return _deepest_exclusive_window(child_window) + return window + + +static func _collect_interactive_controls( + node: Node, + scope: Window, + output: Array[Control], +) -> void: + for child: Node in node.get_children(true): + var child_window := child as Window + if child_window != null and child_window != scope: + continue + var control := child as Control + if control != null and _is_controller_interactive(control): + # Godot marks some visible FileDialog toolbar and menu buttons as + # accessibility-only. Promote those genuine controls for controller + # focus while leaving labels, scrollbars, and splitters untouched. + control.focus_mode = Control.FOCUS_ALL + output.append(control) + _collect_interactive_controls(child, scope, output) + + +static func _is_controller_interactive(control: Control) -> bool: + if ( + control == null + or not control.is_visible_in_tree() + or control.size.x < 8.0 + or control.size.y < 8.0 + or control is ScrollBar + ): + return false + var button := control as BaseButton + if button != null: + return not button.disabled + return ( + control is LineEdit + or control is TextEdit + or control is ItemList + or control is Tree + ) + + +static func _preferred_initial_control( + scope: Window, + controls: Array[Control], +) -> Control: + if scope is FileDialog: + for control: Control in controls: + if control is ItemList and control.accessibility_name == ( + "Directories & Files:" + ): + return control + for control: Control in controls: + if control is LineEdit or control is TextEdit: + return control + if scope is ConfirmationDialog: + var confirmation := scope as ConfirmationDialog + var ok_button: Button = confirmation.get_ok_button() + if controls.has(ok_button): + return ok_button + return controls.front() diff --git a/ui/file_dialog_controller_navigation.gd.uid b/ui/file_dialog_controller_navigation.gd.uid new file mode 100644 index 0000000..580b30c --- /dev/null +++ b/ui/file_dialog_controller_navigation.gd.uid @@ -0,0 +1 @@ +uid://i46n60pwuoqm diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index e3fa3c7..d331d79 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -28,6 +28,12 @@ const ShopInteractionType = preload( const UtilityPageStyleType = preload("res://ui/utility_page_style.gd") const OrganizerTabType = preload("res://ui/components/organizer_tab.gd") const UIMotionType = preload("res://ui/ui_motion.gd") +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) const FALLBACK_SUPPLY_ICON: Texture2D = preload( "res://ui/icons/pictograms/x_light.png" ) @@ -168,6 +174,7 @@ var _cooler_page_active: bool = false var _cooler_modal_open: bool = false var _shop_section: ShopSection = ShopSection.UPGRADES var _shop_tabs: Array[OrganizerTab] = [] +var _controller_mapping_manager: ControllerMappingManagerType func _ready() -> void: @@ -180,6 +187,53 @@ func _ready() -> void: _cooler_purchase.pressed.connect(_purchase_cooler_capacity) +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager + + +func _input(event: InputEvent) -> void: + if ( + not visible + or _closing + or _cooler_page_active + or _cooler_modal_open + ): + return + var button_event := event as InputEventJoypadButton + if button_event == null: + return + var uses_left_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManagerType.ROLE_LB, + ) + if _controller_mapping_manager != null + else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER + ) + var uses_right_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManagerType.ROLE_RB, + ) + if _controller_mapping_manager != null + else button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER + ) + if not (uses_left_bumper or uses_right_bumper): + return + get_viewport().set_input_as_handled() + if not button_event.pressed or _transaction_in_progress: + return + var direction: int = -1 if uses_left_bumper else 1 + var next_section: int = wrapi( + int(_shop_section) + direction, + 0, + ShopSection.size(), + ) + _select_shop_section(next_section, true) + + func _request_shop_cooler() -> bool: if _transaction_in_progress: _set_feedback("Finish the current transaction first.") @@ -193,6 +247,7 @@ func _request_shop_cooler() -> bool: func _focus_shop_section() -> void: _set_feedback("") + _configure_controller_focus() if _shop_section == ShopSection.UPGRADES: _reel_purchase.grab_focus() return @@ -264,6 +319,31 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void: _refresh_supplies() if focus_content and visible: _focus_shop_section() + call_deferred("_configure_controller_focus") + + +func _configure_controller_focus() -> void: + if not visible or _cooler_page_active: + return + var candidates: Array[Control] = [] + _collect_controller_focusables(self, candidates) + ControllerFocusNavigationType.configure_spatial_neighbors(candidates) + + +func _collect_controller_focusables( + root: Node, + output: Array[Control], +) -> void: + for child: Node in root.get_children(): + var control := child as Control + if control != null and not control.is_visible_in_tree(): + continue + if ( + ControllerFocusNavigationType.is_focusable(control) + and not control is ScrollBar + ): + output.append(control) + _collect_controller_focusables(child, output) func _update_shop_tab_selection() -> void: @@ -410,6 +490,7 @@ func open_shop() -> bool: _shop_tab_bar.show() _select_shop_section(ShopSection.UPGRADES, false) _refresh_all() + call_deferred("_configure_controller_focus") _reel_purchase.grab_focus() menu_visibility_changed.emit(true) return true diff --git a/ui/game_ui.gd b/ui/game_ui.gd index d9e6f77..a7f0f76 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -48,6 +48,9 @@ const PlayerSettingsManagerType = preload( const ControllerMappingManagerType = preload( "res://settings/controller_mapping_manager.gd" ) +const KeyboardMouseMappingManagerType = preload( + "res://settings/keyboard_mouse_mapping_manager.gd" +) const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" @@ -198,6 +201,7 @@ var _shared_trigger_rest_by_device: Dictionary[int, float] = {} var _controller_mapping_manager: ControllerMappingManagerType var _settings_manager: PlayerSettingsManagerType var _controller_text_entry_request: Callable +var _controller_text_entry_is_open: Callable func _ready() -> void: @@ -251,8 +255,28 @@ func _ready() -> void: Input.joy_connection_changed.connect(_on_controller_connection_changed) -func set_controller_text_entry_request(request: Callable) -> void: +func set_controller_text_entry_request( + request: Callable, + is_open: Callable = Callable(), +) -> void: _controller_text_entry_request = request + _controller_text_entry_is_open = is_open + + +func request_controller_text_entry_for(control: Control = null) -> bool: + return ( + bool(_controller_text_entry_request.call(control)) + if _controller_text_entry_request.is_valid() + else false + ) + + +func is_controller_text_entry_open() -> bool: + return ( + bool(_controller_text_entry_is_open.call()) + if _controller_text_entry_is_open.is_valid() + else false + ) func setup( @@ -603,10 +627,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool: _chat_ui.refocus_gameplay() return true if accept_pressed: - if ( - _controller_text_entry_request.is_valid() - and bool(_controller_text_entry_request.call()) - ): + if request_controller_text_entry_for(): return true return _chat_ui.request_virtual_keyboard() return false @@ -1175,12 +1196,22 @@ func setup_controller_mapping( _emote_radial_menu.setup_controller_mapping(_controller_mapping_manager) _quick_radial_menu.setup_controller_mapping(_controller_mapping_manager) _player_menu.setup_controller_mapping(_controller_mapping_manager) + _fishing_shop.setup_controller_mapping(_controller_mapping_manager) for panel: SettingsPanelType in [ _title_settings_panel, _pause_settings_panel ]: panel.setup_controller_mapping(_controller_mapping_manager) +func setup_keyboard_mouse_mapping( + mapping_manager: KeyboardMouseMappingManagerType, +) -> void: + for panel: SettingsPanelType in [ + _title_settings_panel, _pause_settings_panel + ]: + panel.setup_keyboard_mouse_mapping(mapping_manager) + + func is_controller_mapping_capturing() -> bool: return ( _title_settings_panel.is_controller_mapping_capturing() @@ -1188,6 +1219,13 @@ func is_controller_mapping_capturing() -> bool: ) +func is_input_mapping_capturing() -> bool: + return ( + _title_settings_panel.is_input_mapping_capturing() + or _pause_settings_panel.is_input_mapping_capturing() + ) + + func _process(delta: float) -> void: _poll_virtual_mouse_controller_state() _update_virtual_mouse(delta) @@ -1199,7 +1237,7 @@ func _update_controller_menu_scroll(delta: float) -> void: if ( _controller_mapping_manager == null or _virtual_mouse_active - or is_controller_mapping_capturing() + or is_input_mapping_capturing() ): return var stick_y: float = _controller_menu_scroll_axis() diff --git a/ui/hotbar.gd b/ui/hotbar.gd index fe26cc6..a32bbf3 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -123,10 +123,10 @@ func begin_controller_placement( var slot: BubbleHotbarSlotType = _slots[index] slot.focus_mode = Control.FOCUS_ALL slot.focus_neighbor_left = slot.get_path_to( - _slots[wrapi(index - 1, 0, slot_count)] + _slots[maxi(index - 1, 0)] ) slot.focus_neighbor_right = slot.get_path_to( - _slots[wrapi(index + 1, 0, slot_count)] + _slots[mini(index + 1, slot_count - 1)] ) slot.focus_neighbor_top = slot.get_path_to(slot) slot.focus_neighbor_bottom = slot.get_path_to(slot) @@ -158,10 +158,10 @@ func begin_controller_management(initial_slot: int) -> void: var slot: BubbleHotbarSlotType = _slots[index] slot.focus_mode = Control.FOCUS_ALL slot.focus_neighbor_left = slot.get_path_to( - _slots[wrapi(index - 1, 0, slot_count)] + _slots[maxi(index - 1, 0)] ) slot.focus_neighbor_right = slot.get_path_to( - _slots[wrapi(index + 1, 0, slot_count)] + _slots[mini(index + 1, slot_count - 1)] ) slot.focus_neighbor_top = slot.get_path_to(slot) slot.focus_neighbor_bottom = slot.get_path_to(slot) diff --git a/ui/interface_font_controller.gd b/ui/interface_font_controller.gd index f8db71e..fe6a93c 100644 --- a/ui/interface_font_controller.gd +++ b/ui/interface_font_controller.gd @@ -1,14 +1,20 @@ class_name InterfaceFontController extends Node +const FileDialogControllerNavigationType = preload( + "res://ui/file_dialog_controller_navigation.gd" +) const STANDARD_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf") const COMPACT_FILE_DIALOG_LIMIT := Vector2i(800, 600) const COMPACT_FILE_DIALOG_MARGIN := Vector2i(12, 12) const COMPACT_FILE_DIALOG_FONT_SIZE: int = 17 +var _controller_text_entry_request: Callable +var _controller_text_entry_is_open: Callable var _game_theme: Theme = preload("res://ui/game_theme.tres") var _utility_theme: Theme var _compact_file_dialog_theme: Theme +var _tracked_file_dialogs: Dictionary[int, WeakRef] = {} func _ready() -> void: @@ -23,6 +29,14 @@ func enforce_standard_font() -> void: _utility_theme.default_font = STANDARD_FONT +func set_controller_text_entry_request( + request: Callable, + is_open: Callable = Callable(), +) -> void: + _controller_text_entry_request = request + _controller_text_entry_is_open = is_open + + func set_readable_font_enabled(_enabled: bool) -> void: # Compatibility seam for callers compiled against the old setting. enforce_standard_font() @@ -47,6 +61,7 @@ func apply_utility_theme(themed_node: Node) -> void: func popup_file_dialog(dialog: FileDialog) -> void: if dialog == null or not dialog.is_inside_tree(): return + _tracked_file_dialogs[dialog.get_instance_id()] = weakref(dialog) var host_window: Window = dialog.get_parent().get_window() var host_size: Vector2i = host_window.size var compact: bool = ( @@ -83,6 +98,55 @@ func popup_file_dialog(dialog: FileDialog) -> void: ) +func _process(_delta: float) -> void: + if ( + _controller_text_entry_is_open.is_valid() + and bool(_controller_text_entry_is_open.call()) + ): + return + for instance_id: int in _tracked_file_dialogs.keys(): + var reference: WeakRef = _tracked_file_dialogs[instance_id] + var dialog := reference.get_ref() as FileDialog + if dialog == null or not is_instance_valid(dialog): + _tracked_file_dialogs.erase(instance_id) + continue + if not dialog.visible: + continue + FileDialogControllerNavigationType.configure(dialog) + _connect_file_dialog_text_controls(dialog) + + +func _connect_file_dialog_text_controls(dialog: FileDialog) -> void: + var scope: Window = FileDialogControllerNavigationType.active_scope(dialog) + for control: Control in ( + FileDialogControllerNavigationType.interactive_controls(scope) + ): + if not (control is LineEdit or control is TextEdit): + continue + var callback := _on_file_dialog_text_gui_input.bind(control) + if not control.gui_input.is_connected(callback): + control.gui_input.connect(callback) + + +func _on_file_dialog_text_gui_input( + event: InputEvent, + control: Control, +) -> void: + var button_event := event as InputEventJoypadButton + if ( + button_event == null + or not button_event.pressed + or ( + button_event.button_index != JOY_BUTTON_A + and not event.is_action_pressed("ui_accept") + ) + or not _controller_text_entry_request.is_valid() + ): + return + if bool(_controller_text_entry_request.call(control)): + control.accept_event() + + func _finalize_compact_file_dialog( dialog: FileDialog, host_size: Vector2i, diff --git a/ui/keyboard_mouse_mapping_panel.gd b/ui/keyboard_mouse_mapping_panel.gd new file mode 100644 index 0000000..2aa824e --- /dev/null +++ b/ui/keyboard_mouse_mapping_panel.gd @@ -0,0 +1,400 @@ +class_name KeyboardMouseMappingPanel +extends Control + +signal closed + +const KeyboardMouseMappingManagerType = preload( + "res://settings/keyboard_mouse_mapping_manager.gd" +) +const UtilityPageStyleType = preload("res://ui/utility_page_style.gd") + +var _mapping_manager: KeyboardMouseMappingManagerType +var _binding_buttons: Dictionary = {} +var _progress_label: Label +var _mapping_scroll: ScrollContainer +var _reset_button: Button +var _close_button: Button +var _capturing_role: StringName = &"" + + +func _ready() -> void: + set_process_input(true) + _build_interface() + hide() + + +func setup(mapping_manager: KeyboardMouseMappingManagerType) -> void: + _mapping_manager = mapping_manager + if not _mapping_manager.mapping_changed.is_connected(_refresh_bindings): + _mapping_manager.mapping_changed.connect(_refresh_bindings) + _refresh_bindings() + + +func open_panel() -> void: + if _mapping_manager == null: + return + _cancel_capture() + show() + _refresh_bindings() + var first_button := _binding_buttons.get( + KeyboardMouseMappingManagerType.ROLE_ORDER.front() + ) as Button + if first_button != null: + first_button.grab_focus() + + +func is_open() -> bool: + return visible + + +func is_capturing() -> bool: + return visible and not _capturing_role.is_empty() + + +func request_back() -> void: + if not visible: + return + if not _capturing_role.is_empty(): + _cancel_capture() + _progress_label.text = "keyboard binding cancelled" + return + close_panel() + + +func close_panel() -> void: + _cancel_capture() + hide() + closed.emit() + + +func _input(event: InputEvent) -> void: + if not visible or _mapping_manager == null or _capturing_role.is_empty(): + return + var key_event := event as InputEventKey + if ( + key_event != null + and key_event.pressed + and not key_event.echo + and key_event.keycode == KEY_ESCAPE + ): + get_viewport().set_input_as_handled() + _cancel_capture() + _progress_label.text = "keyboard binding cancelled" + return + var binding: Dictionary = _mapping_manager.binding_from_event(event) + if binding.is_empty(): + return + get_viewport().set_input_as_handled() + var role: StringName = _capturing_role + var saved: bool = _mapping_manager.set_binding(role, binding) + _cancel_capture() + _progress_label.text = ( + "updated " + _mapping_manager.get_role_label(role) + if saved else "could not save that keyboard binding" + ) + _refresh_bindings() + + +func _build_interface() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + z_index = 100 + UtilityPageStyleType.apply_page(self) + + var frame := PanelContainer.new() + frame.set_anchors_preset(Control.PRESET_FULL_RECT) + frame.offset_left = 18.0 + frame.offset_top = 18.0 + frame.offset_right = -18.0 + frame.offset_bottom = -18.0 + frame.add_theme_stylebox_override( + "panel", + UtilityPageStyleType.rounded_style( + UtilityPageStyleType.OCEAN_PANEL_DEEP, + 20, + ), + ) + add_child(frame) + + var outer_margin := MarginContainer.new() + outer_margin.add_theme_constant_override("margin_left", 24) + outer_margin.add_theme_constant_override("margin_top", 20) + outer_margin.add_theme_constant_override("margin_right", 24) + outer_margin.add_theme_constant_override("margin_bottom", 20) + frame.add_child(outer_margin) + + var page := VBoxContainer.new() + page.add_theme_constant_override("separation", 12) + outer_margin.add_child(page) + + var heading_row := HBoxContainer.new() + heading_row.add_theme_constant_override("separation", 12) + page.add_child(heading_row) + + var title := Label.new() + title.text = "keyboard binds" + title.add_theme_font_size_override("font_size", 28) + title.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY + ) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + heading_row.add_child(title) + + var device_label := Label.new() + device_label.text = "mouse + keyboard" + device_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + device_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + device_label.add_theme_font_size_override("font_size", 15) + device_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY + ) + heading_row.add_child(device_label) + + var instruction_label := Label.new() + instruction_label.text = ( + "select a row, then press a keyboard key or mouse button. " + + "duplicate bindings are marked in red." + ) + instruction_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + instruction_label.add_theme_font_size_override("font_size", 15) + instruction_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY + ) + page.add_child(instruction_label) + + _progress_label = Label.new() + _progress_label.custom_minimum_size.y = 34.0 + _progress_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _progress_label.add_theme_font_size_override("font_size", 18) + _progress_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY + ) + page.add_child(_progress_label) + + _mapping_scroll = ScrollContainer.new() + _mapping_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _mapping_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _mapping_scroll.follow_focus = true + page.add_child(_mapping_scroll) + + var role_grid := GridContainer.new() + role_grid.columns = 2 + role_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + role_grid.add_theme_constant_override("h_separation", 12) + role_grid.add_theme_constant_override("v_separation", 7) + _mapping_scroll.add_child(role_grid) + + for role: StringName in KeyboardMouseMappingManagerType.ROLE_ORDER: + var role_label := Label.new() + role_label.text = KeyboardMouseMappingManagerType.ROLE_LABELS.get( + role, + str(role), + ) + role_label.custom_minimum_size = Vector2(330.0, 42.0) + role_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + role_label.add_theme_font_size_override("font_size", 16) + role_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY + ) + role_grid.add_child(role_label) + + var binding_button := Button.new() + binding_button.custom_minimum_size = Vector2(210.0, 42.0) + binding_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + binding_button.focus_mode = Control.FOCUS_ALL + binding_button.tooltip_text = ( + "change " + _mapping_manager_label(role) + ) + UtilityPageStyleType.apply_compact_ocean_button(binding_button) + binding_button.pressed.connect(_begin_capture.bind(role)) + role_grid.add_child(binding_button) + _binding_buttons[role] = binding_button + + var footer := HBoxContainer.new() + footer.alignment = BoxContainer.ALIGNMENT_END + footer.add_theme_constant_override("separation", 10) + page.add_child(footer) + + _reset_button = Button.new() + _reset_button.text = "restore defaults" + _reset_button.tooltip_text = "restore the project keyboard defaults" + UtilityPageStyleType.apply_ocean_button(_reset_button) + _reset_button.pressed.connect(_reset_mapping) + footer.add_child(_reset_button) + + _close_button = Button.new() + _close_button.text = "done" + UtilityPageStyleType.apply_ocean_button(_close_button) + _close_button.pressed.connect(close_panel) + footer.add_child(_close_button) + _configure_mapping_navigation() + + +func _configure_mapping_navigation() -> void: + var buttons: Array[Button] = [] + for role: StringName in KeyboardMouseMappingManagerType.ROLE_ORDER: + var binding_button := _binding_buttons.get(role) as Button + if binding_button != null: + buttons.append(binding_button) + if buttons.is_empty(): + return + for index: int in buttons.size(): + var button: Button = buttons[index] + var previous: Control = buttons[index - 1] if index > 0 else button + var next: Control = ( + buttons[index + 1] + if index < buttons.size() - 1 + else _close_button + ) + _set_focus_neighbors(button, button, button, previous, next) + button.focus_previous = button.get_path_to(previous) + button.focus_next = button.get_path_to(next) + var last_button: Button = buttons.back() + _set_focus_neighbors( + _reset_button, + _reset_button, + _close_button, + last_button, + _reset_button, + ) + _set_focus_neighbors( + _close_button, + _reset_button, + _close_button, + last_button, + _close_button, + ) + _close_button.focus_previous = _close_button.get_path_to(last_button) + _close_button.focus_next = _close_button.get_path_to(_reset_button) + + +func _set_focus_neighbors( + control: Control, + left: Control, + right: Control, + top: Control, + bottom: Control, +) -> void: + control.focus_neighbor_left = control.get_path_to(left) + control.focus_neighbor_right = control.get_path_to(right) + control.focus_neighbor_top = control.get_path_to(top) + control.focus_neighbor_bottom = control.get_path_to(bottom) + + +func _mapping_manager_label(role: StringName) -> String: + if _mapping_manager == null: + return str( + KeyboardMouseMappingManagerType.ROLE_LABELS.get(role, str(role)) + ) + return _mapping_manager.get_role_label(role) + + +func _begin_capture(role: StringName) -> void: + if _mapping_manager == null: + return + _capturing_role = role + _set_action_buttons_disabled(true) + _progress_label.text = ( + "press a key or mouse button for " + + _mapping_manager.get_role_label(role) + + " • escape cancels" + ) + + +func _cancel_capture() -> void: + _capturing_role = &"" + _set_action_buttons_disabled(false) + if _progress_label != null: + _progress_label.text = "" + + +func _set_action_buttons_disabled(disabled: bool) -> void: + if _reset_button != null: + _reset_button.disabled = disabled + if _close_button != null: + _close_button.disabled = disabled + for button_value: Variant in _binding_buttons.values(): + var button := button_value as Button + if button != null: + button.disabled = disabled + + +func _reset_mapping() -> void: + if _mapping_manager == null: + return + var reset: bool = _mapping_manager.reset_mapping() + _progress_label.text = ( + "restored the project keyboard defaults" + if reset else "could not restore keyboard defaults" + ) + _refresh_bindings() + + +func _refresh_bindings() -> void: + if _mapping_manager == null or _reset_button == null: + return + var bindings: Dictionary = _mapping_manager.get_active_bindings() + for role: StringName in KeyboardMouseMappingManagerType.ROLE_ORDER: + var button := _binding_buttons.get(role) as Button + if button == null: + continue + var raw_binding: Variant = bindings.get(str(role), {}) + button.text = ( + _mapping_manager.binding_label(raw_binding as Dictionary) + if typeof(raw_binding) == TYPE_DICTIONARY else "unmapped" + ) + var conflict_role: StringName = _find_binding_conflict( + role, + raw_binding as Dictionary, + bindings, + ) + _apply_binding_button_style(button, not conflict_role.is_empty()) + button.tooltip_text = ( + "warning: also assigned to " + + _mapping_manager.get_role_label(conflict_role) + if not conflict_role.is_empty() + else "change " + _mapping_manager.get_role_label(role) + ) + _reset_button.disabled = not _mapping_manager.has_custom_mapping() + + +func _find_binding_conflict( + role: StringName, + binding: Dictionary, + bindings: Dictionary, +) -> StringName: + if binding.is_empty(): + return &"" + for other_role: StringName in KeyboardMouseMappingManagerType.ROLE_ORDER: + if other_role == role: + continue + var raw_other: Variant = bindings.get(str(other_role), {}) + if typeof(raw_other) != TYPE_DICTIONARY: + continue + if KeyboardMouseMappingManagerType.bindings_conflict( + binding, + raw_other as Dictionary, + ): + return other_role + return &"" + + +func _apply_binding_button_style(button: Button, has_conflict: bool) -> void: + UtilityPageStyleType.apply_compact_ocean_button(button) + if not has_conflict: + return + var colors: Dictionary = { + &"normal": UtilityPageStyleType.OCEAN_DANGER, + &"hover": UtilityPageStyleType.OCEAN_DANGER.lightened(0.12), + &"pressed": UtilityPageStyleType.OCEAN_DANGER.darkened(0.12), + &"focus": UtilityPageStyleType.OCEAN_DANGER.lightened(0.12), + } + for state: StringName in colors: + var style := UtilityPageStyleType.ocean_button_style( + colors[state] as Color + ) + style.content_margin_left = 10.0 + style.content_margin_right = 10.0 + style.content_margin_top = 4.0 + style.content_margin_bottom = 4.0 + button.add_theme_stylebox_override(state, style) diff --git a/ui/keyboard_mouse_mapping_panel.gd.uid b/ui/keyboard_mouse_mapping_panel.gd.uid new file mode 100644 index 0000000..01e2884 --- /dev/null +++ b/ui/keyboard_mouse_mapping_panel.gd.uid @@ -0,0 +1 @@ +uid://cxgp1c5qok7f6 diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index d27e450..ab183a8 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -24,6 +24,10 @@ const DETAIL_FADE_DURATION: float = 0.12 const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION const HANDWRITTEN_NUMERIC_SCALE: float = 0.8 const PORTRAIT_VIEW_MAX_SIZE := Vector2(860.0, 480.0) +const DETAIL_OVERLAY_EDGE_MARGIN: int = 22 +const DETAIL_OVERLAY_CONTENT_MARGIN: int = 34 +const DETAIL_OVERLAY_TITLE_FONT_SIZE: int = 40 +const DETAIL_OVERLAY_TEXT_FONT_SIZE: int = 36 const INK := Color("251b10") const MUTED_INK := Color("6d5b45") const LOGBOOK_ARTWORK: Texture2D = preload( @@ -50,6 +54,13 @@ const CATALOG_SNAP_DELAY: float = 0.12 const DETAIL_PORTRAIT_SIZE := Vector2(160.0, 88.0) const DETAIL_BOTTOM_INSET: float = 35.0 +enum ControllerZone { + TABS, + ENTRIES, + DETAILS, + OVERLAY, +} + var _collection_log: CollectionLogType var _inventory: FishInventoryType var _catalog: FishPoolType @@ -78,9 +89,14 @@ var _catalog_scroll_down_indicator: TextureRect var _empty_state: Label var _detail_body: VBoxContainer var _detail_portrait_button: Button +var _detail_buttons: Array[Button] = [] var _portrait_overlay: Control var _portrait_overlay_backdrop: Button var _portrait_overlay_artwork: LogbookPortraitType +var _portrait_overlay_title: Label +var _portrait_overlay_text: Label +var _overlay_return_focus: Control +var _controller_zone: ControllerZone = ControllerZone.TABS func _ready() -> void: @@ -136,7 +152,9 @@ func set_interactive(value: bool) -> void: ) for tab: Button in _category_tabs: tab.focus_mode = ( - Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE + Control.FOCUS_ALL + if _interactive and _controller_zone == ControllerZone.TABS + else Control.FOCUS_NONE ) tab.mouse_filter = ( Control.MOUSE_FILTER_STOP @@ -145,31 +163,109 @@ func set_interactive(value: bool) -> void: ) for entry: Button in _entry_buttons.values(): entry.focus_mode = ( - Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE + Control.FOCUS_ALL + if _interactive and _controller_zone == ControllerZone.ENTRIES + else Control.FOCUS_NONE ) entry.mouse_filter = ( Control.MOUSE_FILTER_STOP if _interactive else Control.MOUSE_FILTER_IGNORE ) + for detail_button: Button in _detail_buttons: + if not is_instance_valid(detail_button): + continue + detail_button.focus_mode = ( + Control.FOCUS_ALL + if _interactive and _controller_zone == ControllerZone.DETAILS + else Control.FOCUS_NONE + ) + if _portrait_overlay_backdrop != null: + _portrait_overlay_backdrop.focus_mode = ( + Control.FOCUS_ALL + if _interactive and _controller_zone == ControllerZone.OVERLAY + else Control.FOCUS_NONE + ) func focus_initial() -> void: if not _active or not _interactive: return - if _portrait_overlay != null and _portrait_overlay.visible: + if _controller_zone == ControllerZone.OVERLAY: _portrait_overlay_backdrop.grab_focus() return - var selected := _entry_buttons.get(_selected_id) as Button + if _controller_zone == ControllerZone.TABS: + var category_tab_index: int = _category_tab_categories.find(_category) + if category_tab_index >= 0: + _category_tabs[category_tab_index].grab_focus() + return + if _controller_zone == ControllerZone.DETAILS: + if not _detail_buttons.is_empty(): + _detail_buttons.front().grab_focus() + return + var selected := _entry_buttons.get(_selected_entry_key) as Button if selected != null: selected.grab_focus() elif not _entry_buttons.is_empty(): var first := _entry_buttons.values().front() as Button first.grab_focus() - else: - var category_tab_index: int = _category_tab_categories.find(_category) - if category_tab_index >= 0: - _category_tabs[category_tab_index].grab_focus() + + +func reset_controller_zone() -> void: + _controller_zone = ControllerZone.TABS + set_interactive(_interactive) + call_deferred("focus_initial") + + +func handle_controller_input(event: InputEvent) -> bool: + if not _active or not _interactive: + return false + var accept_pressed: bool = event.is_action_pressed("ui_accept") + var cancel_pressed: bool = event.is_action_pressed("ui_cancel") + if cancel_pressed: + match _controller_zone: + ControllerZone.OVERLAY: + _hide_portrait_overlay() + ControllerZone.DETAILS: + _set_controller_zone(ControllerZone.ENTRIES) + ControllerZone.ENTRIES: + _set_controller_zone(ControllerZone.TABS) + ControllerZone.TABS: + return false + return true + if _controller_zone == ControllerZone.TABS: + if event.is_action_pressed("ui_left"): + _select_adjacent_controller_category(-1) + return true + if event.is_action_pressed("ui_right"): + _select_adjacent_controller_category(1) + return true + if accept_pressed: + if not _entry_buttons.is_empty(): + _set_controller_zone(ControllerZone.ENTRIES) + return true + if _controller_zone == ControllerZone.ENTRIES and accept_pressed: + if not _selected_entry_key.is_empty() and not _detail_buttons.is_empty(): + _set_controller_zone(ControllerZone.DETAILS) + return true + return false + + +func _set_controller_zone(zone: ControllerZone) -> void: + _controller_zone = zone + set_interactive(_interactive) + call_deferred("focus_initial") + + +func _select_adjacent_controller_category(direction: int) -> void: + var current_index: int = _category_tab_categories.find(_category) + var target_index: int = clampi( + current_index + direction, + 0, + _category_tab_categories.size() - 1, + ) + if target_index != current_index: + _select_category(_category_tab_categories[target_index]) func _build_interface() -> void: @@ -715,6 +811,7 @@ func _build_known_details(fish: FishDataType) -> void: _detail_portrait_button.pressed.connect( _show_portrait_overlay.bind(fish.display_texture) ) + _detail_buttons.append(_detail_portrait_button) portrait_column.add_child(_detail_portrait_button) var artwork_center := CenterContainer.new() artwork_center.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -729,11 +826,20 @@ func _build_known_details(fish: FishDataType) -> void: ) artwork_center.add_child(artwork) + var facts_text: String = LogbookCatalog.facts_for(fish) + var facts_button := _make_detail_section_button( + "fish facts", + facts_text, + ) + facts_button.custom_minimum_size = Vector2(190.0, 132.0) + facts_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + facts_button.size_flags_stretch_ratio = 1.0 + summary_columns.add_child(facts_button) var facts_column := VBoxContainer.new() - facts_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL - facts_column.size_flags_stretch_ratio = 1.0 + facts_column.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + facts_column.mouse_filter = Control.MOUSE_FILTER_IGNORE facts_column.add_theme_constant_override("separation", 6) - summary_columns.add_child(facts_column) + facts_button.add_child(facts_column) var facts_heading := _field_label( "shellfish facts" if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH @@ -741,17 +847,37 @@ func _build_known_details(fish: FishDataType) -> void: 16, ) facts_column.add_child(facts_heading) - var facts := _label(LogbookCatalog.facts_for(fish), 16) + var facts := _label(facts_text, 16) facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL facts_column.add_child(facts) - _detail_body.add_child(_build_quality_progress(fish.id)) + var quality_button := _make_detail_section_button( + "quality collection", + _quality_overlay_text(fish.id), + ) + quality_button.custom_minimum_size.y = 132.0 + quality_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _detail_body.add_child(quality_button) + var quality_progress := _build_quality_progress(fish.id) + quality_progress.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + quality_progress.mouse_filter = Control.MOUSE_FILTER_IGNORE + quality_button.add_child(quality_progress) + var stats_button := _make_detail_section_button( + "fish stats", + _stats_overlay_text(fish, catalog_number), + ) + stats_button.custom_minimum_size.y = 190.0 + stats_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + stats_button.size_flags_vertical = Control.SIZE_EXPAND_FILL + _detail_body.add_child(stats_button) var stats_anchor := VBoxContainer.new() + stats_anchor.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + stats_anchor.mouse_filter = Control.MOUSE_FILTER_IGNORE stats_anchor.size_flags_horizontal = Control.SIZE_EXPAND_FILL stats_anchor.size_flags_vertical = Control.SIZE_EXPAND_FILL stats_anchor.alignment = BoxContainer.ALIGNMENT_END - _detail_body.add_child(stats_anchor) + stats_button.add_child(stats_anchor) var stats_columns := HBoxContainer.new() stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL stats_columns.add_theme_constant_override("separation", 28) @@ -806,6 +932,47 @@ func _build_known_details(fish: FishDataType) -> void: bottom_inset.custom_minimum_size.y = DETAIL_BOTTOM_INSET bottom_inset.mouse_filter = Control.MOUSE_FILTER_IGNORE stats_anchor.add_child(bottom_inset) + _configure_detail_focus( + _detail_portrait_button, + facts_button, + quality_button, + stats_button, + ) + set_interactive(_interactive) + + +func _configure_detail_focus( + portrait: Button, + facts: Button, + quality: Button, + stats: Button, +) -> void: + var top_left: Button = portrait if portrait != null else facts + if portrait != null: + portrait.focus_neighbor_left = portrait.get_path_to(portrait) + portrait.focus_neighbor_right = portrait.get_path_to(facts) + portrait.focus_neighbor_top = portrait.get_path_to(portrait) + portrait.focus_neighbor_bottom = portrait.get_path_to(quality) + portrait.focus_previous = portrait.get_path_to(stats) + portrait.focus_next = portrait.get_path_to(facts) + facts.focus_neighbor_left = facts.get_path_to(top_left) + facts.focus_neighbor_right = facts.get_path_to(facts) + facts.focus_neighbor_top = facts.get_path_to(facts) + facts.focus_neighbor_bottom = facts.get_path_to(quality) + facts.focus_previous = facts.get_path_to(top_left) + facts.focus_next = facts.get_path_to(quality) + quality.focus_neighbor_left = quality.get_path_to(quality) + quality.focus_neighbor_right = quality.get_path_to(quality) + quality.focus_neighbor_top = quality.get_path_to(facts) + quality.focus_neighbor_bottom = quality.get_path_to(stats) + quality.focus_previous = quality.get_path_to(facts) + quality.focus_next = quality.get_path_to(stats) + stats.focus_neighbor_left = stats.get_path_to(stats) + stats.focus_neighbor_right = stats.get_path_to(stats) + stats.focus_neighbor_top = stats.get_path_to(quality) + stats.focus_neighbor_bottom = stats.get_path_to(stats) + stats.focus_previous = stats.get_path_to(quality) + stats.focus_next = stats.get_path_to(top_left) func _build_quality_progress(fish_id: StringName) -> VBoxContainer: @@ -866,6 +1033,72 @@ func _build_quality_progress(fish_id: StringName) -> VBoxContainer: return quality_section +func _make_detail_section_button( + section_title: String, + section_text: String, +) -> Button: + var button := Button.new() + button.text = "" + button.tooltip_text = "View %s larger" % section_title + button.accessibility_name = "View %s larger" % section_title + button.add_theme_stylebox_override("normal", _portrait_button_style(false)) + button.add_theme_stylebox_override("hover", _portrait_button_style(true)) + button.add_theme_stylebox_override("focus", _portrait_button_style(true)) + button.add_theme_stylebox_override("pressed", _portrait_button_style(true)) + button.pressed.connect( + _show_detail_text_overlay.bind(section_title, section_text, button) + ) + _detail_buttons.append(button) + return button + + +func _quality_overlay_text(fish_id: StringName) -> String: + var lines: Array[String] = [] + for quality: int in FishQualityType.TIER_COUNT: + var collected: bool = _collection_log.has_discovered_quality( + fish_id, + quality, + ) + lines.append("%s %s" % [ + "collected" if collected else "not collected", + FishQualityType.display_name(quality), + ]) + return "\n".join(lines) + + +func _stats_overlay_text(fish: FishDataType, catalog_number: int) -> String: + var habitat_label: String = ( + "habitat" + if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH + else "body of water" + ) + return "\n".join([ + "catalog number: %s" % ( + "#%03d" % catalog_number if catalog_number > 0 else "unknown" + ), + "%s: %s" % [habitat_label, fish.get_habitat_label()], + "weight range: %.2f–%.2f lb" % [ + fish.get_minimum_weight(), + fish.get_maximum_weight(), + ], + "rarity: %s" % fish.get_rarity_name().to_lower(), + "time of day: %s" % _availability_text(fish), + "value range: %d–%d" % [ + FishQualityType.apply_sale_value( + fish.sell_value_min, + FishQualityType.Tier.BORING, + ), + FishQualityType.apply_sale_value( + fish.sell_value_max, + FishQualityType.Tier.SHINY, + ), + ], + "number owned: %d" % ( + _inventory.get_count(fish.id) if _inventory != null else 0 + ), + ]) + + func _show_no_selection() -> void: _clear_details() var instruction := _label("Select an entry to read its catch record.", 21) @@ -992,6 +1225,7 @@ func _clear_entries() -> void: func _clear_details() -> void: _detail_portrait_button = null + _detail_buttons.clear() for child: Node in _detail_body.get_children(): _detail_body.remove_child(child) child.queue_free() @@ -1110,19 +1344,51 @@ func _build_portrait_overlay() -> void: ) _portrait_overlay.add_child(_portrait_overlay_backdrop) - var center := CenterContainer.new() - center.mouse_filter = Control.MOUSE_FILTER_IGNORE - center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - _portrait_overlay.add_child(center) + var overlay_margin := MarginContainer.new() + overlay_margin.mouse_filter = Control.MOUSE_FILTER_IGNORE + overlay_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _set_margins( + overlay_margin, + DETAIL_OVERLAY_EDGE_MARGIN, + DETAIL_OVERLAY_EDGE_MARGIN, + DETAIL_OVERLAY_EDGE_MARGIN, + DETAIL_OVERLAY_EDGE_MARGIN, + ) + _portrait_overlay.add_child(overlay_margin) var card := PanelContainer.new() card.mouse_filter = Control.MOUSE_FILTER_STOP + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + card.size_flags_vertical = Control.SIZE_EXPAND_FILL card.add_theme_stylebox_override("panel", _portrait_view_style()) - center.add_child(card) + overlay_margin.add_child(card) var margin := MarginContainer.new() - _set_margins(margin, 22, 22, 22, 22) + _set_margins( + margin, + DETAIL_OVERLAY_CONTENT_MARGIN, + DETAIL_OVERLAY_CONTENT_MARGIN, + DETAIL_OVERLAY_CONTENT_MARGIN, + DETAIL_OVERLAY_CONTENT_MARGIN, + ) card.add_child(margin) + var overlay_stack := VBoxContainer.new() + overlay_stack.alignment = BoxContainer.ALIGNMENT_CENTER + overlay_stack.add_theme_constant_override("separation", 18) + margin.add_child(overlay_stack) + _portrait_overlay_title = _field_label( + "", DETAIL_OVERLAY_TITLE_FONT_SIZE + ) + _portrait_overlay_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + overlay_stack.add_child(_portrait_overlay_title) _portrait_overlay_artwork = LogbookPortraitType.new() - margin.add_child(_portrait_overlay_artwork) + _portrait_overlay_artwork.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + overlay_stack.add_child(_portrait_overlay_artwork) + _portrait_overlay_text = _label("", DETAIL_OVERLAY_TEXT_FONT_SIZE) + _portrait_overlay_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _portrait_overlay_text.size_flags_vertical = Control.SIZE_EXPAND_FILL + _portrait_overlay_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + _portrait_overlay_text.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _portrait_overlay_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + overlay_stack.add_child(_portrait_overlay_text) func _show_portrait_overlay(portrait_texture: Texture2D) -> void: @@ -1132,6 +1398,31 @@ func _show_portrait_overlay(portrait_texture: Texture2D) -> void: portrait_texture, PORTRAIT_VIEW_MAX_SIZE, ) + _portrait_overlay_title.text = "artwork" + _portrait_overlay_artwork.visible = true + _portrait_overlay_text.visible = false + _overlay_return_focus = _detail_portrait_button + _controller_zone = ControllerZone.OVERLAY + set_interactive(_interactive) + _portrait_overlay.visible = true + _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + _portrait_overlay_backdrop.grab_focus() + + +func _show_detail_text_overlay( + section_title: String, + section_text: String, + return_focus: Control, +) -> void: + if _portrait_overlay == null: + return + _portrait_overlay_title.text = section_title + _portrait_overlay_artwork.visible = false + _portrait_overlay_text.text = section_text + _portrait_overlay_text.visible = true + _overlay_return_focus = return_focus + _controller_zone = ControllerZone.OVERLAY + set_interactive(_interactive) _portrait_overlay.visible = true _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP _portrait_overlay_backdrop.grab_focus() @@ -1142,14 +1433,16 @@ func _hide_portrait_overlay(restore_focus: bool = true) -> void: return _portrait_overlay.visible = false _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + _controller_zone = ControllerZone.DETAILS + set_interactive(_interactive) if ( restore_focus - and _detail_portrait_button != null - and is_instance_valid(_detail_portrait_button) + and _overlay_return_focus != null + and is_instance_valid(_overlay_return_focus) and _active and _interactive ): - _detail_portrait_button.grab_focus() + _overlay_return_focus.grab_focus() func _on_portrait_overlay_backdrop_input(event: InputEvent) -> void: diff --git a/ui/mail_page.gd b/ui/mail_page.gd index f34c300..d2a063b 100644 --- a/ui/mail_page.gd +++ b/ui/mail_page.gd @@ -31,6 +31,8 @@ var _compose: Control var _letter: Control var _inbox_list: VBoxContainer var _empty_label: Label +var _send_mail_button: Button +var _archive_view_button: Button var _recipient: OptionButton var _greeting: OptionButton var _body: TextEdit @@ -45,16 +47,20 @@ var _attachment_amount_currency_icon: TextureRect var _amount_minus: Button var _amount_plus: Button var _attachment_summary: RichTextLabel +var _compose_cancel: Button var _send_button: Button var _status: Label var _letter_text: Label var _letter_gift: RichTextLabel +var _letter_close: Button var _accept: Button var _decline: Button var _archive: Button var _delete: Button var _current_mail_id := "" var _showing_archive := false +var _active: bool = false +var _interactive: bool = false func _ready() -> void: @@ -85,13 +91,16 @@ func setup( func activate() -> void: + _active = true _show_inbox() - call_deferred("_focus_first") + call_deferred("_refresh_controller_navigation") func deactivate() -> void: + _active = false _current_mail_id = "" _show_inbox() + set_interactive(false) func consume_escape() -> bool: @@ -106,12 +115,263 @@ func is_composing_letter() -> bool: func set_interactive(value: bool) -> void: - mouse_filter = Control.MOUSE_FILTER_PASS if value else Control.MOUSE_FILTER_IGNORE - for node: Node in find_children("*", "BaseButton", true, false): - (node as BaseButton).focus_mode = ( - Control.FOCUS_ALL if value and node.is_visible_in_tree() + _interactive = value and _active + mouse_filter = ( + Control.MOUSE_FILTER_PASS + if _interactive else Control.MOUSE_FILTER_IGNORE + ) + _refresh_controller_navigation() + + +func reset_controller_zone() -> void: + _show_inbox() + call_deferred("_refresh_controller_navigation") + + +func handle_controller_input(event: InputEvent) -> bool: + if not _active or not _interactive: + return false + if event.is_action_pressed("ui_cancel"): + return consume_escape() + return false + + +func _refresh_controller_navigation() -> void: + var controls: Array[Control] = [] + _collect_visible_controller_controls(self, controls) + for control: Control in controls: + var button := control as BaseButton + control.focus_mode = ( + Control.FOCUS_ALL + if _interactive and (button == null or not button.disabled) else Control.FOCUS_NONE ) + ControllerFocusNavigation.configure_spatial_neighbors(controls) + if _compose != null and _compose.visible: + _configure_compose_controller_navigation() + elif _inbox != null and _inbox.visible: + _configure_inbox_controller_navigation() + elif _letter != null and _letter.visible: + _configure_letter_controller_navigation() + if not _interactive or controls.is_empty(): + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null or not is_ancestor_of(focus_owner): + controls.sort_custom(func(first: Control, second: Control) -> bool: + if not is_equal_approx(first.global_position.y, second.global_position.y): + return first.global_position.y < second.global_position.y + return first.global_position.x < second.global_position.x + ) + controls.front().grab_focus() + + +func _configure_inbox_controller_navigation() -> void: + if not _interactive: + return + var entries: Array[Control] = [] + for child: Node in _inbox_list.get_children(): + var button := child as Button + if button != null and _controller_focus_eligible(button): + entries.append(button) + var first_entry: Control = ( + entries.front() if not entries.is_empty() else _archive_view_button + ) + _set_compose_neighbors( + _archive_view_button, + _archive_view_button, + _send_mail_button, + _archive_view_button, + first_entry, + ) + _set_compose_neighbors( + _send_mail_button, + _archive_view_button, + _send_mail_button, + _send_mail_button, + entries.front() if not entries.is_empty() else _send_mail_button, + ) + for index: int in entries.size(): + var entry: Control = entries[index] + _set_compose_neighbors( + entry, + entry, + entry, + entries[index - 1] if index > 0 else _archive_view_button, + entries[index + 1] if index < entries.size() - 1 else entry, + ) + var traversal: Array[Control] = [ + _archive_view_button, + _send_mail_button, + ] + traversal.append_array(entries) + ControllerFocusNavigation.configure_traversal(traversal) + + +func _configure_letter_controller_navigation() -> void: + if not _interactive: + return + var pending_gift: bool = ( + _accept != null + and _accept.is_visible_in_tree() + and _decline != null + and _decline.is_visible_in_tree() + ) + if pending_gift: + _set_compose_neighbors( + _accept, _accept, _accept, _accept, _decline + ) + _set_compose_neighbors( + _decline, _decline, _decline, _accept, _delete + ) + _set_compose_neighbors( + _letter_close, + _letter_close, + _archive, + _letter_close, + _letter_close, + ) + _set_compose_neighbors( + _archive, + _letter_close, + _delete, + _archive, + _archive, + ) + _set_compose_neighbors( + _delete, + _archive, + _delete, + _decline if pending_gift else _delete, + _delete, + ) + var traversal: Array[Control] = [_letter_close, _archive, _delete] + if pending_gift: + traversal = [_accept, _decline, _letter_close, _archive, _delete] + ControllerFocusNavigation.configure_traversal(traversal) + + +func _configure_compose_controller_navigation() -> void: + if not _interactive: + return + var amount_visible: bool = ( + _attachment_amount != null + and _attachment_amount.is_visible_in_tree() + ) + var amount_left: Control = _amount_minus if amount_visible else _compose_cancel + var amount_middle: Control = ( + _attachment_amount if amount_visible else _compose_cancel + ) + var amount_right: Control = _amount_plus if amount_visible else _send_button + _set_compose_neighbors( + _greeting, _greeting, _recipient, _greeting, _body + ) + _set_compose_neighbors( + _recipient, _greeting, _attachment_kind, _recipient, _body + ) + _set_compose_neighbors( + _body, _body, _attachment_choice, _recipient, _salutation + ) + _set_compose_neighbors( + _salutation, _salutation, _compose_cancel, _body, _salutation + ) + _set_compose_neighbors( + _attachment_kind, + _recipient, + _attachment_kind, + _attachment_kind, + _attachment_choice, + ) + _set_compose_neighbors( + _attachment_choice, + _body, + _attachment_choice, + _attachment_kind, + amount_middle, + ) + if amount_visible: + _set_compose_neighbors( + _amount_minus, + _amount_minus, + _attachment_amount, + _attachment_choice, + _compose_cancel, + ) + _set_compose_neighbors( + _attachment_amount, + _amount_minus, + _amount_plus, + _attachment_choice, + _compose_cancel, + ) + _set_compose_neighbors( + _amount_plus, + _attachment_amount, + _amount_plus, + _attachment_choice, + _send_button, + ) + _set_compose_neighbors( + _compose_cancel, + _salutation, + _send_button, + amount_left, + _compose_cancel, + ) + _set_compose_neighbors( + _send_button, + _compose_cancel, + _send_button, + amount_right, + _send_button, + ) + + +func _set_compose_neighbors( + control: Control, + left: Control, + right: Control, + top: Control, + bottom: Control, +) -> void: + if control == null or not control.is_visible_in_tree(): + return + var safe_left: Control = left if _controller_focus_eligible(left) else control + var safe_right: Control = right if _controller_focus_eligible(right) else control + var safe_top: Control = top if _controller_focus_eligible(top) else control + var safe_bottom: Control = ( + bottom if _controller_focus_eligible(bottom) else control + ) + control.focus_neighbor_left = control.get_path_to(safe_left) + control.focus_neighbor_right = control.get_path_to(safe_right) + control.focus_neighbor_top = control.get_path_to(safe_top) + control.focus_neighbor_bottom = control.get_path_to(safe_bottom) + + +func _controller_focus_eligible(control: Control) -> bool: + if ( + control == null + or not control.is_visible_in_tree() + or control.focus_mode == Control.FOCUS_NONE + ): + return false + var button := control as BaseButton + return button == null or not button.disabled + + +func _collect_visible_controller_controls( + root: Node, + output: Array[Control], +) -> void: + for child: Node in root.get_children(): + var control := child as Control + if control != null and not control.is_visible_in_tree(): + continue + if ( + control != null + and (control is BaseButton or control is LineEdit or control is TextEdit) + ): + output.append(control) + _collect_visible_controller_controls(child, output) func _build_ui() -> void: @@ -144,22 +404,22 @@ func _build_inbox() -> Control: title.size = Vector2(500, 42) title.add_theme_font_size_override("font_size", 30) page.add_child(title) - var send := Button.new() - send.text = "send mail" - send.position = Vector2(820, 0) - send.size = Vector2(150, 48) - send.pressed.connect(_show_compose) - page.add_child(send) - var archive_view := Button.new() - archive_view.text = "archive" - archive_view.position = Vector2(654, 0) - archive_view.size = Vector2(154, 48) - archive_view.pressed.connect(func() -> void: + _send_mail_button = Button.new() + _send_mail_button.text = "send mail" + _send_mail_button.position = Vector2(820, 0) + _send_mail_button.size = Vector2(150, 48) + _send_mail_button.pressed.connect(_show_compose) + page.add_child(_send_mail_button) + _archive_view_button = Button.new() + _archive_view_button.text = "archive" + _archive_view_button.position = Vector2(654, 0) + _archive_view_button.size = Vector2(154, 48) + _archive_view_button.pressed.connect(func() -> void: _showing_archive = not _showing_archive - archive_view.text = "inbox" if _showing_archive else "archive" + _archive_view_button.text = "inbox" if _showing_archive else "archive" _refresh_inbox() ) - page.add_child(archive_view) + page.add_child(_archive_view_button) var scroll := ScrollContainer.new() scroll.position = Vector2(12, 62) scroll.size = Vector2(1036, 334) @@ -281,12 +541,12 @@ func _build_compose() -> Control: _attachment_summary.scroll_active = false _attachment_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART page.add_child(_attachment_summary) - var cancel := Button.new() - cancel.text = "cancel" - cancel.position = Vector2(688, 342) - cancel.size = Vector2(126, 48) - cancel.pressed.connect(_show_inbox) - page.add_child(cancel) + _compose_cancel = Button.new() + _compose_cancel.text = "cancel" + _compose_cancel.position = Vector2(688, 342) + _compose_cancel.size = Vector2(126, 48) + _compose_cancel.pressed.connect(_show_inbox) + page.add_child(_compose_cancel) _send_button = Button.new() _send_button.text = "send letter" _send_button.position = Vector2(826, 342) @@ -333,12 +593,12 @@ func _build_letter() -> Control: _service.decline_gift(_current_mail_id) ) page.add_child(_decline) - var close := Button.new() - close.text = "close" - close.position = Vector2(26, 370) - close.size = Vector2(150, 48) - close.pressed.connect(_show_inbox) - page.add_child(close) + _letter_close = Button.new() + _letter_close.text = "close" + _letter_close.position = Vector2(26, 370) + _letter_close.size = Vector2(150, 48) + _letter_close.pressed.connect(_show_inbox) + page.add_child(_letter_close) _archive = Button.new() _archive.text = "archive" _archive.position = Vector2(550, 370) @@ -360,6 +620,7 @@ func _show_inbox() -> void: _letter.hide() _current_mail_id = "" _refresh_inbox() + call_deferred("_refresh_controller_navigation") func _show_compose() -> void: @@ -370,7 +631,7 @@ func _show_compose() -> void: _signature.text = _service.get_local_display_name() _refresh_recipients() _update_send_state() - _greeting.grab_focus() + call_deferred("_refresh_controller_navigation") func _reset_compose() -> void: @@ -427,7 +688,8 @@ func _open_letter(mail_id: String) -> void: "Resolve this gift before deleting the letter." if pending else "" ) if pending: - _accept.grab_focus() + _accept.call_deferred("grab_focus") + call_deferred("_refresh_controller_navigation") func _refresh_inbox() -> void: @@ -444,6 +706,7 @@ func _refresh_inbox() -> void: empty.custom_minimum_size = Vector2(1008, 80) empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _inbox_list.add_child(empty) + call_deferred("_refresh_controller_navigation") return for letter: Dictionary in letters: var button := Button.new() @@ -473,6 +736,7 @@ func _refresh_inbox() -> void: button.pressed.connect(_open_letter.bind(str(letter["mail_id"]))) UtilityPageStyle.apply_ocean_button(button) _inbox_list.add_child(button) + call_deferred("_refresh_controller_navigation") func _refresh_recipients() -> void: @@ -556,6 +820,7 @@ func _refresh_attachment_choices(_index: int) -> void: ) _update_attachment_amount_limit() _update_attachment_summary() + call_deferred("_refresh_controller_navigation") func _update_attachment_amount_limit() -> void: @@ -616,6 +881,7 @@ func _update_send_state() -> void: or not _reservations.validate_attachment(attachment) or not _attachment_is_available(attachment) ) + call_deferred("_refresh_controller_navigation") func _attachment_is_available(attachment: Dictionary) -> bool: diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index d985ebd..7cb9e4c 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -13,6 +13,9 @@ enum Mode { } const DISCOVERY_REFRESH_SECONDS: float = 8.0 +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) const ADDRESS_FORMAT_HELP: String = ( "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; " @@ -189,6 +192,8 @@ func open_page(preserved_endpoint: String = "") -> void: if _mode == Mode.DIRECT: _address.grab_focus() _address.select_all() + else: + _server_list.grab_focus() func close_page() -> void: @@ -196,6 +201,10 @@ func close_page() -> void: hide_for_join_confirmation() +func request_back() -> void: + _on_back_pressed() + + func hide_for_join_confirmation() -> void: _clear_edit_state() _discovery_refresh_timer.stop() @@ -649,6 +658,136 @@ func _refresh() -> void: ) if not warning.is_empty() and _status.text.is_empty(): _status.text = warning + _configure_controller_navigation() + + +func _configure_controller_navigation() -> void: + var mode_buttons: Array[Control] = [ + _discover_button, + _direct_button, + _saved_button, + _recent_button, + ] + var content_controls: Array[Control] = [] + 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 [ + _refresh_button, + _join_button, + _save_button, + _edit_button, + _favorite_button, + _delete_button, + _cancel_button, + _back_button, + ]: + if _controller_focus_eligible(control): + action_controls.append(control) + var all_controls: Array[Control] = [] + all_controls.append_array(mode_buttons) + all_controls.append_array(content_controls) + all_controls.append_array(action_controls) + for control: Control in all_controls: + control.focus_mode = Control.FOCUS_ALL + for control: Control in [ + _address, + _name_edit, + _server_list, + _refresh_button, + _join_button, + _save_button, + _edit_button, + _favorite_button, + _delete_button, + _cancel_button, + _back_button, + ]: + if control not in all_controls: + control.focus_mode = Control.FOCUS_NONE + var primary_content: Control = ( + content_controls.front() + if not content_controls.is_empty() + else action_controls.front() + if not action_controls.is_empty() + else _back_button + ) + for index: int in mode_buttons.size(): + var mode_button: Control = mode_buttons[index] + _set_controller_neighbors( + mode_button, + mode_buttons[maxi(index - 1, 0)], + mode_buttons[mini(index + 1, mode_buttons.size() - 1)], + mode_button, + primary_content, + ) + for index: int in content_controls.size(): + var content: Control = content_controls[index] + var above: Control = ( + content_controls[index - 1] + if index > 0 + else mode_buttons[int(_mode)] + ) + var below: Control = ( + content_controls[index + 1] + if index < content_controls.size() - 1 + else action_controls.front() + if not action_controls.is_empty() + else content + ) + _set_controller_neighbors(content, content, content, above, below) + for index: int in action_controls.size(): + var action: Control = action_controls[index] + _set_controller_neighbors( + action, + action_controls[maxi(index - 1, 0)], + action_controls[mini(index + 1, action_controls.size() - 1)], + content_controls.back() + if not content_controls.is_empty() + else mode_buttons[int(_mode)], + action, + ) + ControllerFocusNavigationType.configure_traversal(all_controls) + _recover_controller_focus(all_controls, primary_content) + + +func _controller_focus_eligible(control: Control) -> bool: + if control == null or not control.is_visible_in_tree(): + return false + var button := control as BaseButton + if button != null and button.disabled: + return false + var line_edit := control as LineEdit + return line_edit == null or line_edit.editable + + +func _set_controller_neighbors( + control: Control, + left: Control, + right: Control, + top: Control, + bottom: Control, +) -> void: + control.focus_neighbor_left = control.get_path_to(left) + control.focus_neighbor_right = control.get_path_to(right) + control.focus_neighbor_top = control.get_path_to(top) + control.focus_neighbor_bottom = control.get_path_to(bottom) + + +func _recover_controller_focus( + controls: Array[Control], + fallback: Control, +) -> void: + if not is_visible_in_tree(): + return + var owner: Control = get_viewport().gui_get_focus_owner() + if owner != null and owner in controls: + return + if owner != null and _delete_confirmation.is_ancestor_of(owner): + return + if fallback != null: + fallback.call_deferred("grab_focus") func _format_entry_details(entry: SavedServerEntry) -> String: @@ -832,9 +971,14 @@ func _on_cancel_pressed() -> void: func _on_back_pressed() -> void: - if _name_entry_active or _delete_armed: + if _delete_armed or _delete_confirmation.visible: + _cancel_delete() + return + if _name_entry_active: _clear_edit_state() _refresh() + var content: Control = _address if _mode == Mode.DIRECT else _server_list + content.call_deferred("grab_focus") return if ( _network_session != null diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index 467943d..64a2fe8 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -9,6 +9,7 @@ const CHECK_ICON: Texture2D = preload( ) const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0) const CHARACTER_FONT_SIZE: int = 34 +const CANONICAL_WINDOW_SIZE: Vector2 = Vector2(1280.0, 720.0) const TRIGGER_PRESS_THRESHOLD: float = 0.55 const TRIGGER_RELEASE_THRESHOLD: float = 0.25 @@ -36,6 +37,11 @@ var _space_button: Button var _check_button: Button var _left_trigger_pressed: bool = false var _right_trigger_pressed: bool = false +var _portable_host_parent: Node +var _portable_host_index: int = -1 +var _portable_target_window: Window +var _portable_target_window_size: Vector2i +var _portable_target_window_position: Vector2i func _ready() -> void: @@ -63,12 +69,17 @@ func is_open() -> bool: func request_for_focused_control() -> bool: - if not _is_available_for_controller() or visible: + return request_for_control(get_viewport().gui_get_focus_owner()) + + +func request_for_control(control: Control = null) -> bool: + if ( + not _is_available_for_controller() + or visible + or not _can_edit(control) + ): return false - var focus_owner: Control = get_viewport().gui_get_focus_owner() - if not _can_edit(focus_owner): - return false - _open_for(focus_owner) + _open_for(control) return true @@ -145,6 +156,7 @@ func _open_for(control: Control) -> void: _target.set("virtual_keyboard_enabled", false) _buffer = str(_target.get("text")) _page = Page.LOWER + _attach_to_target_window(control) show() _refresh_preview() _rebuild_keys() @@ -159,11 +171,62 @@ func _close_keyboard(restore_focus: bool) -> void: ) hide() get_viewport().gui_release_focus() + _restore_portable_host() _target = null if restore_focus and is_instance_valid(prior_target): prior_target.grab_focus() +func _attach_to_target_window(control: Control) -> void: + var target_window: Window = control.get_window() + if target_window == null or target_window == get_window(): + return + _portable_host_parent = get_parent() + _portable_host_index = get_index() + _portable_target_window = target_window + _portable_target_window_size = target_window.size + _portable_target_window_position = target_window.position + var parent_window := target_window.get_parent() as Window + if parent_window != null: + target_window.size = parent_window.size + target_window.position = parent_window.position + reparent(target_window, false) + anchor_left = 0.0 + anchor_top = 0.0 + anchor_right = 0.0 + anchor_bottom = 0.0 + size = CANONICAL_WINDOW_SIZE + var available: Vector2 = Vector2(target_window.size) + var fit_scale: float = minf( + available.x / CANONICAL_WINDOW_SIZE.x, + available.y / CANONICAL_WINDOW_SIZE.y, + ) + scale = Vector2.ONE * maxf(fit_scale, 0.01) + position = (available - CANONICAL_WINDOW_SIZE * fit_scale) * 0.5 + + +func _restore_portable_host() -> void: + if _portable_host_parent == null: + return + var original_parent: Node = _portable_host_parent + var original_index: int = _portable_host_index + var target_window: Window = _portable_target_window + _portable_host_parent = null + _portable_host_index = -1 + _portable_target_window = null + reparent(original_parent, false) + if original_index >= 0: + original_parent.move_child( + self, + mini(original_index, original_parent.get_child_count() - 1), + ) + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + scale = Vector2.ONE + if target_window != null and is_instance_valid(target_window): + target_window.size = _portable_target_window_size + target_window.position = _portable_target_window_position + + func _submit() -> void: var submitted_target: Control = _target var submitted_text: String = _buffer diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index 8e33a30..6326ae5 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -183,7 +183,7 @@ func handle_escape() -> bool: if _confirmation_page.visible: _close_confirmation() elif _join_game_page.visible: - _close_join_game() + _join_game_page.request_back() elif _settings_panel.visible: _settings_panel.handle_back() else: diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 62cf3b9..5ddfde3 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -36,6 +36,9 @@ const PlayerExperienceType = preload( const ControllerMappingManagerType = preload( "res://settings/controller_mapping_manager.gd" ) +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) const FishBatchSelectionType = preload( "res://ui/fish_batch_selection.gd" ) @@ -131,10 +134,13 @@ enum CloseReason { } enum ControllerOwnership { + INVENTORY_TABS, ITEM_LIST, NOTEPAD_ACTIONS, + SORT_FILTER, HOTBAR_MANAGEMENT, HOTBAR_PLACEMENT, + PAGE_CONTENT, } const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0) @@ -149,6 +155,7 @@ const INVENTORY_MAIN_CORNER_RADIUS: int = 58 const INVENTORY_INNER_CORNER_RADIUS: int = 45 const TACKLE_GRID_COLUMNS: int = 3 const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0) +const CONTROLLER_PICKUP_HOLD_SECONDS: float = 0.42 @onready var _navigation_cluster: BubbleClusterType = %NavigationCluster @onready var _presentation_scale_root: Control = %PlayerMenuPresentationScaleRoot @@ -308,7 +315,9 @@ var _last_inventory_section: Section = Section.COOLER var _bag_view: BagView = BagView.EQUIPMENT var _selected_tackle_item_id: StringName var _tackle_item_buttons: Dictionary[StringName, Button] = {} -var _controller_ownership: ControllerOwnership = ControllerOwnership.ITEM_LIST +var _controller_ownership: ControllerOwnership = ( + ControllerOwnership.INVENTORY_TABS +) var _controller_source_section: Section = Section.COOLER var _controller_source_identity: StringName var _controller_notepad_actions: Array[BaseButton] = [] @@ -317,6 +326,8 @@ var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = ( ) var _controller_hotbar_identity: StringName var _controller_previous_hotbar_slot: int = 0 +var _controller_accept_held: bool = false +var _controller_accept_hold_elapsed: float = 0.0 var _sort_mode: SortMode = SortMode.CATCH_ORDER var _sort_descending: bool = true var _fish_selection := FishBatchSelectionType.new() @@ -352,8 +363,10 @@ var _page_incoming_root: Control var _page_outgoing_content_root: Control var _inventory_transition_group: Control var _fish_nodes: Dictionary[StringName, CoolerFishSpriteType] = {} +var _cooler_slot_nodes: Array[Panel] = [] var _sorted_catches: Array[FishCatchType] = [] var _bag_item_nodes: Dictionary[StringName, BagItemSpriteType] = {} +var _bag_slot_nodes: Array[Panel] = [] var _sorted_bag_items: Array[OwnedItemType] = [] var _bag_drag_active: bool = false var _motion_elapsed: float = 0.0 @@ -594,16 +607,13 @@ func setup_controller_mapping( ) -> void: _controller_mapping_manager = mapping_manager _profile_page.setup_controller_mapping(_controller_mapping_manager) + _the_net_page.setup_controller_mapping(_controller_mapping_manager) func set_profile_preview_world_pixel_size(pixel_size: int) -> void: _profile_page.set_world_pixel_size(pixel_size) -var _left_page_trigger_held: bool = false -var _right_page_trigger_held: bool = false - - func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return @@ -616,9 +626,6 @@ func _input(event: InputEvent) -> void: if _handle_controller_page_switch(event): get_viewport().set_input_as_handled() return - if _handle_controller_secondary_switch(event): - get_viewport().set_input_as_handled() - return if _handle_direct_page_shortcut(event): get_viewport().set_input_as_handled() return @@ -640,16 +647,21 @@ func _input(event: InputEvent) -> void: func _handle_controller_ownership_input(event: InputEvent) -> bool: if not visible: return false + if not _is_inventory_section(_current_section): + return _handle_active_page_controller_input(event) var button_event := event as InputEventJoypadButton - var accept_pressed: bool = ( + var accept_event: bool = ( button_event != null - and button_event.pressed and _event_matches_controller_role( event, ControllerMappingManagerType.ROLE_A, JOY_BUTTON_A, ) ) + var accept_pressed: bool = ( + accept_event + and button_event.pressed + ) var cancel_pressed: bool = ( button_event != null and button_event.pressed @@ -659,23 +671,24 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: JOY_BUTTON_B, ) ) - var alternate_pressed: bool = ( - button_event != null - and button_event.pressed - and _event_matches_controller_role( - event, - ControllerMappingManagerType.ROLE_Y, - JOY_BUTTON_Y, - ) - ) + if _controller_ownership == ControllerOwnership.INVENTORY_TABS: + if cancel_pressed: + close_menu() + return true + if accept_pressed: + _enter_inventory_content_zone() + return true + if event.is_action_pressed("ui_left"): + _switch_inventory_tab_direction(-1) + return true + if event.is_action_pressed("ui_right"): + _switch_inventory_tab_direction(1) + return true + return false if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT: if cancel_pressed: _release_controller_ownership(true, false) return true - if alternate_pressed: - if _hotbar != null: - _hotbar.clear_slot(_hotbar.get_selected_slot()) - return true if accept_pressed: return true return false @@ -686,34 +699,90 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: if cancel_pressed: _release_controller_ownership(true, true) return true - if alternate_pressed: - return true return false if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS: if cancel_pressed: _release_controller_ownership(true, false) return true - if alternate_pressed: - return true - var direction: int = 0 - if event.is_action_pressed("ui_left") or event.is_action_pressed("ui_up"): - direction = -1 - elif ( - event.is_action_pressed("ui_right") - or event.is_action_pressed("ui_down") - ): - direction = 1 - if direction != 0: - _focus_next_notepad_action(direction) + return false + if _controller_ownership == ControllerOwnership.SORT_FILTER: + if cancel_pressed: + _enter_inventory_content_zone() return true return false - if alternate_pressed: - return _try_begin_controller_hotbar_placement() - if accept_pressed: - return _try_enter_notepad_controller_ownership() + if _controller_ownership == ControllerOwnership.ITEM_LIST: + if cancel_pressed: + _cancel_controller_accept_hold() + _enter_inventory_tabs_zone() + return true + if _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_SELECT, + JOY_BUTTON_BACK, + ): + if button_event != null and button_event.pressed: + if _current_section == Section.COOLER: + _enter_inventory_sort_zone() + return true + if accept_event: + if button_event.pressed: + _controller_accept_held = true + _controller_accept_hold_elapsed = 0.0 + else: + var was_pending: bool = _controller_accept_held + _cancel_controller_accept_hold() + if was_pending: + _activate_inventory_selection() + return true return false +func _handle_active_page_controller_input(event: InputEvent) -> bool: + var handled: bool = false + match _current_section: + Section.LOGBOOK: + handled = _catalog_logbook.handle_controller_input(event) + Section.NET: + handled = _the_net_page.handle_controller_input(event) + Section.MAIL: + handled = _mail_page.handle_controller_input(event) + Section.PROFILE: + handled = _profile_page.handle_controller_input(event) + Section.PLAYERS: + handled = _players_page.handle_controller_input(event) + if handled: + return true + var button_event := event as InputEventJoypadButton + if ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_B, + JOY_BUTTON_B, + ) + ): + close_menu() + return true + return false + + +func _activate_inventory_selection() -> void: + if _try_enter_notepad_controller_ownership(): + return + if _current_section != Section.BAG: + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + var item_node := focus_owner as BagItemSpriteType + if item_node != null and not item_node.item_id.is_empty(): + _select_bag_item(item_node.item_id) + + +func _cancel_controller_accept_hold() -> void: + _controller_accept_held = false + _controller_accept_hold_elapsed = 0.0 + + func _event_matches_controller_role( event: InputEvent, role: StringName, @@ -761,35 +830,11 @@ func _try_enter_notepad_controller_ownership() -> bool: _controller_source_identity = source_identity _controller_notepad_actions = actions _controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS + ControllerFocusNavigationType.configure_spatial_neighbors(actions) actions.front().call_deferred("grab_focus") return true -func _focus_next_notepad_action(direction: int) -> void: - var available: Array[BaseButton] = [] - for action: BaseButton in _controller_notepad_actions: - if ( - is_instance_valid(action) - and action.visible - and not action.disabled - and action.focus_mode != Control.FOCUS_NONE - ): - available.append(action) - if available.is_empty(): - return - var focused: Control = get_viewport().gui_get_focus_owner() - var current_index: int = available.find(focused) - if current_index < 0: - current_index = 0 if direction > 0 else available.size() - 1 - else: - current_index = wrapi( - current_index + direction, - 0, - available.size(), - ) - available[current_index].grab_focus() - - func _try_begin_controller_hotbar_placement() -> bool: if _hotbar == null: return false @@ -946,88 +991,6 @@ func _restore_controller_item_focus( func _handle_controller_page_switch(event: InputEvent) -> bool: - var button_event: InputEventJoypadButton = event as InputEventJoypadButton - var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion - var use_mapping: bool = ( - _controller_mapping_manager != null - ) - var uses_left_trigger: bool = ( - _controller_mapping_manager.event_uses_role( - event, ControllerMappingManagerType.ROLE_POINTER_MODIFIER - ) - if use_mapping - else ( - motion_event != null - and motion_event.axis == JOY_AXIS_TRIGGER_LEFT - ) - ) - var uses_right_trigger: bool = ( - _controller_mapping_manager.event_uses_role( - event, ControllerMappingManagerType.ROLE_CAMERA_ZOOM - ) - if use_mapping - else ( - motion_event != null - and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT - ) - ) - if ( - not (uses_left_trigger or uses_right_trigger) - or not visible - ): - return false - var is_pressed: bool = ( - button_event.pressed - if button_event != null - else motion_event.axis_value > 0.5 - ) - if not is_pressed: - if uses_left_trigger: - _left_page_trigger_held = false - if uses_right_trigger: - _right_page_trigger_held = false - return true - if ( - (uses_left_trigger and _left_page_trigger_held) - or (uses_right_trigger and _right_page_trigger_held) - ): - return true - if uses_left_trigger: - _left_page_trigger_held = true - if uses_right_trigger: - _right_page_trigger_held = true - # Trigger input belongs to the Player Menu while it is visible, even when a - # transition or modal temporarily prevents changing pages. - if ( - _transitioning - or _page_transitioning - or _sale_confirmation.visible - or get_viewport().gui_is_dragging() - or _controller_ownership != ControllerOwnership.ITEM_LIST - ): - return true - var sections: Array[Section] = [ - _last_inventory_section, - Section.LOGBOOK, - Section.NET, - Section.MAIL, - Section.PROFILE, - Section.PLAYERS, - ] - var current_index: int = 0 - if not _is_inventory_section(_current_section): - current_index = sections.find(_current_section) - if current_index < 0: - current_index = 0 - var direction: int = ( - -1 if uses_left_trigger else 1 - ) - var next_index: int = wrapi(current_index + direction, 0, sections.size()) - _show_section(sections[next_index]) - return true - - -func _handle_controller_secondary_switch(event: InputEvent) -> bool: var button_event := event as InputEventJoypadButton if button_event == null or not visible: return false @@ -1050,59 +1013,40 @@ func _handle_controller_secondary_switch(event: InputEvent) -> bool: return false if not button_event.pressed: return true - var direction: int = -1 if uses_left_bumper else 1 - if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT: - _cycle_from_controller_hotbar_management(direction) - return true if ( _transitioning or _page_transitioning or _sale_confirmation.visible or get_viewport().gui_is_dragging() - or _controller_ownership != ControllerOwnership.ITEM_LIST ): return true - if _is_inventory_section(_current_section): - var inventory_index: int = _get_inventory_tab_index() - if ( - (direction > 0 and inventory_index == 3) - or (direction < 0 and inventory_index == 0) - ): - _begin_controller_hotbar_management() - return true - _show_inventory_tab(inventory_index + direction) + var sections: Array[Section] = [ + _last_inventory_section, + Section.LOGBOOK, + Section.NET, + Section.MAIL, + Section.PROFILE, + Section.PLAYERS, + ] + var current_index: int = 0 + if not _is_inventory_section(_current_section): + current_index = sections.find(_current_section) + if current_index < 0: + current_index = 0 + var direction: int = -1 if uses_left_bumper else 1 + var next_index: int = clampi( + current_index + direction, + 0, + sections.size() - 1, + ) + if next_index == current_index: return true - _cycle_visible_secondary_tabs(direction) + _cancel_controller_accept_hold() + _release_controller_ownership(false, true) + _show_section(sections[next_index]) return true -func _begin_controller_hotbar_management() -> void: - if _hotbar == null: - return - _controller_source_section = _current_section - _controller_source_identity = StringName() - var focus_owner: Control = get_viewport().gui_get_focus_owner() - if _current_section == Section.COOLER: - var fish_node := focus_owner as CoolerFishSpriteType - if fish_node != null: - _controller_source_identity = fish_node.catch_id - elif _current_section == Section.BAG: - var item_node := focus_owner as BagItemSpriteType - if item_node != null: - _controller_source_identity = item_node.item_id - _controller_ownership = ControllerOwnership.HOTBAR_MANAGEMENT - controller_hotbar_management_requested.emit(_hotbar.get_selected_slot()) - - -func _cycle_from_controller_hotbar_management(direction: int) -> void: - _release_controller_ownership(false, false) - var target_index: int = 0 if direction > 0 else 3 - if target_index == _get_inventory_tab_index(): - call_deferred("_focus_current_section") - else: - _show_inventory_tab(target_index) - - func _get_inventory_tab_index() -> int: if _current_section == Section.COOLER: return 0 @@ -1123,41 +1067,69 @@ func _show_inventory_tab(index: int) -> void: _show_section(Section.TACKLE_BOX) -func _cycle_visible_secondary_tabs(direction: int) -> bool: - var navigation_cluster := get_node_or_null("%NavigationCluster") as Control - var grouped_buttons: Dictionary = {} - _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons) - var selected_group: Variant = null - var selected_group_y: float = INF - for group_value: Variant in grouped_buttons.keys(): - var buttons := grouped_buttons[group_value] as Array - if buttons.size() < 2: - continue - var group_y: float = INF - for item: Variant in buttons: - var button := item as BaseButton - group_y = minf(group_y, button.global_position.y) - if group_y < selected_group_y: - selected_group = group_value - selected_group_y = group_y - if selected_group == null: - return false - var tabs := grouped_buttons[selected_group] as Array - tabs.sort_custom(func(first: BaseButton, second: BaseButton) -> bool: - return first.global_position.x < second.global_position.x +func _switch_inventory_tab_direction(direction: int) -> void: + var current_index: int = _get_inventory_tab_index() + var target_index: int = clampi(current_index + direction, 0, 3) + if target_index == current_index: + return + _show_inventory_tab(target_index) + + +func _enter_inventory_tabs_zone() -> void: + if not _is_inventory_section(_current_section): + return + _cancel_controller_accept_hold() + _controller_ownership = ControllerOwnership.INVENTORY_TABS + var active_tab: Button = _inventory_tab_for_section(_current_section) + if ( + active_tab != null + and active_tab.is_visible_in_tree() + and active_tab.focus_mode != Control.FOCUS_NONE + ): + active_tab.call_deferred("grab_focus") + + +func _enter_inventory_content_zone() -> void: + if not _is_inventory_section(_current_section): + return + _cancel_controller_accept_hold() + _controller_ownership = ControllerOwnership.ITEM_LIST + call_deferred("_focus_current_section") + + +func _enter_inventory_sort_zone() -> void: + if _current_section != Section.COOLER: + return + _cancel_controller_accept_hold() + _controller_ownership = ControllerOwnership.SORT_FILTER + _controller_source_section = _current_section + var sort_controls: Array[Control] = [ + _cooler_sort_option, + _cooler_sort_direction, + ] + ControllerFocusNavigationType.configure_spatial_neighbors( + sort_controls ) - var current_index: int = 0 - for index: int in tabs.size(): - var tab := tabs[index] as BaseButton - if tab.button_pressed: - current_index = index - break - var target := tabs[wrapi( - current_index + direction, 0, tabs.size() - )] as BaseButton - target.set_pressed_no_signal(true) - target.pressed.emit() - return true + _cooler_sort_option.call_deferred("grab_focus") + + +func _reset_controller_zone_for_section() -> void: + _cancel_controller_accept_hold() + if _is_inventory_section(_current_section): + _enter_inventory_tabs_zone() + return + _controller_ownership = ControllerOwnership.PAGE_CONTENT + match _current_section: + Section.LOGBOOK: + _catalog_logbook.reset_controller_zone() + Section.NET: + _the_net_page.reset_controller_zone() + Section.MAIL: + _mail_page.reset_controller_zone() + Section.PROFILE: + _profile_page.reset_controller_zone() + Section.PLAYERS: + _players_page.reset_controller_zone() func _collect_visible_toggle_buttons( @@ -1659,6 +1631,7 @@ func _focus_current_section() -> void: _collect_focusable_content(self, navigation_cluster, candidates) if candidates.is_empty(): return + ControllerFocusNavigationType.configure_spatial_neighbors(candidates) candidates.sort_custom(func(first: Control, second: Control) -> bool: if not is_equal_approx(first.global_position.y, second.global_position.y): return first.global_position.y < second.global_position.y @@ -1679,8 +1652,7 @@ func _collect_focusable_content( if control != null and not control.is_visible_in_tree(): continue if ( - control != null - and control.focus_mode != Control.FOCUS_NONE + ControllerFocusNavigationType.is_focusable(control) and not control is ScrollBar ): output.append(control) @@ -1689,6 +1661,19 @@ func _collect_focusable_content( func _process(delta: float) -> void: if visible or _shop_cooler_context_active: + if ( + visible + and _controller_accept_held + and _controller_ownership == ControllerOwnership.ITEM_LIST + and _current_section in [Section.COOLER, Section.BAG] + ): + _controller_accept_hold_elapsed += delta + if ( + _controller_accept_hold_elapsed + >= CONTROLLER_PICKUP_HOLD_SECONDS + ): + _cancel_controller_accept_hold() + _try_begin_controller_hotbar_placement() _motion_elapsed += delta if visible: _navigation_cluster.advance_motion(delta) @@ -1815,10 +1800,10 @@ func _configure_sale_confirmation_focus() -> void: _confirm_sale_button.focus_neighbor_left ) _confirm_sale_button.focus_neighbor_top = ( - _confirm_sale_button.focus_neighbor_left + _confirm_sale_button.get_path_to(_confirm_sale_button) ) _confirm_sale_button.focus_neighbor_bottom = ( - _confirm_sale_button.focus_neighbor_left + _confirm_sale_button.focus_neighbor_top ) _cancel_sale_button.focus_neighbor_left = ( _cancel_sale_button.get_path_to(_confirm_sale_button) @@ -1827,10 +1812,10 @@ func _configure_sale_confirmation_focus() -> void: _cancel_sale_button.focus_neighbor_left ) _cancel_sale_button.focus_neighbor_top = ( - _cancel_sale_button.focus_neighbor_left + _cancel_sale_button.get_path_to(_cancel_sale_button) ) _cancel_sale_button.focus_neighbor_bottom = ( - _cancel_sale_button.focus_neighbor_left + _cancel_sale_button.focus_neighbor_top ) if not _sale_confirmation.visible: for button: Button in [_confirm_sale_button, _cancel_sale_button]: @@ -1906,6 +1891,9 @@ func _show_bag_view(view: BagView) -> void: return if _bag_view == view: return + var preserve_tab_zone: bool = ( + _controller_ownership == ControllerOwnership.INVENTORY_TABS + ) _release_controller_ownership(false, true) _bag_view = view _selected_bag_item_id = StringName() @@ -1915,7 +1903,10 @@ func _show_bag_view(view: BagView) -> void: _refresh_inventory_organizer_tabs() _reserve_visible_secondary_navigation() _set_content_interactive(true) - call_deferred("_focus_current_section") + if preserve_tab_zone: + _enter_inventory_tabs_zone() + else: + call_deferred("_focus_current_section") func _refresh_tackle_box() -> void: @@ -2058,7 +2049,7 @@ func _configure_tackle_column_focus( button.focus_neighbor_left = button.get_path_to(left_target) button.focus_neighbor_right = button.get_path_to(right_target) button.focus_neighbor_top = ( - button.get_path_to(_tackle_sub_tab) + button.get_path_to(button) if index < TACKLE_GRID_COLUMNS else button.get_path_to(buttons[index - TACKLE_GRID_COLUMNS]) ) @@ -2756,7 +2747,7 @@ func _finish_menu_entry(generation: int) -> void: _presentation_tween = null _transitioning = false _set_shell_interactive(true) - _focus_current_section() + _reset_controller_zone_for_section() func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void: @@ -2972,7 +2963,7 @@ func _finish_page_transition(generation: int) -> void: _inventory_sub_tabs.modulate.a = 1.0 _reset_page_transition_visuals() _set_content_interactive(true) - _focus_current_section() + _reset_controller_zone_for_section() func _set_shell_interactive(interactive: bool) -> void: @@ -3049,6 +3040,9 @@ func _set_content_interactive(interactive: bool) -> void: _profile_page.set_interactive( interactive and _current_section == Section.PROFILE ) + _players_page.set_interactive( + interactive and _current_section == Section.PLAYERS + ) var cooler_interactive: bool = ( interactive and _current_section == Section.COOLER ) @@ -3303,7 +3297,7 @@ func _sync_bag_item_nodes(owned_items: Array[OwnedItemType]) -> void: func _layout_bag_items() -> void: - if not is_node_ready() or _sorted_bag_items.is_empty(): + if not is_node_ready(): return _bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED var columns: int = 3 @@ -3327,6 +3321,16 @@ func _layout_bag_items() -> void: if _compact_layout else Vector2(820.0, 240.0) ) + _sync_inventory_slot_visuals( + _bag_item_field, + _bag_slot_nodes, + maxi(9, _sorted_bag_items.size()), + columns, + cell_size, + item_size, + origin, + 18.0, + ) for index: int in _sorted_bag_items.size(): var owned: OwnedItemType = _sorted_bag_items[index] var item_node := _bag_item_nodes.get( @@ -3354,6 +3358,44 @@ func _layout_bag_items() -> void: ) +func _sync_inventory_slot_visuals( + parent: Control, + slots: Array[Panel], + slot_count: int, + columns: int, + cell_size: Vector2, + slot_size: Vector2, + origin: Vector2, + alternate_lane_offset: float, +) -> void: + for slot: Panel in slots: + if is_instance_valid(slot): + slot.queue_free() + slots.clear() + var slot_color: Color = UtilityPageStyle.OCEAN_PANEL_MID + slot_color.a = 0.42 + for index: int in slot_count: + var row: int = floori(float(index) / float(columns)) + var column: int = index % columns + var slot := Panel.new() + slot.name = "InventorySlot%d" % index + slot.mouse_filter = Control.MOUSE_FILTER_IGNORE + slot.z_index = -1 + slot.size = slot_size + slot.position = origin + Vector2( + float(column) * cell_size.x + + (alternate_lane_offset if row % 2 == 1 else 0.0), + float(row) * cell_size.y, + ) + slot.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style(slot_color, 14), + ) + parent.add_child(slot) + parent.move_child(slot, 0) + slots.append(slot) + + func _configure_bag_item_focus() -> void: if _current_section != Section.BAG: return @@ -3374,9 +3416,12 @@ func _configure_bag_item_focus() -> void: for index: int in controls.size(): var control: BagItemSpriteType = controls[index] var column: int = index % 3 - var row: int = floori(float(index) / 3.0) - var left_index: int = row * 3 + maxi(column - 1, 0) - var right_index: int = mini(index + 1, controls.size() - 1) + var left_index: int = index - 1 if column > 0 else index + var right_index: int = ( + index + 1 + if column < 2 and index + 1 < controls.size() + else index + ) control.focus_neighbor_left = control.get_path_to( controls[left_index] ) @@ -3384,12 +3429,14 @@ func _configure_bag_item_focus() -> void: controls[right_index] ) control.focus_neighbor_top = ( - control.get_path_to(active_tab) + control.get_path_to(control) if index < 3 else control.get_path_to(controls[index - 3]) ) control.focus_neighbor_bottom = control.get_path_to( - controls[mini(index + 3, controls.size() - 1)] + controls[index + 3] + if index + 3 < controls.size() + else control ) @@ -3654,9 +3701,13 @@ func _layout_cooler_fish(animate: bool = true) -> void: ) var side_margin: float = 2.0 if _compact_layout else 8.0 var top_margin: float = 4.0 if _compact_layout else 6.0 + var slot_count: int = maxi( + _sorted_catches.size(), + _cooler_capacity.get_capacity() if _cooler_capacity != null else 0, + ) var required_rows: int = ceili( - float(_sorted_catches.size()) / float(columns) - ) if not _sorted_catches.is_empty() else 0 + float(slot_count) / float(columns) + ) if slot_count > 0 else 0 var required_height: float = ( top_margin * 2.0 + float(required_rows) * cell_size.y ) @@ -3667,6 +3718,16 @@ func _layout_cooler_fish(animate: bool = true) -> void: _cooler_host.custom_minimum_size = content_size _fish_field.custom_minimum_size = content_size _cooler_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + _sync_inventory_slot_visuals( + _fish_field, + _cooler_slot_nodes, + slot_count, + columns, + cell_size, + fish_size, + Vector2(side_margin, top_margin), + 5.0, + ) for index: int in _sorted_catches.size(): var fish_catch: FishCatchType = _sorted_catches[index] var fish_node := _fish_nodes.get( @@ -3724,31 +3785,38 @@ func _configure_cooler_fish_focus() -> void: ) for index: int in controls.size(): var control: CoolerFishSpriteType = controls[index] + var column: int = index % columns control.focus_neighbor_left = control.get_path_to( - controls[maxi(index - 1, 0)] + controls[index - 1] if column > 0 else control ) control.focus_neighbor_right = control.get_path_to( - controls[mini(index + 1, controls.size() - 1)] + controls[index + 1] + if column < columns - 1 and index + 1 < controls.size() + else control ) control.focus_neighbor_top = ( control.get_path_to(top_control) + if _shop_cooler_context_active and index < columns + else control.get_path_to(control) if index < columns else control.get_path_to(controls[index - columns]) ) control.focus_neighbor_bottom = ( control.get_path_to(_favorite_bubble) if ( - not _favorite_bubble.disabled + _shop_cooler_context_active + and not _favorite_bubble.disabled and index + columns >= controls.size() ) else control.get_path_to(_sell_all_bubble) if ( - not _sell_all_bubble.disabled + _shop_cooler_context_active + and not _sell_all_bubble.disabled and index + columns >= controls.size() ) - else control.get_path_to( - controls[mini(index + columns, controls.size() - 1)] - ) + else control.get_path_to(controls[index + columns]) + if index + columns < controls.size() + else control.get_path_to(control) ) var focused_node := _fish_nodes.get( _fish_selection.get_focused_id() diff --git a/ui/players_page.gd b/ui/players_page.gd index d36d60f..1a9cdc8 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -2,6 +2,14 @@ class_name PlayersPage extends Control const TOGGLE_STATE_COLOR := Color("c3dfe6") +const DialogControllerNavigationType = preload( + "res://ui/file_dialog_controller_navigation.gd" +) + +enum ControllerZone { + TABS, + BODY, +} var _service: NetworkPlayerListService var _discovery: DiscoveryClient @@ -17,6 +25,9 @@ var _discoverable_toggle: Button var _discoverable_state_label: Label var _host_discovery_status: Label var _current_tab := 0 +var _active: bool = false +var _interactive: bool = false +var _controller_zone: ControllerZone = ControllerZone.TABS func _ready() -> void: @@ -45,12 +56,107 @@ func setup( func activate() -> void: + _active = true _refresh() - _focus_first() + reset_controller_zone() func deactivate() -> void: + _active = false _status.text = "" + set_interactive(false) + + +func set_interactive(interactive: bool) -> void: + _interactive = interactive and _active + var body_controls: Array[Control] = _body_controls() + for index: int in _tabs.get_child_count(): + var tab := _tabs.get_child(index) as Button + if tab != null: + tab.focus_mode = ( + Control.FOCUS_ALL + if _interactive + and _controller_zone == ControllerZone.TABS + and tab.visible + else Control.FOCUS_NONE + ) + for control: Control in body_controls: + var button := control as BaseButton + control.focus_mode = ( + Control.FOCUS_ALL + if _interactive + and _controller_zone == ControllerZone.BODY + and (button == null or not button.disabled) + else Control.FOCUS_NONE + ) + ControllerFocusNavigation.configure_spatial_neighbors(body_controls) + + +func reset_controller_zone() -> void: + _controller_zone = ControllerZone.TABS + set_interactive(_interactive) + call_deferred("_focus_controller_zone") + + +func handle_controller_input(event: InputEvent) -> bool: + if not _active or not _interactive: + return false + if event.is_action_pressed("ui_cancel"): + if _controller_zone == ControllerZone.BODY: + _controller_zone = ControllerZone.TABS + set_interactive(_interactive) + call_deferred("_focus_controller_zone") + return true + return false + if _controller_zone == ControllerZone.TABS: + var direction: int = 0 + if event.is_action_pressed("ui_left"): + direction = -1 + elif event.is_action_pressed("ui_right"): + direction = 1 + if direction != 0: + _select_adjacent_controller_tab(direction) + return true + if event.is_action_pressed("ui_accept"): + if not _body_controls().is_empty(): + _controller_zone = ControllerZone.BODY + set_interactive(_interactive) + call_deferred("_focus_controller_zone") + return true + return false + + +func _select_adjacent_controller_tab(direction: int) -> void: + var available: Array[int] = [] + for index: int in _tabs.get_child_count(): + var tab := _tabs.get_child(index) as Button + if tab != null and tab.visible: + available.append(index) + var current_position: int = available.find(_current_tab) + var target_position: int = clampi( + current_position + direction, + 0, + available.size() - 1, + ) + if target_position != current_position: + _select_tab(available[target_position]) + + +func _focus_controller_zone() -> void: + if not _interactive: + return + if _controller_zone == ControllerZone.TABS: + var tab := _tabs.get_child(_current_tab) as Button + if tab != null and tab.visible: + tab.grab_focus() + return + _focus_first() + + +func _body_controls() -> Array[Control]: + var controls: Array[Control] = [] + _collect_focusable_player_controls(self, controls) + return controls func _build() -> void: @@ -196,7 +302,7 @@ func _select_tab(index: int) -> void: return _current_tab = index _refresh() - _focus_first() + call_deferred("_focus_controller_zone") func _refresh() -> void: @@ -221,6 +327,9 @@ func _refresh() -> void: _build_relationship_rows() 2: _build_ban_rows() + if _controller_zone == ControllerZone.BODY and _body_controls().is_empty(): + _controller_zone = ControllerZone.TABS + set_interactive(_interactive) func _refresh_host_settings() -> void: @@ -582,11 +691,22 @@ func _confirm(text: String, action: Callable) -> void: ) add_child(dialog) dialog.popup_centered(Vector2i(520, 220)) + _configure_confirmation_dialog.call_deferred(dialog) + + +func _configure_confirmation_dialog(dialog: ConfirmationDialog) -> void: + if dialog != null and is_instance_valid(dialog) and dialog.visible: + DialogControllerNavigationType.configure_scope( + dialog, dialog.get_cancel_button() + ) func _focus_first() -> void: var candidates: Array[Control] = [] _collect_focusable_player_controls(self, candidates) + candidates = candidates.filter(func(control: Control) -> bool: + return control.focus_mode != Control.FOCUS_NONE + ) if candidates.is_empty(): return candidates.sort_custom(func(first: Control, second: Control) -> bool: @@ -609,8 +729,7 @@ func _collect_focusable_player_controls( continue if ( control != null - and control.focus_mode != Control.FOCUS_NONE - and not control is ScrollBar + and (control is BaseButton or control is LineEdit) ): output.append(control) _collect_focusable_player_controls(child, output) diff --git a/ui/settings/settings_bubble_page.gd b/ui/settings/settings_bubble_page.gd index dd01309..4489a5d 100644 --- a/ui/settings/settings_bubble_page.gd +++ b/ui/settings/settings_bubble_page.gd @@ -197,6 +197,22 @@ func _set_interactive(interactive: bool) -> void: if interactive else Control.MOUSE_FILTER_IGNORE ) + # Most settings pages are configured while their parent panel is hidden. + # Rebuild the graph after a page becomes visible so hidden-at-ready controls + # do not retain the empty neighbors produced by the initial layout pass. + if interactive: + call_deferred("_refresh_focus_navigation") + else: + ControllerFocusNavigationType.configure_spatial_neighbors( + _focus_controls + ) + + +func _refresh_focus_navigation() -> void: + if visible and not _is_transitioning: + ControllerFocusNavigationType.configure_spatial_neighbors( + _focus_controls + ) func _finish_transition_out(generation: int, completed: Callable) -> void: diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 4a7ec9b..818d35e 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -25,6 +25,15 @@ const ControllerMappingManagerType = preload( const ControllerMappingPanelType = preload( "res://ui/controller_mapping_panel.gd" ) +const KeyboardMouseMappingManagerType = preload( + "res://settings/keyboard_mouse_mapping_manager.gd" +) +const KeyboardMouseMappingPanelType = preload( + "res://ui/keyboard_mouse_mapping_panel.gd" +) +const DialogControllerNavigationType = preload( + "res://ui/file_dialog_controller_navigation.gd" +) signal applied signal closed @@ -110,6 +119,8 @@ var _host_identity: HostIdentityStore var _interface_fonts: InterfaceFontController var _controller_mapping_manager: ControllerMappingManagerType var _controller_mapping_panel: ControllerMappingPanelType +var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType +var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType var _data_folder_dialog: FileDialog var _backup_file_dialog: FileDialog var _export_file_dialog: FileDialog @@ -144,6 +155,7 @@ func _ready() -> void: %SoundBackButton.pressed.connect(handle_back) %ControlsBackButton.pressed.connect(handle_back) %ControllerMapping.pressed.connect(_open_controller_mapping) + %KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping) %AccessibilityBackButton.pressed.connect(handle_back) %DataBackButton.pressed.connect(handle_back) %RootBackButton.gui_input.connect(_on_back_bubble_gui_input) @@ -230,6 +242,11 @@ func _ready() -> void: _controller_mapping_panel.closed.connect( _on_controller_mapping_panel_closed ) + _keyboard_mouse_mapping_panel = KeyboardMouseMappingPanelType.new() + add_child(_keyboard_mouse_mapping_panel) + _keyboard_mouse_mapping_panel.closed.connect( + _on_keyboard_mouse_mapping_panel_closed + ) _style_data_page() _style_sound_page() call_deferred("_refresh_panel_size") @@ -250,6 +267,13 @@ func setup_controller_mapping( _controller_mapping_panel.setup(_controller_mapping_manager) +func setup_keyboard_mouse_mapping( + mapping_manager: KeyboardMouseMappingManagerType, +) -> void: + _keyboard_mouse_mapping_manager = mapping_manager + _keyboard_mouse_mapping_panel.setup(_keyboard_mouse_mapping_manager) + + func setup_data_and_identity( data_root: PlayerDataRoot, identity_backups: IdentityBackupService, @@ -328,6 +352,11 @@ func _finish_panel_close(applied_result: bool) -> void: and _controller_mapping_panel.is_open() ): _controller_mapping_panel.close_panel() + if ( + _keyboard_mouse_mapping_panel != null + and _keyboard_mouse_mapping_panel.is_open() + ): + _keyboard_mouse_mapping_panel.close_panel() for page: SettingsBubblePage in _pages.values(): page.hide_page() _page_stack.clear() @@ -341,6 +370,12 @@ func _finish_panel_close(applied_result: bool) -> void: func handle_back() -> void: + if ( + _keyboard_mouse_mapping_panel != null + and _keyboard_mouse_mapping_panel.is_open() + ): + _keyboard_mouse_mapping_panel.request_back() + return if ( _controller_mapping_panel != null and _controller_mapping_panel.is_open() @@ -366,6 +401,17 @@ func _on_controller_mapping_panel_closed() -> void: %ControllerMapping.grab_focus() +func _open_keyboard_mouse_mapping() -> void: + if _keyboard_mouse_mapping_manager == null: + _feedback.text = "no keyboard binding service is available" + return + _keyboard_mouse_mapping_panel.open_panel() + + +func _on_keyboard_mouse_mapping_panel_closed() -> void: + %KeyboardMapping.grab_focus() + + func get_active_page_id() -> StringName: return _page_stack.back() if not _page_stack.is_empty() else StringName() @@ -377,6 +423,16 @@ func is_controller_mapping_capturing() -> bool: ) +func is_input_mapping_capturing() -> bool: + return ( + is_controller_mapping_capturing() + or ( + _keyboard_mouse_mapping_panel != null + and _keyboard_mouse_mapping_panel.is_capturing() + ) + ) + + func _push_page(page_id: StringName) -> void: if ( _page_transition_active @@ -579,6 +635,9 @@ func _show_existing_data_folder_choice(path: String) -> void: _interface_fonts.apply_utility_theme(dialog) add_child(dialog) dialog.popup_centered(Vector2i(620, 340)) + _configure_confirmation_dialog.call_deferred( + dialog, dialog.get_cancel_button() + ) func _choose_identity_export(identity_type: String) -> void: @@ -662,7 +721,9 @@ func _show_passphrase_dialog(exporting: bool) -> void: if exporting else "Enter the backup passphrase." ) _passphrase_dialog.popup_centered(Vector2i(560, 300)) - _passphrase_entry.grab_focus() + _configure_confirmation_dialog.call_deferred( + _passphrase_dialog, _passphrase_entry + ) func _submit_identity_passphrase() -> void: @@ -707,6 +768,9 @@ func _show_identity_replacement_confirmation() -> void: _interface_fonts.apply_utility_theme(dialog) add_child(dialog) dialog.popup_centered(Vector2i(620, 360)) + _configure_confirmation_dialog.call_deferred( + dialog, dialog.get_cancel_button() + ) func _advance_identity_replacement(dialog: ConfirmationDialog) -> void: @@ -717,6 +781,9 @@ func _advance_identity_replacement(dialog: ConfirmationDialog) -> void: "Replace the active identity and archive the current key locally?" ) dialog.call_deferred("popup_centered", Vector2i(560, 280)) + _configure_confirmation_dialog.call_deferred( + dialog, dialog.get_cancel_button() + ) return _confirm_identity_replacement(dialog) @@ -732,6 +799,16 @@ func _confirm_identity_replacement(dialog: ConfirmationDialog) -> void: dialog.queue_free() +func _configure_confirmation_dialog( + dialog: ConfirmationDialog, + preferred_control: Control, +) -> void: + if dialog != null and is_instance_valid(dialog) and dialog.visible: + DialogControllerNavigationType.configure_scope( + dialog, preferred_control + ) + + func _identity_operation_allowed() -> bool: if _identity_backups == null: _feedback.text = "Identity backup is unavailable." diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index df2e261..a93ed60 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -600,8 +600,8 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("3_page") page_id = &"controls" -bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) +bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")]) initial_focus_path = NodePath("BubbleCluster/MouseValue") back_focus_path = NodePath("BubbleCluster/ControlsBackButton") @@ -713,15 +713,27 @@ motion_phase = 4.7 [node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) -text = "map\ncontroller" -neutral_size = Vector2(160, 150) -desktop_anchor = Vector2(525, 390) -compact_anchor = Vector2(485, 355) -compact_minimum_size = Vector2(148, 138) +text = "controller\nbinds" +neutral_size = Vector2(130, 108) +desktop_anchor = Vector2(470, 390) +compact_anchor = Vector2(425, 350) +compact_minimum_size = Vector2(116, 98) minimum_font_size = 14 -maximum_font_size = 23 +maximum_font_size = 20 motion_phase = 4.9 +[node name="KeyboardMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "keyboard\nbinds" +neutral_size = Vector2(130, 108) +desktop_anchor = Vector2(610, 380) +compact_anchor = Vector2(545, 345) +compact_minimum_size = Vector2(116, 98) +minimum_font_size = 14 +maximum_font_size = 20 +motion_phase = 5.2 + [node name="ControlsBackButton" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) @@ -845,8 +857,8 @@ grow_vertical = 2 script = ExtResource("3_page") page_id = &"data" bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")]) -initial_focus_path = NodePath("BubbleCluster/DataBackButton") +focus_paths = Array[NodePath]([NodePath("Paper/Content/OpenDataFolder"), NodePath("Paper/Content/ChangeDataFolder"), NodePath("Paper/Content/CopyPlayerFingerprint"), NodePath("Paper/Content/IdentityGrid/ExportPlayerIdentity"), NodePath("Paper/Content/IdentityGrid/ImportPlayerIdentity"), NodePath("Paper/Content/IdentityGrid/ExportHostIdentity"), NodePath("Paper/Content/IdentityGrid/ImportHostIdentity"), NodePath("BubbleCluster/DataBackButton")]) +initial_focus_path = NodePath("Paper/Content/OpenDataFolder") back_focus_path = NodePath("BubbleCluster/DataBackButton") [node name="BubbleCluster" type="Control" parent="DataPage"] diff --git a/ui/the_net_page.gd b/ui/the_net_page.gd index ff83a11..047debf 100644 --- a/ui/the_net_page.gd +++ b/ui/the_net_page.gd @@ -11,6 +11,11 @@ enum View { PAYMENTS, } +enum ControllerZone { + TABS, + CLAIMS, +} + var _jobs: PlayerJobService var _world_time: WorldTimeService var _header: Label @@ -18,11 +23,15 @@ var _refresh_label: Label var _forecast_list: HBoxContainer var _tabs: HBoxContainer var _list: VBoxContainer +var _jobs_scroll: ScrollContainer var _status: Label var _current_view: View = View.DAILY var _forecast_start_index: int = -1 var _active: bool = false var _interactive: bool = false +var _controller_zone: ControllerZone = ControllerZone.TABS +var _claim_buttons: Array[Button] = [] +var _controller_mapping_manager: ControllerMappingManager func _ready() -> void: @@ -40,6 +49,10 @@ func setup(jobs: PlayerJobService, world_time: WorldTimeService) -> void: _refresh() +func setup_controller_mapping(mapping_manager: ControllerMappingManager) -> void: + _controller_mapping_manager = mapping_manager + + func activate() -> void: _active = true set_process(true) @@ -68,6 +81,7 @@ func set_interactive(interactive: bool) -> void: button.focus_mode = ( Control.FOCUS_ALL if interactive + and _controller_zone == ControllerZone.TABS else Control.FOCUS_NONE ) button.mouse_filter = ( @@ -75,20 +89,108 @@ func set_interactive(interactive: bool) -> void: if interactive else Control.MOUSE_FILTER_IGNORE ) - _refresh() + for claim_button: Button in _claim_buttons: + if not is_instance_valid(claim_button): + continue + claim_button.disabled = not interactive + claim_button.focus_mode = ( + Control.FOCUS_ALL + if interactive + and _controller_zone == ControllerZone.CLAIMS + and not claim_button.disabled + else Control.FOCUS_NONE + ) + claim_button.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if interactive else Control.MOUSE_FILTER_IGNORE + ) func focus_initial() -> void: - if _interactive and _tabs != null and _tabs.get_child_count() > 0: + if not _interactive: + return + if _controller_zone == ControllerZone.CLAIMS: + if not _claim_buttons.is_empty(): + _claim_buttons.front().grab_focus() + return + if _tabs != null and _tabs.get_child_count() > 0: var button := _tabs.get_child(int(_current_view)) as Button if button != null: button.grab_focus() -func _process(_delta: float) -> void: +func reset_controller_zone() -> void: + _controller_zone = ControllerZone.TABS + set_interactive(_interactive) + call_deferred("focus_initial") + + +func handle_controller_input(event: InputEvent) -> bool: + if not _active or not _interactive: + return false + if _handle_controller_scroll(event): + return true + if event.is_action_pressed("ui_cancel"): + if _controller_zone == ControllerZone.CLAIMS: + _controller_zone = ControllerZone.TABS + set_interactive(_interactive) + call_deferred("focus_initial") + return true + return false + if _controller_zone == ControllerZone.TABS: + var direction: int = 0 + if event.is_action_pressed("ui_left"): + direction = -1 + elif event.is_action_pressed("ui_right"): + direction = 1 + if direction != 0: + var target_index: int = clampi( + int(_current_view) + direction, + 0, + View.size() - 1, + ) + if target_index != int(_current_view): + _select_view(target_index as View) + return true + if event.is_action_pressed("ui_accept"): + if not _claim_buttons.is_empty(): + _controller_zone = ControllerZone.CLAIMS + set_interactive(_interactive) + call_deferred("focus_initial") + return true + return false + + +func _handle_controller_scroll(event: InputEvent) -> bool: + var motion := event as InputEventJoypadMotion + if motion == null or _jobs_scroll == null: + return false + var uses_right_y: bool = ( + _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManager.ROLE_RIGHT_STICK_Y, + ) + if _controller_mapping_manager != null + else motion.axis == JOY_AXIS_RIGHT_Y + ) + if not uses_right_y: + return false + return true + + +func _process(delta: float) -> void: if _active and _jobs != null: _refresh_label.text = _daily_refresh_text() _refresh_forecast(false) + var right_y: float = ( + _controller_mapping_manager.get_role_axis( + ControllerMappingManager.ROLE_RIGHT_STICK_Y + ) + if _controller_mapping_manager != null + else Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y) + ) + if _jobs_scroll != null and absf(right_y) >= 0.18: + _jobs_scroll.scroll_vertical += roundi(right_y * 320.0 * delta) func _build_laptop() -> void: @@ -177,15 +279,15 @@ func _build_laptop() -> void: jobs_column.add_theme_constant_override("separation", 8) layout.add_child(jobs_column) - var scroll := ScrollContainer.new() - scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - jobs_column.add_child(scroll) + _jobs_scroll = ScrollContainer.new() + _jobs_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _jobs_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _jobs_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + jobs_column.add_child(_jobs_scroll) _list = VBoxContainer.new() _list.size_flags_horizontal = Control.SIZE_EXPAND_FILL _list.add_theme_constant_override("separation", 7) - scroll.add_child(_list) + _jobs_scroll.add_child(_list) _status = Label.new() _status.add_theme_font_size_override("font_size", 16) @@ -212,6 +314,7 @@ func _refresh() -> void: for child: Node in _list.get_children(): _list.remove_child(child) child.queue_free() + _claim_buttons.clear() match _current_view: View.DAILY: _build_job_rows(_jobs.get_daily_jobs(), "no daily jobs available") @@ -221,6 +324,10 @@ func _refresh() -> void: ) View.PAYMENTS: _build_payment_rows() + if _controller_zone == ControllerZone.CLAIMS and _claim_buttons.is_empty(): + _controller_zone = ControllerZone.TABS + set_interactive(_interactive) + ControllerFocusNavigation.configure_spatial_neighbors(_claim_buttons) func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void: @@ -311,6 +418,8 @@ func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void: ) claim.pressed.connect(_claim.bind(claim_id)) UtilityPageStyle.apply_compact_ocean_button(claim) + if claimable: + _claim_buttons.append(claim) content.add_child(claim) _list.add_child(row) @@ -357,6 +466,7 @@ func _build_payment_rows() -> void: _claim.bind(str(reward.get("claim_id", ""))) ) UtilityPageStyle.apply_ocean_button(claim) + _claim_buttons.append(claim) content.add_child(claim) _list.add_child(row) diff --git a/ui/title_confirmation_bubble_page.gd b/ui/title_confirmation_bubble_page.gd index a1a75da..b4c3643 100644 --- a/ui/title_confirmation_bubble_page.gd +++ b/ui/title_confirmation_bubble_page.gd @@ -197,14 +197,18 @@ func _configure_focus() -> void: _cancel_button ) _confirm_button.focus_neighbor_right = _confirm_button.focus_neighbor_left - _confirm_button.focus_neighbor_top = _confirm_button.focus_neighbor_left - _confirm_button.focus_neighbor_bottom = _confirm_button.focus_neighbor_left + _confirm_button.focus_neighbor_top = _confirm_button.get_path_to( + _confirm_button + ) + _confirm_button.focus_neighbor_bottom = _confirm_button.focus_neighbor_top _cancel_button.focus_neighbor_left = _cancel_button.get_path_to( _confirm_button ) _cancel_button.focus_neighbor_right = _cancel_button.focus_neighbor_left - _cancel_button.focus_neighbor_top = _cancel_button.focus_neighbor_left - _cancel_button.focus_neighbor_bottom = _cancel_button.focus_neighbor_left + _cancel_button.focus_neighbor_top = _cancel_button.get_path_to( + _cancel_button + ) + _cancel_button.focus_neighbor_bottom = _cancel_button.focus_neighbor_top func _set_interactive(interactive: bool) -> void: @@ -224,6 +228,8 @@ func _set_interactive(interactive: bool) -> void: if interactive else Control.MOUSE_FILTER_IGNORE ) + if interactive: + _configure_focus() func _finish_transition_in( diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 9e17d3e..7406c17 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -556,7 +556,7 @@ func _input(event: InputEvent) -> void: return if ( _settings_panel.visible - and _settings_panel.is_controller_mapping_capturing() + and _settings_panel.is_input_mapping_capturing() ): return if _credits_page.visible: @@ -565,7 +565,7 @@ func _input(event: InputEvent) -> void: return if _join_game_page.visible: if event.is_action_pressed("ui_cancel"): - _close_join_game() + _join_game_page.request_back() get_viewport().set_input_as_handled() return if ( @@ -624,23 +624,18 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool: return false if event is InputEventKey and (event as InputEventKey).echo: return false - var moves_forward: bool = ( + var moves_directionally: bool = ( event.is_action_pressed("ui_down") or event.is_action_pressed("ui_right") - ) - var moves_backward: bool = ( - event.is_action_pressed("ui_up") + or event.is_action_pressed("ui_up") or event.is_action_pressed("ui_left") ) - if not moves_forward and not moves_backward: + if not moves_directionally: return false _navigation_focus_active = true if _primary_menu_has_focus(): return false - if moves_forward: - _get_first_available_menu_button().grab_focus() - else: - _quit_button.grab_focus() + _get_first_available_menu_button().grab_focus() return true diff --git a/ui/ui_pixelation_presenter.gd b/ui/ui_pixelation_presenter.gd index dd0cfbf..4307c57 100644 --- a/ui/ui_pixelation_presenter.gd +++ b/ui/ui_pixelation_presenter.gd @@ -44,7 +44,8 @@ func _ready() -> void: _on_screen_keyboard = OnScreenKeyboardType.new() _ui_root.add_child(_on_screen_keyboard) _game_ui.set_controller_text_entry_request( - Callable(_on_screen_keyboard, "request_for_focused_control") + Callable(_on_screen_keyboard, "request_for_control"), + Callable(_on_screen_keyboard, "is_open"), ) var controller_focus_recovery := ControllerFocusRecoveryType.new() _ui_root.add_child(controller_focus_recovery)