From e12ed54ab8b4139f7c926c16a040e11c6f592c16 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 15 Aug 2026 22:43:29 -0400 Subject: [PATCH] fix controller file dialog navigation --- main/main.gd | 6 +- ...dialog_controller_navigation_validation.gd | 174 +++++++++++++- tests/on_screen_keyboard_validation.gd | 22 +- ui/file_dialog_controller_navigation.gd | 218 +++++++++++++++++- ui/interface_font_controller.gd | 58 +++++ ui/on_screen_keyboard.gd | 17 +- 6 files changed, 469 insertions(+), 26 deletions(-) diff --git a/main/main.gd b/main/main.gd index 5d9268e..210b01b 100644 --- a/main/main.gd +++ b/main/main.gd @@ -1324,8 +1324,10 @@ func _handle_data_root_controller_input(event: InputEvent) -> bool: else _data_setup_dialog.gui_get_focus_owner() ) var focused_button := focused as BaseButton - if focused_button != null and not focused_button.disabled: - focused_button.pressed.emit() + if ( + focused_button != null + and FileDialogControllerNavigation.activate_control(focused_button) + ): return true if ( (focused is LineEdit or focused is TextEdit) diff --git a/tests/file_dialog_controller_navigation_validation.gd b/tests/file_dialog_controller_navigation_validation.gd index a31e56e..838196a 100644 --- a/tests/file_dialog_controller_navigation_validation.gd +++ b/tests/file_dialog_controller_navigation_validation.gd @@ -35,11 +35,19 @@ func _run() -> void: FileDialogControllerNavigationType.interactive_controls(root_scope) ) var directory_list: ItemList + var parent_button: Button 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 + var drive_button: MenuButton + var refresh_button: Button + var favorite_button: Button + var hidden_button: Button + var grid_button: Button + var list_button: Button + var filter_button: Button + var sort_button: MenuButton + var cancel_button: Button + var select_button: Button for control: Control in root_controls: if ( control is ItemList @@ -50,22 +58,125 @@ func _run() -> void: path_edit = control as LineEdit var button := control as Button if button != null: + parent_button = ( + button + if button.tooltip_text == "Go to parent folder." + else parent_button + ) 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 + refresh_button = ( + button + if button.tooltip_text == "Refresh files." + else refresh_button + ) + favorite_button = ( + button + if button.tooltip_text == "(Un)favorite current folder." + else favorite_button + ) + hidden_button = ( + button + if button.tooltip_text == ( + "Toggle the visibility of hidden files." + ) + else hidden_button + ) + grid_button = ( + button + if button.tooltip_text == ( + "View items as a grid of thumbnails." + ) + else grid_button + ) + list_button = ( + button + if button.tooltip_text == "View items as a list." + else list_button + ) + filter_button = ( + button + if button.tooltip_text == ( + "Toggle the visibility of the filter for file names." + ) + else filter_button + ) + cancel_button = button if button.text == "Cancel" else cancel_button + select_button = ( + button + if button.text == "Select Current Folder" + else select_button + ) + var menu := control as MenuButton + if menu != null: + drive_button = menu if menu.accessibility_name == "Drive" else drive_button + sort_button = ( + menu if menu.tooltip_text == "Sort files" else sort_button + ) assert(directory_list != null) + assert(parent_button != null) assert(path_edit != null) assert(create_folder_button != null) - assert(has_menu_button) - assert(has_cancel) - assert(has_select) + assert(drive_button != null) + assert(refresh_button != null) + assert(favorite_button != null) + assert(hidden_button != null) + assert(grid_button != null) + assert(list_button != null) + assert(filter_button != null) + assert(sort_button != null) + assert(cancel_button != null) + assert(select_button != null) assert(dialog.gui_get_focus_owner() == directory_list) + _assert_neighbor(path_edit, &"focus_neighbor_left", parent_button) + _assert_neighbor(path_edit, &"focus_neighbor_right", drive_button) + _assert_neighbor(drive_button, &"focus_neighbor_left", path_edit) + _assert_neighbor(refresh_button, &"focus_neighbor_left", drive_button) + _assert_neighbor(favorite_button, &"focus_neighbor_left", refresh_button) + _assert_neighbor( + create_folder_button, &"focus_neighbor_left", favorite_button + ) + _assert_neighbor(hidden_button, &"focus_neighbor_right", grid_button) + _assert_neighbor(grid_button, &"focus_neighbor_right", list_button) + _assert_neighbor(list_button, &"focus_neighbor_right", filter_button) + _assert_neighbor(filter_button, &"focus_neighbor_right", sort_button) + _assert_neighbor(sort_button, &"focus_neighbor_top", create_folder_button) + _assert_neighbor( + create_folder_button, &"focus_neighbor_bottom", sort_button + ) + _assert_neighbor(directory_list, &"focus_neighbor_top", hidden_button) + _assert_neighbor(directory_list, &"focus_neighbor_bottom", select_button) + _assert_neighbor(cancel_button, &"focus_neighbor_right", select_button) + _assert_neighbor(select_button, &"focus_neighbor_left", cancel_button) + assert(favorite_button.accessibility_name == "favorite current folder") + assert(create_folder_button.accessibility_name == "new folder") + assert(filter_button.accessibility_name == "filter file names") _assert_directionally_reachable(directory_list, root_controls) + directory_list.clear() + directory_list.add_item("test folder") + directory_list.select(0) + directory_list.grab_focus() + assert( + FileDialogControllerNavigationType.move_from_item_list( + directory_list, Vector2.UP + ) + ) + assert(dialog.gui_get_focus_owner() == hidden_button) + directory_list.grab_focus() + assert( + FileDialogControllerNavigationType.move_from_item_list( + directory_list, Vector2.DOWN + ) + ) + assert(dialog.gui_get_focus_owner() == select_button) + assert(FileDialogControllerNavigationType.activate_control(sort_button)) + await process_frame + assert(sort_button.get_popup().visible) + sort_button.get_popup().hide() + await process_frame create_folder_button.pressed.emit() for _frame: int in 3: @@ -124,7 +235,14 @@ func _validate_compact_dialog() -> void: root.size = Vector2i(640, 480) var font_controller := InterfaceFontControllerType.new() root.add_child(font_controller) + var keyboard := OnScreenKeyboardType.new() + root.add_child(keyboard) await process_frame + keyboard.set_enabled(false) + font_controller.set_controller_text_entry_request( + Callable(keyboard, "request_for_control"), + Callable(keyboard, "is_open"), + ) var dialog := FileDialog.new() dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR dialog.access = FileDialog.ACCESS_FILESYSTEM @@ -140,17 +258,43 @@ func _validate_compact_dialog() -> void: FileDialogControllerNavigationType.interactive_controls(scope) ) var directory_list: ItemList + var path_edit: LineEdit + var select_button: Button for control: Control in controls: if ( control is ItemList and control.accessibility_name == "Directories & Files:" ): directory_list = control as ItemList - break + if control is LineEdit and control.accessibility_name == "Path:": + path_edit = control as LineEdit + if control is Button and (control as Button).text == ( + "Select Current Folder" + ): + select_button = control as Button assert(directory_list != null) + assert(path_edit != null) + assert(select_button != null) _assert_directionally_reachable(directory_list, controls) + directory_list.clear() + directory_list.add_item("test folder") + directory_list.select(0) + directory_list.grab_focus() + var down_event := InputEventJoypadButton.new() + down_event.button_index = JOY_BUTTON_DPAD_DOWN + down_event.pressed = true + font_controller.call("_input", down_event) + assert(scope.gui_get_focus_owner() == select_button) + path_edit.grab_focus() + var accept_event := InputEventJoypadButton.new() + accept_event.button_index = JOY_BUTTON_A + accept_event.pressed = true + font_controller.call("_input", accept_event) + assert(keyboard.is_open()) + keyboard.call("_close_keyboard", true) dialog.queue_free() font_controller.queue_free() + keyboard.queue_free() await process_frame @@ -181,3 +325,13 @@ func _assert_directionally_reachable( visited[neighbor.get_instance_id()] = true pending.append(neighbor) assert(visited.size() == expected.size()) + + +func _assert_neighbor( + origin: Control, + property_name: StringName, + expected: Control, +) -> void: + var neighbor_path: NodePath = origin.get(property_name) + var neighbor := origin.get_node_or_null(neighbor_path) as Control + assert(neighbor == expected) diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd index 7a6e49a..27c2dbd 100644 --- a/tests/on_screen_keyboard_validation.gd +++ b/tests/on_screen_keyboard_validation.gd @@ -20,9 +20,18 @@ func _run() -> void: func _validate_default_and_persistence() -> void: var defaults := PlayerSettings.new() assert(not defaults.on_screen_keyboard_enabled) - assert(KeyboardType.should_enable_for_controller(false, false)) - assert(not KeyboardType.should_enable_for_controller(false, true)) - assert(KeyboardType.should_enable_for_controller(true, true)) + assert( + KeyboardType.should_enable_for_controller(false, false, "Linux") + ) + assert( + KeyboardType.should_enable_for_controller(false, true, "Linux") + ) + assert( + not KeyboardType.should_enable_for_controller(false, true, "Android") + ) + assert( + KeyboardType.should_enable_for_controller(true, true, "Android") + ) var manager := SettingsManagerType.new() root.add_child(manager) assert(manager.load_settings()) @@ -47,12 +56,7 @@ func _validate_keyboard_entry() -> void: host.add_child(keyboard) await process_frame keyboard.set_enabled(false) - assert( - keyboard.is_enabled() - == not DisplayServer.has_feature( - DisplayServer.FEATURE_VIRTUAL_KEYBOARD - ) - ) + assert(keyboard.is_enabled()) var activate_event := InputEventJoypadButton.new() activate_event.button_index = JOY_BUTTON_A activate_event.pressed = true diff --git a/ui/file_dialog_controller_navigation.gd b/ui/file_dialog_controller_navigation.gd index 0d49afc..e475ec1 100644 --- a/ui/file_dialog_controller_navigation.gd +++ b/ui/file_dialog_controller_navigation.gd @@ -4,6 +4,24 @@ extends RefCounted const ControllerFocusNavigationType = preload( "res://ui/controller_focus_navigation.gd" ) +const ROW_TOLERANCE: float = 12.0 +const EDGE_TOLERANCE: float = 1.0 + +const ACCESSIBILITY_NAMES_BY_TOOLTIP: Dictionary[String, String] = { + "Go to previous folder.": "previous folder", + "Go to next folder.": "next folder", + "Go to parent folder.": "parent folder", + "Refresh files.": "refresh files", + "(Un)favorite current folder.": "favorite current folder", + "Create a new folder.": "new folder", + "Toggle the visibility of hidden files.": "show hidden files", + "View items as a grid of thumbnails.": "thumbnail view", + "View items as a list.": "list view", + "Toggle the visibility of the filter for file names.": ( + "filter file names" + ), + "Sort files": "sort files", +} static func configure(dialog: FileDialog) -> void: @@ -25,7 +43,10 @@ static func configure_scope( _collect_interactive_controls(scope, scope, controls) if controls.is_empty(): return - ControllerFocusNavigationType.configure_spatial_neighbors(controls) + if scope is FileDialog: + _configure_file_dialog_neighbors(controls) + else: + ControllerFocusNavigationType.configure_spatial_neighbors(controls) var focus_owner: Control = scope.gui_get_focus_owner() if controls.has(preferred_control): if focus_owner != preferred_control: @@ -51,6 +72,44 @@ static func interactive_controls(scope: Window) -> Array[Control]: return controls +static func activate_control(control: Control) -> bool: + if control == null or not control.is_visible_in_tree(): + return false + var menu_button := control as MenuButton + if menu_button != null and not menu_button.disabled: + menu_button.show_popup() + return true + var button := control as BaseButton + if button != null and not button.disabled: + button.pressed.emit() + return true + return false + + +static func move_from_item_list( + item_list: ItemList, + direction: Vector2, +) -> bool: + if ( + item_list == null + or not item_list.has_focus() + or not _item_list_at_edge(item_list, direction) + ): + return false + var neighbor_path: NodePath = _neighbor_path_for_direction( + item_list, direction + ) + var neighbor := item_list.get_node_or_null(neighbor_path) as Control + if ( + neighbor == null + or neighbor == item_list + or not ControllerFocusNavigationType.is_focusable(neighbor) + ): + return false + neighbor.grab_focus() + return true + + static func _deepest_exclusive_window(window: Window) -> Window: for child: Node in window.get_children(true): var child_window := child as Window @@ -79,6 +138,7 @@ static func _collect_interactive_controls( # accessibility-only. Promote those genuine controls for controller # focus while leaving labels, scrollbars, and splitters untouched. control.focus_mode = Control.FOCUS_ALL + _apply_accessibility_name(control) output.append(control) _collect_interactive_controls(child, scope, output) @@ -122,3 +182,159 @@ static func _preferred_initial_control( if controls.has(ok_button): return ok_button return controls.front() + + +static func _configure_file_dialog_neighbors( + controls: Array[Control], +) -> void: + var rows: Array[Array] = [] + var ordered: Array[Control] = controls.duplicate() + ordered.sort_custom(_sort_by_top_then_left) + for control: Control in ordered: + var control_top: float = control.get_global_rect().position.y + var destination_row: Array = [] + for row: Array in rows: + var row_control := row.front() as Control + var row_top: float = ( + row_control.get_global_rect().position.y + ) + if absf(control_top - row_top) <= ROW_TOLERANCE: + destination_row = row + break + if destination_row.is_empty(): + destination_row = [] + rows.append(destination_row) + destination_row.append(control) + for row: Array in rows: + row.sort_custom(_sort_by_left) + for row_index: int in rows.size(): + var row: Array = rows[row_index] + for column_index: int in row.size(): + var control := row[column_index] as Control + var left := row[maxi(column_index - 1, 0)] as Control + var right := row[mini(column_index + 1, row.size() - 1)] as Control + var top: Control = _vertical_neighbor( + control, rows, row_index, -1 + ) + var bottom: Control = _vertical_neighbor( + control, rows, row_index, 1 + ) + 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) + control.focus_previous = control.get_path_to( + ordered[wrapi(ordered.find(control) - 1, 0, ordered.size())] + ) + control.focus_next = control.get_path_to( + ordered[wrapi(ordered.find(control) + 1, 0, ordered.size())] + ) + + +static func _vertical_neighbor( + origin: Control, + rows: Array[Array], + origin_row: int, + direction: int, +) -> Control: + var origin_rect: Rect2 = origin.get_global_rect() + var row_index: int = origin_row + direction + while row_index >= 0 and row_index < rows.size(): + var best: Control + var best_distance: float = INF + for candidate_value: Variant in rows[row_index]: + var candidate := candidate_value as Control + var candidate_rect: Rect2 = candidate.get_global_rect() + if not _horizontally_aligned(origin_rect, candidate_rect): + continue + var distance: float = absf( + origin_rect.get_center().x - candidate_rect.get_center().x + ) + if distance < best_distance: + best = candidate + best_distance = distance + if best != null: + return best + row_index += direction + return origin + + +static func _horizontally_aligned(first: Rect2, second: Rect2) -> bool: + return ( + first.position.x <= second.end.x + EDGE_TOLERANCE + and second.position.x <= first.end.x + EDGE_TOLERANCE + ) + + +static func _sort_by_top_then_left(first: Control, second: Control) -> bool: + var first_position: Vector2 = first.get_global_rect().position + var second_position: Vector2 = second.get_global_rect().position + if absf(first_position.y - second_position.y) > ROW_TOLERANCE: + return first_position.y < second_position.y + return first_position.x < second_position.x + + +static func _sort_by_left(first: Variant, second: Variant) -> bool: + return ( + (first as Control).get_global_rect().position.x + < (second as Control).get_global_rect().position.x + ) + + +static func _apply_accessibility_name(control: Control) -> void: + if not control.accessibility_name.is_empty(): + return + var authored_name: String = ACCESSIBILITY_NAMES_BY_TOOLTIP.get( + control.tooltip_text, "" + ) + if not authored_name.is_empty(): + control.accessibility_name = authored_name + + +static func _item_list_at_edge( + item_list: ItemList, + direction: Vector2, +) -> bool: + if item_list.item_count <= 0: + return true + var selected: PackedInt32Array = item_list.get_selected_items() + if selected.is_empty(): + return false + var selected_rect: Rect2 = item_list.get_item_rect(selected[0]) + var content_bounds: Rect2 = item_list.get_item_rect(0) + for item_index: int in range(1, item_list.item_count): + content_bounds = content_bounds.merge( + item_list.get_item_rect(item_index) + ) + if direction == Vector2.UP: + return selected_rect.position.y <= ( + content_bounds.position.y + EDGE_TOLERANCE + ) + if direction == Vector2.DOWN: + return selected_rect.end.y >= ( + content_bounds.end.y - EDGE_TOLERANCE + ) + if direction == Vector2.LEFT: + return selected_rect.position.x <= ( + content_bounds.position.x + EDGE_TOLERANCE + ) + if direction == Vector2.RIGHT: + return selected_rect.end.x >= ( + content_bounds.end.x - EDGE_TOLERANCE + ) + return false + + +static func _neighbor_path_for_direction( + control: Control, + direction: Vector2, +) -> NodePath: + if direction == Vector2.UP: + return control.focus_neighbor_top + if direction == Vector2.DOWN: + return control.focus_neighbor_bottom + if direction == Vector2.LEFT: + return control.focus_neighbor_left + if direction == Vector2.RIGHT: + return control.focus_neighbor_right + return NodePath() diff --git a/ui/interface_font_controller.gd b/ui/interface_font_controller.gd index fe6a93c..f504236 100644 --- a/ui/interface_font_controller.gd +++ b/ui/interface_font_controller.gd @@ -116,6 +116,64 @@ func _process(_delta: float) -> void: _connect_file_dialog_text_controls(dialog) +func _input(event: InputEvent) -> void: + if ( + _controller_text_entry_is_open.is_valid() + and bool(_controller_text_entry_is_open.call()) + ): + return + if not (event is InputEventJoypadButton or event is InputEventJoypadMotion): + return + var scope: Window = _active_file_dialog_scope() + if scope == null: + return + var focused: Control = scope.gui_get_focus_owner() + if focused == null: + return + var direction: Vector2 = _controller_direction(event) + var item_list := focused as ItemList + if ( + item_list != null + and direction != Vector2.ZERO + and FileDialogControllerNavigationType.move_from_item_list( + item_list, direction + ) + ): + get_viewport().set_input_as_handled() + return + var button_event := event as InputEventJoypadButton + if ( + button_event != null + and button_event.pressed + and event.is_action_pressed(&"ui_accept") + and (focused is LineEdit or focused is TextEdit) + and _controller_text_entry_request.is_valid() + and bool(_controller_text_entry_request.call(focused)) + ): + get_viewport().set_input_as_handled() + + +func _active_file_dialog_scope() -> Window: + for reference_value: WeakRef in _tracked_file_dialogs.values(): + var dialog := reference_value.get_ref() as FileDialog + if dialog == null or not is_instance_valid(dialog) or not dialog.visible: + continue + return FileDialogControllerNavigationType.active_scope(dialog) + return null + + +func _controller_direction(event: InputEvent) -> Vector2: + if event.is_action_pressed(&"ui_up"): + return Vector2.UP + if event.is_action_pressed(&"ui_down"): + return Vector2.DOWN + if event.is_action_pressed(&"ui_left"): + return Vector2.LEFT + if event.is_action_pressed(&"ui_right"): + return Vector2.RIGHT + return Vector2.ZERO + + func _connect_file_dialog_text_controls(dialog: FileDialog) -> void: var scope: Window = FileDialogControllerNavigationType.active_scope(dialog) for control: Control in ( diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index 64a2fe8..6256f0d 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -127,17 +127,26 @@ func _is_available_for_controller() -> bool: return should_enable_for_controller( _enabled, DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD), + OS.get_name(), ) static func should_enable_for_controller( preference_enabled: bool, native_virtual_keyboard_available: bool, + platform_name: String = "", ) -> bool: - # A controller user must always have one viable text-entry path. Android - # and any future display backend with native support can keep using the - # platform keyboard unless the in-game keyboard is explicitly requested. - return preference_enabled or not native_virtual_keyboard_available + # Desktop display backends can advertise native virtual-keyboard support + # without presenting one for controller focus. Only Android delegates to + # that platform keyboard by default. + var resolved_platform: String = ( + OS.get_name() if platform_name.is_empty() else platform_name + ) + return ( + preference_enabled + or resolved_platform != "Android" + or not native_virtual_keyboard_available + ) func _can_edit(control: Control) -> bool: