diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index fca6c46..b205600 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -12,6 +12,12 @@ const TitleConfirmationScene = preload( ) const MailPageType = preload("res://ui/mail_page.gd") const ProfilePageType = preload("res://ui/profile_page.gd") +const PlayerMenuScene = preload("res://ui/player_menu.tscn") +const PlayerMenuType = preload("res://ui/player_menu.gd") +const PlayersPageType = preload("res://ui/players_page.gd") +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const DialogControllerNavigationType = preload( "res://ui/file_dialog_controller_navigation.gd" ) @@ -29,6 +35,7 @@ func _run() -> void: await _validate_data_settings_navigation() await _validate_mail_navigation() await _validate_profile_confirmation_focus() + await _validate_player_menu_nested_controller_back() await _validate_confirmation_dialog_navigation() await _validate_bubble_confirmation_navigation() _validate_mapping_capture_contract() @@ -273,10 +280,156 @@ func _validate_profile_confirmation_focus() -> void: control.focus_mode == Control.FOCUS_ALL, "Profile account focus was not restored after confirmation.", ) + page.call("_enter_controller_customization") + for _frame: int in 2: + await process_frame + var categories: Array[Control] = [] + var category_list := page.get("_category_list") as VBoxContainer + for item: Variant in page.call("_controls_under", category_list): + categories.append(item as Control) + var initial_category_focus := root.gui_get_focus_owner() as Control + _expect( + initial_category_focus in categories, + "Customize did not focus the appearance feature list.", + ) + var down := InputEventAction.new() + down.action = &"ui_down" + down.pressed = true + Input.parse_input_event(down) + for _frame: int in 2: + await process_frame + var next_category_focus := root.gui_get_focus_owner() as Control + _expect( + next_category_focus in categories, + "Moving down dropped focus from the appearance feature list.", + ) + _expect( + next_category_focus != initial_category_focus, + "Moving down did not advance through appearance features.", + ) + down.pressed = false + Input.parse_input_event(down) + var accept_event := InputEventAction.new() + accept_event.action = &"ui_accept" + accept_event.pressed = true + _expect( + bool(page.call("handle_controller_input", accept_event)), + "Appearance feature selection did not consume controller Accept.", + ) + for _frame: int in 3: + await process_frame + var option_groups: Array = page.call("_controller_option_groups") + var option_controls: Array[Control] = [] + if not option_groups.is_empty(): + for item: Variant in option_groups.front(): + var option_control := item as Control + if option_control != null: + option_controls.append(option_control) + _expect( + root.gui_get_focus_owner() in option_controls, + "Selecting an appearance feature did not focus its options.", + ) + var cancel_options := InputEventAction.new() + cancel_options.action = &"ui_cancel" + cancel_options.pressed = true + _expect( + bool(page.call("handle_controller_input", cancel_options)), + "Appearance options did not consume controller Back.", + ) + for _frame: int in 2: + await process_frame + _expect( + root.gui_get_focus_owner() in categories, + "Controller Back did not return to the appearance feature list.", + ) page.queue_free() await process_frame +func _validate_player_menu_nested_controller_back() -> void: + var menu := PlayerMenuScene.instantiate() as Control + root.add_child(menu) + await process_frame + menu.visible = true + menu.set("_current_section", PlayerMenuType.Section.PROFILE) + var profile_page := menu.get("_profile_page") as Control + profile_page.call("activate") + profile_page.call("set_interactive", true) + profile_page.call("_enter_controller_customization") + for _frame: int in 2: + await process_frame + var categories: Array[Control] = [] + var category_list := profile_page.get( + "_category_list" + ) as VBoxContainer + for item: Variant in profile_page.call( + "_controls_under", category_list + ): + categories.append(item as Control) + var initial_focus := root.gui_get_focus_owner() as Control + menu.call("_reserve_visible_secondary_navigation") + await process_frame + _expect( + root.gui_get_focus_owner() in categories, + "Player-menu focus reservation disabled appearance features.", + ) + var down := InputEventAction.new() + down.action = &"ui_down" + down.pressed = true + Input.parse_input_event(down) + for _frame: int in 2: + await process_frame + _expect( + root.gui_get_focus_owner() in categories, + "Player-menu Down dropped appearance feature focus.", + ) + _expect( + root.gui_get_focus_owner() != initial_focus, + "Player-menu Down did not advance appearance feature focus.", + ) + down.pressed = false + Input.parse_input_event(down) + + var mapping_manager := ControllerMappingManagerType.new() + var bindings: Dictionary = ControllerMappingManagerType.default_bindings() + bindings[str(ControllerMappingManagerType.ROLE_B)] = { + "kind": "button", + "button": int(JOY_BUTTON_Y), + } + mapping_manager.set("_profiles", { + "default": { + "controller_name": "controller", + "bindings": bindings, + }, + }) + menu.call("setup_controller_mapping", mapping_manager) + var players_page := menu.get("_players_page") as Control + players_page.set("_active", true) + players_page.set("_interactive", true) + players_page.set( + "_controller_zone", PlayersPageType.ControllerZone.BODY + ) + menu.set("_current_section", PlayerMenuType.Section.PLAYERS) + var mapped_back := InputEventJoypadButton.new() + mapped_back.device = 0 + mapped_back.button_index = JOY_BUTTON_Y + mapped_back.pressed = true + _expect( + bool(menu.call( + "_handle_controller_ownership_input", mapped_back + )), + "Player menu did not consume a mapped controller Back press.", + ) + _expect( + int(players_page.get("_controller_zone")) + == PlayersPageType.ControllerZone.TABS, + "Mapped controller Back skipped the previous player-page zone.", + ) + menu.queue_free() + await process_frame + mapping_manager.free() + + func _validate_mapping_capture_contract() -> void: var title_source: String = FileAccess.get_file_as_string( "res://ui/title_screen.gd" diff --git a/tests/controller_ui_navigation_validation.gd b/tests/controller_ui_navigation_validation.gd index 30871eb..adc0ec4 100644 --- a/tests/controller_ui_navigation_validation.gd +++ b/tests/controller_ui_navigation_validation.gd @@ -104,6 +104,12 @@ func _validate_controller_hierarchy_contract() -> void: 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( + "if not _is_inventory_section(_current_section):\n\t\treturn" + )) + assert(source.contains( + "_dispatch_active_page_controller_input(cancel_event)" + )) assert(source.contains("configure_spatial_neighbors(candidates)")) diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 5ddfde3..635e64d 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -738,19 +738,7 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: 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: + if _dispatch_active_page_controller_input(event): return true var button_event := event as InputEventJoypadButton if ( @@ -762,11 +750,35 @@ func _handle_active_page_controller_input(event: InputEvent) -> bool: JOY_BUTTON_B, ) ): + # Child pages use ui_cancel for their zone transitions. Retry a mapped + # B press as that semantic action before treating it as a request to + # close the entire player menu. + if not event.is_action_pressed("ui_cancel"): + var cancel_event := InputEventAction.new() + cancel_event.action = &"ui_cancel" + cancel_event.pressed = true + if _dispatch_active_page_controller_input(cancel_event): + return true close_menu() return true return false +func _dispatch_active_page_controller_input(event: InputEvent) -> bool: + match _current_section: + Section.LOGBOOK: + return _catalog_logbook.handle_controller_input(event) + Section.NET: + return _the_net_page.handle_controller_input(event) + Section.MAIL: + return _mail_page.handle_controller_input(event) + Section.PROFILE: + return _profile_page.handle_controller_input(event) + Section.PLAYERS: + return _players_page.handle_controller_input(event) + return false + + func _activate_inventory_selection() -> void: if _try_enter_notepad_controller_ownership(): return @@ -1733,6 +1745,11 @@ func _set_descendant_focus_disabled(root: Node) -> void: func _reserve_visible_secondary_navigation() -> void: + # Inventory organizer tabs have dedicated left/right ownership. Other pages + # manage their own controller zones, and their toggle buttons are content, + # not secondary navigation that should be disabled here. + if not _is_inventory_section(_current_section): + return var navigation_cluster := get_node_or_null("%NavigationCluster") as Control var grouped_buttons: Dictionary = {} _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons) diff --git a/ui/profile_page.gd b/ui/profile_page.gd index 82ece0c..ebfdb99 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -217,7 +217,13 @@ func consume_escape() -> bool: func handle_controller_input(event: InputEvent) -> bool: if not _profile_active or not _profile_interactive: return false - if event.is_action_pressed("ui_cancel"): + var cancel_pressed := _event_matches_controller_press( + event, + &"ui_cancel", + ControllerMappingManagerType.ROLE_B, + JOY_BUTTON_B, + ) + if cancel_pressed: if _discard_confirmation.visible: _close_discard_confirmation() return true @@ -242,9 +248,15 @@ func handle_controller_input(event: InputEvent) -> bool: return true if _controller_zone == ControllerZone.ACCOUNT: return false + var accept_pressed := _event_matches_controller_press( + event, + &"ui_accept", + ControllerMappingManagerType.ROLE_A, + JOY_BUTTON_A, + ) if ( _controller_zone == ControllerZone.CATEGORIES - and event.is_action_pressed("ui_accept") + and accept_pressed ): var focused_category := get_viewport().gui_get_focus_owner() as Button if focused_category != null and _category_list.is_ancestor_of( @@ -258,7 +270,7 @@ func handle_controller_input(event: InputEvent) -> bool: return true if ( _controller_zone == ControllerZone.OPTIONS - and event.is_action_pressed("ui_accept") + and accept_pressed ): var groups: Array = _controller_option_groups() if _controller_option_depth < groups.size() - 1: @@ -272,6 +284,22 @@ func handle_controller_input(event: InputEvent) -> bool: return false +func _event_matches_controller_press( + event: InputEvent, + action: StringName, + role: StringName, + fallback_button: JoyButton, +) -> bool: + if event.is_action_pressed(action): + return true + var button_event := event as InputEventJoypadButton + if button_event == null or not button_event.pressed: + return false + if _controller_mapping_manager != null: + return _controller_mapping_manager.event_matches_role(event, role) + return button_event.button_index == fallback_button + + func _process(delta: float) -> void: if ( not _profile_active @@ -384,7 +412,13 @@ func _focus_controller_zone() -> void: controls = _color_picker_controller_controls() for control: Control in controls: var button := control as BaseButton - if button != null and button.button_pressed: + if ( + button != null + and button.button_pressed + and button.focus_mode != Control.FOCUS_NONE + and button.is_visible_in_tree() + and not button.disabled + ): button.grab_focus() return for control: Control in controls: @@ -724,7 +758,7 @@ func _enter_controller_customization() -> void: return _controller_zone = ControllerZone.CATEGORIES _controller_option_depth = 0 - _apply_controller_zone_focus() + _apply_controller_zone_focus.call_deferred() call_deferred("_focus_controller_zone")