From 9692f262f10c505dcfcd63e2a7ceaa7924e387e8 Mon Sep 17 00:00:00 2001 From: Gig Line Dev Date: Mon, 2 Nov 2026 15:04:38 +0100 Subject: [PATCH] Unify action-button states across the UI - Add UiStyles with explicit normal, hover, pressed, focus and disabled styles and apply it to every action button. - Disable timeline Previous/Next at the first/last event and keep Close always available. - Style settings reset-dialog buttons and cover the button-state audit in docs/ui-copy.md. - Add shared-state and timeline-boundary regression tests. --- CHANGELOG.md | 3 ++ MEMORY.md | 6 ++- TODO.md | 8 ++-- docs/development-status.md | 3 +- docs/ui-copy.md | 21 +++++++++- src/game/ui/event_timeline.gd | 33 ++++++++++----- src/game/ui/help_overlay.gd | 2 + src/game/ui/pause_overlay.gd | 15 +------ src/game/ui/radio_panel.gd | 3 ++ src/game/ui/settings_panel.gd | 8 ++++ src/game/ui/shift_review.gd | 15 +------ src/game/ui/start_menu.gd | 37 +++-------------- src/game/ui/ui_styles.gd | 58 +++++++++++++++++++++++++++ src/test/tests/test_event_timeline.gd | 9 ++++- src/test/tests/test_ui_buttons.gd | 26 ++++++++++++ 15 files changed, 172 insertions(+), 75 deletions(-) create mode 100644 src/game/ui/ui_styles.gd create mode 100644 src/test/tests/test_ui_buttons.gd diff --git a/CHANGELOG.md b/CHANGELOG.md index 1240865..c70938b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Semantic Versioning. ### Changed +- All session, settings, radio, Help, review and timeline action buttons now + share explicit normal, hover, pressed, focus and disabled styles. Timeline + Previous/Next disable at their boundaries. - NPC reason answers now use role-specific dialogue contexts, so staff, guests, delivery workers, performers and other archetypes answer instead of silently deflecting the Reason verb. diff --git a/MEMORY.md b/MEMORY.md index 2812caa..72c9982 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -61,6 +61,10 @@ 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 portrait measured a 0.732 vision gap against the photoreal concept. No release build, version bump or tag was created at this gate. +- **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()` + for every new `Button`; CheckButton/OptionButton keep the engine theme. Timeline + Previous/Next disable at the ends; F1 tutorial skip is keyboard-only by design. - **Version and roadmap renumber (2026-11-02):** source is now **0.9.2**; the completed map/NPC work is the patch content. Postponed work is renumbered to **0.9.3 UI**, **0.9.4 NPC**, **0.9.5 textures**, **0.9.6 integration**, then the @@ -702,7 +706,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. - 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()`. -- Headless suite is **258/258**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`. +- Headless suite is **259/259**; 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). - 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 diff --git a/TODO.md b/TODO.md index 9651d34..3aed9d9 100644 --- a/TODO.md +++ b/TODO.md @@ -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, difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete -escalation/access/radio loop, shift review, the Blender asset library, 258 headless + 97 Python +escalation/access/radio loop, shift review, the Blender asset library, 259 headless + 97 Python 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. @@ -76,9 +76,11 @@ screen-design judge before any model/texture changes. No gameplay rules change. ### Button and panel behavior -- [ ] Audit normal/hover/pressed/focus/disabled states on every button, including +- [x] Audit normal/hover/pressed/focus/disabled states on every button, including tutorial skip, venue continue, settings apply/cancel/reset, judge/review, - radio send/emergency hold and Help close. + radio send/emergency hold and Help close. (Shared `UiStyles` states applied; + timeline boundary disabling and the keyboard-only F1 skip are documented in + `docs/ui-copy.md`.) - [ ] Verify keyboard-only and gamepad-only navigation: focus order, wrapping, default focus, confirm/cancel behavior and no focus traps. - [ ] Verify every modal closes with Esc/right-click where documented, recaptures diff --git a/docs/development-status.md b/docs/development-status.md index 2eca631..794e61e 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -5,7 +5,7 @@ Implemented so far: - 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` - 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: **258 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: **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) - 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 - 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. - 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. +- 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`. - 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. diff --git a/docs/ui-copy.md b/docs/ui-copy.md index bbcc4d9..63b2a7a 100644 --- a/docs/ui-copy.md +++ b/docs/ui-copy.md @@ -1,6 +1,6 @@ # UI copy and button inventory -Status: dialogue pass complete; button/focus/font-scale audit in progress for 0.9.3. +Status: dialogue and button-state passes complete; focus/layout audit in progress for 0.9.3. ## Screen inventory @@ -90,6 +90,25 @@ silent profiles can still return an empty context line. | Repetition | Shared tourist/confused/delivery lines recur across venues by design; role-specific reason lines prevent every archetype sounding alike. | Accepted for the shared-archetype model. | | Localization length | Longest authored NPC/door line is 56 characters; longest UI string is `hud.controls` at 172 characters. | Dialogue fits the HUD log; the 1.5–2× string/font-scale layout test remains a separate 0.9.3 task. | +## Button state audit + +All `Button` controls now use `src/game/ui/ui_styles.gd`, which defines explicit +**normal**, **hover**, **pressed**, **focus** and **disabled** styles plus the +matching font colours. This removes the engine-theme fallback that previously +appeared when a custom dark button was disabled or focused by keyboard. + +| Surface | States / behavior checked | Result | +| --- | --- | --- | +| Start menu | Venue rows, Continue, Help, Settings, Quit; focus clamped to the list; Continue hidden when the campaign is complete. | All buttons styled; hidden state is intentional, not a disabled state. | +| Tutorial | F1 skip is a keyboard shortcut, not a button; its hint is non-interactive. | Keyboard-only by design; no hover/focus state to style. | +| Pause | Resume, Restart, Settings, Quit to menu; Resume receives focus on open. | All four states styled; focus is distinct from hover. | +| Settings | Replay tutorial, Reset campaign, key-binding rows, Apply settings, Cancel; rebinding shows “Press…” while listening. | All action buttons styled; reset-confirmation dialog OK/Cancel styled; fields keep the engine control theme. | +| Help | Close; Esc still closes. | Styled; no disabled state is used. | +| Shift review | Continue campaign, Body camera timeline, Return to menu, Quit game; Continue hidden when the campaign is complete. | All styled; hidden state is intentional. | +| Timeline | Previous/Next/Close; Previous is disabled at the first event, Next at the last, and both when empty. | Disabled state verified in `test_event_timeline.gd`; Close stays enabled. | +| 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. | + ## Remaining audit work - Player-facing labels are now routed through `content/strings.en.json`; numeric diff --git a/src/game/ui/event_timeline.gd b/src/game/ui/event_timeline.gd index c3f7f29..b95d3f1 100644 --- a/src/game/ui/event_timeline.gd +++ b/src/game/ui/event_timeline.gd @@ -9,12 +9,15 @@ 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") var events: Array[Dictionary] = [] var index := 0 var _list_label: Label = null var _detail_label: Label = null var _counter_label: Label = null +var _prev_button: Button = null +var _next_button: Button = null var _root: Control = null func _init() -> void: @@ -90,22 +93,25 @@ func _build() -> void: row.add_theme_constant_override("separation", 10) column.add_child(row) - var prev := Button.new() - prev.name = "PrevButton" - prev.text = Strings.lookup("timeline.prev") - prev.pressed.connect(func(): step(-1)) - row.add_child(prev) + _prev_button = Button.new() + _prev_button.name = "PrevButton" + _prev_button.text = Strings.lookup("timeline.prev") + _prev_button.pressed.connect(func(): step(-1)) + UiStyles.apply_button(_prev_button, 15, true) + row.add_child(_prev_button) - var next := Button.new() - next.name = "NextButton" - next.text = Strings.lookup("timeline.next") - next.pressed.connect(func(): step(1)) - row.add_child(next) + _next_button = Button.new() + _next_button.name = "NextButton" + _next_button.text = Strings.lookup("timeline.next") + _next_button.pressed.connect(func(): step(1)) + 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) var hint := Label.new() @@ -154,7 +160,12 @@ func _on_close() -> void: close() func _refresh() -> void: - if events.is_empty(): + var has_events := not events.is_empty() + if _prev_button: + _prev_button.disabled = not has_events or index <= 0 + if _next_button: + _next_button.disabled = not has_events or index >= events.size() - 1 + if not has_events: _counter_label.text = Strings.lookup("timeline.empty") _detail_label.text = "" _list_label.text = "" diff --git a/src/game/ui/help_overlay.gd b/src/game/ui/help_overlay.gd index c4d6ba2..7fe2c20 100644 --- a/src/game/ui/help_overlay.gd +++ b/src/game/ui/help_overlay.gd @@ -4,6 +4,7 @@ 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") signal closed() @@ -70,6 +71,7 @@ func _build() -> void: 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) func open(policy: String = "") -> void: diff --git a/src/game/ui/pause_overlay.gd b/src/game/ui/pause_overlay.gd index 79a074c..95d524a 100644 --- a/src/game/ui/pause_overlay.gd +++ b/src/game/ui/pause_overlay.gd @@ -10,6 +10,7 @@ signal settings_requested() 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 Strings := preload("res://src/core/strings.gd") var _root: Control = null @@ -67,19 +68,7 @@ func _add_button(parent: Control, text: String, node_name: String, cb: Callable) button.name = node_name button.text = text button.custom_minimum_size = Vector2(380, 52) - button.add_theme_font_size_override("font_size", 22) - var normal := StyleBoxFlat.new() - normal.bg_color = Color(0.115, 0.125, 0.15, 0.96) - normal.corner_radius_top_left = 12 - normal.corner_radius_top_right = 12 - normal.corner_radius_bottom_left = 12 - normal.corner_radius_bottom_right = 12 - var hover := normal.duplicate() as StyleBoxFlat - hover.bg_color = Color(0.16, 0.175, 0.21, 0.98) - button.add_theme_stylebox_override("normal", normal) - button.add_theme_stylebox_override("hover", hover) - button.add_theme_stylebox_override("focus", hover) - button.add_theme_stylebox_override("pressed", hover) + UiStyles.apply_button(button, 22) button.pressed.connect(cb) parent.add_child(button) return button diff --git a/src/game/ui/radio_panel.gd b/src/game/ui/radio_panel.gd index a9d5902..213a4d6 100644 --- a/src/game/ui/radio_panel.gd +++ b/src/game/ui/radio_panel.gd @@ -10,6 +10,7 @@ signal cancelled() 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") var panel: PanelContainer var npc_name_label: Label @@ -107,10 +108,12 @@ func _init() -> void: action_button.button_down.connect(func(): _start_hold()) action_button.button_up.connect(func(): _stop_hold(false)) 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) var _title_label: Label diff --git a/src/game/ui/settings_panel.gd b/src/game/ui/settings_panel.gd index 543fe0f..e1c7439 100644 --- a/src/game/ui/settings_panel.gd +++ b/src/game/ui/settings_panel.gd @@ -8,6 +8,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 Strings := preload("res://src/core/strings.gd") signal applied(settings: Dictionary) @@ -135,11 +136,13 @@ func _build() -> void: replay.name = "ReplayTutorialButton" replay.text = Strings.lookup("ui.settings_replay") replay.pressed.connect(func(): replay_tutorial_requested.emit()) + UiStyles.apply_button(replay, 16) replay_row.add_child(replay) var reset_campaign := Button.new() reset_campaign.name = "ResetCampaignButton" reset_campaign.text = Strings.lookup("ui.settings_reset_campaign") reset_campaign.pressed.connect(_confirm_reset_campaign) + UiStyles.apply_button(reset_campaign, 16) replay_row.add_child(reset_campaign) var controls_title := Label.new() @@ -160,6 +163,7 @@ func _build() -> void: var button := Button.new() button.custom_minimum_size = Vector2(130, 0) button.pressed.connect(_on_rebind.bind(action)) + UiStyles.apply_button(button, 14, true) grid.add_child(button) _bind_buttons[action] = button @@ -172,12 +176,14 @@ func _build() -> void: save_button.name = "ApplyButton" save_button.text = Strings.lookup("ui.apply_settings") save_button.pressed.connect(_on_apply) + UiStyles.apply_button(save_button, 16) row.add_child(save_button) var cancel_button := Button.new() cancel_button.name = "CancelButton" cancel_button.text = Strings.lookup("ui.settings_cancel") cancel_button.pressed.connect(_on_cancel) + UiStyles.apply_button(cancel_button, 16) row.add_child(cancel_button) func _add_label_row(parent: Control, text: String) -> Label: @@ -273,6 +279,8 @@ func _confirm_reset_campaign() -> void: dialog.dialog_text = Strings.lookup("ui.settings_reset_confirm") dialog.confirmed.connect(func(): reset_campaign_requested.emit()) add_child(dialog) + UiStyles.apply_button(dialog.get_ok_button(), 16) + UiStyles.apply_button(dialog.get_cancel_button(), 16) dialog.popup_centered() dialog.confirmed.connect(dialog.queue_free) diff --git a/src/game/ui/shift_review.gd b/src/game/ui/shift_review.gd index a039983..910f0ea 100644 --- a/src/game/ui/shift_review.gd +++ b/src/game/ui/shift_review.gd @@ -4,6 +4,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") signal restart_requested() signal continue_requested() @@ -85,19 +86,7 @@ func _init() -> void: func _style_review_button(button: Button) -> void: button.custom_minimum_size = Vector2(300, 52) - button.add_theme_font_size_override("font_size", 22) - var normal := StyleBoxFlat.new() - normal.bg_color = Color(0.115, 0.125, 0.15, 0.96) - normal.corner_radius_top_left = 10 - normal.corner_radius_top_right = 10 - normal.corner_radius_bottom_left = 10 - normal.corner_radius_bottom_right = 10 - var hover := normal.duplicate() as StyleBoxFlat - hover.bg_color = Color(0.16, 0.175, 0.21, 0.98) - button.add_theme_stylebox_override("normal", normal) - button.add_theme_stylebox_override("hover", hover) - button.add_theme_stylebox_override("focus", hover) - button.add_theme_stylebox_override("pressed", hover) + UiStyles.apply_button(button, 22) func show_review(review: Dictionary, scenario: Resource, history: Array = [], campaign_summary: Dictionary = {}) -> void: diff --git a/src/game/ui/start_menu.gd b/src/game/ui/start_menu.gd index 02f37b6..b1622f3 100644 --- a/src/game/ui/start_menu.gd +++ b/src/game/ui/start_menu.gd @@ -11,6 +11,7 @@ signal settings_requested() signal quit_requested() const UiArt := preload("res://src/game/ui/art.gd") +const UiStyles := preload("res://src/game/ui/ui_styles.gd") const Strings := preload("res://src/core/strings.gd") const VENUES := [ @@ -108,7 +109,7 @@ func _build() -> void: _continue_button.name = "ContinueButton" _continue_button.custom_minimum_size = Vector2(460, 52) _continue_button.pressed.connect(_on_continue_pressed) - _style_venue_button(_continue_button) + UiStyles.apply_button(_continue_button, 18) column.add_child(_continue_button) for i in VENUES.size(): @@ -119,7 +120,7 @@ func _build() -> void: button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.custom_minimum_size = Vector2(460, 62) button.pressed.connect(_on_venue_pressed.bind(i)) - _style_venue_button(button) + UiStyles.apply_button(button, 18) column.add_child(button) _buttons.append(button) @@ -136,18 +137,21 @@ func _build() -> void: help_button.name = "HelpButton" help_button.text = Strings.lookup("ui.start_help") help_button.pressed.connect(func(): help_requested.emit()) + UiStyles.apply_button(help_button, 16) row.add_child(help_button) var settings_button := Button.new() settings_button.name = "SettingsButton" settings_button.text = Strings.lookup("ui.start_settings") settings_button.pressed.connect(func(): settings_requested.emit()) + UiStyles.apply_button(settings_button, 16) row.add_child(settings_button) var quit_button := Button.new() quit_button.name = "QuitButton" quit_button.text = Strings.lookup("ui.quit_game") quit_button.pressed.connect(func(): quit_requested.emit()) + UiStyles.apply_button(quit_button, 16) row.add_child(quit_button) var hint := Label.new() @@ -158,35 +162,6 @@ func _build() -> void: _focus_index(0) -## Match the dark, rounded rows in the `start_menu` mockup. -func _style_venue_button(button: Button) -> void: - button.add_theme_font_size_override("font_size", 18) - button.add_theme_color_override("font_color", Color(0.94, 0.94, 0.92)) - button.add_theme_color_override("font_hover_color", Color.WHITE) - var normal := StyleBoxFlat.new() - normal.bg_color = Color(0.115, 0.125, 0.15, 0.96) - normal.corner_radius_top_left = 12 - normal.corner_radius_top_right = 12 - normal.corner_radius_bottom_left = 12 - normal.corner_radius_bottom_right = 12 - normal.content_margin_left = 22 - normal.content_margin_right = 22 - normal.content_margin_top = 10 - normal.content_margin_bottom = 10 - var hover := normal.duplicate() as StyleBoxFlat - hover.bg_color = Color(0.16, 0.175, 0.21, 0.98) - var focus := normal.duplicate() as StyleBoxFlat - focus.bg_color = Color(0.14, 0.155, 0.19, 0.98) - focus.border_color = Color(0.85, 0.86, 0.88) - focus.border_width_left = 2 - focus.border_width_top = 2 - focus.border_width_right = 2 - focus.border_width_bottom = 2 - button.add_theme_stylebox_override("normal", normal) - button.add_theme_stylebox_override("hover", hover) - button.add_theme_stylebox_override("focus", focus) - button.add_theme_stylebox_override("pressed", hover) - func _focus_index(i: int) -> void: if _buttons.is_empty(): return diff --git a/src/game/ui/ui_styles.gd b/src/game/ui/ui_styles.gd new file mode 100644 index 0000000..ede6e3d --- /dev/null +++ b/src/game/ui/ui_styles.gd @@ -0,0 +1,58 @@ +class_name UiStyles +extends RefCounted +## Shared button states for the dark session/UI shell. +## +## Keeping one style table means every normal/hover/pressed/focused/disabled +## state is explicit. Buttons that use the Godot default theme otherwise look +## different from the custom start-menu, pause and review rows as soon as they +## are disabled or receive keyboard focus. + +const TEXT := Color(0.94, 0.94, 0.92) +const TEXT_HOVER := Color.WHITE +const TEXT_DISABLED := Color(0.48, 0.50, 0.56) +const SURFACE := Color(0.115, 0.125, 0.15, 0.96) +const SURFACE_HOVER := Color(0.16, 0.175, 0.21, 0.98) +const SURFACE_PRESSED := Color(0.075, 0.085, 0.105, 0.98) +const SURFACE_DISABLED := Color(0.075, 0.08, 0.095, 0.72) +const ACCENT := Color(0.78, 0.82, 0.88, 0.9) + +static func apply_button(button: Button, font_size: int = 0, compact: bool = false) -> void: + if button == null: + return + if font_size > 0: + button.add_theme_font_size_override("font_size", font_size) + var margin_h := 12 if compact else 18 + var margin_v := 7 if compact else 10 + var normal := _box(SURFACE, margin_h, margin_v) + var hover := _box(SURFACE_HOVER, margin_h, margin_v) + var pressed := _box(SURFACE_PRESSED, margin_h, margin_v) + var disabled := _box(SURFACE_DISABLED, margin_h, margin_v) + var focus := _box(Color(0, 0, 0, 0), margin_h, margin_v) + focus.border_color = ACCENT + focus.border_width_left = 2 + focus.border_width_top = 2 + focus.border_width_right = 2 + focus.border_width_bottom = 2 + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("pressed", pressed) + button.add_theme_stylebox_override("focus", focus) + button.add_theme_stylebox_override("disabled", disabled) + button.add_theme_color_override("font_color", TEXT) + button.add_theme_color_override("font_hover_color", TEXT_HOVER) + button.add_theme_color_override("font_pressed_color", TEXT_HOVER) + button.add_theme_color_override("font_focus_color", TEXT_HOVER) + button.add_theme_color_override("font_disabled_color", TEXT_DISABLED) + +static func _box(color: Color, margin_h: int, margin_v: int) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = color + box.corner_radius_top_left = 10 + box.corner_radius_top_right = 10 + box.corner_radius_bottom_left = 10 + box.corner_radius_bottom_right = 10 + box.content_margin_left = margin_h + box.content_margin_right = margin_h + box.content_margin_top = margin_v + box.content_margin_bottom = margin_v + return box diff --git a/src/test/tests/test_event_timeline.gd b/src/test/tests/test_event_timeline.gd index 624f6ff..3c1841a 100644 --- a/src/test/tests/test_event_timeline.gd +++ b/src/test/tests/test_event_timeline.gd @@ -79,14 +79,21 @@ func test_empty_timeline_is_safe() -> bool: func test_stepping_clamps() -> bool: var timeline: Node = EventTimeline.new() timeline.show_timeline(_review()) + var prev: Button = timeline.find_child("PrevButton", true, false) as Button + var next: Button = timeline.find_child("NextButton", true, false) as Button + var start_prev_disabled := prev != null and prev.disabled + var start_next_enabled := next != null and not next.disabled timeline.step(-1) var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event") timeline.step(1) var b := TestUtils.check_eq(timeline.index, 1, "next advances one event") timeline.step(99) var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event") + var end_next_disabled := next != null and next.disabled + var d := TestUtils.check(start_prev_disabled and start_next_enabled and end_next_disabled, + "timeline boundary buttons disable at the ends") timeline.free() - return a and b and c + return a and b and c and d func test_review_records_a_shift_start_stamp() -> bool: var coordinator: Object = Coordinator.new() diff --git a/src/test/tests/test_ui_buttons.gd b/src/test/tests/test_ui_buttons.gd new file mode 100644 index 0000000..dd3b030 --- /dev/null +++ b/src/test/tests/test_ui_buttons.gd @@ -0,0 +1,26 @@ +extends RefCounted +## Shared UI button states: every normal/hover/pressed/focus/disabled state is +## explicitly styled so the custom dark panels do not fall back to the engine +## theme when a control is focused or disabled. + +const UiStyles := preload("res://src/game/ui/ui_styles.gd") + +func test_shared_button_states_are_complete() -> bool: + var button := Button.new() + UiStyles.apply_button(button, 16) + var states := ["normal", "hover", "pressed", "focus", "disabled"] + var missing: Array[String] = [] + for state in states: + if not button.has_theme_stylebox_override(state): + missing.append(state) + var a := TestUtils.check(missing.is_empty(), + "all five button states have explicit styles (missing: %s)" % str(missing)) + var b := TestUtils.check_eq(button.get_theme_font_size("font_size"), 16, + "the shared style applies the requested font size") + var disabled := button.get_theme_stylebox("disabled") + var normal := button.get_theme_stylebox("normal") + var c := TestUtils.check(disabled != null and normal != null + and disabled.bg_color != normal.bg_color, + "the disabled state is visually distinct from normal") + button.free() + return a and b and c