Add wrapping keyboard and gamepad focus rings
- 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:
1 parent
9692f262f1
commit
8f65751975
17 files changed
+262
-76
No files matched your search
@@ -96,6 +96,11 @@ static func _ensure_gamepad_bindings() -> void:
|
||||
var buttons := {
|
||||
"action_menu": JOY_BUTTON_A,
|
||||
"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,
|
||||
"radio_assist": JOY_BUTTON_Y,
|
||||
"walk_slow": JOY_BUTTON_LEFT_SHOULDER,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +43,7 @@ func test_gamepad_bindings_are_added_once() -> bool:
|
||||
if InputMap.action_get_deadzone(action) > 0.25:
|
||||
problems.append("%s deadzone too high" % action)
|
||||
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}
|
||||
for action in buttons.keys():
|
||||
var found := false
|
||||
|
||||
@@ -34,16 +34,19 @@ func test_start_menu_emits_selected_venue() -> bool:
|
||||
menu.free()
|
||||
return a and b
|
||||
|
||||
func test_start_menu_clamps_focus() -> bool:
|
||||
func test_start_menu_wraps_focus() -> bool:
|
||||
var menu: Node = StartMenu.new()
|
||||
menu._build()
|
||||
menu._focus_index(-5)
|
||||
var targets: Array = menu._focus_targets
|
||||
menu._focus_index(-1)
|
||||
var low: int = menu._index
|
||||
menu._focus_index(99)
|
||||
menu._focus_index(targets.size())
|
||||
var high: int = menu._index
|
||||
menu.free()
|
||||
return TestUtils.check_eq(low, 0, "focus clamps at the top") \
|
||||
and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom")
|
||||
return TestUtils.check_eq(low, targets.size() - 1, "focus wraps from the top to 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:
|
||||
var overlay: Node = PauseOverlay.new()
|
||||
|
||||
@@ -4,6 +4,7 @@ extends RefCounted
|
||||
## theme when a control is focused or disabled.
|
||||
|
||||
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:
|
||||
var button := Button.new()
|
||||
@@ -24,3 +25,16 @@ func test_shared_button_states_are_complete() -> bool:
|
||||
"the disabled state is visually distinct from normal")
|
||||
button.free()
|
||||
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
|
||||
Reference in new issue
Block a user