theme.rs
raw
//! Theme file compiler: `(theme …)` forms with design tokens and style
//! rules, sharing the `.gdc` reader and diagnostics.
//!
//! ```lisp
//! (theme Light
//! (tokens
//! (color-accent "#4682b4")
//! (radius-m 8))
//! (rule (container .button) :background color-accent :corner-radius radius-m)
//! (rule (container .button :hover) :background "#6495ed"))
//! ```
//!
//! Selectors are `widget`, `(widget .class …)`, or `(widget .class… :state)`
//! — type, own classes, and own interaction state only. There are
//! deliberately no descendant combinators: a widget's style never depends on
//! its ancestors, so style invalidation cannot cascade.
//!
//! Rule order is priority: for a property matched by several rules, the last
//! matching rule in the file wins.
//!
//! Validation enforces *base coverage*: a rule with an interaction state may
//! only style properties that some stateless rule (with the same or broader
//! selector) also styles. This guarantees that leaving the state always has
//! a value to return to, so the engine never needs per-widget snapshots of
//! pre-theme values.
use crate::diagnostics::Diagnostic;
use crate::ir::{Literal, parse_color};
use crate::registry::{self, Registry};
use crate::sexpr::{self, Sexpr, Span};
/// A compiled theme: plain data for the runtime style engine to translate.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeIr {
pub name: String,
pub rules: Vec<RuleIr>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuleIr {
/// The name of the widget type this rule selects — a builtin, or one an
/// application declared in a `.gdw`. The runtime engine matches rules on
/// this name.
pub widget: String,
pub classes: Vec<String>,
pub state: Option<StateSel>,
pub props: Vec<ThemeProp>,
}
/// One property of a rule: its name and resolved literal value (token
/// references already inlined), plus the span of the value.
///
/// The span is here because compiling a theme is only half of resolving it: a
/// value like `(asset "icons/check.png")` names a file the *consumer* has to
/// find, and a failure there deserves the same pointed diagnostic as any
/// other theme error.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeProp {
pub name: String,
pub value: Literal,
pub span: Span,
}
/// Interaction states a rule can target.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum StateSel {
Hover,
Active,
Focus,
Disabled,
}
impl StateSel {
fn parse(name: &str) -> Option<Self> {
Some(match name {
"hover" => Self::Hover,
"active" => Self::Active,
"focus" => Self::Focus,
"disabled" => Self::Disabled,
_ => return None,
})
}
}
/// Compile theme source to [`ThemeIr`] against the builtin widget vocabulary
/// alone.
pub fn compile_theme(source: &str) -> Result<ThemeIr, Vec<Diagnostic>> {
compile_theme_with(source, &Registry::default())
}
/// Compile theme source against a vocabulary that includes the widgets an
/// application declared in its `.gdw` manifests, so a rule may name one and
/// set the tokens its manifest declares.
pub fn compile_theme_with(source: &str, registry: &Registry) -> Result<ThemeIr, Vec<Diagnostic>> {
let doc = sexpr::read(source).map_err(|e| vec![Diagnostic::new(e.message, e.span)])?;
let mut forms = doc.values.iter();
let Some(form) = forms.next() else {
return Err(vec![Diagnostic::new(
"file contains no `(theme …)` form",
Span::new(0, 0),
)]);
};
if let Some(extra) = forms.next() {
return Err(vec![Diagnostic::new(
"a theme file holds exactly one `(theme …)` form",
extra.span(),
)]);
}
theme(form, source, registry)
}
fn theme(form: &Sexpr, source: &str, registry: &Registry) -> Result<ThemeIr, Vec<Diagnostic>> {
let mut errors = Vec::new();
let Some(items) = form.as_list() else {
return Err(vec![Diagnostic::new(
"expected a `(theme …)` form",
form.span(),
)]);
};
if items.first().and_then(Sexpr::as_symbol) != Some("theme") {
return Err(vec![Diagnostic::new(
"expected a `(theme …)` form",
form.span(),
)]);
}
let name = match items.get(1) {
Some(Sexpr::Symbol(name, _)) => name.clone(),
other => {
return Err(vec![Diagnostic::new(
"theme needs a name symbol",
other.map_or(form.span(), Sexpr::span),
)]);
}
};
let mut tokens: Vec<(String, Literal, Span)> = Vec::new();
let mut rules = Vec::new();
for item in &items[2..] {
let Some(list) = item.as_list() else {
errors.push(Diagnostic::new(
"expected a `(tokens …)` or `(rule …)` form",
item.span(),
));
continue;
};
match list.first().and_then(Sexpr::as_symbol) {
Some("tokens") => parse_tokens(&list[1..], &mut tokens, &mut errors, source),
Some("rule") => {
if let Some(rule) = parse_rule(
&list[1..],
item.span(),
&tokens,
registry,
&mut errors,
source,
) {
rules.push(rule);
}
}
_ => errors.push(Diagnostic::new(
"expected `(tokens …)` or `(rule …)`",
item.span(),
)),
}
}
check_base_coverage(&rules, &mut errors, form.span());
if errors.is_empty() {
Ok(ThemeIr { name, rules })
} else {
Err(errors)
}
}
/// `(tokens (name value) …)`
fn parse_tokens(
entries: &[Sexpr],
tokens: &mut Vec<(String, Literal, Span)>,
errors: &mut Vec<Diagnostic>,
source: &str,
) {
for entry in entries {
let Some(pair) = entry.as_list() else {
errors.push(Diagnostic::new(
"expected a `(name value)` token pair",
entry.span(),
));
continue;
};
let (Some(Sexpr::Symbol(name, name_span)), Some(value), true) =
(pair.first(), pair.get(1), pair.len() == 2)
else {
errors.push(Diagnostic::new(
"expected a `(name value)` token pair",
entry.span(),
));
continue;
};
if tokens.iter().any(|(n, _, _)| n == name) {
errors.push(Diagnostic::new(
format!("token `{name}` is defined more than once"),
*name_span,
));
continue;
}
match token_value(value, tokens, source) {
Ok(literal) => tokens.push((name.clone(), literal, *name_span)),
Err(diag) => errors.push(diag),
}
}
}
/// A token value: a literal, a color string, or a reference to an earlier
/// token.
fn token_value(
value: &Sexpr,
tokens: &[(String, Literal, Span)],
_source: &str,
) -> Result<Literal, Diagnostic> {
match value {
Sexpr::Int(v, _) => Ok(Literal::Int(*v)),
Sexpr::Float(v, _) => Ok(Literal::Float(*v)),
Sexpr::Str(text, span) => {
if text.starts_with('#') {
parse_color(text).ok_or_else(|| {
Diagnostic::new(
format!(
"`{text}` is not a color; expected `#rgb`, `#rrggbb`, or `#rrggbbaa`"
),
*span,
)
})
} else {
Ok(Literal::Str(text.clone()))
}
}
Sexpr::Symbol(name, span) => tokens
.iter()
.find(|(n, _, _)| n == name)
.map(|(_, v, _)| v.clone())
.ok_or_else(|| {
Diagnostic::new(
format!("`{name}` is not a defined token (tokens must be defined before use)"),
*span,
)
}),
Sexpr::List(items, span) => {
if let Some(result) = crate::path::parse_path_form(items, *span) {
return result.map(Literal::Path);
}
if let Some(result) = crate::asset::parse_asset_form(items, *span) {
return result.map(Literal::Asset);
}
Err(Diagnostic::new(
"expected a color, number, string, token name, `(path …)`, or `(asset …)`",
*span,
))
}
other => Err(Diagnostic::new(
format!("invalid token value ({})", other.kind_name()),
other.span(),
)),
}
}
/// Check a resolved theme value matches the type its property declares.
fn value_matches(value: &Literal, expected: registry::ThemeValueType) -> bool {
use registry::ThemeValueType as T;
match expected {
T::Brush => matches!(value, Literal::Color(..)),
T::Number => matches!(value, Literal::Int(_) | Literal::Float(_)),
T::Str => matches!(value, Literal::Str(_)),
// Paths and assets are interchangeable wherever a graphic is wanted.
T::Graphic => matches!(value, Literal::Path(_) | Literal::Asset(_)),
}
}
fn type_name(expected: registry::ThemeValueType) -> &'static str {
use registry::ThemeValueType as T;
match expected {
T::Brush => "a color",
T::Number => "a number",
T::Str => "a string",
T::Graphic => "a graphic: `(path …)`, `(svg-path …)`, or `(asset …)`",
}
}
/// `(rule selector :prop value …)`
fn parse_rule(
items: &[Sexpr],
span: Span,
tokens: &[(String, Literal, Span)],
registry: &Registry,
errors: &mut Vec<Diagnostic>,
source: &str,
) -> Option<RuleIr> {
let Some(selector) = items.first() else {
errors.push(Diagnostic::new("rule needs a selector", span));
return None;
};
let (widget, classes, state) = parse_selector(selector, registry, errors)?;
let descriptor = registry
.resolve(&widget)
.expect("parse_selector resolved the widget");
let mut props = Vec::new();
let mut rest = items[1..].iter();
while let Some(item) = rest.next() {
let Sexpr::Keyword(prop_name, prop_span) = item else {
errors.push(Diagnostic::new(
format!(
"expected `:property value` pairs, found {}",
item.kind_name()
),
item.span(),
));
return None;
};
let Some(value) = rest.next() else {
errors.push(Diagnostic::new(
format!("`:{prop_name}` needs a value"),
*prop_span,
));
return None;
};
// Themes carry visual appearance tokens (brushes, metrics, marks) —
// not layout or events. The theme-property table is the source of
// truth for what is themable and what type each token expects.
let Some(expected) = descriptor.theme_value_type(prop_name) else {
match descriptor.lookup(prop_name) {
Some(_) => errors.push(Diagnostic::new(
format!(
"`:{prop_name}` is not themable; themes set visual properties, \
not layout or events"
),
*prop_span,
)),
None => errors.push(Diagnostic::new(
format!("`{widget}` has no themable property `:{prop_name}`"),
*prop_span,
)),
}
continue;
};
match token_value(value, tokens, source) {
Ok(literal) if value_matches(&literal, expected) => props.push(ThemeProp {
name: prop_name.clone(),
value: literal,
span: value.span(),
}),
Ok(_) => errors.push(Diagnostic::new(
format!("`:{prop_name}` expects {}", type_name(expected)),
value.span(),
)),
Err(diag) => errors.push(diag),
}
}
Some(RuleIr {
widget,
classes,
state,
props,
})
}
/// `widget` or `(widget .class… :state?)`
fn parse_selector(
selector: &Sexpr,
registry: &Registry,
errors: &mut Vec<Diagnostic>,
) -> Option<(String, Vec<String>, Option<StateSel>)> {
let parse_widget = |name: &str, span: Span, errors: &mut Vec<Diagnostic>| {
registry
.resolve(name)
.map(|d| d.name.to_string())
.or_else(|| {
errors.push(Diagnostic::new(
format!("unknown widget `{name}` in selector"),
span,
));
None
})
};
match selector {
Sexpr::Symbol(name, span) => Some((parse_widget(name, *span, errors)?, Vec::new(), None)),
Sexpr::List(items, span) => {
let Some(Sexpr::Symbol(name, name_span)) = items.first() else {
errors.push(Diagnostic::new(
"selector must start with a widget name",
*span,
));
return None;
};
let widget = parse_widget(name, *name_span, errors)?;
let mut classes = Vec::new();
let mut state = None;
for item in &items[1..] {
match item {
Sexpr::Symbol(text, span) if text.starts_with('.') => {
classes.push(text[1..].to_owned());
if text.len() == 1 {
errors.push(Diagnostic::new("empty class selector", *span));
return None;
}
}
Sexpr::Keyword(name, span) => match StateSel::parse(name) {
Some(s) if state.is_none() => state = Some(s),
Some(_) => {
errors.push(Diagnostic::new("selector already has a state", *span));
return None;
}
None => {
errors.push(Diagnostic::new(
format!(
"unknown state `:{name}`; expected :hover, :active, :focus, or :disabled"
),
*span,
));
return None;
}
},
other => {
errors.push(Diagnostic::new(
format!(
"selector items are `.class` or `:state`, found {}",
other.kind_name()
),
other.span(),
));
return None;
}
}
}
Some((widget, classes, state))
}
other => {
errors.push(Diagnostic::new(
format!("expected a selector, found {}", other.kind_name()),
other.span(),
));
None
}
}
}
/// Every property in a stateful rule must be covered by some stateless rule
/// whose selector is the same or broader (class subset), so leaving the
/// state always restores a themed value.
fn check_base_coverage(rules: &[RuleIr], errors: &mut Vec<Diagnostic>, theme_span: Span) {
for rule in rules.iter().filter(|r| r.state.is_some()) {
for prop in &rule.props {
let covered = rules.iter().any(|base| {
base.state.is_none()
&& base.widget == rule.widget
&& base.classes.iter().all(|c| rule.classes.contains(c))
&& base.props.iter().any(|p| p.name == prop.name)
});
if !covered {
errors.push(Diagnostic::new(
format!(
"state rule for `{}` sets `:{}` but no stateless rule \
covers it; add a base rule so leaving the state has a \
value to restore",
rule.widget, prop.name
),
theme_span,
));
}
}
}
}
#[cfg(test)]
mod tests;