Skip to main content

ratatui_style/
token.rs

1//! CSS custom properties — the `var()` resolution target.
2//!
3//! A [`ThemeTokens`] table maps variable names (without the `--` prefix) to
4//! [`Token`] values. `var(--name)` references in a [`crate::style::CssStyle`]
5//! are resolved against this table during the cascade (see `cascade.rs`).
6
7use std::collections::HashMap;
8
9use ratatui::style::Color as RColor;
10
11use crate::box_model::{BorderStyle, BoxEdges, Length};
12use crate::color::Color;
13use crate::error::{CssError, Result};
14use crate::media::{MediaContext, MediaQuery};
15
16/// A CSS custom-property value. Supports [`Color`], [`Length`], [`BoxEdges`]
17/// (for `padding`/`margin`), and [`BorderStyle`] (for `border-style`). These
18/// are the value types whose fields carry `var()` references: the color fields
19/// (`color`, `background`, `underline-color`, border color), the length fields
20/// (`width`/`height`), `padding`/`margin`, and a border's *style*. A border's
21/// *edges* (`Borders`) cannot yet be driven by `var()`.
22///
23/// [`Token::Var`] covers the case where a custom property is itself a bare
24/// `var(--other)` reference: its ultimate type (color vs length vs edges vs
25/// style) is not knowable at parse time, so it is stored untyped and resolved by
26/// following the chain via [`ThemeTokens::get_color`] /
27/// [`ThemeTokens::get_length`] / [`ThemeTokens::get_box_edges`] /
28/// [`ThemeTokens::get_border_style`].
29#[derive(Debug, Clone, PartialEq)]
30pub enum Token {
31    Color(Color),
32    Length(Length),
33    BoxEdges(BoxEdges),
34    BorderStyle(BorderStyle),
35    /// A bare `var(--other)` reference whose type is determined by what `--other`
36    /// ultimately resolves to. Every typed getter follows the chain.
37    Var { name: String },
38}
39
40impl From<Color> for Token {
41    fn from(c: Color) -> Self {
42        Token::Color(c)
43    }
44}
45
46impl From<Length> for Token {
47    fn from(l: Length) -> Self {
48        Token::Length(l)
49    }
50}
51
52impl From<BoxEdges> for Token {
53    fn from(e: BoxEdges) -> Self {
54        Token::BoxEdges(e)
55    }
56}
57
58impl From<BorderStyle> for Token {
59    fn from(b: BorderStyle) -> Self {
60        Token::BorderStyle(b)
61    }
62}
63
64/// The typed resolution path through the token table. Used internally by
65/// [`ThemeTokens::best_media_map`] to decide whether a binding is compatible
66/// with the requested path (so that, e.g., a length binding does not shadow a
67/// color reference of the same name).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69enum TokenKind {
70    Color,
71    Length,
72    BoxEdges,
73    BorderStyle,
74}
75
76impl TokenKind {
77    /// `true` if `tok` is usable on this path: a `Var` is always compatible
78    /// (its type is determined by what it resolves to); a concrete token is
79    /// compatible only with its own path. The one coercion: a bare cell count
80    /// (`Length::Cells`) is also usable on the box-edges path (a single integer
81    /// is a valid uniform box shorthand), so `--pad: 2` works for both
82    /// `width: var(--pad)` and `padding: var(--pad)`.
83    fn compatible_with(self, tok: &Token) -> bool {
84        match tok {
85            Token::Var { .. } => true,
86            Token::Color(_) => self == TokenKind::Color,
87            Token::Length(Length::Cells(_)) => {
88                self == TokenKind::Length || self == TokenKind::BoxEdges
89            }
90            Token::Length(_) => self == TokenKind::Length,
91            Token::BoxEdges(_) => self == TokenKind::BoxEdges,
92            Token::BorderStyle(_) => self == TokenKind::BorderStyle,
93        }
94    }
95}
96
97/// Parse a CSS string into a `Token`. Mirrors [`Color::from(&str)`]: a valid
98/// color expression becomes `Token::Color`; anything else (including a valid
99/// length like `"50%"`) degrades to a reset color. This keeps the ergonomic
100/// `tokens_mut().insert("accent", "#00d4ff")` form working for the common
101/// color case; for a length token, pass a [`Length`] explicitly.
102impl From<&str> for Token {
103    fn from(s: &str) -> Self {
104        Token::Color(Color::from(s))
105    }
106}
107
108impl From<String> for Token {
109    fn from(s: String) -> Self {
110        Token::from(s.as_str())
111    }
112}
113
114/// A map of CSS custom-property names to [`Token`] values.
115///
116/// The default (media-agnostic) map lives in `vars`. Media-gated overrides —
117/// declared via `:root { --x: … }` *inside* an `@media` block — live in
118/// `media_vars`, an ordered list of `(query, map)` pairs in source order. The
119/// media-aware getters ([`get_color_with`](Self::get_color_with) /
120/// [`get_length_with`](Self::get_length_with)) consult `media_vars` and pick
121/// the **most specific** matching query that binds `name` — specificity being
122/// the number of conditions in the matching alternative (a 2-condition query
123/// beats a 1-condition one), with ties broken by source order (later wins).
124/// If no override matches/binds, the default `vars` is the fallback.
125#[derive(Debug, Clone, Default, PartialEq)]
126pub struct ThemeTokens {
127    vars: HashMap<String, Token>,
128    media_vars: Vec<(MediaQuery, HashMap<String, Token>)>,
129}
130
131impl ThemeTokens {
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Insert/overwrite a variable. `name` is given without the `--` prefix.
137    pub fn set<T: Into<Token>>(mut self, name: impl Into<String>, value: T) -> Self {
138        self.vars.insert(name.into(), value.into());
139        self
140    }
141
142    /// Insert/overwrite a variable (mutable).
143    pub fn insert<T: Into<Token>>(&mut self, name: impl Into<String>, value: T) {
144        self.vars.insert(name.into(), value.into());
145    }
146
147    /// Insert a media-gated override. `query` is the enclosing `@media` query;
148    /// `name` is given without the `--` prefix. If an entry for an equal `query`
149    /// already exists in `media_vars`, the name is inserted into that entry's
150    /// map (same-name overwrites); otherwise a new `(query, map)` entry is
151    /// appended, preserving source order.
152    pub fn insert_media<T: Into<Token>>(
153        &mut self,
154        query: MediaQuery,
155        name: impl Into<String>,
156        value: T,
157    ) {
158        // Find an existing entry with the same query (by equality) and accumulate
159        // into it; otherwise append a fresh entry. Equality on `MediaQuery` is
160        // structural, so two textually-identical queries collapse to one map.
161        let key = name.into();
162        for (q, map) in &mut self.media_vars {
163            if q == &query {
164                map.insert(key.clone(), value.into());
165                return;
166            }
167        }
168        let mut map = HashMap::new();
169        map.insert(key, value.into());
170        self.media_vars.push((query, map));
171    }
172
173    /// Builder form of [`insert_media`](Self::insert_media).
174    pub fn set_media<T: Into<Token>>(
175        mut self,
176        query: MediaQuery,
177        name: impl Into<String>,
178        value: T,
179    ) -> Self {
180        self.insert_media(query, name, value);
181        self
182    }
183
184    /// Look up a variable by name (without `--`), default map only.
185    pub fn get(&self, name: &str) -> Option<&Token> {
186        self.vars.get(name)
187    }
188
189    /// True iff `name` is defined in the default map OR in any media-gated
190    /// override. Used by strict-mode parsing: a `var(--name)` is "defined" if
191    /// it is declared *anywhere*, even inside an `@media` block.
192    pub fn is_defined(&self, name: &str) -> bool {
193        if self.vars.contains_key(name) {
194            return true;
195        }
196        self.media_vars
197            .iter()
198            .any(|(_, map)| map.contains_key(name))
199    }
200
201    /// Convenience: look up a variable as a [`Color`], if it holds one.
202    ///
203    /// Follows [`Token::Var`] chains: a `--a: var(--b)` reference resolves to
204    /// whatever `--b` (transitively) is, and the result is returned only if the
205    /// terminal value is a color.
206    pub fn get_color(&self, name: &str) -> Option<&Color> {
207        let mut cur = name;
208        for _ in 0..32 {
209            match self.vars.get(cur)? {
210                Token::Color(c) => return Some(c),
211                Token::Var { name: next } => cur = next,
212                // A length (or anything else) is not a color.
213                _ => return None,
214            }
215        }
216        None
217    }
218
219    /// Convenience: look up a variable as a [`Length`], if it holds one.
220    ///
221    /// Follows [`Token::Var`] chains like [`get_color`](Self::get_color).
222    pub fn get_length(&self, name: &str) -> Option<&Length> {
223        let mut cur = name;
224        for _ in 0..32 {
225            match self.vars.get(cur)? {
226                Token::Length(l) => return Some(l),
227                Token::Var { name: next } => cur = next,
228                // A color (or anything else) is not a length.
229                _ => return None,
230            }
231        }
232        None
233    }
234
235    /// Convenience: look up a variable as a [`BoxEdges`], if it holds one.
236    /// Follows [`Token::Var`] chains like [`get_color`](Self::get_color).
237    pub fn get_box_edges(&self, name: &str) -> Option<&BoxEdges> {
238        let mut cur = name;
239        for _ in 0..32 {
240            match self.vars.get(cur)? {
241                Token::BoxEdges(e) => return Some(e),
242                Token::Var { name: next } => cur = next,
243                _ => return None,
244            }
245        }
246        None
247    }
248
249    /// Convenience: look up a variable as a [`BorderStyle`], if it holds one.
250    /// Follows [`Token::Var`] chains like [`get_color`](Self::get_color).
251    pub fn get_border_style(&self, name: &str) -> Option<&BorderStyle> {
252        let mut cur = name;
253        for _ in 0..32 {
254            match self.vars.get(cur)? {
255                Token::BorderStyle(b) => return Some(b),
256                Token::Var { name: next } => cur = next,
257                _ => return None,
258            }
259        }
260        None
261    }
262
263    /// Media-aware color lookup. Among all `media_vars` entries whose query
264    /// [`MediaQuery::matches`] `media` **and** whose map binds `name` (following
265    /// [`Token::Var`] chains, themselves resolved media-aware), the **most
266    /// specific** one wins — specificity is the number of conditions in the
267    /// matching alternative (e.g. `(min-width:80) and (color)` at 2 conditions
268    /// beats `(min-width:80)` at 1), with ties broken by source order (the
269    /// later entry wins). If no override matches/binds, falls back to the
270    /// default [`get_color`](Self::get_color). Returns an **owned** [`Color`]
271    /// because the resolved value may live in any one of several maps and there
272    /// is no single stable borrow.
273    pub fn get_color_with(&self, name: &str, media: &MediaContext) -> Option<Color> {
274        self.resolve_color_with(name, media, 0)
275    }
276
277    /// Media-aware length lookup — analogous to [`get_color_with`](Self::get_color_with).
278    /// Most-specific matching query wins (ties → source order). Returns an
279    /// owned [`Length`].
280    pub fn get_length_with(&self, name: &str, media: &MediaContext) -> Option<Length> {
281        self.resolve_length_with(name, media, 0)
282    }
283
284    /// Media-aware box-edges lookup — for `padding`/`margin`. Most-specific
285    /// matching query wins (ties → source order). Returns an owned [`BoxEdges`].
286    pub fn get_box_edges_with(&self, name: &str, media: &MediaContext) -> Option<BoxEdges> {
287        self.resolve_box_edges_with(name, media, 0)
288    }
289
290    /// Media-aware border-style lookup — for `border-style`. Most-specific
291    /// matching query wins (ties → source order). Returns an owned
292    /// [`BorderStyle`].
293    pub fn get_border_style_with(&self, name: &str, media: &MediaContext) -> Option<BorderStyle> {
294        self.resolve_border_style_with(name, media, 0)
295    }
296
297    /// Recursive, depth-capped, cycle-guarded color resolver for the
298    /// media-aware path. Among all matching media overrides, the most specific
299    /// one that binds `name` (to a color, or to a `Var` chain) wins; ties go to
300    /// the later source-order entry. Falls back to `vars` for the default.
301    /// `Token::Var` chains are followed recursively through the same media
302    /// context.
303    fn resolve_color_with(&self, name: &str, media: &MediaContext, depth: u8) -> Option<Color> {
304        if depth > 32 {
305            return None;
306        }
307        // Pick the most-specific matching media override that binds `name` to a
308        // color-compatible token (a `Color` or a `Var` chain — any other type
309        // is a type mismatch and does not count as binding for the color path).
310        if let Some(map) = self.best_media_map(name, media, TokenKind::Color) {
311            // SAFETY-free: best_media_map only returns Some(map) when map[name]
312            // exists and is Color-or-Var; re-fetch the token.
313            match map.get(name).expect("best_media_map guarantees map[name] present") {
314                Token::Color(c) => return Some(c.clone()),
315                Token::Var { name: next } => {
316                    return self.resolve_color_with(next, media, depth + 1);
317                }
318                // Unreachable: best_media_map rejects non-color tokens on this path.
319                _ => return None,
320            }
321        }
322        // Default fallback.
323        match self.vars.get(name)? {
324            Token::Color(c) => Some(c.clone()),
325            Token::Var { name: next } => self.resolve_color_with(next, media, depth + 1),
326            _ => None,
327        }
328    }
329
330    /// Recursive, depth-capped length resolver for the media-aware path.
331    /// Mirrors [`resolve_color_with`](Self::resolve_color_with): most-specific
332    /// matching override wins, ties → source order.
333    fn resolve_length_with(&self, name: &str, media: &MediaContext, depth: u8) -> Option<Length> {
334        if depth > 32 {
335            return None;
336        }
337        if let Some(map) = self.best_media_map(name, media, TokenKind::Length) {
338            match map.get(name).expect("best_media_map guarantees map[name] present") {
339                Token::Length(l) => return Some(l.clone()),
340                Token::Var { name: next } => {
341                    return self.resolve_length_with(next, media, depth + 1);
342                }
343                _ => return None,
344            }
345        }
346        match self.vars.get(name)? {
347            Token::Length(l) => Some(l.clone()),
348            Token::Var { name: next } => self.resolve_length_with(next, media, depth + 1),
349            _ => None,
350        }
351    }
352
353    /// Recursive, depth-capped box-edges resolver for the media-aware path.
354    /// A `Length::Cells(n)` terminal is coerced to `BoxEdges::uniform(n)` (a
355    /// single integer is a valid uniform box shorthand).
356    fn resolve_box_edges_with(
357        &self,
358        name: &str,
359        media: &MediaContext,
360        depth: u8,
361    ) -> Option<BoxEdges> {
362        if depth > 32 {
363            return None;
364        }
365        if let Some(map) = self.best_media_map(name, media, TokenKind::BoxEdges) {
366            return match map.get(name).expect("best_media_map guarantees map[name] present") {
367                Token::BoxEdges(e) => Some(*e),
368                Token::Length(Length::Cells(n)) => Some(BoxEdges::uniform(*n)),
369                Token::Var { name: next } => {
370                    self.resolve_box_edges_with(next, media, depth + 1)
371                }
372                _ => None,
373            };
374        }
375        match self.vars.get(name)? {
376            Token::BoxEdges(e) => Some(*e),
377            Token::Length(Length::Cells(n)) => Some(BoxEdges::uniform(*n)),
378            Token::Var { name: next } => self.resolve_box_edges_with(next, media, depth + 1),
379            _ => None,
380        }
381    }
382
383    /// Recursive, depth-capped border-style resolver for the media-aware path.
384    fn resolve_border_style_with(
385        &self,
386        name: &str,
387        media: &MediaContext,
388        depth: u8,
389    ) -> Option<BorderStyle> {
390        if depth > 32 {
391            return None;
392        }
393        if let Some(map) = self.best_media_map(name, media, TokenKind::BorderStyle) {
394            match map.get(name).expect("best_media_map guarantees map[name] present") {
395                Token::BorderStyle(b) => return Some(*b),
396                Token::Var { name: next } => {
397                    return self.resolve_border_style_with(next, media, depth + 1);
398                }
399                _ => return None,
400            }
401        }
402        match self.vars.get(name)? {
403            Token::BorderStyle(b) => Some(*b),
404            Token::Var { name: next } => self.resolve_border_style_with(next, media, depth + 1),
405            _ => None,
406        }
407    }
408
409    /// Find the map of the **most specific** matching media override that binds
410    /// `name` to a token usable on the requested path (`kind`).
411    ///
412    /// A `Token::Var` binding is compatible with every path (its type is
413    /// determined by what it resolves to). A concrete-typed binding is
414    /// compatible only with its own path (`Color` → color path, `Length` →
415    /// length path, etc.); anything else is a type mismatch and is skipped.
416    ///
417    /// Ranking: among entries whose query [`MediaQuery::matches`] `media` AND
418    /// whose map binds `name` to a path-compatible token, the winner is the one
419    /// with the highest [`MediaQuery::matching_specificity`] (most conditions in
420    /// the matching alternative). Ties are broken by **source order**: since we
421    /// scan `media_vars` front-to-back and replace the current winner whenever
422    /// the new entry's specificity is `>=` (equal-or-greater), a later
423    /// equal-specificity entry takes precedence (later wins).
424    ///
425    /// Returns `None` if no matching override binds `name` compatibly.
426    fn best_media_map(
427        &self,
428        name: &str,
429        media: &MediaContext,
430        kind: TokenKind,
431    ) -> Option<&HashMap<String, Token>> {
432        let mut best: Option<(&HashMap<String, Token>, usize)> = None;
433        for (query, map) in &self.media_vars {
434            // The query must match under the active context; skip if not.
435            let spec = match query.matching_specificity(media) {
436                Some(s) => s,
437                None => continue,
438            };
439            // ...and bind name to a path-compatible token.
440            let tok = match map.get(name) {
441                Some(t) => t,
442                None => continue,
443            };
444            if !kind.compatible_with(tok) {
445                continue;
446            }
447            // Keep the entry with the highest specificity; on a tie, the LATER
448            // source-order entry wins. Since we iterate front-to-back, replace
449            // the current winner whenever `spec >= cur_spec` (equal → later
450            // entry wins, strictly greater → more specific wins).
451            match best {
452                Some((_, cur_spec)) if cur_spec > spec => {}
453                _ => best = Some((map, spec)),
454            }
455        }
456        best.map(|(map, _)| map)
457    }
458
459    /// Merge another token set into this one; `other` wins on conflict (both
460    /// the default map and the media-gated overrides, the latter appended in
461    /// source order so other's overrides come later / win).
462    pub fn merge(&mut self, other: &ThemeTokens) {
463        for (k, v) in &other.vars {
464            self.vars.insert(k.clone(), v.clone());
465        }
466        for (q, map) in &other.media_vars {
467            self.media_vars.push((q.clone(), map.clone()));
468        }
469    }
470
471    pub fn is_empty(&self) -> bool {
472        self.vars.is_empty()
473    }
474
475    pub fn len(&self) -> usize {
476        self.vars.len()
477    }
478}
479
480/// Resolve a `var()` reference chain to a concrete ratatui color.
481///
482/// - `Literal` / `Reset` map straight through.
483/// - `Var` is looked up in `tokens`; if absent, the `var()` fallback is used;
484///   if there is no fallback, returns [`CssError::UndefinedVariable`].
485/// - `Inherit` resolves to `Reset` (it should have been folded in by the
486///   inheritance pass already).
487/// - Cycles / chains deeper than 32 return [`CssError::CircularVariable`].
488///
489/// This is the default-media wrapper: it calls
490/// [`resolve_strict_with_media`] with [`MediaContext::default`], so media-gated
491/// overrides do NOT participate (the default map is still consulted). Use the
492/// `_with_media` variant to gate overrides against a terminal context.
493pub fn resolve_strict(color: &Color, tokens: &ThemeTokens) -> Result<RColor> {
494    resolve_strict_with_media(color, tokens, &MediaContext::default())
495}
496
497/// Lenient variant used by the cascade: unresolved variables degrade to
498/// `Reset` rather than failing the whole render. Default-media wrapper around
499/// [`resolve_with_media`].
500pub fn resolve(color: &Color, tokens: &ThemeTokens) -> RColor {
501    resolve_with_media(color, tokens, &MediaContext::default())
502}
503
504/// Media-aware strict resolution: like [`resolve_strict`] but the `var()` chain
505/// is resolved via [`ThemeTokens::get_color_with`] against `media`, so
506/// `@media`-gated token overrides participate when their query matches.
507pub fn resolve_strict_with_media(
508    color: &Color,
509    tokens: &ThemeTokens,
510    media: &MediaContext,
511) -> Result<RColor> {
512    resolve_inner(color, tokens, media, 0)
513}
514
515/// Media-aware lenient resolution: like [`resolve`] but consults media-gated
516/// overrides. Unresolved variables degrade to `Reset`.
517pub fn resolve_with_media(color: &Color, tokens: &ThemeTokens, media: &MediaContext) -> RColor {
518    resolve_strict_with_media(color, tokens, media).unwrap_or(RColor::Reset)
519}
520
521fn resolve_inner(
522    color: &Color,
523    tokens: &ThemeTokens,
524    media: &MediaContext,
525    depth: u8,
526) -> Result<RColor> {
527    if depth > 32 {
528        return Err(CssError::circular_variable(
529            "var() reference chain too deep (depth > 32)",
530        ));
531    }
532    match color {
533        Color::Literal(c) => Ok(*c),
534        Color::Reset => Ok(RColor::Reset),
535        Color::Inherit => Ok(RColor::Reset),
536        Color::Var { name, fallback } => match tokens.get_color_with(name, media) {
537            Some(referent) => resolve_inner(&referent, tokens, media, depth + 1),
538            None => match fallback {
539                Some(fb) => resolve_inner(fb, tokens, media, depth + 1),
540                None => Err(CssError::undefined_variable(name.clone())),
541            },
542        },
543    }
544}
545
546/// Resolve a `var()` reference chain to a concrete [`Length`].
547///
548/// Mirrors [`resolve_inner`] (and its lenient wrapper, [`resolve`]) but for the
549/// [`Length`] path. The lenient semantics are identical: a missing variable, a
550/// type mismatch (e.g. a name bound to a `Color`), or a too-deep chain all
551/// degrade to [`Length::Auto`] rather than failing the whole render. The strict
552/// form surfaces the error instead.
553///
554/// Default-media wrapper around [`resolve_length_strict_with_media`].
555pub fn resolve_length_strict(length: &Length, tokens: &ThemeTokens) -> Result<Length> {
556    resolve_length_strict_with_media(length, tokens, &MediaContext::default())
557}
558
559/// Lenient variant used by the cascade: unresolved/mistyped length variables
560/// degrade to [`Length::Auto`] rather than failing the whole render.
561/// Default-media wrapper around [`resolve_length_with_media`].
562pub fn resolve_length(length: &Length, tokens: &ThemeTokens) -> Length {
563    resolve_length_with_media(length, tokens, &MediaContext::default())
564}
565
566/// Media-aware strict length resolution: like [`resolve_length_strict`] but the
567/// `var()` chain is resolved via [`ThemeTokens::get_length_with`] against
568/// `media`.
569pub fn resolve_length_strict_with_media(
570    length: &Length,
571    tokens: &ThemeTokens,
572    media: &MediaContext,
573) -> Result<Length> {
574    resolve_length_inner(length, tokens, media, 0)
575}
576
577/// Media-aware lenient length resolution: like [`resolve_length`] but consults
578/// media-gated overrides.
579pub fn resolve_length_with_media(
580    length: &Length,
581    tokens: &ThemeTokens,
582    media: &MediaContext,
583) -> Length {
584    resolve_length_strict_with_media(length, tokens, media).unwrap_or(Length::Auto)
585}
586
587fn resolve_length_inner(
588    length: &Length,
589    tokens: &ThemeTokens,
590    media: &MediaContext,
591    depth: u8,
592) -> Result<Length> {
593    if depth > 32 {
594        return Err(CssError::circular_variable(
595            "var() reference chain too deep (depth > 32)",
596        ));
597    }
598    match length {
599        Length::Auto | Length::Cells(_) | Length::Percent(_) | Length::Min(_) | Length::Max(_) => {
600            Ok(length.clone())
601        }
602        Length::Var { name, fallback } => match tokens.get_length_with(name, media) {
603            Some(referent) => resolve_length_inner(&referent, tokens, media, depth + 1),
604            None => match fallback {
605                Some(fb) => resolve_length_inner(fb, tokens, media, depth + 1),
606                None => Err(CssError::undefined_variable(name.clone())),
607            },
608        },
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    #[test]
617    fn resolves_simple_var() {
618        let tokens = ThemeTokens::new().set("accent", Color::literal(RColor::Blue));
619        let c = Color::var("accent");
620        assert_eq!(resolve_strict(&c, &tokens).unwrap(), RColor::Blue);
621    }
622
623    #[test]
624    fn resolves_chain() {
625        let tokens = ThemeTokens::new()
626            .set("accent", Color::var("blue"))
627            .set("blue", Color::literal(RColor::Blue));
628        assert_eq!(resolve_strict(&Color::var("accent"), &tokens).unwrap(), RColor::Blue);
629    }
630
631    #[test]
632    fn uses_fallback() {
633        let tokens = ThemeTokens::new();
634        let c = Color::Var { name: "missing".into(), fallback: Some(Box::new(Color::literal(RColor::Red))) };
635        assert_eq!(resolve_strict(&c, &tokens).unwrap(), RColor::Red);
636    }
637
638    #[test]
639    fn undefined_is_error_strict_but_reset_lenient() {
640        let tokens = ThemeTokens::new();
641        assert!(resolve_strict(&Color::var("nope"), &tokens).is_err());
642        assert_eq!(resolve(&Color::var("nope"), &tokens), RColor::Reset);
643    }
644
645    #[test]
646    fn token_table_holds_length() {
647        let tokens = ThemeTokens::new().set("w", Length::Cells(22));
648        assert_eq!(tokens.get_length("w"), Some(&Length::Cells(22)));
649        // A length slot is not a color slot.
650        assert_eq!(tokens.get_color("w"), None);
651        // And vice versa.
652        let tokens = ThemeTokens::new().set("c", Color::literal(RColor::Blue));
653        assert_eq!(tokens.get_color("c"), Some(&Color::literal(RColor::Blue)));
654        assert_eq!(tokens.get_length("c"), None);
655    }
656
657    #[test]
658    fn length_var_resolves_strict() {
659        let tokens = ThemeTokens::new().set("w", Length::Cells(22));
660        assert_eq!(
661            resolve_length_strict(&Length::Var { name: "w".into(), fallback: None }, &tokens).unwrap(),
662            Length::Cells(22)
663        );
664    }
665
666    #[test]
667    fn length_var_chain() {
668        let tokens = ThemeTokens::new()
669            .set("w", Length::Var { name: "w2".into(), fallback: None })
670            .set("w2", Length::Cells(10));
671        assert_eq!(
672            resolve_length_strict(&Length::Var { name: "w".into(), fallback: None }, &tokens).unwrap(),
673            Length::Cells(10)
674        );
675    }
676
677    #[test]
678    fn length_var_undefined_degrades_to_auto_lenient() {
679        let tokens = ThemeTokens::new();
680        assert!(resolve_length_strict(&Length::Var { name: "nope".into(), fallback: None }, &tokens).is_err());
681        assert_eq!(
682            resolve_length(&Length::Var { name: "nope".into(), fallback: None }, &tokens),
683            Length::Auto
684        );
685    }
686
687    #[test]
688    fn length_var_mistype_degrades_to_auto_lenient() {
689        // A name bound to a Color is a type mismatch on the length path.
690        let tokens = ThemeTokens::new().set("c", Color::literal(RColor::Blue));
691        assert_eq!(
692            resolve_length(&Length::Var { name: "c".into(), fallback: None }, &tokens),
693            Length::Auto
694        );
695    }
696
697    #[test]
698    fn length_var_undefined_uses_fallback() {
699        // An undefined name WITH a fallback resolves to the fallback
700        // (recursively), mirroring the color var() path.
701        let tokens = ThemeTokens::new();
702        let l = Length::Var {
703            name: "missing".into(),
704            fallback: Some(Box::new(Length::Cells(7))),
705        };
706        assert_eq!(resolve_length_strict(&l, &tokens).unwrap(), Length::Cells(7));
707        assert_eq!(resolve_length(&l, &tokens), Length::Cells(7));
708    }
709
710    // ---------------------------------------------------------------------
711    // Media-gated overrides (P4-3)
712    // ---------------------------------------------------------------------
713
714    fn mq(s: &str) -> MediaQuery {
715        MediaQuery::parse(s).unwrap()
716    }
717    fn ctx(cols: u16) -> MediaContext {
718        MediaContext {
719            cols,
720            ..Default::default()
721        }
722    }
723
724    #[test]
725    fn get_color_with_uses_media_override_when_matching() {
726        let tokens = ThemeTokens::new()
727            .set("accent", Color::literal(RColor::Red))
728            .set_media(
729                mq("(min-width: 80)"),
730                "accent",
731                Color::literal(RColor::Blue),
732            );
733        // Matching context → override (blue).
734        assert_eq!(
735            tokens.get_color_with("accent", &ctx(100)),
736            Some(Color::literal(RColor::Blue))
737        );
738        // Non-matching context → default (red).
739        assert_eq!(
740            tokens.get_color_with("accent", &ctx(60)),
741            Some(Color::literal(RColor::Red))
742        );
743        // Default-only getter still returns the default (red), unaffected.
744        assert_eq!(
745            tokens.get_color("accent"),
746            Some(&Color::literal(RColor::Red))
747        );
748    }
749
750    #[test]
751    fn get_color_with_falls_back_when_override_is_for_a_different_name() {
752        // A media override for --other should not affect --accent.
753        let tokens = ThemeTokens::new()
754            .set("accent", Color::literal(RColor::Red))
755            .set_media(
756                mq("(min-width: 80)"),
757                "other",
758                Color::literal(RColor::Green),
759            );
760        assert_eq!(
761            tokens.get_color_with("accent", &ctx(100)),
762            Some(Color::literal(RColor::Red)),
763            "override for --other must not shadow --accent"
764        );
765    }
766
767    #[test]
768    fn get_color_with_last_matching_override_wins() {
769        // Two queries both match ctx{cols:100}; the later source-order entry wins.
770        let tokens = ThemeTokens::new()
771            .set("accent", Color::literal(RColor::Red))
772            .set_media(mq("(min-width: 50)"), "accent", Color::literal(RColor::Green))
773            .set_media(mq("(min-width: 80)"), "accent", Color::literal(RColor::Blue));
774        assert_eq!(
775            tokens.get_color_with("accent", &ctx(100)),
776            Some(Color::literal(RColor::Blue)),
777            "last-matching media override wins by source order"
778        );
779        // At cols:60 only the first query matches → green.
780        assert_eq!(
781            tokens.get_color_with("accent", &ctx(60)),
782            Some(Color::literal(RColor::Green))
783        );
784    }
785
786    #[test]
787    fn get_color_with_chains_through_media_var() {
788        // --x: var(--y), both media-gated under the matching ctx.
789        let tokens = ThemeTokens::new().set_media(
790            mq("(min-width: 80)"),
791            "x",
792            Token::Var { name: "y".to_string() },
793        );
794        let tokens = tokens.set_media(
795            mq("(min-width: 80)"),
796            "y",
797            Color::literal(RColor::Magenta),
798        );
799        assert_eq!(
800            tokens.get_color_with("x", &ctx(100)),
801            Some(Color::literal(RColor::Magenta)),
802            "media-gated var() chain resolves through both media entries"
803        );
804        // Non-matching context → none (no default for x/y).
805        assert_eq!(tokens.get_color_with("x", &ctx(40)), None);
806    }
807
808    #[test]
809    fn get_length_with_uses_media_override() {
810        let tokens = ThemeTokens::new()
811            .set("w", Length::Cells(5))
812            .set_media(mq("(min-width: 80)"), "w", Length::Cells(50));
813        assert_eq!(tokens.get_length_with("w", &ctx(100)), Some(Length::Cells(50)));
814        assert_eq!(tokens.get_length_with("w", &ctx(40)), Some(Length::Cells(5)));
815        // Default-only getter unaffected.
816        assert_eq!(tokens.get_length("w"), Some(&Length::Cells(5)));
817    }
818
819    #[test]
820    fn insert_media_accumulates_same_query_into_one_map() {
821        // Two insert_media calls with the same query string accumulate into one
822        // map entry (same query reused).
823        let q = mq("(min-width: 80)");
824        let mut tokens = ThemeTokens::new();
825        tokens.insert_media(q.clone(), "a", Color::literal(RColor::Red));
826        tokens.insert_media(q.clone(), "b", Color::literal(RColor::Green));
827        // Both resolve under the matching ctx.
828        assert_eq!(tokens.get_color_with("a", &ctx(100)), Some(Color::literal(RColor::Red)));
829        assert_eq!(tokens.get_color_with("b", &ctx(100)), Some(Color::literal(RColor::Green)));
830        // Same-name within one query overwrites.
831        tokens.insert_media(q, "a", Color::literal(RColor::Blue));
832        assert_eq!(tokens.get_color_with("a", &ctx(100)), Some(Color::literal(RColor::Blue)));
833    }
834
835    #[test]
836    fn is_defined_checks_default_and_all_media_maps() {
837        let mut tokens = ThemeTokens::new();
838        tokens.insert("default_only", Color::literal(RColor::Red));
839        tokens.insert_media(mq("(min-width: 80)"), "media_only", Color::literal(RColor::Red));
840        assert!(tokens.is_defined("default_only"));
841        assert!(tokens.is_defined("media_only"));
842        assert!(!tokens.is_defined("neither"));
843    }
844
845    #[test]
846    fn resolve_with_media_gates_var_against_context() {
847        // End-to-end: resolve() (default media) → default; resolve_with_media()
848        // (matching) → override.
849        let tokens = ThemeTokens::new()
850            .set("accent", Color::literal(RColor::Red))
851            .set_media(mq("(min-width: 80)"), "accent", Color::literal(RColor::Blue));
852        // Default: red (media override not consulted).
853        assert_eq!(resolve(&Color::var("accent"), &tokens), RColor::Red);
854        // Matching ctx: blue.
855        assert_eq!(
856            resolve_with_media(&Color::var("accent"), &tokens, &ctx(100)),
857            RColor::Blue
858        );
859        // Non-matching ctx: red (fallback to default).
860        assert_eq!(
861            resolve_with_media(&Color::var("accent"), &tokens, &ctx(40)),
862            RColor::Red
863        );
864    }
865
866    #[test]
867    fn resolve_length_with_media_gates_var_against_context() {
868        let tokens = ThemeTokens::new()
869            .set("w", Length::Cells(5))
870            .set_media(mq("(min-width: 80)"), "w", Length::Cells(50));
871        assert_eq!(
872            resolve_length_with_media(&Length::Var { name: "w".into(), fallback: None }, &tokens, &ctx(100)),
873            Length::Cells(50)
874        );
875        assert_eq!(
876            resolve_length_with_media(&Length::Var { name: "w".into(), fallback: None }, &tokens, &ctx(40)),
877            Length::Cells(5)
878        );
879    }
880
881    #[test]
882    fn merge_merges_media_vars_too() {
883        let other = ThemeTokens::new()
884            .set("a", Color::literal(RColor::Red))
885            .set_media(mq("(min-width: 80)"), "a", Color::literal(RColor::Blue));
886        let mut mine = ThemeTokens::new();
887        mine.merge(&other);
888        assert_eq!(mine.get_color("a"), Some(&Color::literal(RColor::Red)));
889        assert_eq!(mine.get_color_with("a", &ctx(100)), Some(Color::literal(RColor::Blue)));
890    }
891
892    // ---------------------------------------------------------------------
893    // Media-token specificity cascade (P5-3)
894    // ---------------------------------------------------------------------
895
896    #[test]
897    fn get_color_with_picks_more_specific_override() {
898        // media_vars: [ ((min-width:80), {--x: red}),
899        //               ((min-width:80) and (color), {--x: blue}) ].
900        // Under ctx matching BOTH (cols:100, color), the 2-condition query is
901        // MORE specific → --x resolves to BLUE (not red), even though red is
902        // the later/equal-source entry… here red is FIRST. The point: the
903        // 2-condition entry wins regardless of position.
904        let tokens = ThemeTokens::new()
905            .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red))
906            .set_media(mq("(min-width: 80) and (color)"), "x", Color::literal(RColor::Blue));
907        assert_eq!(
908            tokens.get_color_with("x", &ctx(100)),
909            Some(Color::literal(RColor::Blue)),
910            "the 2-condition override is more specific and wins"
911        );
912    }
913
914    #[test]
915    fn get_color_with_specificity_tie_falls_back_to_source_order() {
916        // Two matching overrides with EQUAL condition counts (1 each) → later
917        // source-order wins (existing behavior preserved for ties).
918        let tokens = ThemeTokens::new()
919            .set_media(mq("(min-width: 50)"), "x", Color::literal(RColor::Red))
920            .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Blue));
921        // cols:100 matches both (both are 1-condition) → later (blue) wins.
922        assert_eq!(
923            tokens.get_color_with("x", &ctx(100)),
924            Some(Color::literal(RColor::Blue)),
925            "equal specificity → later source-order wins"
926        );
927    }
928
929    #[test]
930    fn get_color_with_less_specific_does_not_override_more_specific() {
931        // Reverse the insertion order from the first test: the more-specific
932        // (2-condition) override is inserted FIRST. It must STILL win.
933        let tokens = ThemeTokens::new()
934            .set_media(mq("(min-width: 80) and (color)"), "x", Color::literal(RColor::Blue))
935            .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red));
936        assert_eq!(
937            tokens.get_color_with("x", &ctx(100)),
938            Some(Color::literal(RColor::Blue)),
939            "more-specific wins regardless of source position"
940        );
941    }
942
943    #[test]
944    fn get_color_with_single_override_unchanged() {
945        // Regression: a single matching override behaves exactly as before.
946        let tokens = ThemeTokens::new()
947            .set("x", Color::literal(RColor::Red))
948            .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Blue));
949        assert_eq!(tokens.get_color_with("x", &ctx(100)), Some(Color::literal(RColor::Blue)));
950        // Non-matching → default fallback.
951        assert_eq!(tokens.get_color_with("x", &ctx(40)), Some(Color::literal(RColor::Red)));
952    }
953
954    #[test]
955    fn get_color_with_no_override_falls_back_to_default() {
956        // Regression: no media override at all → default vars.
957        let tokens = ThemeTokens::new().set("x", Color::literal(RColor::Red));
958        assert_eq!(tokens.get_color_with("x", &ctx(100)), Some(Color::literal(RColor::Red)));
959        assert_eq!(tokens.get_color_with("x", &ctx(40)), Some(Color::literal(RColor::Red)));
960    }
961
962    #[test]
963    fn get_color_with_specificity_var_chain() {
964        // The winning (more-specific) override binds --x to a var() chain whose
965        // target is also in a (less-specific) override. The chain must resolve
966        // through the same media context, picking the more-specific --y too.
967        let tokens = ThemeTokens::new()
968            // Less specific: --x = red (direct), --y = magenta.
969            .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red))
970            .set_media(mq("(min-width: 80)"), "y", Color::literal(RColor::Magenta))
971            // More specific: --x = var(--y).
972            .set_media(mq("(min-width: 80) and (color)"), "x", Token::Var { name: "y".into() });
973        // cols:100, color → --x resolves via the 2-condition entry (var --y),
974        // and --y resolves via the 1-condition entry → magenta.
975        assert_eq!(
976            tokens.get_color_with("x", &ctx(100)),
977            Some(Color::literal(RColor::Magenta)),
978            "more-specific var() chain resolves through the same media context"
979        );
980    }
981
982    #[test]
983    fn get_length_with_picks_more_specific_override() {
984        // Length path mirrors the color path.
985        let tokens = ThemeTokens::new()
986            .set_media(mq("(min-width: 80)"), "w", Length::Cells(5))
987            .set_media(mq("(min-width: 80) and (color)"), "w", Length::Cells(50));
988        assert_eq!(
989            tokens.get_length_with("w", &ctx(100)),
990            Some(Length::Cells(50)),
991            "more-specific length override wins"
992        );
993    }
994
995    #[test]
996    fn get_color_with_more_specific_skipped_when_it_binds_different_name() {
997        // The more-specific query matches but does NOT bind `x` — only the
998        // less-specific one binds `x`. So the less-specific entry wins (the
999        // more-specific one is not a candidate for `x`).
1000        let tokens = ThemeTokens::new()
1001            .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red))
1002            .set_media(mq("(min-width: 80) and (color)"), "other", Color::literal(RColor::Blue));
1003        assert_eq!(
1004            tokens.get_color_with("x", &ctx(100)),
1005            Some(Color::literal(RColor::Red)),
1006            "a more-specific query that does not bind `x` does not shadow `x`"
1007        );
1008        // And `other` resolves under the more-specific query.
1009        assert_eq!(
1010            tokens.get_color_with("other", &ctx(100)),
1011            Some(Color::literal(RColor::Blue))
1012        );
1013    }
1014
1015    // ---------------------------------------------------------------------
1016    // BoxEdges / BorderStyle token paths (P6-4)
1017    // ---------------------------------------------------------------------
1018
1019    #[test]
1020    fn get_box_edges_resolves_named_token() {
1021        let tokens = ThemeTokens::new().set("pad", BoxEdges::uniform(2));
1022        assert_eq!(tokens.get_box_edges("pad"), Some(&BoxEdges::uniform(2)));
1023        // Default (non-media) getter.
1024        assert_eq!(
1025            tokens.get_box_edges_with("pad", &MediaContext::default()),
1026            Some(BoxEdges::uniform(2))
1027        );
1028    }
1029
1030    #[test]
1031    fn get_box_edges_follows_var_chain() {
1032        let edges = BoxEdges { top: 1, right: 2, bottom: 3, left: 4 };
1033        let tokens = ThemeTokens::new()
1034            .set("pad", Token::Var { name: "pad2".into() })
1035            .set("pad2", edges);
1036        assert_eq!(tokens.get_box_edges("pad"), Some(&edges));
1037    }
1038
1039    #[test]
1040    fn get_border_style_resolves_named_token() {
1041        let tokens = ThemeTokens::new().set("bs", BorderStyle::Rounded);
1042        assert_eq!(tokens.get_border_style("bs"), Some(&BorderStyle::Rounded));
1043        assert_eq!(
1044            tokens.get_border_style_with("bs", &MediaContext::default()),
1045            Some(BorderStyle::Rounded)
1046        );
1047    }
1048
1049    #[test]
1050    fn get_border_style_follows_var_chain() {
1051        let tokens = ThemeTokens::new()
1052            .set("bs", Token::Var { name: "bs2".into() })
1053            .set("bs2", BorderStyle::Double);
1054        assert_eq!(tokens.get_border_style("bs"), Some(&BorderStyle::Double));
1055    }
1056
1057    #[test]
1058    fn get_box_edges_with_media_specificity_override() {
1059        // A more-specific media override wins for a BoxEdges token.
1060        let tokens = ThemeTokens::new()
1061            .set_media(
1062                mq("(min-width: 80)"),
1063                "pad",
1064                BoxEdges::uniform(1),
1065            )
1066            .set_media(
1067                mq("(min-width: 80) and (color)"),
1068                "pad",
1069                BoxEdges::uniform(2),
1070            );
1071        assert_eq!(
1072            tokens.get_box_edges_with("pad", &ctx(100)),
1073            Some(BoxEdges::uniform(2)),
1074            "more-specific media override wins for box-edges"
1075        );
1076    }
1077
1078    #[test]
1079    fn get_border_style_with_media_override() {
1080        let tokens = ThemeTokens::new()
1081            .set("bs", BorderStyle::Single)
1082            .set_media(mq("(min-width: 80)"), "bs", BorderStyle::Rounded);
1083        assert_eq!(
1084            tokens.get_border_style_with("bs", &ctx(100)),
1085            Some(BorderStyle::Rounded)
1086        );
1087        assert_eq!(
1088            tokens.get_border_style_with("bs", &ctx(40)),
1089            Some(BorderStyle::Single)
1090        );
1091    }
1092
1093    #[test]
1094    fn box_edges_token_is_not_a_color() {
1095        // A BoxEdges binding does not satisfy a color reference.
1096        let tokens = ThemeTokens::new().set("pad", BoxEdges::uniform(1));
1097        assert_eq!(tokens.get_color("pad"), None);
1098    }
1099
1100    #[test]
1101    fn border_style_token_is_not_a_length() {
1102        let tokens = ThemeTokens::new().set("bs", BorderStyle::Rounded);
1103        assert_eq!(tokens.get_length("bs"), None);
1104    }
1105}