forked from woofmeow/straywild
340 lines
9.9 KiB
GDScript
340 lines
9.9 KiB
GDScript
class_name FileDialogControllerNavigation
|
|
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:
|
|
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
|
|
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
|
|
|
|
|
|
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
|
|
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
|
|
_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()
|
|
|
|
|
|
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()
|