Feat/fidelity pass 2 #23
@@ -0,0 +1,229 @@
|
||||
extends SceneTree
|
||||
|
||||
## Reads the BUILT theme and fails if any state puts text on a fill it cannot be
|
||||
## read against.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/ui_contrast_check.gd
|
||||
##
|
||||
## The point is that this interrogates `UITheme.build()` rather than the palette
|
||||
## constants. A table of colours checked against itself agrees by construction
|
||||
## and catches nothing; what goes wrong in practice is a stylebox whose fill was
|
||||
## changed without the matching `font_*_color`, or a state Godot draws that
|
||||
## nobody remembered exists — `hover_pressed` on a toggle, `font_hover_color` on
|
||||
## a PopupMenu. Those are exactly what this finds, because it asks the theme what
|
||||
## it will actually draw.
|
||||
##
|
||||
## WCAG 2.1 floors: 4.5:1 for body text, 3:1 for text at or above ~24 px.
|
||||
##
|
||||
## ── Two ways text can be legible, and only one of them is a colour pair ───────
|
||||
##
|
||||
## A label on a BUTTON sits on a solid chip. The fill is right behind the glyphs
|
||||
## and nothing else is, so the only thing that can make it readable is the pair,
|
||||
## and the fix when it fails is to invert the label with the fill. Those are
|
||||
## checked as `SOLID`.
|
||||
##
|
||||
## A label on a PROGRESS BAR does not have one backdrop. It straddles the filled
|
||||
## part and the empty part, and no single text colour can beat both a hot papaya
|
||||
## and a near-black at once — the pair is unfixable by construction. What carries
|
||||
## it is the heavy ink outline the theme puts on every glyph, which is the same
|
||||
## mechanism that keeps menu text readable straight over the 3D scene, and it is
|
||||
## the theme's first stated rule for exactly this reason.
|
||||
##
|
||||
## So that case is checked as `OUTLINED`, against what actually does the work:
|
||||
##
|
||||
## outline vs fill >= 3.0 the outline has to separate from the backdrop
|
||||
## text vs outline >= 4.5 and the glyph has to separate from its outline
|
||||
## outline_size >= 4 px thin enough and there is no outline to read
|
||||
##
|
||||
## This is a narrower allowance than it looks. It is only granted where the
|
||||
## backdrop genuinely varies, and it substitutes two ratios for one rather than
|
||||
## waiving the requirement — an unoutlined label over a bar still fails.
|
||||
##
|
||||
## Semi-transparent fills (PANEL, DEAD_FILL) are measured as if opaque. They are
|
||||
## drawn over the dark 3D scene or over a darker panel, so the opaque reading is
|
||||
## the pessimistic one for paper-on-dark and the accurate one for ink-on-light.
|
||||
|
||||
const FLOOR_BODY := 4.5
|
||||
const FLOOR_LARGE := 3.0
|
||||
## An outline thinner than this is a hairline, not a backdrop.
|
||||
const MIN_OUTLINE := 4
|
||||
|
||||
const SOLID := "solid"
|
||||
const OUTLINED := "outlined"
|
||||
|
||||
## class -> [[stylebox, font colour, large?, mode], ...]
|
||||
##
|
||||
## Only the pairs Godot really composites. `focus` is drawn OVER `normal` rather
|
||||
## than instead of it, so it is checked against its own fill (which is the same
|
||||
## fill) and its own font colour.
|
||||
##
|
||||
## Tree has no `font_hovered_color` in Godot 4 — a hovered row keeps `font_color`
|
||||
## and a hovered-selected one keeps `font_selected_color` — so its hover fills
|
||||
## are checked against the colours that will really be drawn on them. Asking for
|
||||
## a colour the control does not have is itself reported, which is how this was
|
||||
## found.
|
||||
const PAIRS := {
|
||||
"Button": [
|
||||
["normal", "font_color", false, SOLID],
|
||||
["hover", "font_hover_color", false, SOLID],
|
||||
["pressed", "font_pressed_color", false, SOLID],
|
||||
["hover_pressed", "font_hover_pressed_color", false, SOLID],
|
||||
["focus", "font_focus_color", false, SOLID],
|
||||
["disabled", "font_disabled_color", false, SOLID],
|
||||
],
|
||||
"ItemList": [
|
||||
["panel", "font_color", false, SOLID],
|
||||
["hovered", "font_hovered_color", false, SOLID],
|
||||
["selected", "font_selected_color", false, SOLID],
|
||||
["selected_focus", "font_selected_color", false, SOLID],
|
||||
["hovered_selected", "font_selected_color", false, SOLID],
|
||||
],
|
||||
"Tree": [
|
||||
["panel", "font_color", false, SOLID],
|
||||
["hovered", "font_color", false, SOLID],
|
||||
["selected", "font_selected_color", false, SOLID],
|
||||
["hovered_selected", "font_selected_color", false, SOLID],
|
||||
],
|
||||
"PopupMenu": [
|
||||
["panel", "font_color", false, SOLID],
|
||||
["panel", "font_disabled_color", false, SOLID],
|
||||
["panel", "font_accelerator_color", false, SOLID],
|
||||
["hover", "font_hover_color", false, SOLID],
|
||||
],
|
||||
"TabContainer": [
|
||||
["tab_selected", "font_selected_color", false, SOLID],
|
||||
["tab_unselected", "font_unselected_color", false, SOLID],
|
||||
["tab_hovered", "font_hovered_color", false, SOLID],
|
||||
["tab_disabled", "font_disabled_color", false, SOLID],
|
||||
],
|
||||
"LineEdit": [
|
||||
["normal", "font_color", false, SOLID],
|
||||
["normal", "font_placeholder_color", false, SOLID],
|
||||
["normal", "selection_color:font_selected_color", false, SOLID],
|
||||
["read_only", "font_uneditable_color", false, SOLID],
|
||||
],
|
||||
# The readout straddles both halves of the bar — see the note above.
|
||||
"ProgressBar": [
|
||||
["background", "font_color", false, OUTLINED],
|
||||
["fill", "font_color", false, OUTLINED],
|
||||
],
|
||||
}
|
||||
|
||||
## Every button-ish theme type gets Button's pair list, since `_button_look`
|
||||
## gives them all the same treatment.
|
||||
const BUTTON_LIKE := ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
|
||||
"LinkButton"]
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
var t: Theme = UITheme.build()
|
||||
var fails: Array = []
|
||||
var checked := 0
|
||||
|
||||
var classes := PAIRS.duplicate()
|
||||
for cls in BUTTON_LIKE:
|
||||
classes[cls] = PAIRS["Button"]
|
||||
|
||||
for cls in classes:
|
||||
for pair in classes[cls]:
|
||||
var sb_name: String = pair[0]
|
||||
var fg_name: String = pair[1]
|
||||
var large: bool = pair[2]
|
||||
var mode: String = pair[3]
|
||||
|
||||
if not _has_flat(t, cls, sb_name):
|
||||
fails.append("%s: no StyleBoxFlat '%s'" % [cls, sb_name])
|
||||
continue
|
||||
var bg: Color = _fill_of(t, cls, sb_name)
|
||||
|
||||
# "a:b" means the fill comes from colour `a` rather than a stylebox —
|
||||
# LineEdit's text selection paints a colour, not a box.
|
||||
var fg_key := fg_name
|
||||
if ":" in fg_name:
|
||||
var parts := fg_name.split(":")
|
||||
if not t.has_color(parts[0], cls):
|
||||
fails.append("%s: no colour '%s'" % [cls, parts[0]])
|
||||
continue
|
||||
bg = t.get_color(parts[0], cls)
|
||||
fg_key = parts[1]
|
||||
|
||||
if not t.has_color(fg_key, cls):
|
||||
fails.append("%s: no colour '%s'" % [cls, fg_key])
|
||||
continue
|
||||
var fg: Color = t.get_color(fg_key, cls)
|
||||
|
||||
checked += 1
|
||||
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
|
||||
var where := "%s/%s + %s" % [cls, sb_name, fg_key]
|
||||
if mode == OUTLINED:
|
||||
fails.append_array(_check_outlined(t, cls, where, bg, fg, floor_))
|
||||
continue
|
||||
var ratio := UITheme.contrast(fg, bg)
|
||||
if ratio < floor_:
|
||||
fails.append("%s = %.2f:1 (needs %.1f) fill=%s text=%s"
|
||||
% [where, ratio, floor_, bg.to_html(false), fg.to_html(false)])
|
||||
|
||||
# The declared table too, for the pairs no theme entry describes — a Label
|
||||
# over a panel, the level card's caption over its scrim.
|
||||
for entry in UITheme.state_table():
|
||||
var name: String = entry[0]
|
||||
var fill: Color = entry[1]
|
||||
var text: Color = entry[2]
|
||||
var large: bool = entry[3]
|
||||
checked += 1
|
||||
var ratio := UITheme.contrast(text, fill)
|
||||
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
|
||||
if ratio < floor_:
|
||||
fails.append("table %s = %.2f:1 (needs %.1f)" % [name, ratio, floor_])
|
||||
|
||||
print("UI CONTRAST: %d pairs checked" % checked)
|
||||
if fails.is_empty():
|
||||
print("PASS — every state reads")
|
||||
quit(0)
|
||||
return
|
||||
for f in fails:
|
||||
print(" FAIL ", f)
|
||||
print("FAIL — %d unreadable state(s)" % fails.size())
|
||||
quit(1)
|
||||
|
||||
|
||||
## Text whose backdrop varies, read either directly or against its own outline.
|
||||
##
|
||||
## The outline is a FALLBACK, not a replacement: where the glyph already beats the
|
||||
## backdrop on its own the outline is free to be the same colour as that backdrop
|
||||
## and simply do nothing, which is what a paper readout over the bar's near-black
|
||||
## trough is. It is only when the direct pair fails — paper over hot papaya — that
|
||||
## the outline has to carry it, and then both of its ratios must hold.
|
||||
##
|
||||
## Returns the failures rather than printing, so the caller keeps the tally.
|
||||
func _check_outlined(t: Theme, cls: String, where: String, bg: Color, fg: Color,
|
||||
floor_: float) -> Array:
|
||||
var out: Array = []
|
||||
if UITheme.contrast(fg, bg) >= floor_:
|
||||
return out
|
||||
if not t.has_color("font_outline_color", cls):
|
||||
return ["%s: outlined but no font_outline_color" % where]
|
||||
var edge: Color = t.get_color("font_outline_color", cls)
|
||||
var width: int = t.get_constant("outline_size", cls) \
|
||||
if t.has_constant("outline_size", cls) else 0
|
||||
if width < MIN_OUTLINE:
|
||||
out.append("%s: outline is %d px, needs %d" % [where, width, MIN_OUTLINE])
|
||||
var edge_bg := UITheme.contrast(edge, bg)
|
||||
if edge_bg < FLOOR_LARGE:
|
||||
out.append("%s: outline vs fill = %.2f:1 (needs %.1f) outline=%s fill=%s"
|
||||
% [where, edge_bg, FLOOR_LARGE, edge.to_html(false), bg.to_html(false)])
|
||||
var fg_edge := UITheme.contrast(fg, edge)
|
||||
if fg_edge < floor_:
|
||||
out.append("%s: text vs outline = %.2f:1 (needs %.1f) text=%s outline=%s"
|
||||
% [where, fg_edge, floor_, fg.to_html(false), edge.to_html(false)])
|
||||
return out
|
||||
|
||||
|
||||
## Whether the theme has a MEASURABLE box under that name. Only StyleBoxFlat
|
||||
## carries a fill colour; anything else is not something to take a ratio against.
|
||||
func _has_flat(t: Theme, cls: String, sb_name: String) -> bool:
|
||||
return t.has_stylebox(sb_name, cls) and t.get_stylebox(sb_name, cls) is StyleBoxFlat
|
||||
|
||||
|
||||
func _fill_of(t: Theme, cls: String, sb_name: String) -> Color:
|
||||
return (t.get_stylebox(sb_name, cls) as StyleBoxFlat).bg_color
|
||||
+250
-19
@@ -6,7 +6,7 @@ class_name UITheme
|
||||
##
|
||||
## The look is a charged cel comic — near-black violet ink, hot papaya, and a
|
||||
## lightning yellow that only ever appears at the moment something is pressed.
|
||||
## Three rules hold it together:
|
||||
## Four rules hold it together:
|
||||
##
|
||||
## INK EVERYTHING every panel, chip and letter carries a heavy dark edge.
|
||||
## It is what makes flat colour read as drawn rather than
|
||||
@@ -18,11 +18,28 @@ class_name UITheme
|
||||
## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt.
|
||||
## Spend it anywhere else and the moment of input stops
|
||||
## standing out.
|
||||
## EVERY STATE READS a fill and the text on it are chosen together, and the
|
||||
## pair is checked. See `STATE_TABLE` and `contrast`.
|
||||
##
|
||||
## Everything here is static. Screens ask for `apply_global` once and then use
|
||||
## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of
|
||||
## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away
|
||||
## from the rest of the game's look.
|
||||
##
|
||||
## ── Why the state table exists ────────────────────────────────────────────────
|
||||
##
|
||||
## The palette has two light accents (papaya, volt) and one very dark one (ink),
|
||||
## and a control changes its FILL on hover and press. Text that reads perfectly
|
||||
## at rest — paper on near-black — inverts to paper-on-yellow the instant the
|
||||
## pointer arrives, which is a contrast ratio of 1.1:1. It is invisible.
|
||||
##
|
||||
## This is the single most common way a stylised UI becomes unreadable, and it is
|
||||
## also the complaint HoYoverse's own audience has levelled at Zenless Zone
|
||||
## Zero's menus. The fix is not vigilance, it is arithmetic: `ink_for` picks the
|
||||
## legible text colour for any fill, every state declares its pair in
|
||||
## `STATE_TABLE`, and `debug/ui_contrast_check.gd` fails the build if one of them
|
||||
## drops below the WCAG floor. A state cannot ship unreadable without the check
|
||||
## going red.
|
||||
|
||||
const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf"
|
||||
|
||||
@@ -42,6 +59,14 @@ const MAGENTA := Color(1.00, 0.18, 0.52)
|
||||
## Older screens name the accent `TEAL`; it is the electric cyan now.
|
||||
const TEAL := CYAN
|
||||
|
||||
## Disabled: the fill goes flat and the label desaturates, but it does NOT go so
|
||||
## dim that it stops being a word. A disabled control still has to say what it
|
||||
## would do — "Start Match" greyed out is information, an illegible smudge is
|
||||
## not. This pair sits above 5:1, where the old 0.42-grey sat at 3.2:1.
|
||||
const DEAD_FILL := Color(0.10, 0.10, 0.14, 0.92)
|
||||
const DEAD_EDGE := Color(0.30, 0.29, 0.36)
|
||||
const DEAD_TEXT := Color(0.55, 0.54, 0.62)
|
||||
|
||||
## How far a chip leans. Applied as opposite corners round and the other two
|
||||
## nearly square, which is as close to a skew as a StyleBoxFlat can get.
|
||||
const LEAN := 18
|
||||
@@ -49,6 +74,85 @@ const LEAN := 18
|
||||
static var _theme: Theme = null
|
||||
|
||||
|
||||
# ── Contrast ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
## Relative luminance, per WCAG 2.1. Alpha is ignored: a chip's fill is drawn
|
||||
## over the panel behind it, and assuming the fill is opaque is the pessimistic
|
||||
## reading, which is the one worth checking.
|
||||
static func luminance(c: Color) -> float:
|
||||
var ch := [c.r, c.g, c.b]
|
||||
for i in 3:
|
||||
var v: float = ch[i]
|
||||
ch[i] = v / 12.92 if v <= 0.04045 else pow((v + 0.055) / 1.055, 2.4)
|
||||
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]
|
||||
|
||||
|
||||
## WCAG contrast ratio between two colours, 1.0 (identical) .. 21.0 (black on
|
||||
## white). 4.5 is the floor for body text, 3.0 for text above ~24 px.
|
||||
static func contrast(a: Color, b: Color) -> float:
|
||||
var la := luminance(a)
|
||||
var lb := luminance(b)
|
||||
return (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05)
|
||||
|
||||
|
||||
## The legible text colour for a given fill: ink on a light chip, paper on a dark
|
||||
## one. This is what makes a hover safe — the fill is free to become papaya or
|
||||
## volt because the label follows it.
|
||||
static func ink_for(fill: Color) -> Color:
|
||||
return INK if contrast(INK, fill) >= contrast(PAPER, fill) else PAPER
|
||||
|
||||
|
||||
## Every (fill, text) pair the theme puts on screen, as
|
||||
## `["<class>/<state>", fill, text, is_large_text]`.
|
||||
##
|
||||
## Written out rather than derived so the check has something to compare the
|
||||
## built theme AGAINST — a table generated from the theme would agree with it by
|
||||
## construction and catch nothing. Large text (headings, the wordmark, the HUD's
|
||||
## big numerals) is allowed the 3:1 floor WCAG gives it.
|
||||
static func state_table() -> Array:
|
||||
var out: Array = []
|
||||
for cls in BUTTON_CLASSES:
|
||||
out.append_array([
|
||||
[cls + "/normal", INK_SOFT, PAPER, false],
|
||||
[cls + "/hover", PAPAYA, ink_for(PAPAYA), false],
|
||||
[cls + "/pressed", VOLT, ink_for(VOLT), false],
|
||||
[cls + "/focus", INK_SOFT, PAPER, false],
|
||||
[cls + "/disabled", DEAD_FILL, DEAD_TEXT, false],
|
||||
])
|
||||
out.append_array([
|
||||
["Label/on_panel", PANEL, PAPER, false],
|
||||
["Label/dim_on_panel", PANEL, PAPER_DIM, false],
|
||||
["Label/title", PANEL, PAPAYA, true],
|
||||
["LineEdit/normal", INK_SOFT, PAPER, false],
|
||||
["LineEdit/placeholder", INK_SOFT, PAPER_DIM, false],
|
||||
["ItemList/normal", PANEL_DEEP, PAPER, false],
|
||||
["ItemList/hovered", INK_SOFT, PAPER, false],
|
||||
["ItemList/selected", PAPAYA, ink_for(PAPAYA), false],
|
||||
["ItemList/selected_focus", PAPAYA_HOT, ink_for(PAPAYA_HOT), false],
|
||||
["PopupMenu/normal", PANEL_DEEP, PAPER, false],
|
||||
["PopupMenu/hover", PAPAYA, ink_for(PAPAYA), false],
|
||||
["PopupMenu/disabled", PANEL_DEEP, DEAD_TEXT, false],
|
||||
["TabContainer/selected", PAPAYA, ink_for(PAPAYA), false],
|
||||
["TabContainer/unselected", INK_SOFT, PAPER_DIM, false],
|
||||
["TabContainer/hovered", PAPAYA_HOT, ink_for(PAPAYA_HOT), false],
|
||||
["Tree/normal", PANEL_DEEP, PAPER, false],
|
||||
["Tree/selected", PAPAYA, ink_for(PAPAYA), false],
|
||||
["Card/label", INK, PAPER, true],
|
||||
["Card/hover_label", INK, VOLT, true],
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
## Which theme types get the chip button treatment. Godot theme types do not
|
||||
## inherit styling from Button, so each one has to be told the same thing.
|
||||
const BUTTON_CLASSES := ["Button", "OptionButton", "MenuButton", "CheckBox",
|
||||
"CheckButton", "LinkButton"]
|
||||
|
||||
|
||||
# ── Styleboxes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
## A leaning comic chip: flat fill, heavy ink edge, hard offset shadow.
|
||||
##
|
||||
## Kept at its original name and argument order — main_menu and match_hud both
|
||||
@@ -93,6 +197,24 @@ static func panel(bg: Color = PANEL, border: Color = INK,
|
||||
return sb
|
||||
|
||||
|
||||
## A tight row chip — a list entry, a popup line, a tab — with the lean dropped.
|
||||
##
|
||||
## The lean is energy on a button the eye lands on. Repeated down twenty rows of
|
||||
## a list it reads as a stack of broken rectangles, so rows get square corners
|
||||
## and keep the fill and the edge.
|
||||
static func row(bg: Color, border: Color = INK, border_w: int = 2) -> StyleBoxFlat:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = bg
|
||||
sb.border_color = border
|
||||
sb.set_border_width_all(border_w)
|
||||
sb.set_corner_radius_all(4)
|
||||
sb.content_margin_left = 12
|
||||
sb.content_margin_right = 12
|
||||
sb.content_margin_top = 5
|
||||
sb.content_margin_bottom = 5
|
||||
return sb
|
||||
|
||||
|
||||
static func build() -> Theme:
|
||||
if _theme:
|
||||
return _theme
|
||||
@@ -105,12 +227,10 @@ static func build() -> Theme:
|
||||
# ── Buttons ──────────────────────────────────────────────────────────
|
||||
# Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and
|
||||
# flashes volt on press. The press state drops its shadow, so the chip
|
||||
# visibly slams down into the page rather than just changing colour.
|
||||
_button_look(t, "Button")
|
||||
for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
|
||||
"LinkButton"]:
|
||||
# Godot theme types do not inherit styling from Button, so each one has
|
||||
# to be told the same thing.
|
||||
# visibly slams down into the page rather than just changing colour. Every
|
||||
# state's text colour comes from `ink_for` its own fill, so the label
|
||||
# inverts WITH the chip instead of vanishing into it.
|
||||
for cls in BUTTON_CLASSES:
|
||||
_button_look(t, cls)
|
||||
|
||||
# ── Labels: ink outline everywhere, for readability straight over 3D ──
|
||||
@@ -125,45 +245,110 @@ static func build() -> Theme:
|
||||
# ── Inputs ───────────────────────────────────────────────────────────
|
||||
t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false))
|
||||
t.set_stylebox("focus", "LineEdit", box(INK_SOFT, CYAN, 4, 3, false))
|
||||
t.set_stylebox("read_only", "LineEdit", box(DEAD_FILL, DEAD_EDGE, 4, 3, false))
|
||||
t.set_color("font_color", "LineEdit", PAPER)
|
||||
t.set_color("font_uneditable_color", "LineEdit", DEAD_TEXT)
|
||||
t.set_color("caret_color", "LineEdit", VOLT)
|
||||
t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM)
|
||||
# Selected text: volt fill wants ink glyphs, same rule as a pressed chip.
|
||||
t.set_color("font_selected_color", "LineEdit", INK)
|
||||
t.set_color("selection_color", "LineEdit", VOLT)
|
||||
|
||||
t.set_stylebox("normal", "SpinBox", box(INK_SOFT, PAPAYA, 4, 3, false))
|
||||
t.set_color("font_color", "SpinBox", PAPER)
|
||||
|
||||
# ── Sliders: the filled part is the charged part ─────────────────────
|
||||
t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false))
|
||||
t.set_stylebox("grabber_area", "HSlider", box(PAPAYA, INK, 2, 2, false))
|
||||
t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, INK, 2, 2, false))
|
||||
t.set_stylebox("slider", "VSlider", box(INK_SOFT, INK, 2, 2, false))
|
||||
t.set_stylebox("grabber_area", "VSlider", box(PAPAYA, INK, 2, 2, false))
|
||||
t.set_stylebox("grabber_area_highlight", "VSlider", box(VOLT, INK, 2, 2, false))
|
||||
|
||||
# ── Progress bars ────────────────────────────────────────────────────
|
||||
t.set_stylebox("background", "ProgressBar", row(INK, INK_SOFT, 2))
|
||||
t.set_stylebox("fill", "ProgressBar", row(PAPAYA, INK, 0))
|
||||
t.set_color("font_color", "ProgressBar", PAPER)
|
||||
t.set_color("font_outline_color", "ProgressBar", INK)
|
||||
t.set_constant("outline_size", "ProgressBar", 6)
|
||||
|
||||
# ── Panels / lists ───────────────────────────────────────────────────
|
||||
t.set_stylebox("panel", "PanelContainer", panel())
|
||||
t.set_stylebox("panel", "Panel", panel())
|
||||
t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3))
|
||||
t.set_color("font_color", "ItemList", PAPER)
|
||||
t.set_color("font_selected_color", "ItemList", INK)
|
||||
t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false))
|
||||
t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false))
|
||||
t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false))
|
||||
t.set_color("font_selected_color", "ItemList", ink_for(PAPAYA))
|
||||
t.set_color("font_hovered_color", "ItemList", PAPER)
|
||||
t.set_color("font_outline_color", "ItemList", INK)
|
||||
t.set_constant("outline_size", "ItemList", 5)
|
||||
t.set_stylebox("selected", "ItemList", row(PAPAYA))
|
||||
t.set_stylebox("selected_focus", "ItemList", row(PAPAYA_HOT, CYAN))
|
||||
# Hover keeps PAPER text, so the fill must stay DARK. A papaya hover under a
|
||||
# papaya selection also made the two states indistinguishable — the row you
|
||||
# were pointing at looked exactly like the row you had chosen.
|
||||
t.set_stylebox("hovered", "ItemList", row(INK_SOFT, PAPAYA))
|
||||
t.set_stylebox("hovered_selected", "ItemList", row(PAPAYA_HOT, CYAN))
|
||||
t.set_stylebox("cursor", "ItemList", row(Color(0, 0, 0, 0), CYAN))
|
||||
t.set_stylebox("cursor_unfocused", "ItemList", row(Color(0, 0, 0, 0), PAPER_DIM))
|
||||
|
||||
# ── Trees (settings, loadout lists) ──────────────────────────────────
|
||||
t.set_stylebox("panel", "Tree", panel(PANEL_DEEP, INK, 3))
|
||||
t.set_color("font_color", "Tree", PAPER)
|
||||
t.set_color("font_selected_color", "Tree", ink_for(PAPAYA))
|
||||
t.set_color("font_outline_color", "Tree", INK)
|
||||
t.set_constant("outline_size", "Tree", 5)
|
||||
t.set_stylebox("selected", "Tree", row(PAPAYA))
|
||||
t.set_stylebox("selected_focus", "Tree", row(PAPAYA_HOT, CYAN))
|
||||
t.set_stylebox("hovered", "Tree", row(INK_SOFT, PAPAYA))
|
||||
t.set_stylebox("hovered_selected", "Tree", row(PAPAYA_HOT, CYAN))
|
||||
|
||||
# ── Scrollbars: visible at rest, charged under the thumb ─────────────
|
||||
for cls in ["HScrollBar", "VScrollBar"]:
|
||||
t.set_stylebox("scroll", cls, row(INK, INK_SOFT, 1))
|
||||
t.set_stylebox("grabber", cls, row(PAPER_DIM, INK, 1))
|
||||
t.set_stylebox("grabber_highlight", cls, row(PAPAYA, INK, 1))
|
||||
t.set_stylebox("grabber_pressed", cls, row(VOLT, INK, 1))
|
||||
|
||||
# ── Tabs ─────────────────────────────────────────────────────────────
|
||||
t.set_stylebox("panel", "TabContainer", panel())
|
||||
t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false))
|
||||
t.set_stylebox("tab_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false))
|
||||
t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, INK, 6, 3, false))
|
||||
t.set_color("font_selected_color", "TabContainer", INK)
|
||||
t.set_stylebox("tab_disabled", "TabContainer", box(DEAD_FILL, DEAD_EDGE, 6, 3, false))
|
||||
t.set_color("font_selected_color", "TabContainer", ink_for(PAPAYA))
|
||||
t.set_color("font_unselected_color", "TabContainer", PAPER_DIM)
|
||||
t.set_color("font_hovered_color", "TabContainer", INK)
|
||||
t.set_color("font_hovered_color", "TabContainer", ink_for(PAPAYA_HOT))
|
||||
t.set_color("font_disabled_color", "TabContainer", DEAD_TEXT)
|
||||
t.set_color("font_outline_color", "TabContainer", INK)
|
||||
t.set_constant("outline_size", "TabContainer", 5)
|
||||
|
||||
# ── Popups (OptionButton dropdowns) ──────────────────────────────────
|
||||
#
|
||||
# The dropdown is the one surface where a missing hover colour is fatal: a
|
||||
# PopupMenu draws its hover fill but keeps `font_color` unless
|
||||
# `font_hover_color` is set, so a papaya row under paper text was the least
|
||||
# readable thing in the game and it appeared on every settings menu.
|
||||
t.set_stylebox("panel", "PopupMenu", panel(PANEL_DEEP, PAPAYA, 3))
|
||||
t.set_stylebox("hover", "PopupMenu", box(PAPAYA, INK, 4, 0, false))
|
||||
t.set_stylebox("hover", "PopupMenu", row(PAPAYA, INK, 0))
|
||||
t.set_color("font_color", "PopupMenu", PAPER)
|
||||
t.set_color("font_hover_color", "PopupMenu", INK)
|
||||
t.set_color("font_hover_color", "PopupMenu", ink_for(PAPAYA))
|
||||
t.set_color("font_disabled_color", "PopupMenu", DEAD_TEXT)
|
||||
t.set_color("font_accelerator_color", "PopupMenu", PAPER_DIM)
|
||||
t.set_color("font_separator_color", "PopupMenu", PAPAYA)
|
||||
t.set_color("font_outline_color", "PopupMenu", INK)
|
||||
t.set_constant("outline_size", "PopupMenu", 5)
|
||||
|
||||
# ── Dialogs ──────────────────────────────────────────────────────────
|
||||
t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4))
|
||||
t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4))
|
||||
t.set_color("title_color", "Window", VOLT)
|
||||
|
||||
# ── Tooltips ─────────────────────────────────────────────────────────
|
||||
t.set_stylebox("panel", "TooltipPanel", panel(PANEL_DEEP, VOLT, 2))
|
||||
t.set_color("font_color", "TooltipLabel", PAPER)
|
||||
t.set_color("font_outline_color", "TooltipLabel", INK)
|
||||
t.set_constant("outline_size", "TooltipLabel", 5)
|
||||
|
||||
_theme = t
|
||||
return t
|
||||
|
||||
@@ -172,15 +357,28 @@ static func _button_look(t: Theme, cls: String) -> void:
|
||||
t.set_stylebox("normal", cls, box(INK_SOFT, PAPAYA))
|
||||
t.set_stylebox("hover", cls, box(PAPAYA, INK))
|
||||
t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false))
|
||||
# Hover-while-pressed is its own stylebox on a toggle button. Without it a
|
||||
# CheckButton the pointer is over reverts to the hover fill, so a toggle
|
||||
# reads as OFF for as long as you are touching it.
|
||||
t.set_stylebox("hover_pressed", cls, box(VOLT, CYAN, 10, 3, false))
|
||||
t.set_stylebox("focus", cls, box(INK_SOFT, CYAN))
|
||||
t.set_stylebox("disabled", cls, box(Color(0.10, 0.10, 0.14, 0.85), Color(0.28, 0.27, 0.33)))
|
||||
t.set_stylebox("disabled", cls, box(DEAD_FILL, DEAD_EDGE))
|
||||
t.set_color("font_color", cls, PAPER)
|
||||
t.set_color("font_hover_color", cls, INK)
|
||||
t.set_color("font_pressed_color", cls, INK)
|
||||
t.set_color("font_hover_color", cls, ink_for(PAPAYA))
|
||||
t.set_color("font_pressed_color", cls, ink_for(VOLT))
|
||||
t.set_color("font_hover_pressed_color", cls, ink_for(VOLT))
|
||||
t.set_color("font_focus_color", cls, PAPER)
|
||||
t.set_color("font_disabled_color", cls, Color(0.42, 0.41, 0.48))
|
||||
t.set_color("font_disabled_color", cls, DEAD_TEXT)
|
||||
t.set_color("font_outline_color", cls, INK)
|
||||
t.set_constant("outline_size", cls, 5)
|
||||
# The icon has to invert with the label. A paper glyph on a volt chip is the
|
||||
# same 1.1:1 the text would have been, and a CheckBox is ALL icon.
|
||||
t.set_color("icon_normal_color", cls, PAPER)
|
||||
t.set_color("icon_hover_color", cls, ink_for(PAPAYA))
|
||||
t.set_color("icon_pressed_color", cls, ink_for(VOLT))
|
||||
t.set_color("icon_hover_pressed_color", cls, ink_for(VOLT))
|
||||
t.set_color("icon_focus_color", cls, PAPER)
|
||||
t.set_color("icon_disabled_color", cls, DEAD_TEXT)
|
||||
|
||||
|
||||
## Apply the theme to the whole root window. Idempotent, and cheap enough that
|
||||
@@ -223,6 +421,17 @@ static func heading(text: String, size: int = 32) -> Label:
|
||||
return l
|
||||
|
||||
|
||||
## A quieter line of body copy — a hint, a port number, a mode's description.
|
||||
static func caption(text: String, size: int = 20) -> Label:
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", size)
|
||||
l.add_theme_color_override("font_color", PAPER_DIM)
|
||||
l.add_theme_color_override("font_outline_color", INK)
|
||||
l.add_theme_constant_override("outline_size", 5)
|
||||
return l
|
||||
|
||||
|
||||
## A bolt-struck rule, for separating sections.
|
||||
static func divider(strike_at: float = 0.34) -> BoltRule:
|
||||
var b := BoltRule.new()
|
||||
@@ -240,6 +449,28 @@ static func card() -> PanelContainer:
|
||||
return p
|
||||
|
||||
|
||||
## The one loud button on a screen — the thing the player came here to press.
|
||||
##
|
||||
## Papaya-filled at rest instead of ink-filled, so it is the first thing the eye
|
||||
## lands on, and correspondingly ink-lettered. Hover goes hotter and press still
|
||||
## flashes volt, so the state ladder is unchanged; only the resting colour moves.
|
||||
static func primary_button(text: String, size: int = 44) -> Button:
|
||||
var b := Button.new()
|
||||
b.text = text
|
||||
b.add_theme_font_size_override("font_size", size)
|
||||
b.add_theme_stylebox_override("normal", box(PAPAYA, INK, 12, 4))
|
||||
b.add_theme_stylebox_override("hover", box(PAPAYA_HOT, VOLT, 12, 4))
|
||||
b.add_theme_stylebox_override("pressed", box(VOLT, INK, 12, 4, false))
|
||||
b.add_theme_stylebox_override("focus", box(PAPAYA, CYAN, 12, 4))
|
||||
b.add_theme_color_override("font_color", ink_for(PAPAYA))
|
||||
b.add_theme_color_override("font_hover_color", ink_for(PAPAYA_HOT))
|
||||
b.add_theme_color_override("font_pressed_color", ink_for(VOLT))
|
||||
b.add_theme_color_override("font_focus_color", ink_for(PAPAYA))
|
||||
b.add_theme_color_override("font_outline_color", Color(INK.r, INK.g, INK.b, 0.55))
|
||||
b.add_theme_constant_override("outline_size", 4)
|
||||
return b
|
||||
|
||||
|
||||
## Sounds and the hover kick for every button under `root` (recursive).
|
||||
##
|
||||
## The kick is a 4% scale-up on hover and a snap back on exit. It is small on
|
||||
|
||||
Reference in New Issue
Block a user