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;