//! 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) -> 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, state: Option, 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, } /// 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 = 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 = 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, 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> { 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::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::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> { 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> { 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 { 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 { 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 { 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 { 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;