netfishing/ui/file_dialog_controller_navigation.gd

327 lines
9.5 KiB
GDScript3
Raw Normal View History

class_name FileDialogControllerNavigation
extends RefCounted
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
2026-08-15 22:43:29 -04:00
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:
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
2026-08-15 22:43:29 -04:00
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:
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
2026-08-15 22:43:29 -04:00
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
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
2026-08-15 22:43:29 -04:00
_apply_accessibility_name(control)
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()
2026-08-15 22:43:29 -04:00
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()