theming.md raw
Theming
A theme moves every visual decision — colors, fonts, radii — out of components and into a data file that can be swapped at runtime. A component styled for theming declares structure and classes only; the theme supplies the rest.
Theme files (.gdt) use the same reader and diagnostics as component
files:
(theme Light
(tokens
(surface "#f5f5f5")
(ink "#1a1a1a")
(accent "#4682b4")
(accent-hover "#6495ed")
(radius-m 8))
(rule container :background surface)
(rule (button .primary) :background accent :corner-radius radius-m)
(rule (button .primary :hover) :background accent-hover)
(rule text :color ink :font-size 14 :font-family "DejaVu Sans")
(rule (text .title) :font-size 20))
Tokens
(tokens (name value) …) declares named design values — colors,
numbers, strings — referenced by bare name in rules. Rules may also use
literals directly; tokens exist so a palette is defined once.
Rules
(rule selector :property value …). The selector is:
widget— a widget type (container,text,text-input,scroll-area,button,checkbox,radio,switch,slider,stepper,progress,separator,tab,list-item,expander, …);(widget .class …)— the type plus required style classes (the widget’s own:classlist must contain all of them);(widget .class… :state)— additionally, an interaction state::hover,:active(pressed),:focus(holds keyboard focus), or:disabled.
The stylable properties are the widget’s own appearance tokens —
brushes (:background, :color), metrics (:corner-radius,
:focus-ring-width), and, for marks like the checkbox check,
graphics. Each token is checked against the widget type and its
expected value type, with pointed diagnostics (a color where a graphic is
expected, an unknown token, and so on). Controls expose the extra tokens
their paint reads — a button has :focus-ring-color, a checkbox has
:box-fill, :box-fill-checked, :check-mark, :check-color, a
text-input has :border-color, :caret-color, :selection-color, a
scroll-area has :thumb-color, …
A text has :link-color and :link-underline-width, which dress the
(link …) spans inside a paragraph. They are here rather than in the
widget for the ordinary reason: “blue and underlined” is a convention, not
a law, and a paragraph has no more business hard-coding what a link looks
like than a button has hard-coding its own background. The underline is a
width because that is what an underline is, and zero is no underline — the
:border-width precedent. It takes the link’s own color, so there is one
place to change a link’s appearance rather than two that can disagree:
(rule text :link-color "#1a5fb4" :link-underline-width 1)
A paragraph’s own :color and :font-size are stylable too, but the
default theme deliberately says nothing about them: a theme that stated
them would be deciding what every piece of prose in every application looks
like.
Note what is not there: a text input has no :focus-border-color, and
a button no :focus-ring-visible. “While focused” is what the :focus
selector already says, so the focused field simply restates
:border-color and :border-width:
(rule text-input :border-color line :border-width 1)
(rule (text-input :focus) :border-color accent :border-width 2)
This is the seam the whole design rests on: a control paints from the
tokens it resolved and never consults its own interaction state, so the
theme is the only place appearance is decided. (Widget-internal state
is different — a checkbox reads its own checked field to pick between
:box-fill and :box-fill-checked, because that is a fact about the
widget rather than about the pointer.)
Two deliberate design rules:
- No descendant combinators. A rule matches on the widget’s own type, classes, and state — never its ancestors. A widget’s computed style therefore depends only on itself, so changing one widget’s state or classes restyles that widget alone; nothing cascades.
- Base coverage. A stateful (
:hover/:active/:focus/:disabled) rule may only style properties that some stateless rule for the same (or a broader) selector also styles. Leaving the state then always has a value to return to. Violations are compile-time diagnostics.
Graphics
A graphic is something to draw into a rectangle: vector geometry, or a file’s pixels. Wherever a token takes one, either will do.
(rule checkbox :check-mark (path (move 0.24 0.52) (line 0.43 0.70) (line 0.76 0.30)))
(rule checkbox :check-mark (svg-path "M0.24 0.52 L0.43 0.70 L0.76 0.30"))
(rule checkbox :check-mark (asset "icons/check.png"))
The widget hands the graphic a rectangle and it scales to fill it, so
path coordinates are in the mark’s own space (conventionally the unit
square [0,1]²).
The two are not interchangeable in who supplies the color, and the
language doesn’t pretend otherwise. A path is bare geometry, so the
widget’s color tokens paint it — the checkbox strokes its mark with
:check-color at :check-width. Pixels carry their own color, so a
raster mark ignores those tokens and varies across states by swapping
the asset, which is how icon themes work anyway:
(rule checkbox :check-mark (asset "icons/check.png"))
(rule (checkbox :disabled) :check-mark (asset "icons/check-grey.png"))
A theme’s assets resolve when the theme is compiled, against a search
path you supply — unlike a .gdc’s, which the typed build embeds. A
theme file is data your application loads at runtime, so its icons are
found and decoded then:
let dirs = [PathBuf::from("themes")];
let theme = Theme::parse_with_assets(&fs::read_to_string("themes/light.gdt")?, &dirs)?;
Plain Theme::parse has no search path, so a theme that names an asset
gets a diagnostic saying so rather than failing obscurely. A file that
cannot be found is a diagnostic pointing at the value, like any other
theme error.
Nothing about (asset …) is image-specific: it names a file, and the
type of the token it lands in decides what the bytes mean.
The default theme
Every std control’s appearance is data: it lives in a built-in default theme that is always present, layered under whatever theme the app sets. So controls look right with no app theme at all, and an app theme overrides selectively — set only the tokens you want to change and the rest fall through to the default. For example, to give checkboxes a dash instead of a check and a green accent, without touching anything else:
(theme App
(rule checkbox
:box-fill-checked "#2e8b57"
:box-border-color-checked "#2e8b57"
:check-mark (svg-path "M0.24 0.5 L0.76 0.5")))
The layering is Theme::over (rule concatenation, the later theme
winning); set_theme performs it for you against the default.
The fallback theme
There are in fact three layers, not two:
app theme (optional, yours)
over default theme (guiduck-core/src/default.gdt — the shipped look)
over fallback theme (style::fallback_theme() — built in Rust)
The fallback is deliberately plain: square corners, no hover or active feedback, primary colors. It is not a second opinion about how controls should look — the default theme overrides every token it sets, and a test pins that coverage in both directions, so you never see it in a working build.
It exists because it is the one definition of each control’s token set. Controls initialize their tokens from it rather than each carrying a palette of literals in code, so a token has exactly one home. And because it is code rather than parsed source, it cannot fail to load: if the default theme ever failed to compile, guiduck would report the diagnostics and carry on with plain-looking but entirely usable controls instead of refusing to start.
Both are readable as reference: default.gdt is the theme to fork,
fallback_theme() is the floor beneath it.
For a property matched by several rules, the last matching rule in the file wins — order is priority.
Loading and switching
use guiduck::core::Theme;
let theme = Theme::parse(&std::fs::read_to_string("themes/light.gdt")?)
.map_err(|diags| /* render with guiduck::core::style::render_diagnostics */)?;
tree.set_theme(Some(theme));
A theme that names assets needs somewhere to find them — usually its own directory:
let theme = Theme::parse_with_assets(
&std::fs::read_to_string("themes/light.gdt")?,
&[std::path::PathBuf::from("themes")],
)?;
Themes are runtime data: set_theme restyles the live tree, so
switching themes is one call (see the m4_themed example, whose
“Switch theme” button swaps light and dark from inside a handler).
A rule that only touches paint properties (a hover color, say) marks
only paint dirt — hovering never re-runs layout.
The style pass applies matched rules through the widgets’ normal
setters, so a rule that styles a property overrides whatever the
component set inline. The convention for themable components is
therefore to declare structure and classes only, leaving every visual
property to the theme — components/ThemedDemo.gdc with
themes/{light,dark}.gdt in the repository is the worked example.