Add wrapping keyboard and gamepad focus rings
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Skipped

- Link every modal's enabled controls into a wrapping UiFocus ring and give each screen a default focus target.
- Add gamepad A/B bindings for ui_accept/ui_cancel; include Start menu footer actions and Continue in its focus ring.
- Skip hidden/disabled controls, close focus traps via per-modal cancel behavior, and return review to the menu on cancel.
- Record the focus audit and add regression coverage.
This commit is contained in:
Gig Line Dev committed 2026-11-02 16:05:53 +01:00
1 parent 9692f262f1
commit 8f65751975
17 files changed
+262 -76

No files matched your search

+39 -17
View File
@@ -25,6 +25,8 @@ const VENUES := [
var _index := 0
var _buttons: Array[Button] = []
var _footer_buttons: Array[Button] = []
var _focus_targets: Array[Button] = []
var _root: Control = null
var _status: Label = null
var _continue_button: Button = null
@@ -139,6 +141,7 @@ func _build() -> void:
help_button.pressed.connect(func(): help_requested.emit())
UiStyles.apply_button(help_button, 16)
row.add_child(help_button)
_footer_buttons.append(help_button)
var settings_button := Button.new()
settings_button.name = "SettingsButton"
@@ -146,6 +149,7 @@ func _build() -> void:
settings_button.pressed.connect(func(): settings_requested.emit())
UiStyles.apply_button(settings_button, 16)
row.add_child(settings_button)
_footer_buttons.append(settings_button)
var quit_button := Button.new()
quit_button.name = "QuitButton"
@@ -153,6 +157,7 @@ func _build() -> void:
quit_button.pressed.connect(func(): quit_requested.emit())
UiStyles.apply_button(quit_button, 16)
row.add_child(quit_button)
_footer_buttons.append(quit_button)
var hint := Label.new()
hint.text = Strings.lookup("ui.start_hint")
@@ -160,13 +165,28 @@ func _build() -> void:
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
_refresh_focus_targets()
_focus_index(0)
## Continue + venue rows + footer actions, in visual order. Hidden Continue is
## skipped so keyboard/gamepad focus never lands on an invisible control.
func _refresh_focus_targets() -> void:
_focus_targets.clear()
if _continue_button and _continue_button.visible:
_focus_targets.append(_continue_button)
_focus_targets.append_array(_buttons)
_focus_targets.append_array(_footer_buttons)
func _focus_index(i: int) -> void:
if _buttons.is_empty():
if _focus_targets.is_empty():
return
_index = clampi(i, 0, _buttons.size() - 1)
_buttons[_index].grab_focus()
_index = wrapi(i, 0, _focus_targets.size())
_focus_targets[_index].grab_focus()
func _activate_focus() -> void:
if _focus_targets.is_empty():
return
_focus_targets[_index].pressed.emit()
func open(campaign: Dictionary = {}) -> void:
_campaign = campaign.duplicate(true)
@@ -184,6 +204,7 @@ func open(campaign: Dictionary = {}) -> void:
var next_label := _venue_label(next_id)
_continue_button.text = Strings.fmt("ui.continue_shift", [next_label]) if next_id != "" else Strings.lookup("ui.campaign_complete_short")
_continue_button.visible = not complete
_refresh_focus_targets()
_focus_index(_index)
func _venue_label(id: String) -> String:
@@ -203,20 +224,21 @@ func close() -> void:
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_UP, KEY_W:
_focus_index(_index - 1)
get_viewport().set_input_as_handled()
KEY_DOWN, KEY_S:
_focus_index(_index + 1)
get_viewport().set_input_as_handled()
KEY_ENTER, KEY_KP_ENTER, KEY_SPACE:
_on_venue_pressed(_index)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
quit_requested.emit()
get_viewport().set_input_as_handled()
if event is InputEventKey and event.echo:
return
var key := event as InputEventKey
if event.is_action_pressed("ui_up") or (key != null and key.pressed and key.keycode == KEY_W):
_focus_index(_index - 1)
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_down") or (key != null and key.pressed and key.keycode == KEY_S):
_focus_index(_index + 1)
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_accept"):
_activate_focus()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_cancel"):
quit_requested.emit()
get_viewport().set_input_as_handled()
func _on_venue_pressed(i: int) -> void:
if i < 0 or i >= VENUES.size():