Unify action-button states across the UI
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped

- 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.
This commit is contained in:
Gig Line Dev committed 2026-11-02 15:04:38 +01:00
1 parent 98c31fe44c
commit 9692f262f1
15 files changed
+172 -75

No files matched your search

+3
View File
@@ -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.
+5 -1
View File
@@ -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
+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,
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
+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)
- 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.
+20 -1
View File
@@ -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
+22 -11
View File
@@ -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 = ""
+2
View File
@@ -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:
+2 -13
View File
@@ -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
+3
View File
@@ -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
+8
View File
@@ -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)
+2 -13
View File
@@ -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:
+6 -31
View File
@@ -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
+58
View File
@@ -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
+8 -1
View File
@@ -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()
+26
View File
@@ -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