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
+261 -75

No files matched your search

+3
View File
@@ -11,6 +11,9 @@ Semantic Versioning.
- All session, settings, radio, Help, review and timeline action buttons now - All session, settings, radio, Help, review and timeline action buttons now
share explicit normal, hover, pressed, focus and disabled styles. Timeline share explicit normal, hover, pressed, focus and disabled styles. Timeline
Previous/Next disable at their boundaries. Previous/Next disable at their boundaries.
- Keyboard and gamepad focus rings now wrap through every modal, with explicit
default focus and A/B accept/cancel bindings; hidden Continue and disabled
timeline controls are skipped.
- NPC reason answers now use role-specific dialogue contexts, so staff, guests, - NPC reason answers now use role-specific dialogue contexts, so staff, guests,
delivery workers, performers and other archetypes answer instead of silently delivery workers, performers and other archetypes answer instead of silently
deflecting the Reason verb. deflecting the Reason verb.
+7 -1
View File
@@ -61,6 +61,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
gallery is 13 good / 0 bad, the screen-design judge is 13/13 and a clean gallery is 13 good / 0 bad, the screen-design judge is 13/13 and a clean
portrait measured a 0.732 vision gap against the photoreal concept. No portrait measured a 0.732 vision gap against the photoreal concept. No
release build, version bump or tag was created at this gate. release build, version bump or tag was created at this gate.
- **UI focus (2026-11-02):** `src/game/ui/ui_focus.gd` links visible/enabled
controls into a wrapping ring; each modal sets default focus on open. Start menu
uses its own focus-target list so Continue, venue rows and footer actions share
one ring. `GuardController._ensure_gamepad_bindings()` adds A/B to
`ui_accept`/`ui_cancel` so pad-only users can confirm/cancel; hidden Continue and
disabled timeline buttons never enter the ring.
- **Button states (2026-11-02):** `src/game/ui/ui_styles.gd` owns the five action-button - **Button states (2026-11-02):** `src/game/ui/ui_styles.gd` owns the five action-button
states and font colours for the dark session/UI shell. Use `UiStyles.apply_button()` states and font colours for the dark session/UI shell. Use `UiStyles.apply_button()`
for every new `Button`; CheckButton/OptionButton keep the engine theme. Timeline for every new `Button`; CheckButton/OptionButton keep the engine theme. Timeline
@@ -706,7 +712,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. - Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
- Access loadout is implemented; do not re-fix `override_access` as if it were still unread. - Access loadout is implemented; do not re-fix `override_access` as if it were still unread.
- `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`.
- Headless suite is **259/259**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`. - Headless suite is **260/260**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`.
- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args). - Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args).
- GDScript inference trap that broke the main scene: a `var` typed as a base class - GDScript inference trap that broke the main scene: a `var` typed as a base class
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
+5 -3
View File
@@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these
Baseline: **v0.9.2** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume, Baseline: **v0.9.2** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume,
difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete
escalation/access/radio loop, shift review, the Blender asset library, 259 headless + 97 Python escalation/access/radio loop, shift review, the Blender asset library, 260 headless + 97 Python
tests, a clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong tests, a clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong
release-candidate foundation, not yet a 0.10.0 release candidate. release-candidate foundation, not yet a 0.10.0 release candidate.
@@ -81,8 +81,10 @@ screen-design judge before any model/texture changes. No gameplay rules change.
radio send/emergency hold and Help close. (Shared `UiStyles` states applied; radio send/emergency hold and Help close. (Shared `UiStyles` states applied;
timeline boundary disabling and the keyboard-only F1 skip are documented in timeline boundary disabling and the keyboard-only F1 skip are documented in
`docs/ui-copy.md`.) `docs/ui-copy.md`.)
- [ ] Verify keyboard-only and gamepad-only navigation: focus order, wrapping, - [x] Verify keyboard-only and gamepad-only navigation: focus order, wrapping,
default focus, confirm/cancel behavior and no focus traps. default focus, confirm/cancel behavior and no focus traps. (Focus rings,
gamepad A/B UI bindings, default focus and per-modal cancel behavior are
recorded in `docs/ui-copy.md`.)
- [ ] Verify every modal closes with Esc/right-click where documented, recaptures - [ ] Verify every modal closes with Esc/right-click where documented, recaptures
the mouse, and does not leave the guard frozen or the menu visible. the mouse, and does not leave the guard frozen or the menu visible.
- [ ] Test buttons at minimum/maximum HUD font scale and with 1.5–2× longer - [ ] Test buttons at minimum/maximum HUD font scale and with 1.5–2× longer
+2 -1
View File
@@ -5,7 +5,7 @@ Implemented so far:
- Godot 4.7.2 project scaffold, input map, procedural world builder (CI/local/release unified) - Godot 4.7.2 project scaffold, input map, procedural world builder (CI/local/release unified)
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
- Three authored scenarios (El Capitan Entertainment Centre / Hollywood Boulevard, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools - Three authored scenarios (El Capitan Entertainment Centre / Hollywood Boulevard, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **259 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) - Headless unit tests: **260 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) - Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
@@ -51,6 +51,7 @@ Implemented so far:
- Joke Shop / Sunset Strip map (map optimization): the second venue is now a stylized reconstruction of The Comedy Store block at 8433 Sunset Blvd, with a 27 m black hall, signature-covered sloping canopy, circular **THE JOKE SHOP** blade sign, red velvet curtain bays, a west-end entrance, cream and white low-rise neighbours, a dry hillside with stucco houses and palms behind the roofline, and a low retail/billboard frontage across Sunset. The same geometry serves day and late lighting through the phase system. Real-site references and limits are in [`joke-shop-map.md`](joke-shop-map.md); the map measures **22,680 triangles across 454 meshes** in the budget test. - Joke Shop / Sunset Strip map (map optimization): the second venue is now a stylized reconstruction of The Comedy Store block at 8433 Sunset Blvd, with a 27 m black hall, signature-covered sloping canopy, circular **THE JOKE SHOP** blade sign, red velvet curtain bays, a west-end entrance, cream and white low-rise neighbours, a dry hillside with stucco houses and palms behind the roofline, and a low retail/billboard frontage across Sunset. The same geometry serves day and late lighting through the phase system. Real-site references and limits are in [`joke-shop-map.md`](joke-shop-map.md); the map measures **22,680 triangles across 454 meshes** in the budget test.
- Venue geometry (v0.7.0, Joke Shop frontage superseded by the map-optimization pass): the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes. - Venue geometry (v0.7.0, Joke Shop frontage superseded by the map-optimization pass): the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report. - Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
- Keyboard/gamepad focus pass (0.9.3 work): `UiFocus` rings wrap through visible/enabled controls, hidden Continue and disabled timeline actions are skipped, each modal has a default focus, and A/B are bound to `ui_accept`/`ui_cancel` for pad-only navigation. Recorded in `docs/ui-copy.md`.
- Button-state pass (0.9.3 work): a shared `UiStyles` helper now gives every action button explicit normal, hover, pressed, focus and disabled styles; the timeline disables Previous/Next at its boundaries, while hidden continue states and the keyboard-only tutorial skip remain intentional. Recorded in `docs/ui-copy.md`. - Button-state pass (0.9.3 work): a shared `UiStyles` helper now gives every action button explicit normal, hover, pressed, focus and disabled styles; the timeline disables Previous/Next at its boundaries, while hidden continue states and the keyboard-only tutorial skip remain intentional. Recorded in `docs/ui-copy.md`.
- Dialogue pass (0.9.3 work): NPC reason answers now use the authored `reason` context, with an in-character answer for every shared archetype; Hollywood and Studio gained door-staff lines; radio cancellation, tutorial Ask terminology, help, start hints and campaign reset copy were clarified. Audit recorded in `docs/ui-copy.md`. - Dialogue pass (0.9.3 work): NPC reason answers now use the authored `reason` context, with an in-character answer for every shared archetype; Hollywood and Studio gained door-staff lines; radio cancellation, tutorial Ask terminology, help, start hints and campaign reset copy were clarified. Audit recorded in `docs/ui-copy.md`.
- Pre-build NPC review (2026-11-02): all eight library characters were staged at portrait, conversation and crowd distance on Hollywood and the Joke Shop, in day and night lighting, with the normal crowd hidden (`src/test/capture_npc_review.gd`). No P0 issues were found. Two P1 gear-fit issues were fixed with runtime attach offsets: the authorized-guest lanyard badge now clears the jacket and the firefighter helmet sits above the eyes. Backpack straps, caps, hands and the intentionally low-poly faces were accepted for the stylized target; close-range flashlight wash remains an optional-lighting follow-up. The 24-NPC budget stayed at 6.93 ms, the gallery passed 13 good / 0 bad and the screen-design judge stayed 13/13, and a clean portrait scored a 0.732 vision gap against the photoreal concept. - Pre-build NPC review (2026-11-02): all eight library characters were staged at portrait, conversation and crowd distance on Hollywood and the Joke Shop, in day and night lighting, with the normal crowd hidden (`src/test/capture_npc_review.gd`). No P0 issues were found. Two P1 gear-fit issues were fixed with runtime attach offsets: the authorized-guest lanyard badge now clears the jacket and the firefighter helmet sits above the eyes. Backpack straps, caps, hands and the intentionally low-poly faces were accepted for the stylized target; close-range flashlight wash remains an optional-lighting follow-up. The 24-NPC budget stayed at 6.93 ms, the gallery passed 13 good / 0 bad and the screen-design judge stayed 13/13, and a clean portrait scored a 0.732 vision gap against the photoreal concept.
+21 -2
View File
@@ -1,6 +1,6 @@
# UI copy and button inventory # UI copy and button inventory
Status: dialogue and button-state passes complete; focus/layout audit in progress for 0.9.3. Status: dialogue, button-state and keyboard/gamepad focus passes complete; layout/end-to-end audit in progress for 0.9.3.
## Screen inventory ## Screen inventory
@@ -109,10 +109,29 @@ appeared when a custom dark button was disabled or focused by keyboard.
| Radio | Send request, Send emergency, Cancel. Emergency hold uses press/release, so the action button stays enabled while holding. | All styled; disabling it during the hold would break confirmation. | | Radio | Send request, Send emergency, Cancel. Emergency hold uses press/release, so the action button stays enabled while holding. | All styled; disabling it during the hold would break confirmation. |
| Toggles | CheckButton/OptionButton controls keep the engine theme. | Intentional: the shared style targets action buttons, not checkbox/radio affordances. | | Toggles | CheckButton/OptionButton controls keep the engine theme. | Intentional: the shared style targets action buttons, not checkbox/radio affordances. |
## Keyboard and gamepad focus
`UiFocus` builds an explicit focus ring for each modal, skips hidden and
disabled controls, and makes Up/Down, Tab/Shift+Tab and the D-pad/stick wrap.
`GuardController._ensure_gamepad_bindings()` adds A to `ui_accept` and B to
`ui_cancel`; the built-in directional UI actions already carry D-pad/stick
events.
| Surface | Default focus | Cancellation / trap check |
| --- | --- | --- |
| Start menu | First visible target: Continue when present, otherwise the first venue. | A/Enter/Space activates the focused row; B/Esc quits; Continue, venue rows and footer actions share one wrapping ring. |
| Pause | Resume. | B/Esc resumes; the four actions wrap. |
| Settings | First slider. | B/Esc cancels; focus ring covers sliders, toggles, difficulty, rebinds, Apply and Cancel; sliders keep Left/Right for value adjustment. |
| Help | Search field. | B/Esc closes; Search and Close share a ring. |
| Shift review | Continue when available, otherwise the timeline. | B/Esc returns to the menu; all four actions are in the ring and hidden Continue is skipped. |
| Timeline | First enabled action: Next when Previous is disabled, Close when empty. | B/Esc closes; Previous/Next leave the ring when disabled so focus cannot land on a dead button. |
| Radio | Send action. | B/Esc cancels; form fields, action and Cancel are linked; A confirms the focused action. |
| Tutorial | None; F1 is the documented skip shortcut. | Keyboard-only by design, no focus trap because the card is mouse-transparent and non-modal. |
## Remaining audit work ## Remaining audit work
- Player-facing labels are now routed through `content/strings.en.json`; numeric - Player-facing labels are now routed through `content/strings.en.json`; numeric
values, empty-state clears and debug-only text remain in code by design. values, empty-state clears and debug-only text remain in code by design.
- Verify focus/hover/pressed/disabled states with keyboard, gamepad and mouse. - Focus/hover/pressed/disabled states and keyboard/gamepad rings are recorded above.
- Test long strings and 1.75x HUD font scale. - Test long strings and 1.75x HUD font scale.
- Capture every dialog/button state and run `screen_designs.py compare` plus the pinned judge. - Capture every dialog/button state and run `screen_designs.py compare` plus the pinned judge.
+5
View File
@@ -96,6 +96,11 @@ static func _ensure_gamepad_bindings() -> void:
var buttons := { var buttons := {
"action_menu": JOY_BUTTON_A, "action_menu": JOY_BUTTON_A,
"cancel": JOY_BUTTON_B, "cancel": JOY_BUTTON_B,
# UI focus navigation uses Godot's built-in UI actions. Bind the pad
# accept/cancel buttons too, so every modal works without a mouse or
# keyboard. D-pad and stick navigation are already in ui_up/down/left/right.
"ui_accept": JOY_BUTTON_A,
"ui_cancel": JOY_BUTTON_B,
"inspect": JOY_BUTTON_X, "inspect": JOY_BUTTON_X,
"radio_assist": JOY_BUTTON_Y, "radio_assist": JOY_BUTTON_Y,
"walk_slow": JOY_BUTTON_LEFT_SHOULDER, "walk_slow": JOY_BUTTON_LEFT_SHOULDER,
+21 -9
View File
@@ -10,6 +10,7 @@ signal closed()
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.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 events: Array[Dictionary] = []
var index := 0 var index := 0
@@ -18,6 +19,7 @@ var _detail_label: Label = null
var _counter_label: Label = null var _counter_label: Label = null
var _prev_button: Button = null var _prev_button: Button = null
var _next_button: Button = null var _next_button: Button = null
var _close_button: Button = null
var _root: Control = null var _root: Control = null
func _init() -> void: func _init() -> void:
@@ -107,12 +109,12 @@ func _build() -> void:
UiStyles.apply_button(_next_button, 15, true) UiStyles.apply_button(_next_button, 15, true)
row.add_child(_next_button) row.add_child(_next_button)
var close := Button.new() _close_button = Button.new()
close.name = "CloseButton" _close_button.name = "CloseButton"
close.text = Strings.lookup("timeline.close") _close_button.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close) _close_button.pressed.connect(_on_close)
UiStyles.apply_button(close, 15, true) UiStyles.apply_button(_close_button, 15, true)
row.add_child(close) row.add_child(_close_button)
var hint := Label.new() var hint := Label.new()
hint.text = Strings.lookup("timeline.hint") hint.text = Strings.lookup("timeline.hint")
@@ -144,6 +146,9 @@ func show_timeline(review: Dictionary) -> void:
_root.visible = true _root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_refresh() _refresh()
var first := UiFocus.first_enabled([_prev_button, _next_button, _close_button])
if first:
first.grab_focus()
func step(direction: int) -> void: func step(direction: int) -> void:
if events.is_empty(): if events.is_empty():
@@ -165,6 +170,12 @@ func _refresh() -> void:
_prev_button.disabled = not has_events or index <= 0 _prev_button.disabled = not has_events or index <= 0
if _next_button: if _next_button:
_next_button.disabled = not has_events or index >= events.size() - 1 _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: if not has_events:
_counter_label.text = Strings.lookup("timeline.empty") _counter_label.text = Strings.lookup("timeline.empty")
_detail_label.text = "" _detail_label.text = ""
@@ -225,6 +236,10 @@ func _stamp(at_ms: int) -> String:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not visible: if not visible:
return 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: if event is InputEventKey and event.pressed and not event.echo:
match event.keycode: match event.keycode:
KEY_LEFT, KEY_A: KEY_LEFT, KEY_A:
@@ -233,6 +248,3 @@ func _unhandled_input(event: InputEvent) -> void:
KEY_RIGHT, KEY_D: KEY_RIGHT, KEY_D:
step(1) step(1)
get_viewport().set_input_as_handled() 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 Strings := preload("res://src/core/strings.gd")
const InputBindings := preload("res://src/core/input_bindings.gd") const InputBindings := preload("res://src/core/input_bindings.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
signal closed() signal closed()
var _root: Control = null var _root: Control = null
var _search: LineEdit = null var _search: LineEdit = null
var _close_button: Button = null
var _body: Label = null var _body: Label = null
var _policy: String = "" var _policy: String = ""
var _lines: Array[String] = [] var _lines: Array[String] = []
@@ -67,12 +69,13 @@ func _build() -> void:
_body.vertical_alignment = VERTICAL_ALIGNMENT_TOP _body.vertical_alignment = VERTICAL_ALIGNMENT_TOP
column.add_child(_body) column.add_child(_body)
var close := Button.new() _close_button = Button.new()
close.text = Strings.lookup("ui.help_close") _close_button.text = Strings.lookup("ui.help_close")
close.pressed.connect(func(): close_panel()) _close_button.pressed.connect(func(): close_panel())
close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER _close_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
UiStyles.apply_button(close, 16) UiStyles.apply_button(_close_button, 16)
column.add_child(close) column.add_child(_close_button)
UiFocus.link([_search, _close_button], false)
func open(policy: String = "") -> void: func open(policy: String = "") -> void:
_policy = policy _policy = policy
@@ -80,6 +83,8 @@ func open(policy: String = "") -> void:
_root.visible = true _root.visible = true
_search.text = "" _search.text = ""
_refresh("") _refresh("")
UiFocus.link([_search, _close_button], false)
_search.grab_focus()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func close_panel() -> void: func close_panel() -> void:
@@ -106,7 +111,6 @@ func _refresh(filter: String) -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not visible: if not visible:
return return
if event is InputEventKey and event.pressed and not event.echo \ if event.is_action_pressed("ui_cancel"):
and event.keycode == KEY_ESCAPE:
close_panel() close_panel()
get_viewport().set_input_as_handled() 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 UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.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") const Strings := preload("res://src/core/strings.gd")
var _root: Control = null var _root: Control = null
var _resume_button: Button = null var _resume_button: Button = null
var _buttons: Array[Button] = []
func _ready() -> void: func _ready() -> void:
layer = 18 layer = 18
@@ -53,9 +55,11 @@ func _build() -> void:
column.add_child(title) column.add_child(title)
_resume_button = _add_button(column, Strings.lookup("ui.pause_resume"), "ResumeButton", func(): resume_requested.emit()) _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()) _buttons.append(_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()) _buttons.append(_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_menu"), "MenuButton", func(): quit_to_menu_requested.emit()))
_buttons.push_front(_resume_button)
UiFocus.link(_buttons)
var hint := Label.new() var hint := Label.new()
hint.text = Strings.lookup("ui.pause_hint") hint.text = Strings.lookup("ui.pause_hint")
@@ -77,6 +81,7 @@ func open() -> void:
visible = true visible = true
_root.visible = true _root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
UiFocus.link(_buttons)
if _resume_button: if _resume_button:
_resume_button.grab_focus() _resume_button.grab_focus()
@@ -87,6 +92,6 @@ func close() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not visible: if not visible:
return 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() resume_requested.emit()
get_viewport().set_input_as_handled() 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 Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.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 panel: PanelContainer
var npc_name_label: Label var npc_name_label: Label
@@ -19,6 +20,8 @@ var desc_edit: LineEdit
var weapons_check: CheckBox var weapons_check: CheckBox
var danger_option: OptionButton var danger_option: OptionButton
var action_button: Button var action_button: Button
var cancel_button: Button
var _focus_controls: Array[Control] = []
var hold_hint: Label var hold_hint: Label
var status_label: Label var status_label: Label
@@ -110,11 +113,14 @@ func _init() -> void:
action_button.pressed.connect(_on_action_pressed) action_button.pressed.connect(_on_action_pressed)
UiStyles.apply_button(action_button, 16) UiStyles.apply_button(action_button, 16)
buttons.add_child(action_button) buttons.add_child(action_button)
var cancel_btn := Button.new() cancel_button = Button.new()
cancel_btn.text = Strings.lookup("ui.radio_cancel") cancel_button.text = Strings.lookup("ui.radio_cancel")
cancel_btn.pressed.connect(func(): cancelled.emit()) cancel_button.pressed.connect(func(): cancelled.emit())
UiStyles.apply_button(cancel_btn, 16) UiStyles.apply_button(cancel_button, 16)
buttons.add_child(cancel_btn) 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 var _title_label: Label
@@ -148,6 +154,8 @@ func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
_holding = false _holding = false
_hold_time = 0.0 _hold_time = 0.0
visible = true visible = true
UiFocus.link(_focus_controls, false)
action_button.grab_focus()
## Non-emergency requests are a normal click. Emergency calls require the ## Non-emergency requests are a normal click. Emergency calls require the
## press-and-hold path, so the click release must not submit early. ## 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" 2: return "elevated"
3: return "immediate threat" 3: return "immediate threat"
_: return "unknown" _: 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 InputBindings := preload("res://src/core/input_bindings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.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") const Strings := preload("res://src/core/strings.gd")
signal applied(settings: Dictionary) signal applied(settings: Dictionary)
@@ -244,8 +245,17 @@ func open(current: Dictionary) -> void:
_sync_labels() _sync_labels()
visible = true visible = true
_root.visible = true _root.visible = true
_link_focus()
if _sensitivity:
_sensitivity.grab_focus()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) 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: func close() -> void:
visible = false visible = false
_root.visible = false _root.visible = false
@@ -334,6 +344,6 @@ func _unhandled_input(event: InputEvent) -> void:
if pressed and _apply_remap(event): if pressed and _apply_remap(event):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return 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() _on_cancel()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
+38 -19
View File
@@ -5,6 +5,7 @@ extends CanvasLayer
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.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 restart_requested()
signal continue_requested() signal continue_requested()
@@ -16,6 +17,9 @@ var _review: Dictionary = {}
var report_label: Label var report_label: Label
var _campaign_label: Label = null var _campaign_label: Label = null
var _continue_button: Button = null var _continue_button: Button = null
var _timeline_button: Button = null
var _restart_button: Button = null
var _quit_button: Button = null
func _init() -> void: func _init() -> void:
name = "ShiftReview" name = "ShiftReview"
@@ -56,33 +60,36 @@ func _init() -> void:
center.add_child(_campaign_label) center.add_child(_campaign_label)
_continue_button = Button.new() _continue_button = Button.new()
_continue_button.name = "ContinueButton"
_continue_button.text = Strings.lookup("review.continue_campaign") _continue_button.text = Strings.lookup("review.continue_campaign")
_continue_button.pressed.connect(func(): continue_requested.emit()) _continue_button.pressed.connect(func(): continue_requested.emit())
_continue_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER _continue_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(_continue_button) center.add_child(_continue_button)
var timeline := Button.new() _timeline_button = Button.new()
timeline.name = "TimelineButton" _timeline_button.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button") _timeline_button.text = Strings.lookup("review.timeline_button")
timeline.pressed.connect(func(): timeline_requested.emit(_review)) _timeline_button.pressed.connect(func(): timeline_requested.emit(_review))
timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER _timeline_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(timeline) center.add_child(_timeline_button)
var restart := Button.new() _restart_button = Button.new()
restart.text = Strings.lookup("ui.review_return_menu") _restart_button.name = "ReturnMenuButton"
restart.pressed.connect(func(): restart_requested.emit()) _restart_button.text = Strings.lookup("ui.review_return_menu")
restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER _restart_button.pressed.connect(func(): restart_requested.emit())
center.add_child(restart) _restart_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(_restart_button)
var quit_btn := Button.new() _quit_button = Button.new()
quit_btn.text = Strings.lookup("ui.quit_game") _quit_button.name = "QuitButton"
quit_btn.pressed.connect(func(): get_tree().quit()) _quit_button.text = Strings.lookup("ui.quit_game")
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER _quit_button.pressed.connect(func(): get_tree().quit())
_style_review_button(timeline) _quit_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_style_review_button(_timeline_button)
_style_review_button(_continue_button) _style_review_button(_continue_button)
_style_review_button(restart) _style_review_button(_restart_button)
_style_review_button(quit_btn) _style_review_button(_quit_button)
center.add_child(quit_btn) center.add_child(_quit_button)
func _style_review_button(button: Button) -> void: func _style_review_button(button: Button) -> void:
button.custom_minimum_size = Vector2(300, 52) button.custom_minimum_size = Vector2(300, 52)
@@ -155,3 +162,15 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = [],
if _continue_button: if _continue_button:
_continue_button.visible = not campaign_complete and next_venue != "" _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 _index := 0
var _buttons: Array[Button] = [] var _buttons: Array[Button] = []
var _footer_buttons: Array[Button] = []
var _focus_targets: Array[Button] = []
var _root: Control = null var _root: Control = null
var _status: Label = null var _status: Label = null
var _continue_button: Button = null var _continue_button: Button = null
@@ -139,6 +141,7 @@ func _build() -> void:
help_button.pressed.connect(func(): help_requested.emit()) help_button.pressed.connect(func(): help_requested.emit())
UiStyles.apply_button(help_button, 16) UiStyles.apply_button(help_button, 16)
row.add_child(help_button) row.add_child(help_button)
_footer_buttons.append(help_button)
var settings_button := Button.new() var settings_button := Button.new()
settings_button.name = "SettingsButton" settings_button.name = "SettingsButton"
@@ -146,6 +149,7 @@ func _build() -> void:
settings_button.pressed.connect(func(): settings_requested.emit()) settings_button.pressed.connect(func(): settings_requested.emit())
UiStyles.apply_button(settings_button, 16) UiStyles.apply_button(settings_button, 16)
row.add_child(settings_button) row.add_child(settings_button)
_footer_buttons.append(settings_button)
var quit_button := Button.new() var quit_button := Button.new()
quit_button.name = "QuitButton" quit_button.name = "QuitButton"
@@ -153,6 +157,7 @@ func _build() -> void:
quit_button.pressed.connect(func(): quit_requested.emit()) quit_button.pressed.connect(func(): quit_requested.emit())
UiStyles.apply_button(quit_button, 16) UiStyles.apply_button(quit_button, 16)
row.add_child(quit_button) row.add_child(quit_button)
_footer_buttons.append(quit_button)
var hint := Label.new() var hint := Label.new()
hint.text = Strings.lookup("ui.start_hint") 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)) hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint) column.add_child(hint)
_refresh_focus_targets()
_focus_index(0) _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: func _focus_index(i: int) -> void:
if _buttons.is_empty(): if _focus_targets.is_empty():
return return
_index = clampi(i, 0, _buttons.size() - 1) _index = wrapi(i, 0, _focus_targets.size())
_buttons[_index].grab_focus() _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: func open(campaign: Dictionary = {}) -> void:
_campaign = campaign.duplicate(true) _campaign = campaign.duplicate(true)
@@ -184,6 +204,7 @@ func open(campaign: Dictionary = {}) -> void:
var next_label := _venue_label(next_id) 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.text = Strings.fmt("ui.continue_shift", [next_label]) if next_id != "" else Strings.lookup("ui.campaign_complete_short")
_continue_button.visible = not complete _continue_button.visible = not complete
_refresh_focus_targets()
_focus_index(_index) _focus_index(_index)
func _venue_label(id: String) -> String: func _venue_label(id: String) -> String:
@@ -203,20 +224,21 @@ func close() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not visible: if not visible:
return return
if event is InputEventKey and event.pressed and not event.echo: if event is InputEventKey and event.echo:
match event.keycode: return
KEY_UP, KEY_W: var key := event as InputEventKey
_focus_index(_index - 1) if event.is_action_pressed("ui_up") or (key != null and key.pressed and key.keycode == KEY_W):
get_viewport().set_input_as_handled() _focus_index(_index - 1)
KEY_DOWN, KEY_S: get_viewport().set_input_as_handled()
_focus_index(_index + 1) elif event.is_action_pressed("ui_down") or (key != null and key.pressed and key.keycode == KEY_S):
get_viewport().set_input_as_handled() _focus_index(_index + 1)
KEY_ENTER, KEY_KP_ENTER, KEY_SPACE: get_viewport().set_input_as_handled()
_on_venue_pressed(_index) elif event.is_action_pressed("ui_accept"):
get_viewport().set_input_as_handled() _activate_focus()
KEY_ESCAPE: get_viewport().set_input_as_handled()
quit_requested.emit() elif event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled() quit_requested.emit()
get_viewport().set_input_as_handled()
func _on_venue_pressed(i: int) -> void: func _on_venue_pressed(i: int) -> void:
if i < 0 or i >= VENUES.size(): 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
+1
View File
@@ -43,6 +43,7 @@ func test_gamepad_bindings_are_added_once() -> bool:
if InputMap.action_get_deadzone(action) > 0.25: if InputMap.action_get_deadzone(action) > 0.25:
problems.append("%s deadzone too high" % action) problems.append("%s deadzone too high" % action)
var buttons := {"action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B, var buttons := {"action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B,
"ui_accept": JOY_BUTTON_A, "ui_cancel": JOY_BUTTON_B,
"inspect": JOY_BUTTON_X, "walk_slow": JOY_BUTTON_LEFT_SHOULDER} "inspect": JOY_BUTTON_X, "walk_slow": JOY_BUTTON_LEFT_SHOULDER}
for action in buttons.keys(): for action in buttons.keys():
var found := false var found := false
+8 -5
View File
@@ -34,16 +34,19 @@ func test_start_menu_emits_selected_venue() -> bool:
menu.free() menu.free()
return a and b return a and b
func test_start_menu_clamps_focus() -> bool: func test_start_menu_wraps_focus() -> bool:
var menu: Node = StartMenu.new() var menu: Node = StartMenu.new()
menu._build() menu._build()
menu._focus_index(-5) var targets: Array = menu._focus_targets
menu._focus_index(-1)
var low: int = menu._index var low: int = menu._index
menu._focus_index(99) menu._focus_index(targets.size())
var high: int = menu._index var high: int = menu._index
menu.free() menu.free()
return TestUtils.check_eq(low, 0, "focus clamps at the top") \ return TestUtils.check_eq(low, targets.size() - 1, "focus wraps from the top to the bottom") \
and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom") and TestUtils.check_eq(high, 0, "focus wraps from the bottom to the top") \
and TestUtils.check(targets.size() > StartMenu.VENUES.size(),
"Continue and footer actions are in the keyboard focus ring")
func test_pause_overlay_buttons_emit() -> bool: func test_pause_overlay_buttons_emit() -> bool:
var overlay: Node = PauseOverlay.new() var overlay: Node = PauseOverlay.new()
+14
View File
@@ -4,6 +4,7 @@ extends RefCounted
## theme when a control is focused or disabled. ## theme when a control is focused or disabled.
const UiStyles := preload("res://src/game/ui/ui_styles.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
func test_shared_button_states_are_complete() -> bool: func test_shared_button_states_are_complete() -> bool:
var button := Button.new() var button := Button.new()
@@ -24,3 +25,16 @@ func test_shared_button_states_are_complete() -> bool:
"the disabled state is visually distinct from normal") "the disabled state is visually distinct from normal")
button.free() button.free()
return a and b and c return a and b and c
func test_focus_helper_skips_hidden_and_disabled_controls() -> bool:
var hidden := Button.new()
hidden.visible = false
var disabled := Button.new()
disabled.disabled = true
var normal := Button.new()
var first := UiFocus.first_enabled([hidden, disabled, normal])
var a := TestUtils.check_eq(first, normal, "focus skips hidden and disabled controls")
hidden.free()
disabled.free()
normal.free()
return a