2026-08-08 14:23:28 -04:00
|
|
|
class_name ControllerFocusRecovery
|
|
|
|
|
extends Node
|
|
|
|
|
|
|
|
|
|
const CONTROLLER_AXIS_THRESHOLD: float = 0.35
|
|
|
|
|
const SEMANTIC_MATCH_BONUS: float = 1000000.0
|
2026-08-11 20:02:54 -04:00
|
|
|
const KEYBOARD_NAVIGATION_ACTIONS: Array[StringName] = [
|
|
|
|
|
&"ui_accept",
|
|
|
|
|
&"ui_cancel",
|
|
|
|
|
&"ui_up",
|
|
|
|
|
&"ui_down",
|
|
|
|
|
&"ui_left",
|
|
|
|
|
&"ui_right",
|
|
|
|
|
&"ui_focus_next",
|
|
|
|
|
&"ui_focus_prev",
|
|
|
|
|
&"ui_page_up",
|
|
|
|
|
&"ui_page_down",
|
|
|
|
|
&"ui_home",
|
|
|
|
|
&"ui_end",
|
|
|
|
|
]
|
2026-08-08 14:23:28 -04:00
|
|
|
|
|
|
|
|
var _controller_active: bool = false
|
2026-08-11 20:02:54 -04:00
|
|
|
var _focus_navigation_active: bool = false
|
2026-08-08 14:23:28 -04:00
|
|
|
var _last_focus_center: Vector2 = Vector2.ZERO
|
|
|
|
|
var _last_focus_key: String = ""
|
|
|
|
|
var _scope_chain: Array[WeakRef] = []
|
2026-08-11 20:02:54 -04:00
|
|
|
var _pending_focus: WeakRef
|
|
|
|
|
var _pointer_button_down: bool = false
|
2026-08-08 14:23:28 -04:00
|
|
|
var _recovery_generation: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
|
set_process_input(true)
|
|
|
|
|
set_process(true)
|
|
|
|
|
get_viewport().gui_focus_changed.connect(_on_gui_focus_changed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
|
|
|
var viewport := get_viewport()
|
|
|
|
|
if viewport.gui_focus_changed.is_connected(_on_gui_focus_changed):
|
|
|
|
|
viewport.gui_focus_changed.disconnect(_on_gui_focus_changed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
|
|
|
if event is InputEventJoypadButton:
|
|
|
|
|
var button_event := event as InputEventJoypadButton
|
|
|
|
|
if button_event.pressed:
|
2026-08-11 20:02:54 -04:00
|
|
|
_pointer_button_down = false
|
2026-08-08 14:23:28 -04:00
|
|
|
_controller_active = true
|
2026-08-11 20:02:54 -04:00
|
|
|
_focus_navigation_active = true
|
2026-08-08 14:23:28 -04:00
|
|
|
if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER:
|
|
|
|
|
_recovery_generation += 1
|
|
|
|
|
_scope_chain.clear()
|
2026-08-11 20:02:54 -04:00
|
|
|
_pending_focus = null
|
|
|
|
|
return
|
|
|
|
|
_request_pending_focus()
|
2026-08-08 14:23:28 -04:00
|
|
|
return
|
|
|
|
|
if event is InputEventJoypadMotion:
|
|
|
|
|
if absf((event as InputEventJoypadMotion).axis_value) >= (
|
|
|
|
|
CONTROLLER_AXIS_THRESHOLD
|
|
|
|
|
):
|
2026-08-11 20:02:54 -04:00
|
|
|
_pointer_button_down = false
|
2026-08-08 14:23:28 -04:00
|
|
|
_controller_active = true
|
2026-08-11 20:02:54 -04:00
|
|
|
_focus_navigation_active = true
|
|
|
|
|
_request_pending_focus()
|
|
|
|
|
return
|
|
|
|
|
if event is InputEventMouseMotion:
|
|
|
|
|
_pointer_button_down = (
|
|
|
|
|
(event as InputEventMouseMotion).button_mask != 0
|
|
|
|
|
)
|
|
|
|
|
_leave_focus_navigation(not _pointer_button_down)
|
2026-08-08 14:23:28 -04:00
|
|
|
return
|
|
|
|
|
if event is InputEventMouseButton:
|
2026-08-11 20:02:54 -04:00
|
|
|
var mouse_button := event as InputEventMouseButton
|
|
|
|
|
if mouse_button.button_index in [
|
|
|
|
|
MOUSE_BUTTON_LEFT,
|
|
|
|
|
MOUSE_BUTTON_RIGHT,
|
|
|
|
|
MOUSE_BUTTON_MIDDLE,
|
|
|
|
|
]:
|
|
|
|
|
_pointer_button_down = mouse_button.pressed
|
|
|
|
|
_leave_focus_navigation(false)
|
|
|
|
|
if not mouse_button.pressed:
|
|
|
|
|
_release_current_pointer_focus.call_deferred()
|
2026-08-08 14:23:28 -04:00
|
|
|
return
|
|
|
|
|
if event is InputEventKey and (event as InputEventKey).pressed:
|
2026-08-11 20:02:54 -04:00
|
|
|
_pointer_button_down = false
|
2026-08-08 14:23:28 -04:00
|
|
|
_controller_active = false
|
2026-08-11 20:02:54 -04:00
|
|
|
if _is_keyboard_navigation_event(event):
|
|
|
|
|
_focus_navigation_active = true
|
|
|
|
|
_request_pending_focus()
|
|
|
|
|
else:
|
|
|
|
|
_leave_focus_navigation()
|
2026-08-08 14:23:28 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
|
|
|
if (
|
|
|
|
|
_controller_active
|
|
|
|
|
and not _scope_chain.is_empty()
|
|
|
|
|
and get_viewport().gui_get_focus_owner() == null
|
|
|
|
|
):
|
|
|
|
|
_recover_focus(_recovery_generation)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_gui_focus_changed(control: Control) -> void:
|
|
|
|
|
_recovery_generation += 1
|
|
|
|
|
if control != null:
|
2026-08-11 20:02:54 -04:00
|
|
|
if _focus_navigation_active:
|
|
|
|
|
_pending_focus = null
|
|
|
|
|
if _controller_active and control is BaseButton:
|
|
|
|
|
_remember_focus(control)
|
|
|
|
|
return
|
|
|
|
|
if _keeps_pointer_focus(control):
|
|
|
|
|
return
|
|
|
|
|
if _is_focusable(control):
|
|
|
|
|
_pending_focus = weakref(control)
|
|
|
|
|
_release_focus_if_inactive.call_deferred(control)
|
2026-08-08 14:23:28 -04:00
|
|
|
return
|
|
|
|
|
if not _controller_active or _scope_chain.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var generation: int = _recovery_generation
|
|
|
|
|
_recover_focus.call_deferred(generation)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _remember_focus(control: Control) -> void:
|
|
|
|
|
_last_focus_center = control.get_global_rect().get_center()
|
|
|
|
|
_last_focus_key = _semantic_key(control)
|
|
|
|
|
_scope_chain.clear()
|
|
|
|
|
var recovery_root: Node = get_parent()
|
|
|
|
|
var ancestor: Node = control.get_parent()
|
|
|
|
|
while ancestor != null:
|
|
|
|
|
if ancestor is Control:
|
|
|
|
|
_scope_chain.append(weakref(ancestor))
|
|
|
|
|
if ancestor == recovery_root:
|
|
|
|
|
break
|
|
|
|
|
ancestor = ancestor.get_parent()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _recover_focus(generation: int) -> void:
|
|
|
|
|
if generation != _recovery_generation:
|
|
|
|
|
return
|
|
|
|
|
if not _controller_active or get_viewport().gui_get_focus_owner() != null:
|
|
|
|
|
return
|
|
|
|
|
for scope_reference: WeakRef in _scope_chain:
|
|
|
|
|
var scope := scope_reference.get_ref() as Control
|
|
|
|
|
if (
|
|
|
|
|
scope == null
|
|
|
|
|
or not is_instance_valid(scope)
|
|
|
|
|
or not scope.is_inside_tree()
|
|
|
|
|
):
|
|
|
|
|
continue
|
2026-08-11 20:02:54 -04:00
|
|
|
if not scope.is_visible_in_tree():
|
|
|
|
|
_scope_chain.clear()
|
|
|
|
|
return
|
2026-08-08 14:23:28 -04:00
|
|
|
var replacement := _best_replacement_in(scope)
|
|
|
|
|
if replacement != null:
|
|
|
|
|
replacement.grab_focus()
|
2026-08-11 20:02:54 -04:00
|
|
|
else:
|
|
|
|
|
_scope_chain.clear()
|
|
|
|
|
return
|
|
|
|
|
_scope_chain.clear()
|
2026-08-08 14:23:28 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _best_replacement_in(scope: Control) -> Control:
|
|
|
|
|
var best: Control = null
|
|
|
|
|
var best_score: float = INF
|
|
|
|
|
for node: Node in scope.find_children("*", "Control", true, false):
|
|
|
|
|
var candidate := node as Control
|
|
|
|
|
if not _is_focusable(candidate):
|
|
|
|
|
continue
|
|
|
|
|
var distance: float = candidate.get_global_rect().get_center().distance_squared_to(
|
|
|
|
|
_last_focus_center
|
|
|
|
|
)
|
|
|
|
|
if not _last_focus_key.is_empty() and _semantic_key(candidate) == _last_focus_key:
|
|
|
|
|
distance -= SEMANTIC_MATCH_BONUS
|
|
|
|
|
if distance < best_score:
|
|
|
|
|
best = candidate
|
|
|
|
|
best_score = distance
|
|
|
|
|
return best
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _is_focusable(control: Control) -> bool:
|
|
|
|
|
if (
|
|
|
|
|
control == null
|
|
|
|
|
or not control.is_inside_tree()
|
|
|
|
|
or not control.is_visible_in_tree()
|
2026-08-11 20:02:54 -04:00
|
|
|
or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL]
|
2026-08-08 14:23:28 -04:00
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
var button := control as BaseButton
|
|
|
|
|
return button == null or not button.disabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _semantic_key(control: Control) -> String:
|
|
|
|
|
if control.has_meta(&"controller_focus_key"):
|
|
|
|
|
return str(control.get_meta(&"controller_focus_key"))
|
|
|
|
|
var button := control as Button
|
|
|
|
|
var label: String = button.text if button != null else ""
|
|
|
|
|
var tooltip: String = control.tooltip_text
|
|
|
|
|
tooltip = tooltip.trim_suffix(" (show variants)")
|
|
|
|
|
tooltip = tooltip.trim_suffix(" (hide variants)")
|
|
|
|
|
return "%s|%s|%s" % [control.get_class(), label, tooltip]
|
2026-08-11 20:02:54 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _leave_focus_navigation(release_focus: bool = true) -> void:
|
|
|
|
|
_controller_active = false
|
|
|
|
|
_focus_navigation_active = false
|
|
|
|
|
_recovery_generation += 1
|
|
|
|
|
_scope_chain.clear()
|
|
|
|
|
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
|
|
|
|
if focus_owner == null or _keeps_pointer_focus(focus_owner):
|
|
|
|
|
return
|
|
|
|
|
if _is_focusable(focus_owner):
|
|
|
|
|
_pending_focus = weakref(focus_owner)
|
|
|
|
|
if release_focus:
|
|
|
|
|
_release_focus_if_inactive.call_deferred(focus_owner)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _release_focus_if_inactive(control: Control) -> void:
|
|
|
|
|
if (
|
|
|
|
|
_focus_navigation_active
|
|
|
|
|
or _pointer_button_down
|
|
|
|
|
or control == null
|
|
|
|
|
or not is_instance_valid(control)
|
|
|
|
|
or get_viewport().gui_get_focus_owner() != control
|
|
|
|
|
or _keeps_pointer_focus(control)
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
get_viewport().gui_release_focus()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _release_current_pointer_focus() -> void:
|
|
|
|
|
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
|
|
|
|
if focus_owner != null:
|
|
|
|
|
_release_focus_if_inactive(focus_owner)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _request_pending_focus() -> void:
|
|
|
|
|
if (
|
|
|
|
|
not _focus_navigation_active
|
|
|
|
|
or _pending_focus == null
|
|
|
|
|
or get_viewport().gui_get_focus_owner() != null
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
_restore_pending_focus.call_deferred(_recovery_generation)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _restore_pending_focus(generation: int) -> void:
|
|
|
|
|
if (
|
|
|
|
|
generation != _recovery_generation
|
|
|
|
|
or not _focus_navigation_active
|
|
|
|
|
or _pending_focus == null
|
|
|
|
|
or get_viewport().gui_get_focus_owner() != null
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var target := _pending_focus.get_ref() as Control
|
|
|
|
|
if not _is_focusable(target):
|
|
|
|
|
_pending_focus = null
|
|
|
|
|
return
|
|
|
|
|
_pending_focus = null
|
|
|
|
|
target.grab_focus()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _is_keyboard_navigation_event(event: InputEvent) -> bool:
|
|
|
|
|
if event.is_action_pressed(&"open_backpack"):
|
|
|
|
|
return false
|
|
|
|
|
if (
|
|
|
|
|
event.is_action_pressed(&"ui_cancel")
|
|
|
|
|
and get_viewport().gui_get_focus_owner() == null
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
for action: StringName in KEYBOARD_NAVIGATION_ACTIONS:
|
|
|
|
|
if event.is_action_pressed(action):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _keeps_pointer_focus(control: Control) -> bool:
|
|
|
|
|
return control is LineEdit or control is TextEdit
|