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 :class list 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.