Files
claude 61680d00d7
ci / verify (push) Successful in 48s
Style the rest of the interface: panels, sliders, scrollbars, cards
Buttons were the easy half. This is everything else.

Dialogs sit on panels now rather than being text over a dimmed world -- the
pause menu, character select, upgrades, settings and credits all root through
UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK.
The set is cream throughout, which is right for buttons and wrong for a dialog
over a dark dungeon; tinting keeps the pixel border and the corner shape and
lets every label the game already draws in light colours stay readable, instead
of recolouring every label in five screens to suit the art.

Sliders and scrollbars are the pack's too, section headings sit on its banner
ribbon, and an upgrade card's rarity is now its frame rather than a word on it
-- three choices are compared at a glance and a colour reads faster than a
label.

Two bugs of the same shape, and neither was findable without looking at the
screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's
minimum size, which for a StyleBoxTexture is its content margins. Mine were
zero, so both resolved the correct stylebox, reported the correct texture, and
drew a groove zero pixels tall. A probe confirmed the theme was resolving
perfectly while the track was invisible. Tests now assert every slider and
scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md.

The first attempt at the slider groove also used the pack's HOLLOW bar sprite,
whose middle is transparent -- tinting it dark left an outline and nothing
else. It uses the solid one.

tools/screenshot.tscn gained the upgrade screen, which it has to stage: the
game scene owns that screen's visibility and re-asserts it every frame, so
setting `visible` lasted exactly one frame, and standing the player at the NPC
client-side lasted until reconciliation pulled them back. It now moves the
player on both sides and holds it until the shot. That tool has caught four
bugs the automated gates all passed.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:45:46 +02:00

215 lines
9.4 KiB
GDScript

extends GutTest
## The control theme. A missing texture gives a null and a silently unstyled
## control, which looks exactly like the theme never having been applied -- so
## every path is checked rather than assumed.
func test_every_declared_texture_loads() -> void:
for key in UiTheme.TEXTURES:
var tex := UiTheme.texture(key)
assert_not_null(tex, "%s (%s) did not load" % [key, UiTheme.TEXTURES[key]])
assert_gt(tex.get_width(), 0)
assert_gt(tex.get_height(), 0)
func test_the_theme_styles_the_controls_the_game_actually_builds() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled", "focus"]:
assert_true(t.has_stylebox(state, "Button"), "Button has no %s style" % state)
assert_true(t.has_stylebox("panel", "Panel"))
assert_true(t.has_stylebox("panel", "PanelContainer"))
assert_true(t.has_stylebox("normal", "LineEdit"))
## The pack's surfaces are cream with a black outline, so text on them has to be
## dark. Everything the game draws over the world stays light; only controls
## sitting on this art flip.
func test_text_on_the_packs_surfaces_is_dark() -> void:
var t := UiTheme.get_theme()
for key in ["font_color", "font_hover_color", "font_pressed_color"]:
var c: Color = t.get_color(key, "Button")
assert_lt(c.get_luminance(), 0.35, "%s is too light for cream art" % key)
assert_lt((t.get_color("font_color", "LineEdit") as Color).get_luminance(), 0.35)
## Nine-patch margins are what stop a 1px border smearing across a stretched
## button. Zero margins would render as a plain stretched texture.
func test_button_styles_are_nine_patched() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled"]:
var sb := t.get_stylebox(state, "Button") as StyleBoxTexture
assert_not_null(sb)
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
assert_gt(sb.get_texture_margin(side), 0.0,
"%s has no stretch margin on side %d" % [state, side])
## The corner size cannot exceed half the sprite, or the two caps overlap and
## the middle has negative width.
func test_stretch_margins_fit_inside_their_sprites() -> void:
var t := UiTheme.get_theme()
for entry in [["normal", "Button"], ["panel", "Panel"], ["normal", "LineEdit"]]:
var sb := t.get_stylebox(entry[0], entry[1]) as StyleBoxTexture
var size := Vector2(sb.texture.get_size())
assert_lte(sb.get_texture_margin(SIDE_LEFT) + sb.get_texture_margin(SIDE_RIGHT),
size.x, "%s margins exceed its width" % entry[1])
assert_lte(sb.get_texture_margin(SIDE_TOP) + sb.get_texture_margin(SIDE_BOTTOM),
size.y, "%s margins exceed its height" % entry[1])
## Built once and shared. Themes are read-only data, so a second instance would
## be waste rather than isolation.
func test_the_theme_is_built_once() -> void:
assert_eq(UiTheme.get_theme(), UiTheme.get_theme())
## The four button states have to be four different pictures, or the interface
## gives no feedback and the pack's state art is going unused.
func test_the_button_states_are_visually_distinct() -> void:
var seen := {}
for key in ["button", "button_hover", "button_pressed", "button_disabled"]:
seen[UiTheme.TEXTURES[key]] = true
assert_eq(seen.size(), 4, "two button states share a texture")
## The inventory draws slots at exactly twice the source size. A non-integer
## scale on pixel art with nearest filtering gives uneven pixel widths, which on
## a 1px border reads as a wobble along every edge.
func test_the_inventory_slot_is_drawn_at_a_whole_multiple() -> void:
var slot := UiTheme.texture("slot")
var scale := HUD_SLOT / float(slot.get_width())
assert_eq(scale, floorf(scale), "slot art is scaled by %f" % scale)
assert_eq(slot.get_width(), slot.get_height(), "the slot should be square")
const HUD_SLOT := 64.0
## A Control resolves its theme by walking up the parent chain, which is why
## one assignment near the root covers screens that share no parent. Checked on
## the mechanism rather than on a Window, so it runs headless.
func test_the_theme_reaches_controls_through_their_ancestors() -> void:
var root: Control = autofree(Control.new())
root.theme = UiTheme.get_theme()
var middle := Control.new()
root.add_child(middle)
var button := Button.new()
middle.add_child(button)
assert_eq(button.get_theme_stylebox("normal", "Button"),
UiTheme.get_theme().get_stylebox("normal", "Button"),
"a button two levels down should still find the theme")
assert_eq(button.get_theme_color("font_color", "Button"), UiTheme.INK)
func test_applying_to_nothing_is_harmless() -> void:
UiTheme.apply_to(null)
assert_true(true, "a null root must not be a crash")
func test_a_themed_root_fills_its_parent_and_carries_the_theme() -> void:
var root: Control = autofree(UiTheme.themed_root())
assert_eq(root.theme, UiTheme.get_theme())
assert_eq(root.anchor_right, 1.0)
assert_eq(root.anchor_bottom, 1.0)
## Every screen in the game is built in code and hangs off either a plain Node
## or a CanvasLayer, and theme inheritance travels Control-to-Control ONLY --
## the chain breaks at the first non-Control parent. Setting the theme once on
## the Window therefore looked like it worked and styled nothing, which is a
## mistake that is invisible until somebody looks at the screen. So: every
## screen is instantiated here and asked what its buttons actually resolve.
func test_every_screen_actually_gets_the_theme() -> void:
var screens := {
"HUD": "res://src/ui/hud.gd",
"GameMenu": "res://src/ui/game_menu.gd",
"CharacterSelect": "res://src/ui/character_select.gd",
"UpgradeScreen": "res://src/ui/upgrade_screen.gd",
"SettingsScreen": "res://src/ui/settings_screen.gd",
"CreditsScreen": "res://src/ui/credits_screen.gd",
"MainMenu": "res://src/ui/main_menu.gd",
}
var wanted := UiTheme.get_theme().get_stylebox("normal", "Button")
for name in screens:
var node: Node = (load(screens[name]) as Script).new()
add_child_autofree(node)
var buttons := node.find_children("*", "Button", true, false)
assert_gt(buttons.size(), 0, "%s has no button to check" % name)
for b: Button in buttons:
assert_eq(b.get_theme_stylebox("normal", "Button"), wanted,
"%s: '%s' is not themed" % [name, b.text])
## A Slider draws its groove in a band whose thickness is the stylebox's own
## minimum size -- which for a StyleBoxTexture is its content margins. With
## those at zero the track resolves, reports the right texture, and draws
## nothing, which is exactly what it did.
func test_slider_styleboxes_have_a_thickness() -> void:
var t := UiTheme.get_theme()
for key in ["slider", "grabber_area", "grabber_area_highlight"]:
var sb := t.get_stylebox(key, "HSlider")
assert_gt(sb.get_minimum_size().y, 0.0,
"HSlider/%s would draw with no height" % key)
## The filled portion has to sit inside the groove, not overhang it.
func test_the_filled_portion_is_no_thicker_than_the_groove() -> void:
var t := UiTheme.get_theme()
assert_lte(t.get_stylebox("grabber_area", "HSlider").get_minimum_size().y,
t.get_stylebox("slider", "HSlider").get_minimum_size().y)
func test_the_slider_grabber_is_big_enough_to_aim_at() -> void:
var grab := UiTheme.get_theme().get_icon("grabber", "HSlider")
assert_not_null(grab)
assert_gte(grab.get_width(), 12, "an 8px grabber is a speck at this scale")
assert_lte(grab.get_height(), 26, "and one taller than its row overflows it")
## Nearest-neighbour and a whole multiple, or the pixel art turns to mush.
func test_scaling_a_sprite_keeps_it_a_whole_multiple() -> void:
var src := UiTheme.texture("grabber")
var out := UiTheme.scaled("grabber", 2)
assert_eq(out.get_width(), src.get_width() * 2)
assert_eq(out.get_height(), src.get_height() * 2)
## Every scrollbar piece has to be styled or the default grey chrome shows
## through on the two screens that scroll.
func test_scrollbars_are_styled() -> void:
var t := UiTheme.get_theme()
for bar_class in ["VScrollBar", "HScrollBar"]:
for key in ["scroll", "grabber", "grabber_highlight", "grabber_pressed"]:
assert_true(t.has_stylebox(key, bar_class),
"%s/%s is unstyled" % [bar_class, key])
## And they need a thickness, for the same reason sliders do: a ScrollBar sizes
## itself from its styleboxes' minimum size, so styling one with no content
## margins produces a bar zero pixels wide -- which looks exactly like having
## no scrollbar, and did.
func test_scrollbars_have_a_thickness_in_both_directions() -> void:
var t := UiTheme.get_theme()
for bar_class in ["VScrollBar", "HScrollBar"]:
for key in ["scroll", "grabber"]:
var size := t.get_stylebox(key, bar_class).get_minimum_size()
assert_gt(size.x, 0.0, "%s/%s has no width" % [bar_class, key])
assert_gt(size.y, 0.0, "%s/%s has no height" % [bar_class, key])
## Panels are the pack's shape in this game's colours: the art is cream, and a
## cream dialog over a dark dungeon would need every label in five screens
## recoloured. Tinting the stylebox keeps the border and the existing text.
func test_dialog_panels_are_tinted_dark() -> void:
var sb := UiTheme.get_theme().get_stylebox("panel", "PanelContainer") as StyleBoxTexture
assert_lt(sb.modulate_color.get_luminance(), 0.35,
"a bright panel would make every light label on it unreadable")
assert_gt(sb.get_minimum_size().x, 0.0, "a dialog needs padding")
func test_a_card_panel_carries_its_tint() -> void:
var card: PanelContainer = autofree(UiTheme.card_panel(Color(0.9, 0.2, 0.2)))
var sb := card.get_theme_stylebox("panel") as StyleBoxTexture
assert_not_null(sb)
assert_almost_eq(sb.modulate_color.r, 0.9, 0.001)