# 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: ```lisp (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: ```lisp (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`: ```lisp (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. ```lisp (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: ```lisp (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: ```rust 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: ```lisp (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 ```rust 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: ```rust 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.