fix controller file dialog navigation

This commit is contained in:
Alexander Sellite 2026-08-15 22:43:29 -04:00
parent 2208977c8d
commit e12ed54ab8
6 changed files with 469 additions and 26 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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 (

View file

@ -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: