Skip to main content

kui_core/
tokens.rs

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