//! Intent resolution use crate::{Rgb, ThemeColors, ThemeMeta, darken, lighten, readable_on}; use serde::Serialize; use std::collections::BTreeMap; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::derive_tonal_steps; /// Base intents: (TOML dotted source key, canonical token key). The token key /// is the CSS-var stem (`--{token}`) and the `rgb()` lookup key. /// /// Read straight from the loaded theme, which is not quite the same as read /// from the file: `content.secondary` and `content.muted` are tonal steps of /// `content.primary` and are filled in at load by [`derive_tonal_steps`], so /// they arrive here already computed and take this path like any other. pub const BASE_INTENTS: &[(&str, &str)] = &[ ("surface.page", "surface-page"), ("surface.raised", "surface-raised"), ("surface.sunken", "surface-sunken"), ("surface.overlay", "surface-overlay"), ("content.primary", "content"), ("content.secondary", "content-secondary"), ("content.muted", "content-muted"), ("action.primary", "action"), ("status.danger", "danger"), ("status.success", "success"), ("status.warning", "warning"), ("status.info", "info"), ("line.border", "border"), ("category.one", "category-one"), ("category.two", "category-two"), ("category.three", "category-three"), ("category.four", "category-four"), ("category.five", "category-five"), ("category.six", "category-six"), ]; /// A fully resolved intent layer: every token key → concrete `#rrggbb`. /// Includes both authored base intents and the computed derived intents. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SemanticTokens { pub meta: ThemeMeta, /// token-key → resolved hex. Stable, deterministic ordering. pub intents: BTreeMap, } impl SemanticTokens { /// Resolved hex for a token key, if present. pub fn hex(&self, key: &str) -> Option<&str> { self.intents.get(key).map(String::as_str) } /// Resolved RGB tuple for a token key (for egui / native consumers). /// /// `None` for a translucent token. Two intents are emitted as `rgba(...)` /// rather than hex, `overlay` and `elevation`, and dropping the alpha would /// hand a native consumer an opaque near-black where it asked for a scrim. /// Those want [`rgba`](Self::rgba). pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> { self.intents .get(key) .and_then(|h| Rgb::from_hex(h)) .map(Rgb::tuple) } /// Resolved RGBA tuple for a token key, alpha as 0-255. /// /// Reads both spellings, so a caller that does not care whether an intent /// happens to be translucent can use this for everything: an opaque token /// comes back at 255. /// /// It exists because a CSS consumer can take `rgba(...)` as a string /// straight out of [`hex`](Self::hex) and a native one cannot. Without it /// the two translucent intents are reachable from a stylesheet and from /// nowhere else, which is the coupling deriving in the crate was meant to /// avoid. pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> { let value = self.intents.get(key)?; if let Some(rgb) = Rgb::from_hex(value) { let (r, g, b) = rgb.tuple(); return Some((r, g, b, 255)); } let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?; let mut parts = inner.split(',').map(str::trim); let r = parts.next()?.parse().ok()?; let g = parts.next()?.parse().ok()?; let b = parts.next()?.parse().ok()?; let alpha: f32 = parts.next()?.parse().ok()?; if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) { return None; } Some((r, g, b, (alpha * 255.0).round() as u8)) } } /// Resolve an authored theme into the full intent token set. /// /// 1. Copy each present base intent from the authored colors. /// 2. Compute the derived interactive states from the base intents, so every /// consumer gets identical output. /// /// Each derived token is emitted only when its source intents exist, mirroring /// the skip-missing behavior of the rest of the crate. pub fn resolve(theme: &ThemeColors) -> SemanticTokens { let mut intents: BTreeMap = BTreeMap::new(); // 1. Base intents (authored). Copy only values that parse as a hex color and // re-emit them in canonical `#rrggbb` form, so an authored value can never // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined // raw into a `\"\n[content]\nprimary = \"#111111\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!( t.hex("surface-page").is_none(), "non-hex base intent leaked" ); assert_eq!(t.hex("content").unwrap(), "#111111"); // The injected markup appears in no resolved value. assert!(!t.intents.values().any(|v| v.contains('<'))); } #[test] fn resolve_skips_derived_when_source_missing() { // No [action] => no action-derived tokens. let theme = parse_theme_str( "x", "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!(t.hex("action").is_none()); assert!(t.hex("action-hover").is_none()); assert!(t.hex("selection").is_none()); assert_eq!( t.hex("border-strong").unwrap(), darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex() ); } #[test] fn rgb_accessor_for_native_consumers() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let t = resolve(&theme); assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1))); assert_eq!(t.rgb("nonexistent"), None); } // ---- css emit ---- #[test] fn intent_css_vars_wraps_root_and_includes_tokens() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let css = intent_css_vars(&resolve(&theme)); assert!(css.starts_with(":root {\n")); assert!(css.contains(" --surface-page: #2e3440;\n")); assert!(css.contains(" --danger: #bf616a;\n")); assert!(css.contains(" --action-hover: ")); assert!(css.trim_end().ends_with('}')); } }