Skip to main content

kui_core/
tokens.rs

1//! Tokens: named colours and lengths an app declares beside the theme and
2//! the metrics, and references a colour or length prop by name
3//! (`docs/adr/0027-tokens-beside-the-theme.md`).
4//!
5//! A [`Theme`] is twenty-three roles the stock widgets paint from; a
6//! [`Metrics`] is sixteen lengths they are built from. Both are closed,
7//! and an app whose palette *is* the design — LCARS peach, tangerine, a
8//! sidebar width — has a vocabulary neither names. [`Tokens`] is that
9//! vocabulary: a colour token carries a light and a dark half (the same
10//! value twice, in the common case) **or a recipe over an earlier token**
11//! (`docs/adr/0028-derived-tokens.md`: a source and a chain of
12//! [`ColorOp`]s, folded on read), a length token is one number in
13//! logical px, and the app declares them once, whole. Where a role is
14//! read as `ui.theme().surface`, a token is referenced by name in the
15//! prop — `bg = "$peach"` — and the binding that lowers the node resolves
16//! it through [`TokenLookup`] before the core sees a `NodeSpec`, so the
17//! core's open path knows nothing about names.
18//!
19//! One table per origin. A host's names are the host's and an
20//! extension's are its own: a lookup reads the table of the origin whose
21//! view is running, then the host's, so a guest can paint in the host's
22//! vocabulary without the host passing it and still name a grey of its
23//! own, and a plugin's declaration never replaces the host's palette.
24//! The theme's roles and the metrics' roles are reachable by the same
25//! spelling — `$surface`, `$radius` — through a reserved range in front of
26//! the app's, so a declared token that takes a role's name is refused
27//! ([`crate::diag::RESERVED_TOKEN`]) rather than shadowing it.
28
29use rustc_hash::FxHashMap;
30
31use crate::color::Color;
32use crate::env::Appearance;
33use crate::metrics::Metrics;
34use crate::schema::{METRIC_ROLES, THEME_ROLES};
35use crate::theme::Theme;
36
37/// A colour token: one value per base, or a recipe over an earlier token
38/// or a theme role (ADR 0028). [`ColorToken::same`] is the unthemed case,
39/// and what a declaration with one colour builds; a derived one is built
40/// by [`Tokens::derive`], since its source is an index into the table
41/// that holds it.
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub enum ColorToken {
44    Value {
45        light: Color,
46        dark: Color,
47    },
48    /// `from`, with the ops at `ops` in [`Tokens`]' chain applied in
49    /// order. `from` is a colour token declared before this one in the
50    /// same table, or a theme role — never a later token, so the chain is
51    /// acyclic by construction and a read recurses at most the table's
52    /// length.
53    Derived {
54        from: TokenRef,
55        ops: OpRange,
56    },
57}
58
59impl ColorToken {
60    pub fn same(c: Color) -> Self {
61        Self::Value { light: c, dark: c }
62    }
63
64    pub fn themed(light: Color, dark: Color) -> Self {
65        Self::Value { light, dark }
66    }
67
68    /// The declared halves of a value token; `None` for a derived one,
69    /// whose halves are whatever its recipe makes of its source's
70    /// ([`Tokens::halves`]).
71    pub fn halves(&self) -> Option<(Color, Color)> {
72        match *self {
73            Self::Value { light, dark } => Some((light, dark)),
74            Self::Derived { .. } => None,
75        }
76    }
77
78    pub fn is_derived(&self) -> bool {
79        matches!(self, Self::Derived { .. })
80    }
81}
82
83/// One step of a derived token's recipe, as declared: a verb and its
84/// operands, a colour named by its token or role. Each is a method the
85/// core already paints with — `lift` and `darken` are [`Color::mix`]
86/// toward white and black, `raise` is [`Theme::raise`] (toward the front
87/// of whichever base is in effect), `alpha` is [`Color::with_alpha`],
88/// `mix` is [`Color::mix`] toward another token, and `readable` is
89/// [`Color::toward_contrast`] toward black or white — whichever reads on
90/// the named colour — until it clears the ratio on it.
91#[derive(Clone, Debug, PartialEq)]
92pub enum ColorOp {
93    Lift(f32),
94    Darken(f32),
95    Raise(f32),
96    Alpha(f32),
97    Mix(String, f32),
98    Readable(String, f32),
99}
100
101impl ColorOp {
102    /// The verbs, in the order C numbers them (`KuiColorOp.op`).
103    pub const VERBS: [&'static str; 6] = ["lift", "darken", "raise", "alpha", "mix", "readable"];
104
105    /// The verb's spelling, as every binding writes it in a tuple's first
106    /// slot and as the devtools print it.
107    pub fn verb(&self) -> &'static str {
108        match self {
109            Self::Lift(_) => "lift",
110            Self::Darken(_) => "darken",
111            Self::Raise(_) => "raise",
112            Self::Alpha(_) => "alpha",
113            Self::Mix(..) => "mix",
114            Self::Readable(..) => "readable",
115        }
116    }
117
118    /// Whether `verb` takes a colour operand before its number (`mix`,
119    /// `readable`); `None` for a verb that is none of the six.
120    pub fn takes_color(verb: &str) -> Option<bool> {
121        match verb {
122            "lift" | "darken" | "raise" | "alpha" => Some(false),
123            "mix" | "readable" => Some(true),
124            _ => None,
125        }
126    }
127
128    /// A step from its spelling: the verb, the colour operand a verb that
129    /// takes one names, and the number. `None` for an unknown verb or a
130    /// colour given to a verb that takes none (and the reverse).
131    pub fn parse(verb: &str, color: Option<&str>, t: f32) -> Option<Self> {
132        Some(match (verb, color) {
133            ("lift", None) => Self::Lift(t),
134            ("darken", None) => Self::Darken(t),
135            ("raise", None) => Self::Raise(t),
136            ("alpha", None) => Self::Alpha(t),
137            ("mix", Some(c)) => Self::Mix(c.to_string(), t),
138            ("readable", Some(c)) => Self::Readable(c.to_string(), t),
139            _ => return None,
140        })
141    }
142}
143
144/// A step with its operand resolved to the table's index or a role: what
145/// the chain stores, so a read follows indices and never a name.
146#[derive(Clone, Copy, Debug, PartialEq)]
147enum Step {
148    Lift(f32),
149    Darken(f32),
150    Raise(f32),
151    Alpha(f32),
152    Mix(TokenRef, f32),
153    Readable(TokenRef, f32),
154}
155
156/// Where a derived token's steps are in [`Tokens`]' chain: the table owns
157/// the ops, so the token stays `Copy`.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub struct OpRange {
160    start: u16,
161    len: u16,
162}
163
164/// Why a derived token was dropped at declaration: the name it was given
165/// and the source or operand that resolved to no colour token declared
166/// before it and no theme role.
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct Unresolved {
169    pub token: String,
170    pub source: String,
171}
172
173/// What kind of value a token holds, and which prop slots it fits.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub enum TokenKind {
176    Color,
177    Length,
178}
179
180impl TokenKind {
181    pub fn name(self) -> &'static str {
182        match self {
183            TokenKind::Color => "color",
184            TokenKind::Length => "length",
185        }
186    }
187}
188
189/// Where a name resolved to: a role, or an app token by index.
190#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191pub enum TokenRef {
192    ColorRole(u16),
193    LengthRole(u16),
194    Color(u16),
195    Length(u16),
196}
197
198impl TokenRef {
199    pub fn kind(self) -> TokenKind {
200        match self {
201            TokenRef::ColorRole(_) | TokenRef::Color(_) => TokenKind::Color,
202            TokenRef::LengthRole(_) | TokenRef::Length(_) => TokenKind::Length,
203        }
204    }
205
206    /// The index a binding writes on the wire for this reference: roles
207    /// first, the app's after them, one space per kind
208    /// ([`COLOR_ROLES`] / [`LENGTH_ROLES`] wide).
209    pub fn index(self) -> u16 {
210        match self {
211            TokenRef::ColorRole(i) | TokenRef::LengthRole(i) => i,
212            TokenRef::Color(i) => COLOR_ROLES + i,
213            TokenRef::Length(i) => LENGTH_ROLES + i,
214        }
215    }
216}
217
218/// How many indices the theme's roles take in front of the app's colour
219/// tokens: `$surface` is index 1 whatever the app declared.
220pub const COLOR_ROLES: u16 = THEME_ROLES.len() as u16;
221/// The same for the metrics' roles in front of the app's lengths.
222pub const LENGTH_ROLES: u16 = METRIC_ROLES.len() as u16;
223
224/// Why a name did not resolve.
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub enum TokenError {
227    /// Nothing declared it, and it is no role.
228    Unknown(String),
229    /// It exists, as the other kind: a length in a colour slot or the
230    /// reverse.
231    Kind {
232        name: String,
233        is: TokenKind,
234        wanted: TokenKind,
235    },
236}
237
238impl std::fmt::Display for TokenError {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        match self {
241            TokenError::Unknown(name) => write!(
242                f,
243                "`${name}` names no token: nothing declared it and it is not a theme or metrics \
244                 role, so this declaration paints nothing"
245            ),
246            TokenError::Kind { name, is, wanted } => write!(
247                f,
248                "`${name}` is a {} token, and this slot takes a {}",
249                is.name(),
250                wanted.name()
251            ),
252        }
253    }
254}
255
256/// One origin's declared tokens, in declaration order. Built with the
257/// chaining constructors and handed to [`crate::Core::set_tokens`] whole;
258/// each call replaces the caller's table.
259#[derive(Clone, Debug, Default, PartialEq)]
260pub struct Tokens {
261    colors: Vec<(String, ColorToken)>,
262    lengths: Vec<(String, f32)>,
263    by_name: FxHashMap<String, TokenRef>,
264    /// Every derived token's steps, end to end; a token holds its range.
265    ops: Vec<Step>,
266    /// Names refused because a role owns them, kept so the core can warn
267    /// once per name when the table is declared.
268    reserved: Vec<String>,
269    /// Derived tokens dropped because a source did not resolve, kept for
270    /// the same reason.
271    unresolved: Vec<Unresolved>,
272}
273
274impl Tokens {
275    pub fn new() -> Self {
276        Self::default()
277    }
278
279    /// A colour with one value for both bases.
280    pub fn color(self, name: impl Into<String>, c: Color) -> Self {
281        self.color_token(name, ColorToken::same(c))
282    }
283
284    /// A colour with a value per base.
285    pub fn color_themed(self, name: impl Into<String>, light: Color, dark: Color) -> Self {
286        self.color_token(name, ColorToken::themed(light, dark))
287    }
288
289    pub fn color_token(mut self, name: impl Into<String>, token: ColorToken) -> Self {
290        let name = name.into();
291        if is_role(&name) {
292            self.reserved.push(name);
293            return self;
294        }
295        match self.by_name.get(&name) {
296            Some(TokenRef::Color(i)) => self.colors[*i as usize].1 = token,
297            _ => {
298                let i = self.colors.len() as u16;
299                self.colors.push((name.clone(), token));
300                self.by_name.insert(name, TokenRef::Color(i));
301            }
302        }
303        self
304    }
305
306    /// A colour computed from another (ADR 0028): `from` is a colour
307    /// token already in this table or a theme role, and `ops` the steps
308    /// applied to it in order, each colour operand likewise a name
309    /// declared before this one. A source that resolves to nothing —
310    /// undeclared, a length, this token itself, or one declared after it
311    /// — drops the declaration and is reported by [`Tokens::unresolved`];
312    /// the core raises `unknown-token` for each when the table is set. An
313    /// empty chain is an alias.
314    pub fn derive(
315        mut self,
316        name: impl Into<String>,
317        from: &str,
318        ops: impl IntoIterator<Item = ColorOp>,
319    ) -> Self {
320        let name = name.into();
321        if is_role(&name) {
322            self.reserved.push(name);
323            return self;
324        }
325        // The index this token will have — its own if it is being
326        // re-declared, else the next — and every source sits below it.
327        let own = match self.by_name.get(&name) {
328            Some(TokenRef::Color(i)) => *i,
329            _ => self.colors.len() as u16,
330        };
331        let source = |t: &Self, s: &str| -> Option<TokenRef> {
332            match role_ref(s) {
333                Some(r @ TokenRef::ColorRole(_)) => Some(r),
334                Some(_) => None,
335                None => match t.color_id(s) {
336                    Some(r @ TokenRef::Color(i)) if i < own => Some(r),
337                    _ => None,
338                },
339            }
340        };
341        let unresolved = |s: &str| Unresolved {
342            token: name.clone(),
343            source: s.to_string(),
344        };
345        let Some(from) = source(&self, from) else {
346            self.unresolved.push(unresolved(from));
347            return self;
348        };
349        let mut steps = Vec::new();
350        for op in ops {
351            let step = match op {
352                ColorOp::Lift(t) => Step::Lift(t),
353                ColorOp::Darken(t) => Step::Darken(t),
354                ColorOp::Raise(t) => Step::Raise(t),
355                ColorOp::Alpha(a) => Step::Alpha(a),
356                ColorOp::Mix(ref other, t) => match source(&self, other) {
357                    Some(r) => Step::Mix(r, t),
358                    None => {
359                        self.unresolved.push(unresolved(other));
360                        return self;
361                    }
362                },
363                ColorOp::Readable(ref on, ratio) => match source(&self, on) {
364                    Some(r) => Step::Readable(r, ratio),
365                    None => {
366                        self.unresolved.push(unresolved(on));
367                        return self;
368                    }
369                },
370            };
371            steps.push(step);
372        }
373        let range = OpRange {
374            start: self.ops.len() as u16,
375            len: steps.len() as u16,
376        };
377        self.ops.extend(steps);
378        self.color_token(name, ColorToken::Derived { from, ops: range })
379    }
380
381    /// A length in logical px, before `env.scale`.
382    pub fn length(mut self, name: impl Into<String>, px: f32) -> Self {
383        let name = name.into();
384        if is_role(&name) {
385            self.reserved.push(name);
386            return self;
387        }
388        match self.by_name.get(&name) {
389            Some(TokenRef::Length(i)) => self.lengths[*i as usize].1 = px,
390            _ => {
391                let i = self.lengths.len() as u16;
392                self.lengths.push((name.clone(), px));
393                self.by_name.insert(name, TokenRef::Length(i));
394            }
395        }
396        self
397    }
398
399    /// The reference a declared name resolves to in this table alone —
400    /// what a Rust app holds instead of the name.
401    pub fn id(&self, name: &str) -> Option<TokenRef> {
402        self.by_name.get(name).copied()
403    }
404
405    pub fn color_id(&self, name: &str) -> Option<TokenRef> {
406        self.id(name).filter(|r| r.kind() == TokenKind::Color)
407    }
408
409    pub fn length_id(&self, name: &str) -> Option<TokenRef> {
410        self.id(name).filter(|r| r.kind() == TokenKind::Length)
411    }
412
413    /// The colours, in declaration order.
414    pub fn colors(&self) -> &[(String, ColorToken)] {
415        &self.colors
416    }
417
418    /// The lengths, in declaration order.
419    pub fn lengths(&self) -> &[(String, f32)] {
420        &self.lengths
421    }
422
423    /// The names a role owns that this declaration tried to take.
424    pub fn reserved(&self) -> &[String] {
425        &self.reserved
426    }
427
428    /// The derived tokens this declaration dropped, with the source that
429    /// did not resolve.
430    pub fn unresolved(&self) -> &[Unresolved] {
431        &self.unresolved
432    }
433
434    pub fn is_empty(&self) -> bool {
435        self.colors.is_empty() && self.lengths.is_empty()
436    }
437
438    /// The colour token at `i` under `theme`: a value's half, or a
439    /// derived token's recipe folded over its source's — recursing
440    /// through a derived source, which is always an earlier index. A
441    /// derived colour comes back rounded to eight bits a channel, so it is
442    /// exactly what a reader gets through `0xRRGGBBAA`.
443    pub fn resolve_color(&self, i: u16, theme: &Theme) -> Color {
444        match self.colors[i as usize].1 {
445            ColorToken::Value { light, dark } => {
446                if theme.is_dark() {
447                    dark
448                } else {
449                    light
450                }
451            }
452            ColorToken::Derived { from, ops } => {
453                let mut c = self.source_color(from, theme);
454                for step in &self.ops[ops.start as usize..(ops.start + ops.len) as usize] {
455                    c = match *step {
456                        Step::Lift(t) => c.mix(Color::WHITE, t),
457                        Step::Darken(t) => c.mix(Color::BLACK, t),
458                        Step::Raise(t) => theme.raise(c, t),
459                        Step::Alpha(a) => c.with_alpha(a),
460                        Step::Mix(other, t) => c.mix(self.source_color(other, theme), t),
461                        Step::Readable(on, ratio) => {
462                            let on = self.source_color(on, theme);
463                            c.toward_contrast(crate::widgets::readable_on(on), on, ratio, 0.0)
464                        }
465                    };
466                }
467                // Rounded to eight bits a channel: the value every binding
468                // paints is the one `to_hex` reads back, so a C host that
469                // reads a derived colour and writes it (it has no reference)
470                // lowers the same quad as a `$name` does.
471                Color::hex(c.to_hex())
472            }
473        }
474    }
475
476    fn source_color(&self, r: TokenRef, theme: &Theme) -> Color {
477        match r {
478            TokenRef::ColorRole(i) => (THEME_ROLES[i as usize].get)(theme),
479            TokenRef::Color(i) => self.resolve_color(i, theme),
480            TokenRef::LengthRole(_) | TokenRef::Length(_) => unreachable!("a colour source"),
481        }
482    }
483
484    /// Both halves of the colour token at `i`, the light first: a value's
485    /// as declared; a derived token's computed under `theme` for the half
486    /// in effect and under a theme of the other appearance with the same
487    /// accent for the other — what the devtools show beside the swatch.
488    pub fn halves(&self, i: u16, theme: &Theme) -> (Color, Color) {
489        if let Some(h) = self.colors[i as usize].1.halves() {
490            return h;
491        }
492        let other = Theme::derive(
493            if theme.is_dark() {
494                Appearance::Light
495            } else {
496                Appearance::Dark
497            },
498            Some(theme.accent),
499        );
500        let here = self.resolve_color(i, theme);
501        let there = self.resolve_color(i, &other);
502        if theme.is_dark() {
503            (there, here)
504        } else {
505            (here, there)
506        }
507    }
508
509    /// A derived token's recipe as the devtools print it — `peach → lift
510    /// 0.3 → alpha 0.5` — or `None` for a value.
511    pub fn recipe(&self, i: u16) -> Option<String> {
512        let ColorToken::Derived { from, ops } = self.colors[i as usize].1 else {
513            return None;
514        };
515        let name = |r: TokenRef| -> &str {
516            match r {
517                TokenRef::ColorRole(i) => THEME_ROLES[i as usize].node,
518                TokenRef::Color(i) => &self.colors[i as usize].0,
519                _ => unreachable!("a colour source"),
520            }
521        };
522        let mut out = name(from).to_string();
523        for step in &self.ops[ops.start as usize..(ops.start + ops.len) as usize] {
524            out.push_str(" → ");
525            out.push_str(&match *step {
526                Step::Lift(t) => format!("lift {t}"),
527                Step::Darken(t) => format!("darken {t}"),
528                Step::Raise(t) => format!("raise {t}"),
529                Step::Alpha(a) => format!("alpha {a}"),
530                Step::Mix(r, t) => format!("mix {} {t}", name(r)),
531                Step::Readable(r, ratio) => format!("readable {} {ratio}", name(r)),
532            });
533        }
534        Some(out)
535    }
536}
537
538/// Whether a name is a theme or metrics role under either spelling
539/// (`border_strong` and `borderStrong` both are).
540pub fn is_role(name: &str) -> bool {
541    role_ref(name).is_some()
542}
543
544/// The role a name is, under either spelling.
545pub fn role_ref(name: &str) -> Option<TokenRef> {
546    if let Some(i) = THEME_ROLES
547        .iter()
548        .position(|r| r.name == name || r.node == name)
549    {
550        return Some(TokenRef::ColorRole(i as u16));
551    }
552    METRIC_ROLES
553        .iter()
554        .position(|r| r.name == name || r.node == name)
555        .map(|i| TokenRef::LengthRole(i as u16))
556}
557
558/// A frame's view of the tokens a lowering can reference: the running
559/// origin's table over the host's, and the roles in front of both.
560/// Borrowed from the core for the length of a lowering
561/// ([`crate::Core::token_lookup`]).
562#[derive(Clone, Copy)]
563pub struct TokenLookup<'a> {
564    pub(crate) own: Option<&'a Tokens>,
565    pub(crate) host: Option<&'a Tokens>,
566    pub(crate) theme: &'a Theme,
567    pub(crate) metrics: &'a Metrics,
568    /// The session a named `family` registers in (ADR 0037); none for a
569    /// lookup built without a core.
570    pub(crate) session: Option<&'a crate::session::Session>,
571}
572
573impl<'a> TokenLookup<'a> {
574    /// The table a by-index reference reads: the running origin's, or
575    /// the host's when it declared none.
576    fn table(&self) -> Option<&'a Tokens> {
577        self.own.or(self.host)
578    }
579
580    /// What a name resolves to: a role under either spelling, then the
581    /// running origin's table, then the host's.
582    pub fn resolve(&self, name: &str) -> Option<TokenRef> {
583        if let Some(r) = role_ref(name) {
584            return Some(r);
585        }
586        if let Some(r) = self.own.and_then(|t| t.id(name)) {
587            return Some(r);
588        }
589        self.host.and_then(|t| t.id(name))
590    }
591
592    /// The colour a name resolves to this frame.
593    pub fn color(&self, name: &str) -> Result<Color, TokenError> {
594        match self.resolve(name) {
595            None => Err(TokenError::Unknown(name.to_string())),
596            Some(r) if r.kind() != TokenKind::Color => Err(TokenError::Kind {
597                name: name.to_string(),
598                is: r.kind(),
599                wanted: TokenKind::Color,
600            }),
601            Some(TokenRef::ColorRole(i)) => Ok((THEME_ROLES[i as usize].get)(self.theme)),
602            Some(TokenRef::Color(i)) => Ok(self.color_in(name, i)),
603            Some(_) => unreachable!(),
604        }
605    }
606
607    /// The length a name resolves to this frame.
608    pub fn length(&self, name: &str) -> Result<f32, TokenError> {
609        match self.resolve(name) {
610            None => Err(TokenError::Unknown(name.to_string())),
611            Some(r) if r.kind() != TokenKind::Length => Err(TokenError::Kind {
612                name: name.to_string(),
613                is: r.kind(),
614                wanted: TokenKind::Length,
615            }),
616            Some(TokenRef::LengthRole(i)) => Ok((METRIC_ROLES[i as usize].get)(self.metrics)),
617            Some(TokenRef::Length(i)) => Ok(self.length_in(name, i)),
618            Some(_) => unreachable!(),
619        }
620    }
621
622    // A by-name hit came from whichever table holds the name; the index
623    // is only meaningful in that table, so it is re-read by name.
624    fn color_in(&self, name: &str, i: u16) -> Color {
625        let own = self.own.filter(|t| t.id(name) == Some(TokenRef::Color(i)));
626        let t = own.or(self.host).expect("resolved in a table");
627        t.resolve_color(i, self.theme)
628    }
629
630    fn length_in(&self, name: &str, i: u16) -> f32 {
631        let own = self.own.filter(|t| t.id(name) == Some(TokenRef::Length(i)));
632        let t = own.or(self.host).expect("resolved in a table");
633        t.lengths[i as usize].1
634    }
635
636    /// A colour by wire index ([`TokenRef::index`]): a role below
637    /// [`COLOR_ROLES`], the running origin's table above. `None` past the
638    /// end — a reference to a token the table no longer holds.
639    pub fn color_at(&self, index: u32) -> Option<Color> {
640        if index < COLOR_ROLES as u32 {
641            return Some((THEME_ROLES[index as usize].get)(self.theme));
642        }
643        let i = index - COLOR_ROLES as u32;
644        self.table()
645            .filter(|t| (i as usize) < t.colors.len())
646            .map(|t| t.resolve_color(i as u16, self.theme))
647    }
648
649    /// A length by wire index, the same way over [`LENGTH_ROLES`].
650    pub fn length_at(&self, index: u32) -> Option<f32> {
651        if index < LENGTH_ROLES as u32 {
652            return Some((METRIC_ROLES[index as usize].get)(self.metrics));
653        }
654        let i = (index - LENGTH_ROLES as u32) as usize;
655        self.table().and_then(|t| t.lengths.get(i)).map(|(_, v)| *v)
656    }
657
658    /// Every colour token a reader in this origin sees, resolved for the
659    /// frame: the running origin's over the host's, by name, in the host's
660    /// order then the guest's additions. Roles are not listed — they are
661    /// the theme's reading.
662    pub fn colors(&self) -> Vec<(&'a str, Color)> {
663        let mut out: Vec<(&'a str, Color)> = Vec::new();
664        for t in [self.host, self.own].into_iter().flatten() {
665            for (i, (name, _)) in t.colors.iter().enumerate() {
666                // A name later declared as a length leaves its colour
667                // entry behind; the name binds the length, so the
668                // listing does too.
669                if t.id(name) != Some(TokenRef::Color(i as u16)) {
670                    continue;
671                }
672                let c = t.resolve_color(i as u16, self.theme);
673                match out.iter_mut().find(|(n, _)| *n == name.as_str()) {
674                    Some(slot) => slot.1 = c,
675                    None => out.push((name.as_str(), c)),
676                }
677            }
678        }
679        out
680    }
681
682    /// The same for lengths.
683    pub fn lengths(&self) -> Vec<(&'a str, f32)> {
684        let mut out: Vec<(&'a str, f32)> = Vec::new();
685        for t in [self.host, self.own].into_iter().flatten() {
686            for (i, (name, v)) in t.lengths.iter().enumerate() {
687                if t.id(name) != Some(TokenRef::Length(i as u16)) {
688                    continue;
689                }
690                match out.iter_mut().find(|(n, _)| *n == name.as_str()) {
691                    Some(slot) => slot.1 = *v,
692                    None => out.push((name.as_str(), *v)),
693                }
694            }
695        }
696        out
697    }
698
699    /// The names a colour paints under this frame, for an inspector
700    /// printing a value: every token (own over host) whose resolved
701    /// colour is `c`. Two names with one value are both listed.
702    pub fn color_names(&self, c: Color) -> Vec<&'a str> {
703        self.colors()
704            .into_iter()
705            .filter(|(_, v)| *v == c)
706            .map(|(n, _)| n)
707            .collect()
708    }
709
710    /// The same for a length.
711    pub fn length_names(&self, v: f32) -> Vec<&'a str> {
712        self.lengths()
713            .into_iter()
714            .filter(|(_, x)| *x == v)
715            .map(|(n, _)| n)
716            .collect()
717    }
718}
719
720/// A `$name` reference as a prop value spells it: the name without the
721/// sigil, or `None` for a value that is not a reference.
722pub fn reference(s: &str) -> Option<&str> {
723    s.strip_prefix('$').filter(|n| !n.is_empty())
724}
725
726/// A lookup plus the names it could not answer — the shape every
727/// by-name lowering wants (Lua's props, a keyframe stop or an entrance
728/// in any binding, since those cross as plain data with the name still
729/// in them), so the one miss policy (ADR 0027 decision 4, backlog AR14)
730/// is written once: a `$name` that resolves to nothing, or to the other
731/// kind, is `None` — the slot is left at the row's default, the way the
732/// prop would be if it were not declared — and the miss is remembered for
733/// the caller to raise as `unknown-token` once the lookup's borrow of the
734/// core is handed back (`Core::warn_unknown_token`).
735pub struct NameRefs<'a> {
736    look: TokenLookup<'a>,
737    missed: Vec<TokenError>,
738    missed_families: Vec<String>,
739}
740
741impl<'a> NameRefs<'a> {
742    pub fn new(look: TokenLookup<'a>) -> Self {
743        Self {
744            look,
745            missed: Vec::new(),
746            missed_families: Vec::new(),
747        }
748    }
749
750    /// The `family` a text names (ADR 0037): a stock one by its spelling
751    /// (`sans`, `serif`, `mono`), else an installed or loaded family,
752    /// registered in the session and drawn by its handle. A name nothing
753    /// matches is sans, and remembered for the caller to raise as
754    /// `unknown-family` (`Core::warn_unknown_family`).
755    pub fn family(&mut self, name: &str) -> crate::spec::FontFamily {
756        use crate::spec::FontFamily;
757        if let Some(stock) = FontFamily::ALL.iter().find(|f| f.name() == Some(name)) {
758            return *stock;
759        }
760        match self.look.session.and_then(|s| s.register_family(name)) {
761            Some(id) => FontFamily::Custom(id),
762            None => {
763                if !self.missed_families.iter().any(|m| m == name) {
764                    self.missed_families.push(name.to_string());
765                }
766                FontFamily::Sans
767            }
768        }
769    }
770
771    /// The family names that matched nothing, taken; each one line of
772    /// `unknown-family` for the caller to raise.
773    pub fn take_missed_families(&mut self) -> Vec<String> {
774        std::mem::take(&mut self.missed_families)
775    }
776
777    /// The lookup itself, for a caller that reads a token without the
778    /// miss bookkeeping.
779    pub fn lookup(&self) -> TokenLookup<'a> {
780        self.look
781    }
782
783    /// The colour `name` resolves to this frame, or `None` (remembered).
784    pub fn color(&mut self, name: &str) -> Option<Color> {
785        match self.look.color(name) {
786            Ok(c) => Some(c),
787            Err(e) => {
788                self.missed.push(e);
789                None
790            }
791        }
792    }
793
794    /// The length `name` resolves to this frame, or `None` (remembered).
795    pub fn length(&mut self, name: &str) -> Option<f32> {
796        match self.look.length(name) {
797            Ok(v) => Some(v),
798            Err(e) => {
799                self.missed.push(e);
800                None
801            }
802        }
803    }
804
805    /// A value that is a `$name` colour reference, resolved; `None` for a
806    /// value that is not a reference. A reference that missed is
807    /// `Some(None)`: consumed, unresolved, remembered.
808    pub fn color_ref(&mut self, v: &crate::value::Value) -> Option<Option<Color>> {
809        let name = v.as_str().and_then(reference)?;
810        Some(self.color(name))
811    }
812
813    /// The same for a length.
814    pub fn length_ref(&mut self, v: &crate::value::Value) -> Option<Option<f32>> {
815        let name = v.as_str().and_then(reference)?;
816        Some(self.length(name))
817    }
818
819    /// The names that resolved to nothing, taken; each one line of
820    /// `unknown-token` for the caller to raise.
821    pub fn take_missed(&mut self) -> Vec<TokenError> {
822        std::mem::take(&mut self.missed)
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    #[test]
831    fn a_role_name_is_refused_and_remembered() {
832        let t = Tokens::new()
833            .color("surface", Color::WHITE)
834            .length("radius", 3.0)
835            .color("peach", Color::WHITE);
836        assert_eq!(t.reserved(), ["surface", "radius"]);
837        assert_eq!(t.colors().len(), 1);
838        assert!(t.lengths().is_empty());
839        // Both spellings are the role's.
840        assert!(is_role("border_strong"));
841        assert!(is_role("borderStrong"));
842        assert!(is_role("controlPadX"));
843    }
844
845    #[test]
846    fn redeclaring_a_name_keeps_its_index() {
847        let t = Tokens::new()
848            .color("a", Color::WHITE)
849            .color("b", Color::BLACK)
850            .color("a", Color::BLACK);
851        assert_eq!(t.id("a"), Some(TokenRef::Color(0)));
852        assert_eq!(t.colors()[0].1, ColorToken::same(Color::BLACK));
853        assert_eq!(t.colors().len(), 2);
854    }
855
856    /// A name declared as both kinds binds the later one; the earlier
857    /// entry stays reachable by its index (the wire's contract) but is
858    /// not what the name lists as.
859    #[test]
860    fn a_name_declared_twice_lists_once() {
861        let t = Tokens::new().color("gap", Color::WHITE).length("gap", 6.0);
862        let theme = Theme::default();
863        let metrics = Metrics::default();
864        let look = TokenLookup {
865            own: Some(&t),
866            host: None,
867            theme: &theme,
868            metrics: &metrics,
869            session: None,
870        };
871        assert_eq!(look.colors(), vec![]);
872        assert_eq!(look.lengths(), vec![("gap", 6.0)]);
873        assert_eq!(look.color_names(Color::WHITE), Vec::<&str>::new());
874        assert_eq!(
875            look.color_at(COLOR_ROLES as u32),
876            Some(Color::WHITE),
877            "by index still"
878        );
879    }
880
881    #[test]
882    fn wire_indices_put_the_roles_first() {
883        assert_eq!(TokenRef::ColorRole(1).index(), 1);
884        assert_eq!(TokenRef::Color(0).index(), COLOR_ROLES);
885        assert_eq!(TokenRef::Length(2).index(), LENGTH_ROLES + 2);
886    }
887
888    #[test]
889    fn a_reference_is_a_dollar_and_a_name() {
890        assert_eq!(reference("$peach"), Some("peach"));
891        assert_eq!(reference("$"), None);
892        assert_eq!(reference("#fff"), None);
893    }
894
895    // -- derived tokens (ADR 0028) ----------------------------------------
896
897    const PEACH: Color = Color {
898        r: 1.0,
899        g: 0.8,
900        b: 0.6,
901        a: 1.0,
902    };
903
904    /// A derived colour is rounded to eight bits a channel, so it is
905    /// compared as hex — against an expectation built the same way.
906    fn same(a: Color, b: Color) -> bool {
907        a.to_hex() == b.to_hex()
908    }
909
910    fn q(c: Color) -> Color {
911        Color::hex(c.to_hex())
912    }
913
914    /// A chain folds in order over the source, and the same two steps the
915    /// other way round give a different colour when they do not commute.
916    #[test]
917    fn a_chain_folds_in_declaration_order() {
918        let t = Tokens::new()
919            .color("peach", PEACH)
920            .color("black", Color::BLACK)
921            .derive("lit", "peach", [ColorOp::Lift(0.3)])
922            .derive("wash", "peach", [ColorOp::Lift(0.3), ColorOp::Alpha(0.5)])
923            .derive(
924                "lit_then_read",
925                "peach",
926                [ColorOp::Lift(0.3), ColorOp::Readable("black".into(), 4.5)],
927            )
928            .derive(
929                "read_then_lit",
930                "peach",
931                [ColorOp::Readable("black".into(), 4.5), ColorOp::Lift(0.3)],
932            );
933        let theme = Theme::dark();
934        let hover = t.resolve_color(2, &theme);
935        assert!(same(hover, PEACH.mix(Color::WHITE, 0.3)));
936        let wash = t.resolve_color(3, &theme);
937        assert!(same(wash, q(PEACH.mix(Color::WHITE, 0.3)).with_alpha(0.5)));
938        // Peach already reads on black, so the readable step is a no-op
939        // after the lift; before it, the lift still applies after.
940        let a = t.resolve_color(4, &theme);
941        let b = t.resolve_color(5, &theme);
942        assert!(same(a, PEACH.mix(Color::WHITE, 0.3)));
943        assert!(same(b, PEACH.mix(Color::WHITE, 0.3)));
944        assert_eq!(t.recipe(3).as_deref(), Some("peach → lift 0.3 → alpha 0.5"));
945        assert_eq!(t.recipe(0), None);
946    }
947
948    /// A derived token follows its source's half, and a role source
949    /// follows the theme; `raise` goes toward the front of the base.
950    #[test]
951    fn a_derived_token_follows_the_appearance_with_its_source() {
952        let t = Tokens::new()
953            .color_themed("ink", Color::BLACK, Color::WHITE)
954            .derive("ink_soft", "ink", [ColorOp::Alpha(0.5)])
955            .derive("accent_up", "accent", [ColorOp::Raise(0.2)])
956            .derive("alias", "accent", []);
957        let (light, dark) = (Theme::light(), Theme::dark());
958        assert!(same(
959            t.resolve_color(1, &light),
960            Color::BLACK.with_alpha(0.5)
961        ));
962        assert!(same(
963            t.resolve_color(1, &dark),
964            Color::WHITE.with_alpha(0.5)
965        ));
966        assert!(same(
967            t.resolve_color(2, &dark),
968            dark.accent.mix(Color::WHITE, 0.2)
969        ));
970        assert!(same(
971            t.resolve_color(2, &light),
972            light.accent.mix(Color::BLACK, 0.2)
973        ));
974        assert_eq!(t.resolve_color(3, &dark), dark.accent);
975        assert_eq!(t.recipe(2).as_deref(), Some("accent → raise 0.2"));
976        // The devtools' halves: the one in effect is the frame's, the
977        // other under the other base with the same accent.
978        let (l, d) = t.halves(1, &dark);
979        assert_eq!((l.to_hex() & 0xff, d.to_hex() & 0xff), (0x80, 0x80));
980        assert_eq!((l.r, d.r), (0.0, 1.0));
981    }
982
983    /// A derived token may derive from a derived one; `mix` reaches
984    /// another token; `readable` moves toward whichever of black and
985    /// white reads on its operand.
986    #[test]
987    fn sources_chain_and_readable_reaches() {
988        let t = Tokens::new()
989            .color("peach", PEACH)
990            .color("white", Color::WHITE)
991            .derive("lit", "peach", [ColorOp::Lift(0.3)])
992            .derive("lit2", "lit", [ColorOp::Lift(0.35)])
993            .derive("halfway", "peach", [ColorOp::Mix("white".into(), 0.5)])
994            .derive(
995                "on_white",
996                "peach",
997                [ColorOp::Readable("white".into(), 4.5)],
998            );
999        let theme = Theme::dark();
1000        let pressed = t.resolve_color(3, &theme);
1001        assert!(same(
1002            pressed,
1003            q(PEACH.mix(Color::WHITE, 0.3)).mix(Color::WHITE, 0.35)
1004        ));
1005        assert!(same(
1006            t.resolve_color(4, &theme),
1007            PEACH.mix(Color::WHITE, 0.5)
1008        ));
1009        let on_white = t.resolve_color(5, &theme);
1010        assert!(
1011            on_white.contrast(Color::WHITE) >= 4.5,
1012            "moved toward black until it read"
1013        );
1014        assert_eq!(t.recipe(5).as_deref(), Some("peach → readable white 4.5"));
1015    }
1016
1017    /// A source that is undeclared, a length, a later token, or the
1018    /// token itself drops the declaration and says which name failed.
1019    #[test]
1020    fn an_unresolved_source_drops_the_token_and_is_reported() {
1021        let t = Tokens::new()
1022            .color("peach", PEACH)
1023            .length("gap", 6.0)
1024            .derive("a", "peech", [])
1025            .derive("b", "gap", [])
1026            .derive("c", "later", [])
1027            .color("later", Color::WHITE)
1028            .derive("d", "peach", [ColorOp::Mix("nothing".into(), 0.5)])
1029            .derive("peach", "peach", [ColorOp::Lift(0.1)]);
1030        assert_eq!(t.colors().len(), 2, "peach and later");
1031        assert_eq!(t.id("a"), None);
1032        assert_eq!(
1033            t.colors()[0].1,
1034            ColorToken::same(PEACH),
1035            "peach is not its own lift"
1036        );
1037        let dropped: Vec<(&str, &str)> = t
1038            .unresolved()
1039            .iter()
1040            .map(|u| (u.token.as_str(), u.source.as_str()))
1041            .collect();
1042        assert_eq!(
1043            dropped,
1044            [
1045                ("a", "peech"),
1046                ("b", "gap"),
1047                ("c", "later"),
1048                ("d", "nothing"),
1049                ("peach", "peach")
1050            ]
1051        );
1052    }
1053
1054    /// A derived token re-declared as a value, and a value as derived,
1055    /// keep the index; a lookup resolves either by index and by name.
1056    #[test]
1057    fn a_derived_token_resolves_through_the_lookup() {
1058        let t = Tokens::new()
1059            .color("peach", PEACH)
1060            .color("lit", Color::BLACK)
1061            .derive("lit", "peach", [ColorOp::Lift(0.3)]);
1062        assert_eq!(t.id("lit"), Some(TokenRef::Color(1)));
1063        assert!(t.colors()[1].1.is_derived());
1064        let theme = Theme::dark();
1065        let metrics = Metrics::default();
1066        let look = TokenLookup {
1067            own: Some(&t),
1068            host: None,
1069            theme: &theme,
1070            metrics: &metrics,
1071            session: None,
1072        };
1073        let want = PEACH.mix(Color::WHITE, 0.3);
1074        assert!(same(look.color("lit").unwrap(), want));
1075        assert!(same(look.color_at(COLOR_ROLES as u32 + 1).unwrap(), want));
1076        assert_eq!(look.color_names(q(want)), vec!["lit"]);
1077    }
1078
1079    #[test]
1080    fn a_verb_parses_with_the_operands_it_takes() {
1081        assert_eq!(ColorOp::parse("lift", None, 0.3), Some(ColorOp::Lift(0.3)));
1082        assert_eq!(
1083            ColorOp::parse("mix", Some("ink"), 0.5),
1084            Some(ColorOp::Mix("ink".into(), 0.5))
1085        );
1086        assert_eq!(ColorOp::parse("lift", Some("ink"), 0.3), None);
1087        assert_eq!(ColorOp::parse("mix", None, 0.5), None);
1088        assert_eq!(ColorOp::parse("glow", None, 0.5), None);
1089        assert_eq!(ColorOp::takes_color("readable"), Some(true));
1090        assert_eq!(ColorOp::takes_color("glow"), None);
1091        assert_eq!(ColorOp::VERBS.iter().position(|v| *v == "alpha"), Some(3));
1092    }
1093}