style.rs raw

//! The runtime style engine: themes, rule matching, and computed styles.
//!
//! A widget's computed style depends only on its own type, style classes,
//! and interaction state — there are no descendant combinators, so changing
//! one widget's state never invalidates another's style. Styles are applied
//! through the widgets' ordinary setters, which compare-and-set and mark
//! their classified dirt; re-applying an unchanged style is therefore a
//! no-op, which also stands in for a computed-style cache until profiling
//! says otherwise.

use std::path::PathBuf;

use guiduck_component_core::ir::Literal;
use guiduck_component_core::sexpr::Span;
use guiduck_component_core::theme::{RuleIr, StateSel, ThemeIr};

/// The widget vocabulary a theme is checked against: the builtins, plus
/// whatever `.gdw` manifests an application declared. Re-exported so an
/// application can hand its own to [`Theme::parse_with_widgets`].
pub use guiduck_component_core::registry::Registry;
use guiduck_scene::paint::{Brush, Color};

use crate::dirty::Dirt;
use crate::graphic::Graphic;

/// Compiler diagnostics for theme files, re-exported so applications can
/// report load failures with source excerpts.
pub use guiduck_component_core::diagnostics::{Diagnostic, render as render_diagnostics};

/// Interaction states the style engine can select on.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct InteractionState {
    pub hover: bool,
    pub active: bool,
    pub focus: bool,
    pub disabled: bool,
}

impl InteractionState {
    fn matches(self, sel: Option<StateSel>) -> bool {
        match sel {
            None => true,
            Some(StateSel::Hover) => self.hover,
            Some(StateSel::Active) => self.active,
            Some(StateSel::Focus) => self.focus,
            Some(StateSel::Disabled) => self.disabled,
        }
    }
}

/// A themable property value.
#[derive(Clone, Debug, PartialEq)]
pub enum StyleValue {
    Brush(Brush),
    Number(f64),
    Str(String),
    /// A mark the widget scales into the element it decorates (the checkbox
    /// check, e.g.) — vector geometry or pixels, interchangeably.
    Graphic(Graphic),
}

/// One style rule, translated to runtime types and stamped with the dirt
/// class of its most invasive property.
#[derive(Clone, Debug)]
struct Rule {
    /// The widget type name the rule selects. Matching is by name because the
    /// vocabulary is open: a theme may style a widget the application declared
    /// in a `.gdw`.
    widget: String,
    classes: Vec<String>,
    state: Option<StateSel>,
    props: Vec<(String, StyleValue)>,
    /// The maximum dirt this rule can cause when its values apply — a
    /// hover rule touching only colors is paint-only, one touching
    /// font-size forces layout. Recorded at load for the scheduler and for
    /// tests; actual per-frame dirt still comes from the setters, which
    /// also see whether a value really changed.
    pub dirt: Dirt,
}

/// A loaded theme.
#[derive(Clone, Debug)]
pub struct Theme {
    pub name: String,
    rules: Vec<Rule>,
}

/// The shipped default theme's source, as embedded in the binary.
const DEFAULT_THEME_SOURCE: &str = include_str!("default.gdt");

/// The always-present default theme: the appearance every std control
/// resolves against, layered under any application theme. Compiled once from
/// the shipped source, over [`fallback_theme`], and shared by every tree.
///
/// A default theme that fails to compile is reported and skipped rather than
/// fatal — the fallback alone still dresses every control, so the
/// application runs.
pub fn default_theme() -> &'static Theme {
    use std::sync::OnceLock;
    static DEFAULT: OnceLock<Theme> = OnceLock::new();
    DEFAULT.get_or_init(|| compile_default(DEFAULT_THEME_SOURCE))
}

/// Compile a default theme over the fallback, reporting rather than
/// panicking if it will not compile. Separate from [`default_theme`]'s
/// one-time initialization so the recovery path is reachable in a test.
fn compile_default(source: &str) -> Theme {
    match Theme::parse(source) {
        Ok(theme) => theme.over(fallback_theme()),
        Err(diagnostics) => {
            eprintln!(
                "guiduck: the shipped default theme failed to compile; \
                 controls will use the built-in fallback appearance.\n{}",
                render_diagnostics(&diagnostics, source, "default.gdt")
            );
            fallback_theme().clone()
        }
    }
}

/// The built-in fallback theme: a deliberately plain but complete floor
/// beneath [`default_theme`].
///
/// It earns its place twice. It is the **single definition of every std
/// control's token set**, so controls initialize their tokens from it instead
/// of each carrying a duplicate palette in code. And, being built here rather
/// than parsed, it cannot fail to load: whatever happens to the default theme,
/// every control still resolves a complete, usable appearance.
///
/// It is intentionally spartan — square corners, no hover or active feedback —
/// so it reads as a floor rather than as a second opinion about how controls
/// should look. That is the default theme's job, and it overrides every token
/// set here (a two-way coverage test pins both directions).
pub fn fallback_theme() -> &'static Theme {
    use std::sync::OnceLock;
    static FALLBACK: OnceLock<Theme> = OnceLock::new();
    FALLBACK.get_or_init(build_fallback_theme)
}

fn build_fallback_theme() -> Theme {
    /// `0xRRGGBBAA`.
    fn brush(rgba: u32) -> StyleValue {
        let [r, g, b, a] = rgba.to_be_bytes();
        StyleValue::Brush(Color::from_rgba8(r, g, b, a).into())
    }
    fn num(v: f64) -> StyleValue {
        StyleValue::Number(v)
    }
    fn rule(widget: &'static str, state: Option<StateSel>, props: &[(&str, StyleValue)]) -> Rule {
        let props: Vec<(String, StyleValue)> = props
            .iter()
            .map(|(name, value)| ((*name).to_owned(), value.clone()))
            .collect();
        let dirt = props
            .iter()
            .fold(Dirt::CLEAN, |acc, (name, _)| acc.union(prop_dirt(name)));
        Rule {
            widget: widget.to_owned(),
            classes: Vec::new(),
            state,
            props,
            dirt,
        }
    }

    const INK: u32 = 0x000000ff;
    const MUTED: u32 = 0xa0a0a0ff;
    const SURFACE: u32 = 0xffffffff;
    const LINE: u32 = 0x808080ff;
    const ACCENT: u32 = 0x3060c0ff;
    const FACE: u32 = 0xd0d0d0ff;
    const FACE_DISABLED: u32 = 0xe8e8e8ff;
    const CLEAR: u32 = 0x00000000;

    // The ▾ a dropdown field carries, in the unit square.
    let down_arrow = || {
        let mut path = guiduck_scene::geom::BezPath::new();
        path.move_to((0.15, 0.35));
        path.line_to((0.85, 0.35));
        path.line_to((0.5, 0.75));
        path.close_path();
        StyleValue::Graphic(Graphic::Path(path))
    };

    // A menu row: the title of a bar menu, a submenu row, or an item. The
    // highlight is a token variant rather than a state selector, because
    // being the current row is the widget's own business (the checkbox's
    // `checked` precedent).
    let row_tokens = || {
        let mut mark = guiduck_scene::geom::BezPath::new();
        mark.move_to((0.3, 0.1));
        mark.line_to((0.75, 0.5));
        mark.line_to((0.3, 0.9));
        mark.close_path();
        vec![
            ("background", brush(CLEAR)),
            ("background-highlighted", brush(ACCENT)),
            ("color", brush(INK)),
            ("color-highlighted", brush(SURFACE)),
            ("accel-color", brush(MUTED)),
            ("corner-radius", num(0.0)),
            ("submenu-mark", StyleValue::Graphic(Graphic::Path(mark))),
        ]
    };

    // A check mark in the unit square, scaled into the box by the widget.
    let check_mark = {
        let mut path = guiduck_scene::geom::BezPath::new();
        path.move_to((0.24, 0.52));
        path.line_to((0.43, 0.70));
        path.line_to((0.76, 0.30));
        StyleValue::Graphic(Graphic::Path(path))
    };

    // A dot in the unit square, for the radio.
    let dot = {
        let mut path = guiduck_scene::geom::BezPath::new();
        path.move_to((0.5, 0.26));
        path.curve_to((0.63, 0.26), (0.74, 0.37), (0.74, 0.5));
        path.curve_to((0.74, 0.63), (0.63, 0.74), (0.5, 0.74));
        path.curve_to((0.37, 0.74), (0.26, 0.63), (0.26, 0.5));
        path.curve_to((0.26, 0.37), (0.37, 0.26), (0.5, 0.26));
        path.close_path();
        StyleValue::Graphic(Graphic::Path(path))
    };

    // A knob for the switch: the same circle, sitting in a long track.
    let knob = {
        let mut path = guiduck_scene::geom::BezPath::new();
        path.move_to((0.72, 0.5));
        path.curve_to((0.72, 0.62), (0.62, 0.72), (0.5, 0.72));
        path.curve_to((0.38, 0.72), (0.28, 0.62), (0.28, 0.5));
        path.curve_to((0.28, 0.38), (0.38, 0.28), (0.5, 0.28));
        path.curve_to((0.62, 0.28), (0.72, 0.38), (0.72, 0.5));
        path.close_path();
        StyleValue::Graphic(Graphic::Path(path))
    };

    // A triangle the expander turns a quarter when it opens.
    let expand_mark = {
        let mut path = guiduck_scene::geom::BezPath::new();
        path.move_to((0.3, 0.2));
        path.line_to((0.7, 0.5));
        path.line_to((0.3, 0.8));
        path.close_path();
        StyleValue::Graphic(Graphic::Path(path))
    };

    Theme {
        name: "Fallback".to_owned(),
        rules: vec![
            rule(
                "button",
                None,
                &[
                    ("background", brush(FACE)),
                    ("color", brush(INK)),
                    ("corner-radius", num(0.0)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "button",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "button",
                Some(StateSel::Disabled),
                &[
                    ("background", brush(FACE_DISABLED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "checkbox",
                None,
                &[
                    ("box-fill", brush(SURFACE)),
                    ("box-fill-checked", brush(ACCENT)),
                    ("box-border-color", brush(LINE)),
                    ("box-border-color-checked", brush(ACCENT)),
                    ("box-border-width", num(1.0)),
                    ("box-corner-radius", num(0.0)),
                    ("check-mark", check_mark.clone()),
                    ("check-color", brush(SURFACE)),
                    ("check-width", num(2.0)),
                    ("color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "checkbox",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "checkbox",
                Some(StateSel::Disabled),
                &[
                    ("box-fill", brush(FACE_DISABLED)),
                    ("box-fill-checked", brush(FACE_DISABLED)),
                    ("box-border-color", brush(MUTED)),
                    ("box-border-color-checked", brush(MUTED)),
                    ("check-color", brush(MUTED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "switch",
                None,
                &[
                    ("box-fill", brush(SURFACE)),
                    ("box-fill-checked", brush(ACCENT)),
                    ("box-border-color", brush(LINE)),
                    ("box-border-color-checked", brush(ACCENT)),
                    ("box-border-width", num(0.0)),
                    ("box-corner-radius", num(9.0)),
                    ("check-mark", knob.clone()),
                    ("check-color", brush(SURFACE)),
                    ("check-width", num(2.0)),
                    ("color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "switch",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "switch",
                Some(StateSel::Disabled),
                &[
                    ("box-fill", brush(FACE_DISABLED)),
                    ("box-fill-checked", brush(FACE_DISABLED)),
                    ("box-border-color", brush(MUTED)),
                    ("box-border-color-checked", brush(MUTED)),
                    ("check-color", brush(MUTED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "radio",
                None,
                &[
                    ("box-fill", brush(SURFACE)),
                    ("box-fill-checked", brush(SURFACE)),
                    ("box-border-color", brush(LINE)),
                    ("box-border-color-checked", brush(ACCENT)),
                    ("box-border-width", num(1.0)),
                    ("box-corner-radius", num(9.0)),
                    ("check-mark", dot.clone()),
                    ("check-color", brush(ACCENT)),
                    ("check-width", num(2.0)),
                    ("color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "radio",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "radio",
                Some(StateSel::Disabled),
                &[
                    ("box-fill", brush(FACE_DISABLED)),
                    ("box-fill-checked", brush(FACE_DISABLED)),
                    ("box-border-color", brush(MUTED)),
                    ("box-border-color-checked", brush(MUTED)),
                    ("check-color", brush(MUTED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "slider",
                None,
                &[
                    ("track-color", brush(FACE_DISABLED)),
                    ("fill-color", brush(ACCENT)),
                    ("thumb-color", brush(SURFACE)),
                    ("thumb-size", num(16.0)),
                    ("thickness", num(4.0)),
                    ("corner-radius", num(0.0)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "slider",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "slider",
                Some(StateSel::Disabled),
                &[
                    ("track-color", brush(FACE_DISABLED)),
                    ("fill-color", brush(MUTED)),
                    ("thumb-color", brush(FACE_DISABLED)),
                ],
            ),
            rule(
                "stepper",
                None,
                &[
                    ("background", brush(SURFACE)),
                    ("color", brush(INK)),
                    ("border-color", brush(LINE)),
                    ("border-width", num(1.0)),
                    ("corner-radius", num(0.0)),
                    ("button-color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "stepper",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "stepper",
                Some(StateSel::Disabled),
                &[
                    ("background", brush(FACE_DISABLED)),
                    ("color", brush(MUTED)),
                    ("button-color", brush(MUTED)),
                ],
            ),
            rule(
                "tab",
                None,
                &[
                    ("box-fill", brush(CLEAR)),
                    ("box-fill-checked", brush(ACCENT)),
                    ("box-border-color", brush(LINE)),
                    ("box-border-color-checked", brush(ACCENT)),
                    ("box-border-width", num(0.0)),
                    ("box-corner-radius", num(0.0)),
                    ("check-mark", check_mark.clone()),
                    ("check-color", brush(CLEAR)),
                    ("check-width", num(2.0)),
                    ("color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "tab",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "tab",
                Some(StateSel::Disabled),
                &[
                    ("box-fill", brush(FACE_DISABLED)),
                    ("box-fill-checked", brush(FACE_DISABLED)),
                    ("box-border-color", brush(MUTED)),
                    ("box-border-color-checked", brush(MUTED)),
                    ("check-color", brush(MUTED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "switch",
                None,
                &[
                    ("box-fill", brush(CLEAR)),
                    ("box-fill-checked", brush(ACCENT)),
                    ("box-border-color", brush(LINE)),
                    ("box-border-color-checked", brush(ACCENT)),
                    ("box-border-width", num(0.0)),
                    ("box-corner-radius", num(9.0)),
                    ("check-mark", knob.clone()),
                    ("check-color", brush(CLEAR)),
                    ("check-width", num(2.0)),
                    ("color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "switch",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "switch",
                Some(StateSel::Disabled),
                &[
                    ("box-fill", brush(FACE_DISABLED)),
                    ("box-fill-checked", brush(FACE_DISABLED)),
                    ("box-border-color", brush(MUTED)),
                    ("box-border-color-checked", brush(MUTED)),
                    ("check-color", brush(MUTED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "list-item",
                None,
                &[
                    ("box-fill", brush(CLEAR)),
                    ("box-fill-checked", brush(ACCENT)),
                    ("box-border-color", brush(LINE)),
                    ("box-border-color-checked", brush(ACCENT)),
                    ("box-border-width", num(0.0)),
                    ("box-corner-radius", num(0.0)),
                    ("check-mark", check_mark.clone()),
                    ("check-color", brush(CLEAR)),
                    ("check-width", num(2.0)),
                    ("color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "list-item",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "list-item",
                Some(StateSel::Disabled),
                &[
                    ("box-fill", brush(FACE_DISABLED)),
                    ("box-fill-checked", brush(FACE_DISABLED)),
                    ("box-border-color", brush(MUTED)),
                    ("box-border-color-checked", brush(MUTED)),
                    ("check-color", brush(MUTED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "switch",
                None,
                &[
                    ("box-fill", brush(CLEAR)),
                    ("box-fill-checked", brush(ACCENT)),
                    ("box-border-color", brush(LINE)),
                    ("box-border-color-checked", brush(ACCENT)),
                    ("box-border-width", num(0.0)),
                    ("box-corner-radius", num(9.0)),
                    ("check-mark", knob.clone()),
                    ("check-color", brush(CLEAR)),
                    ("check-width", num(2.0)),
                    ("color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "switch",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "switch",
                Some(StateSel::Disabled),
                &[
                    ("box-fill", brush(FACE_DISABLED)),
                    ("box-fill-checked", brush(FACE_DISABLED)),
                    ("box-border-color", brush(MUTED)),
                    ("box-border-color-checked", brush(MUTED)),
                    ("check-color", brush(MUTED)),
                    ("color", brush(MUTED)),
                ],
            ),
            rule(
                "tab-list",
                None,
                &[("background", brush(CLEAR)), ("corner-radius", num(0.0))],
            ),
            rule(
                "list",
                None,
                &[("background", brush(SURFACE)), ("corner-radius", num(0.0))],
            ),
            rule(
                "expander",
                None,
                &[
                    ("background", brush(CLEAR)),
                    ("color", brush(INK)),
                    ("corner-radius", num(0.0)),
                    ("expand-mark", expand_mark.clone()),
                    ("expand-color", brush(INK)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                ],
            ),
            rule(
                "expander",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule(
                "expander",
                Some(StateSel::Disabled),
                &[("color", brush(MUTED)), ("expand-color", brush(MUTED))],
            ),
            rule(
                "separator",
                None,
                &[("color", brush(LINE)), ("thickness", num(1.0))],
            ),
            rule(
                "progress",
                None,
                &[
                    ("track-color", brush(FACE_DISABLED)),
                    ("fill-color", brush(ACCENT)),
                    ("corner-radius", num(0.0)),
                    ("thickness", num(6.0)),
                ],
            ),
            rule(
                "text-input",
                None,
                &[
                    ("background", brush(SURFACE)),
                    ("color", brush(INK)),
                    ("caret-color", brush(INK)),
                    ("selection-color", brush(0x3060c060)),
                    ("border-color", brush(LINE)),
                    ("border-width", num(1.0)),
                    ("corner-radius", num(0.0)),
                ],
            ),
            rule(
                "text-input",
                Some(StateSel::Focus),
                &[("border-color", brush(ACCENT)), ("border-width", num(2.0))],
            ),
            // A field that opens a list is a field: the same appearance.
            rule(
                "combo-box",
                None,
                &[
                    ("background", brush(SURFACE)),
                    ("color", brush(INK)),
                    ("caret-color", brush(INK)),
                    ("selection-color", brush(0x3060c060)),
                    ("border-color", brush(LINE)),
                    ("border-width", num(1.0)),
                    ("corner-radius", num(0.0)),
                ],
            ),
            rule(
                "combo-box",
                Some(StateSel::Focus),
                &[("border-color", brush(ACCENT)), ("border-width", num(2.0))],
            ),
            rule(
                "scroll-area",
                None,
                &[
                    ("thumb-color", brush(0x606060a0)),
                    ("thumb-corner-radius", num(3.0)),
                ],
            ),
            // A paragraph's own color and font are the widget's defaults, not
            // the theme's; what a *link span* inside one looks like is the
            // theme's, because "blue and underlined" is a convention rather
            // than something a paragraph could know.
            rule(
                "text",
                None,
                &[
                    ("link-color", brush(ACCENT)),
                    ("link-underline-width", num(1.0)),
                    ("selection-color", brush(0x3060c060)),
                ],
            ),
            // Only a `(path …)` source reads this; pixels carry their own.
            rule("image", None, &[("color", brush(INK))]),
            rule("menu-bar", None, &[("background", brush(FACE))]),
            rule(
                "dialog",
                None,
                &[
                    ("background", brush(SURFACE)),
                    ("color", brush(INK)),
                    ("border-color", brush(LINE)),
                    ("border-width", num(1.0)),
                    ("corner-radius", num(0.0)),
                    ("scrim-color", brush(0x00000060)),
                ],
            ),
            rule(
                "tooltip",
                None,
                &[
                    ("background", brush(0x2a2a30ff)),
                    ("border-color", brush(CLEAR)),
                    ("border-width", num(0.0)),
                    ("corner-radius", num(0.0)),
                    ("color", brush(SURFACE)),
                ],
            ),
            rule(
                "menu-panel",
                None,
                &[
                    ("background", brush(SURFACE)),
                    ("border-color", brush(LINE)),
                    ("border-width", num(1.0)),
                    ("corner-radius", num(0.0)),
                ],
            ),
            rule(
                "menu-separator",
                None,
                &[("color", brush(LINE)), ("thickness", num(1.0))],
            ),
            rule(
                "dropdown",
                None,
                &[
                    ("background", brush(SURFACE)),
                    ("color", brush(INK)),
                    ("border-color", brush(LINE)),
                    ("border-width", num(1.0)),
                    ("corner-radius", num(0.0)),
                    ("focus-ring-color", brush(CLEAR)),
                    ("focus-ring-width", num(2.0)),
                    ("arrow-mark", down_arrow()),
                    ("arrow-color", brush(INK)),
                ],
            ),
            rule(
                "dropdown",
                Some(StateSel::Focus),
                &[("focus-ring-color", brush(ACCENT))],
            ),
            rule("menu", None, &row_tokens()),
            rule("menu-item", None, &row_tokens()),
        ],
    }
}

impl Theme {
    /// Translate compiled theme IR into runtime form, resolving any
    /// `(asset …)` values against `asset_dirs`.
    pub fn from_ir(ir: &ThemeIr, asset_dirs: &[PathBuf]) -> Result<Self, Vec<Diagnostic>> {
        let mut rules = Vec::with_capacity(ir.rules.len());
        let mut errors = Vec::new();
        for rule in &ir.rules {
            match rule_from_ir(rule, asset_dirs) {
                Ok(rule) => rules.push(rule),
                Err(mut diagnostics) => errors.append(&mut diagnostics),
            }
        }
        if errors.is_empty() {
            Ok(Self {
                name: ir.name.clone(),
                rules,
            })
        } else {
            Err(errors)
        }
    }

    /// Compile theme source text and translate it.
    ///
    /// The theme may not name assets: it has nowhere to look for them. Use
    /// [`parse_with_assets`](Self::parse_with_assets) for a theme with icons.
    pub fn parse(source: &str) -> Result<Self, Vec<Diagnostic>> {
        Self::parse_with_assets(source, &[])
    }

    /// Compile theme source text whose `(asset "…")` values resolve against
    /// `asset_dirs`, probed in order.
    ///
    /// Unlike a `.gdc`, whose assets the typed build embeds, a theme is data
    /// the application loads at runtime — so its assets are found and decoded
    /// here, when the theme is compiled, and a missing file is a diagnostic
    /// pointing at the value. Passing the theme file's own directory is the
    /// usual choice.
    pub fn parse_with_assets(
        source: &str,
        asset_dirs: &[PathBuf],
    ) -> Result<Self, Vec<Diagnostic>> {
        Self::parse_with_widgets(source, asset_dirs, &Registry::default())
    }

    /// [`parse_with_assets`](Self::parse_with_assets) against an application's
    /// own widget vocabulary, so a rule may select a widget its `.gdw`
    /// manifests declared and set the tokens they declared.
    ///
    /// The builtins need no registry — they are a closed set every `Registry`
    /// already answers for — so the plainer entry points above are adapters
    /// over this one with an empty vocabulary, exactly as the `.gdc` side's
    /// are. Build the registry with
    /// [`load_widgets`](guiduck_component_core::resolve::load_widgets) over
    /// the same directories the crate's `widget-path` names.
    pub fn parse_with_widgets(
        source: &str,
        asset_dirs: &[PathBuf],
        registry: &Registry,
    ) -> Result<Self, Vec<Diagnostic>> {
        let ir = guiduck_component_core::theme::compile_theme_with(source, registry)?;
        Self::from_ir(&ir, asset_dirs)
    }

    /// Resolve the computed style for one widget. Later rules win per
    /// property (file order is priority).
    pub fn resolve(
        &self,
        widget_type: &str,
        classes: &[String],
        state: InteractionState,
    ) -> ComputedStyle {
        let mut computed = ComputedStyle::default();
        for rule in &self.rules {
            if rule.widget != widget_type
                || !state.matches(rule.state)
                || !rule.classes.iter().all(|c| classes.contains(c))
            {
                continue;
            }
            for (name, value) in &rule.props {
                computed.set(name, value.clone());
            }
        }
        computed
    }

    /// Layer this theme over `base`: the result carries the base's rules
    /// first and this theme's after, so this theme wins wherever both
    /// speak (file order is priority). The name stays this theme's.
    pub fn over(mut self, base: &Theme) -> Theme {
        let mut rules = base.rules.clone();
        rules.append(&mut self.rules);
        Theme {
            name: self.name,
            rules,
        }
    }

    /// The stamped dirt of every rule that could apply to a widget type —
    /// the scheduler's upper bound for a state flip on that type.
    pub fn max_dirt_for(&self, widget_type: &str) -> Dirt {
        self.rules
            .iter()
            .filter(|r| r.widget == widget_type)
            .fold(Dirt::CLEAN, |acc, r| acc.union(r.dirt))
    }
}

fn rule_from_ir(rule: &RuleIr, asset_dirs: &[PathBuf]) -> Result<Rule, Vec<Diagnostic>> {
    let mut props: Vec<(String, StyleValue)> = Vec::with_capacity(rule.props.len());
    let mut errors = Vec::new();
    for prop in &rule.props {
        match style_value(&prop.value, prop.span, asset_dirs) {
            Ok(value) => props.push((prop.name.clone(), value)),
            Err(diagnostic) => errors.push(diagnostic),
        }
    }
    if !errors.is_empty() {
        return Err(errors);
    }
    let dirt = props
        .iter()
        .fold(Dirt::CLEAN, |acc, (name, _)| acc.union(prop_dirt(name)));
    Ok(Rule {
        widget: rule.widget.clone(),
        classes: rule.classes.clone(),
        state: rule.state,
        props,
        dirt,
    })
}

/// The compiler half of the property table classifies for the *theme*; the
/// setters remain the runtime source of truth.
fn prop_dirt(name: &str) -> Dirt {
    match name {
        "text" | "font-size" | "font-family" => Dirt::LAYOUT,
        _ => Dirt::PAINT,
    }
}

/// Translate a compiled theme value into a runtime one.
///
/// Everything is a pure translation except an `(asset …)`, which names a file
/// that has to be found and decoded — so this is where the theme's asset
/// search path is consulted, and where a value can fail. The span is the
/// value's own, so a failure points at it in the theme source.
fn style_value(
    literal: &Literal,
    span: Span,
    asset_dirs: &[PathBuf],
) -> Result<StyleValue, Diagnostic> {
    Ok(match literal {
        Literal::Color(r, g, b, a) => StyleValue::Brush(Color::from_rgba8(*r, *g, *b, *a).into()),
        Literal::Int(v) => StyleValue::Number(*v as f64),
        Literal::Float(v) => StyleValue::Number(*v),
        Literal::Str(v) => StyleValue::Str(v.clone()),
        Literal::Bool(v) => StyleValue::Number(*v as u8 as f64),
        Literal::Path(cmds) => StyleValue::Graphic(Graphic::from_path_cmds(cmds)),
        Literal::Asset(path) => StyleValue::Graphic(load_theme_asset(path, span, asset_dirs)?),
        // Lists, records, rich text, and a scroll axis are not theme value
        // types; the theme grammar never produces them here.
        Literal::List(_) | Literal::Record { .. } | Literal::Rich(_) | Literal::ScrollAxes(_) => {
            StyleValue::Number(0.0)
        }
    })
}

/// Find and decode an `(asset …)` a theme names.
///
/// Unlike a `.gdc` asset, which the typed build embeds, a theme is data the
/// application loads at runtime — so its assets are read now, from a search
/// path the caller supplies. A theme compiled without one (plain
/// [`Theme::parse`]) has nowhere to look, and says so rather than failing
/// obscurely.
fn load_theme_asset(path: &str, span: Span, asset_dirs: &[PathBuf]) -> Result<Graphic, Diagnostic> {
    if asset_dirs.is_empty() {
        return Err(Diagnostic::new(
            format!(
                "cannot resolve asset `{path}`: this theme was compiled with no asset \
                 search path (use `Theme::parse_with_assets` to give it one)"
            ),
            span,
        ));
    }
    let found = guiduck_component_core::resolve::find_asset(asset_dirs, path)
        .map_err(|why| Diagnostic::new(format!("cannot resolve asset `{path}`: {why}"), span))?;
    let bytes = std::fs::read(&found)
        .map_err(|e| Diagnostic::new(format!("cannot read {}: {e}", found.display()), span))?;
    let brush = crate::asset::decode_image(&bytes).map_err(|why| {
        Diagnostic::new(
            format!("asset `{path}` is not an image this build can decode: {why}"),
            span,
        )
    })?;
    Ok(Graphic::Image(brush))
}

/// The merged property set for one widget in one state.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ComputedStyle {
    props: Vec<(String, StyleValue)>,
}

impl ComputedStyle {
    fn set(&mut self, name: &str, value: StyleValue) {
        match self.props.iter_mut().find(|(n, _)| n == name) {
            Some((_, slot)) => *slot = value,
            None => self.props.push((name.to_owned(), value)),
        }
    }

    pub fn is_empty(&self) -> bool {
        self.props.is_empty()
    }

    /// The names of every property this style carries, in resolution order.
    pub fn names(&self) -> impl Iterator<Item = &str> {
        self.props.iter().map(|(name, _)| name.as_str())
    }

    pub fn brush(&self, name: &str) -> Option<&Brush> {
        match self.get(name) {
            Some(StyleValue::Brush(brush)) => Some(brush),
            _ => None,
        }
    }

    pub fn number(&self, name: &str) -> Option<f64> {
        match self.get(name) {
            Some(StyleValue::Number(v)) => Some(*v),
            _ => None,
        }
    }

    pub fn string(&self, name: &str) -> Option<&str> {
        match self.get(name) {
            Some(StyleValue::Str(v)) => Some(v),
            _ => None,
        }
    }

    pub fn graphic(&self, name: &str) -> Option<&Graphic> {
        match self.get(name) {
            Some(StyleValue::Graphic(graphic)) => Some(graphic),
            _ => None,
        }
    }

    fn get(&self, name: &str) -> Option<&StyleValue> {
        self.props.iter().find(|(n, _)| n == name).map(|(_, v)| v)
    }
}

/// Copies appearance tokens out of a computed style into a widget's token
/// slots, remembering whether any of them actually landed differently.
///
/// Every std control reads its tokens through this, which is what keeps
/// "a token the style doesn't carry leaves the slot alone" and "only a real
/// change is dirt" one decision rather than one per widget per token. The
/// widget decides what to do with [`changed`](StyleReader::changed) — usually
/// mark paint dirt, sometimes also drop a cached text layout.
pub struct StyleReader<'a> {
    style: &'a ComputedStyle,
    changed: bool,
}

impl<'a> StyleReader<'a> {
    pub fn new(style: &'a ComputedStyle) -> Self {
        Self {
            style,
            changed: false,
        }
    }

    pub fn brush(&mut self, slot: &mut Brush, name: &str) {
        if let Some(value) = self.style.brush(name)
            && slot != value
        {
            *slot = value.clone();
            self.changed = true;
        }
    }

    pub fn number(&mut self, slot: &mut f64, name: &str) {
        if let Some(value) = self.style.number(name)
            && *slot != value
        {
            *slot = value;
            self.changed = true;
        }
    }

    pub fn string(&mut self, slot: &mut String, name: &str) {
        if let Some(value) = self.style.string(name)
            && slot != value
        {
            *slot = value.to_owned();
            self.changed = true;
        }
    }

    pub fn graphic(&mut self, slot: &mut Graphic, name: &str) {
        if let Some(value) = self.style.graphic(name)
            && slot != value
        {
            *slot = value.clone();
            self.changed = true;
        }
    }

    /// Whether any slot changed.
    pub fn changed(&self) -> bool {
        self.changed
    }
}

#[cfg(test)]
mod tests;