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

+21 -9
View File
@@ -10,6 +10,7 @@ signal closed()
const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
var events: Array[Dictionary] = []
var index := 0
@@ -18,6 +19,7 @@ var _detail_label: Label = null
var _counter_label: Label = null
var _prev_button: Button = null
var _next_button: Button = null
var _close_button: Button = null
var _root: Control = null
func _init() -> void:
@@ -107,12 +109,12 @@ func _build() -> void:
UiStyles.apply_button(_next_button, 15, true)
row.add_child(_next_button)
var close := Button.new()
close.name = "CloseButton"
close.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close)
UiStyles.apply_button(close, 15, true)
row.add_child(close)
_close_button = Button.new()
_close_button.name = "CloseButton"
_close_button.text = Strings.lookup("timeline.close")
_close_button.pressed.connect(_on_close)
UiStyles.apply_button(_close_button, 15, true)
row.add_child(_close_button)
var hint := Label.new()
hint.text = Strings.lookup("timeline.hint")
@@ -144,6 +146,9 @@ func show_timeline(review: Dictionary) -> void:
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_refresh()
var first := UiFocus.first_enabled([_prev_button, _next_button, _close_button])
if first:
first.grab_focus()
func step(direction: int) -> void:
if events.is_empty():
@@ -165,6 +170,12 @@ func _refresh() -> void:
_prev_button.disabled = not has_events or index <= 0
if _next_button:
_next_button.disabled = not has_events or index >= events.size() - 1
UiFocus.link([_prev_button, _next_button, _close_button])
# A disabled control cannot keep focus; move to the other end of the row.
if _prev_button and _prev_button.disabled and _prev_button.has_focus() and _close_button:
_close_button.grab_focus()
if _next_button and _next_button.disabled and _next_button.has_focus() and _close_button:
_close_button.grab_focus()
if not has_events:
_counter_label.text = Strings.lookup("timeline.empty")
_detail_label.text = ""
@@ -225,6 +236,10 @@ func _stamp(at_ms: int) -> String:
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("ui_cancel"):
close()
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_LEFT, KEY_A:
@@ -233,6 +248,3 @@ func _unhandled_input(event: InputEvent) -> void:
KEY_RIGHT, KEY_D:
step(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
+12 -8
View File
@@ -5,11 +5,13 @@ extends CanvasLayer
const Strings := preload("res://src/core/strings.gd")
const InputBindings := preload("res://src/core/input_bindings.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
signal closed()
var _root: Control = null
var _search: LineEdit = null
var _close_button: Button = null
var _body: Label = null
var _policy: String = ""
var _lines: Array[String] = []
@@ -67,12 +69,13 @@ func _build() -> void:
_body.vertical_alignment = VERTICAL_ALIGNMENT_TOP
column.add_child(_body)
var close := Button.new()
close.text = Strings.lookup("ui.help_close")
close.pressed.connect(func(): close_panel())
close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
UiStyles.apply_button(close, 16)
column.add_child(close)
_close_button = Button.new()
_close_button.text = Strings.lookup("ui.help_close")
_close_button.pressed.connect(func(): close_panel())
_close_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
UiStyles.apply_button(_close_button, 16)
column.add_child(_close_button)
UiFocus.link([_search, _close_button], false)
func open(policy: String = "") -> void:
_policy = policy
@@ -80,6 +83,8 @@ func open(policy: String = "") -> void:
_root.visible = true
_search.text = ""
_refresh("")
UiFocus.link([_search, _close_button], false)
_search.grab_focus()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func close_panel() -> void:
@@ -106,7 +111,6 @@ func _refresh(filter: String) -> void:
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_ESCAPE:
if event.is_action_pressed("ui_cancel"):
close_panel()
get_viewport().set_input_as_handled()
+9 -4
View File
@@ -11,10 +11,12 @@ signal quit_to_menu_requested()
const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
const Strings := preload("res://src/core/strings.gd")
var _root: Control = null
var _resume_button: Button = null
var _buttons: Array[Button] = []
func _ready() -> void:
layer = 18
@@ -53,9 +55,11 @@ func _build() -> void:
column.add_child(title)
_resume_button = _add_button(column, Strings.lookup("ui.pause_resume"), "ResumeButton", func(): resume_requested.emit())
_add_button(column, Strings.lookup("ui.pause_restart"), "RestartButton", func(): restart_requested.emit())
_add_button(column, Strings.lookup("ui.pause_settings"), "SettingsButton", func(): settings_requested.emit())
_add_button(column, Strings.lookup("ui.pause_menu"), "MenuButton", func(): quit_to_menu_requested.emit())
_buttons.append(_add_button(column, Strings.lookup("ui.pause_restart"), "RestartButton", func(): restart_requested.emit()))
_buttons.append(_add_button(column, Strings.lookup("ui.pause_settings"), "SettingsButton", func(): settings_requested.emit()))
_buttons.append(_add_button(column, Strings.lookup("ui.pause_menu"), "MenuButton", func(): quit_to_menu_requested.emit()))
_buttons.push_front(_resume_button)
UiFocus.link(_buttons)
var hint := Label.new()
hint.text = Strings.lookup("ui.pause_hint")
@@ -77,6 +81,7 @@ func open() -> void:
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
UiFocus.link(_buttons)
if _resume_button:
_resume_button.grab_focus()
@@ -87,6 +92,6 @@ func close() -> void:
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if event.is_action_pressed("ui_cancel"):
resume_requested.emit()
get_viewport().set_input_as_handled()
+20 -5
View File
@@ -11,6 +11,7 @@ const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
var panel: PanelContainer
var npc_name_label: Label
@@ -19,6 +20,8 @@ var desc_edit: LineEdit
var weapons_check: CheckBox
var danger_option: OptionButton
var action_button: Button
var cancel_button: Button
var _focus_controls: Array[Control] = []
var hold_hint: Label
var status_label: Label
@@ -110,11 +113,14 @@ func _init() -> void:
action_button.pressed.connect(_on_action_pressed)
UiStyles.apply_button(action_button, 16)
buttons.add_child(action_button)
var cancel_btn := Button.new()
cancel_btn.text = Strings.lookup("ui.radio_cancel")
cancel_btn.pressed.connect(func(): cancelled.emit())
UiStyles.apply_button(cancel_btn, 16)
buttons.add_child(cancel_btn)
cancel_button = Button.new()
cancel_button.text = Strings.lookup("ui.radio_cancel")
cancel_button.pressed.connect(func(): cancelled.emit())
UiStyles.apply_button(cancel_button, 16)
buttons.add_child(cancel_button)
_focus_controls = [location_edit, desc_edit, weapons_check, danger_option,
action_button, cancel_button]
UiFocus.link(_focus_controls, false)
var _title_label: Label
@@ -148,6 +154,8 @@ func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
_holding = false
_hold_time = 0.0
visible = true
UiFocus.link(_focus_controls, false)
action_button.grab_focus()
## Non-emergency requests are a normal click. Emergency calls require the
## press-and-hold path, so the click release must not submit early.
@@ -200,3 +208,10 @@ func _danger_name(level: int) -> String:
2: return "elevated"
3: return "immediate threat"
_: return "unknown"
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("ui_cancel"):
cancelled.emit()
get_viewport().set_input_as_handled()
+11 -1
View File
@@ -9,6 +9,7 @@ extends CanvasLayer
const InputBindings := preload("res://src/core/input_bindings.gd")
const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
const Strings := preload("res://src/core/strings.gd")
signal applied(settings: Dictionary)
@@ -244,8 +245,17 @@ func open(current: Dictionary) -> void:
_sync_labels()
visible = true
_root.visible = true
_link_focus()
if _sensitivity:
_sensitivity.grab_focus()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _link_focus() -> void:
var controls: Array = []
for node in _root.find_children("*", "Control", true, false):
controls.append(node)
UiFocus.link(controls, false)
func close() -> void:
visible = false
_root.visible = false
@@ -334,6 +344,6 @@ func _unhandled_input(event: InputEvent) -> void:
if pressed and _apply_remap(event):
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if event.is_action_pressed("ui_cancel"):
_on_cancel()
get_viewport().set_input_as_handled()
+39 -20
View File
@@ -5,6 +5,7 @@ extends CanvasLayer
const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
signal restart_requested()
signal continue_requested()
@@ -16,6 +17,9 @@ var _review: Dictionary = {}
var report_label: Label
var _campaign_label: Label = null
var _continue_button: Button = null
var _timeline_button: Button = null
var _restart_button: Button = null
var _quit_button: Button = null
func _init() -> void:
name = "ShiftReview"
@@ -56,33 +60,36 @@ func _init() -> void:
center.add_child(_campaign_label)
_continue_button = Button.new()
_continue_button.name = "ContinueButton"
_continue_button.text = Strings.lookup("review.continue_campaign")
_continue_button.pressed.connect(func(): continue_requested.emit())
_continue_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(_continue_button)
var timeline := Button.new()
timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button")
timeline.pressed.connect(func(): timeline_requested.emit(_review))
timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(timeline)
_timeline_button = Button.new()
_timeline_button.name = "TimelineButton"
_timeline_button.text = Strings.lookup("review.timeline_button")
_timeline_button.pressed.connect(func(): timeline_requested.emit(_review))
_timeline_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(_timeline_button)
var restart := Button.new()
restart.text = Strings.lookup("ui.review_return_menu")
restart.pressed.connect(func(): restart_requested.emit())
restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(restart)
_restart_button = Button.new()
_restart_button.name = "ReturnMenuButton"
_restart_button.text = Strings.lookup("ui.review_return_menu")
_restart_button.pressed.connect(func(): restart_requested.emit())
_restart_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(_restart_button)
var quit_btn := Button.new()
quit_btn.text = Strings.lookup("ui.quit_game")
quit_btn.pressed.connect(func(): get_tree().quit())
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_style_review_button(timeline)
_quit_button = Button.new()
_quit_button.name = "QuitButton"
_quit_button.text = Strings.lookup("ui.quit_game")
_quit_button.pressed.connect(func(): get_tree().quit())
_quit_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_style_review_button(_timeline_button)
_style_review_button(_continue_button)
_style_review_button(restart)
_style_review_button(quit_btn)
center.add_child(quit_btn)
_style_review_button(_restart_button)
_style_review_button(_quit_button)
center.add_child(_quit_button)
func _style_review_button(button: Button) -> void:
button.custom_minimum_size = Vector2(300, 52)
@@ -154,4 +161,16 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = [],
_campaign_label.text = ""
if _continue_button:
_continue_button.visible = not campaign_complete and next_venue != ""
visible = true
visible = true
var buttons: Array = [_continue_button, _timeline_button, _restart_button, _quit_button]
UiFocus.link(buttons)
var first := UiFocus.first_enabled(buttons)
if first:
first.grab_focus()
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("ui_cancel"):
restart_requested.emit()
get_viewport().set_input_as_handled()
+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():
+45
View File
@@ -0,0 +1,45 @@
class_name UiFocus
extends RefCounted
## Small focus-ring helper for keyboard and gamepad navigation.
##
## Godot's default traversal stops at the end of a list. Linking every enabled
## control back to the other end makes Up/Down (and optionally Left/Right) and
## Tab/Shift+Tab wrap predictably inside a modal.
static func link(controls: Array, include_horizontal: bool = true) -> void:
var valid: Array[Control] = []
for node in controls:
var control := node as Control
if control == null or not control.visible or control.focus_mode == Control.FOCUS_NONE:
continue
if control is BaseButton and (control as BaseButton).disabled:
continue
if control.is_inside_tree() and control.get_viewport() != null:
valid.append(control)
if valid.is_empty():
return
var count := valid.size()
for i in count:
var control := valid[i]
var previous: Control = valid[(i - 1 + count) % count]
var next: Control = valid[(i + 1) % count]
var prev_path := control.get_path_to(previous)
var next_path := control.get_path_to(next)
control.focus_neighbor_top = prev_path
control.focus_neighbor_bottom = next_path
control.focus_next = next_path
control.focus_previous = prev_path
if include_horizontal:
control.focus_neighbor_left = prev_path
control.focus_neighbor_right = next_path
## First enabled control in reading order, or null when every control is off.
static func first_enabled(controls: Array) -> Control:
for node in controls:
var control := node as Control
if control == null or not control.visible or control.focus_mode == Control.FOCUS_NONE:
continue
if control is BaseButton and (control as BaseButton).disabled:
continue
return control
return null