feat: overhaul controller menu accessibility

This commit is contained in:
Alexander Sellite 2026-08-15 19:20:42 -04:00
parent a7837c3711
commit 4f9c85989b
46 changed files with 4091 additions and 396 deletions

View file

@ -3,16 +3,22 @@ extends RefCounted
const PERPENDICULAR_WEIGHT: float = 2.5
const MINIMUM_FORWARD_DISTANCE: float = 0.5
const MAXIMUM_DIRECTION_SLOPE: float = 1.0
static func configure_spatial_neighbors(controls: Array) -> void:
var candidates: Array[Control] = []
for item: Variant in controls:
var control := item as Control
if control == null or not control.is_visible_in_tree():
if not is_focusable(control):
if control != null:
_clear_neighbors(control)
continue
candidates.append(control)
var traversal_order: Array[Control] = candidates.duplicate()
traversal_order.sort_custom(_sort_by_position)
for control: Control in candidates:
_enable_ancestor_scroll_follow(control)
control.focus_neighbor_left = _neighbor_path(
control, candidates, Vector2.LEFT
)
@ -25,6 +31,80 @@ static func configure_spatial_neighbors(controls: Array) -> void:
control.focus_neighbor_bottom = _neighbor_path(
control, candidates, Vector2.DOWN
)
var traversal_index: int = traversal_order.find(control)
control.focus_previous = control.get_path_to(
traversal_order[wrapi(
traversal_index - 1,
0,
traversal_order.size(),
)]
)
control.focus_next = control.get_path_to(
traversal_order[wrapi(
traversal_index + 1,
0,
traversal_order.size(),
)]
)
static func configure_traversal(controls: Array) -> void:
var candidates: Array[Control] = []
for item: Variant in controls:
var control := item as Control
if is_focusable(control):
candidates.append(control)
elif control != null:
control.focus_previous = NodePath()
control.focus_next = NodePath()
if candidates.is_empty():
return
for index: int in candidates.size():
var control: Control = candidates[index]
_enable_ancestor_scroll_follow(control)
control.focus_previous = control.get_path_to(
candidates[wrapi(index - 1, 0, candidates.size())]
)
control.focus_next = control.get_path_to(
candidates[wrapi(index + 1, 0, candidates.size())]
)
static func is_focusable(control: Control) -> bool:
if (
control == null
or not control.is_visible_in_tree()
or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL]
):
return false
var button := control as BaseButton
return button == null or not button.disabled
static func _clear_neighbors(control: Control) -> void:
control.focus_neighbor_left = NodePath()
control.focus_neighbor_right = NodePath()
control.focus_neighbor_top = NodePath()
control.focus_neighbor_bottom = NodePath()
control.focus_previous = NodePath()
control.focus_next = NodePath()
static func _enable_ancestor_scroll_follow(control: Control) -> void:
var ancestor: Node = control.get_parent()
while ancestor != null:
var scroll := ancestor as ScrollContainer
if scroll != null:
scroll.follow_focus = true
ancestor = ancestor.get_parent()
static func _sort_by_position(first: Control, second: Control) -> bool:
var first_center: Vector2 = first.get_global_rect().get_center()
var second_center: Vector2 = second.get_global_rect().get_center()
if not is_equal_approx(first_center.y, second_center.y):
return first_center.y < second_center.y
return first_center.x < second_center.x
static func _neighbor_path(
@ -36,7 +116,7 @@ static func _neighbor_path(
var best: Control = null
var best_score: float = INF
for candidate: Control in candidates:
if candidate == origin or candidate.focus_mode == Control.FOCUS_NONE:
if candidate == origin or not is_focusable(candidate):
continue
var delta: Vector2 = (
candidate.get_global_rect().get_center() - origin_center
@ -45,6 +125,8 @@ static func _neighbor_path(
if forward_distance <= MINIMUM_FORWARD_DISTANCE:
continue
var perpendicular_distance: float = absf(delta.cross(direction))
if perpendicular_distance > forward_distance * MAXIMUM_DIRECTION_SLOPE:
continue
var score: float = (
forward_distance
+ perpendicular_distance * PERPENDICULAR_WEIGHT
@ -53,5 +135,5 @@ static func _neighbor_path(
best = candidate
best_score = score
if best == null:
return NodePath()
return origin.get_path_to(origin)
return origin.get_path_to(best)