Skip to main content

lattice_theme/
lib.rs

1//! Renderer-neutral theme primitives.
2//!
3//! The `Color` / `Style` / `Modifiers` / `NamedColor` value types,
4//! the rich-vocabulary attribute types (`FontScale` / `Weight` /
5//! `FamilyId`), and the `parse_color` helper. Every type here is
6//! pure data with no renderer-specific dependency. Renderer crates
7//! (`lattice-ui-tui`, `lattice-ui-gpui`) ship adapters that convert
8//! these into their native style types (ratatui `Style` / `Color`,
9//! GPUI `Hsla` + per-run font shaping).
10//!
11//! Until T.1 (theme-system slice plan) these lived in
12//! `lattice-host/src/ui/theme.rs`; they moved here so cells, modes,
13//! the host, and both renderers share one definition. The host
14//! re-exports them from their old path so existing call sites are
15//! unchanged. The element registry + palette + resolution land here
16//! next (T.2/T.3).
17//!
18//! Design: `docs/dev/architecture/theme-system.md`.
19
20mod element;
21mod palette;
22mod registry;
23mod themes;
24
25pub use element::{
26    ColorRef, ElementId, ElementName, ElementOwner, ModifierSet, StyleSpec, ThemeElement,
27};
28pub use palette::{Palette, PaletteKey, default_palette, macchiato_palette};
29pub use registry::{
30    BuiltinElementIds, ElementInfo, InMemoryThemeRegistry, ResolvedTheme, ThemeRegistry,
31    ThemeRegistryHandle, register_builtins,
32};
33pub use themes::{NamedTheme, builtin_themes};
34
35/// A single style: optional foreground + optional background +
36/// modifiers (bold/italic/etc) + the rich-vocabulary attributes
37/// (`scale` / `family` / `weight`). `None` for fg/bg means "do not
38/// set this channel" (matches ratatui's empty-style semantics and
39/// GPUI's `Style::transparent_black` background semantics).
40///
41/// `Eq + Hash` is load-bearing: the host folds a content-hash of the
42/// `Theme` into [`lattice_cells::MatrixVersion::theme`] so a palette
43/// change rebuilds the cell matrix. Every field must therefore be
44/// `Hash` — which is why the rich-vocabulary attributes use
45/// fixed-point / enum / interned-id representations
46/// ([`FontScale`] is `u16` hundredths, not `f32`) rather than the
47/// `f32` an authoring `StyleSpec` carries (T.2 resolves the ratio to
48/// fixed-point here).
49#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
50pub struct Style {
51    pub fg: Option<Color>,
52    pub bg: Option<Color>,
53    pub modifiers: Modifiers,
54    // ---- rich vocabulary (theme-system §3.4) ----
55    /// Relative height multiplier (emacs `:height` float, quantized
56    /// to fixed-point). `None` ⇒ 1.0×. Honored by the GPUI peer's
57    /// per-run font shaping (T.10); a no-op on the fixed-grid TUI.
58    pub scale: Option<FontScale>,
59    /// Font family selector. `None` ⇒ the buffer's default family.
60    /// Honored by GPUI; a no-op on the TUI (single grid font).
61    pub family: Option<FamilyId>,
62    /// Font weight, finer than the `bold` modifier. `None` ⇒
63    /// inherit/default. Honored by GPUI; the TUI maps any
64    /// bold-or-heavier weight to its bold attribute.
65    pub weight: Option<Weight>,
66}
67
68impl Style {
69    /// Style with no fg/bg/modifiers -- the renderer's "use my
70    /// existing style." Equivalent to `ratatui::Style::default()`
71    /// or `ratatui::Style::new()`.
72    pub fn empty() -> Self {
73        Self::default()
74    }
75
76    pub fn fg(mut self, color: Color) -> Self {
77        self.fg = Some(color);
78        self
79    }
80
81    pub fn bg(mut self, color: Color) -> Self {
82        self.bg = Some(color);
83        self
84    }
85
86    pub fn bold(mut self) -> Self {
87        self.modifiers.bold = true;
88        self
89    }
90
91    pub fn italic(mut self) -> Self {
92        self.modifiers.italic = true;
93        self
94    }
95
96    pub fn underline(mut self) -> Self {
97        self.modifiers.underline = true;
98        self
99    }
100
101    pub fn dim(mut self) -> Self {
102        self.modifiers.dim = true;
103        self
104    }
105
106    pub fn reverse(mut self) -> Self {
107        self.modifiers.reverse = true;
108        self
109    }
110
111    /// Set the relative height multiplier (rich vocabulary).
112    pub fn scale(mut self, scale: FontScale) -> Self {
113        self.scale = Some(scale);
114        self
115    }
116
117    /// Set the font family (rich vocabulary).
118    pub fn family(mut self, family: FamilyId) -> Self {
119        self.family = Some(family);
120        self
121    }
122
123    /// Set the font weight (rich vocabulary).
124    pub fn weight(mut self, weight: Weight) -> Self {
125        self.weight = Some(weight);
126        self
127    }
128}
129
130/// Text-attribute modifiers. Bools rather than bitflags so a new
131/// modifier (strikethrough, blink, ...) is a struct-field add
132/// instead of a flag-byte expansion; the renderers' adapter code
133/// pattern-matches against the explicit field set rather than
134/// chasing flag bits.
135#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct Modifiers {
137    pub bold: bool,
138    pub italic: bool,
139    pub underline: bool,
140    pub dim: bool,
141    pub reverse: bool,
142}
143
144/// Relative font-height multiplier, stored as **hundredths**
145/// (`100` = 1.0×, `160` = 1.6×). Fixed-point rather than `f32` so
146/// [`Style`] stays `Eq + Hash` (the theme is content-hashed into the
147/// cell-matrix version). An authoring `StyleSpec` carries an `f32`
148/// ratio; resolution quantizes it here.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
150pub struct FontScale(pub u16);
151
152impl FontScale {
153    /// 1.0× — the no-op scale.
154    pub const ONE: FontScale = FontScale(100);
155
156    /// Quantize an `f32` ratio (e.g. `1.6`) to fixed-point
157    /// hundredths. Clamps negatives to 0.
158    pub fn from_ratio(ratio: f32) -> Self {
159        let h = (ratio * 100.0).round();
160        FontScale(if h < 0.0 { 0 } else { h as u16 })
161    }
162
163    /// The multiplier as an `f32` ratio (e.g. `1.6`). Used by the
164    /// GPUI peer when sizing a run.
165    pub fn as_ratio(self) -> f32 {
166        self.0 as f32 / 100.0
167    }
168}
169
170/// Font weight, finer-grained than the `bold` [`Modifiers`] flag.
171/// Maps onto the GPUI peer's font-weight axis; the TUI renders any
172/// weight at `SemiBold` or heavier as its bold attribute.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
174pub enum Weight {
175    Thin,
176    ExtraLight,
177    Light,
178    Normal,
179    Medium,
180    SemiBold,
181    Bold,
182    ExtraBold,
183    Black,
184}
185
186/// An interned font-family selector. The name→id interning + the
187/// id→family resolution live with the renderer-side font table
188/// (T.10); the id is renderer-neutral so a `Style` can name a family
189/// without the theme crate depending on a font stack.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
191pub struct FamilyId(pub u32);
192
193/// Renderer-neutral color. The variants cover every shape any
194/// terminal-or-GPU renderer ever needs: `Default` for "use the
195/// terminal/window's default", `Named` for the 16 ANSI palette
196/// names (TUI's 16-color fallback path), `Indexed` for the
197/// 256-color palette, `Rgb` for 24-bit truecolor.
198///
199/// TUI renderer maps `Rgb` to `Indexed`-closest-match when the
200/// terminal doesn't support truecolor. GPUI ignores `Named` /
201/// `Indexed` lookups in palette-aware mode and reads `Rgb`
202/// directly. The host owns the lossless form; each renderer
203/// owns its own lossy-mapping at adapter time.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
205pub enum Color {
206    /// Terminal / window default for this channel. Maps to
207    /// `ratatui::Color::Reset`.
208    Default,
209    /// One of the 16 named ANSI colors. The TUI's primary
210    /// palette path; GPUI maps these to its theme's named-color
211    /// table.
212    Named(NamedColor),
213    /// 256-color palette index (xterm 256-color extension).
214    Indexed(u8),
215    /// 24-bit truecolor.
216    Rgb(u8, u8, u8),
217}
218
219impl Color {
220    /// Convert to a 24-bit `0xRRGGBB` packed `u32` for GPU-side
221    /// renderers (which want raw truecolor, not the renderer-
222    /// neutral [`Color`] enum). [`Color::Default`] returns
223    /// `fallback` — the caller decides what "use the terminal /
224    /// window default channel" means in pixel-space.
225    ///
226    /// Named colors map to canonical ANSI RGB values that match
227    /// what xterm + most modern terminal emulators use. The
228    /// indexed (xterm 256) path computes the 6×6×6 cube + the
229    /// 24-step grayscale ramp standardly.
230    pub fn to_rgb_u32(self, fallback: u32) -> u32 {
231        use NamedColor as N;
232        match self {
233            Color::Default => fallback,
234            Color::Rgb(r, g, b) => ((r as u32) << 16) | ((g as u32) << 8) | (b as u32),
235            Color::Named(n) => match n {
236                N::Black => 0x000000,
237                N::Red => 0xcd0000,
238                N::Green => 0x00cd00,
239                N::Yellow => 0xcdcd00,
240                N::Blue => 0x0000ee,
241                N::Magenta => 0xcd00cd,
242                N::Cyan => 0x00cdcd,
243                N::Gray => 0xe5e5e5,
244                N::DarkGray => 0x7f7f7f,
245                N::LightRed => 0xff0000,
246                N::LightGreen => 0x00ff00,
247                N::LightYellow => 0xffff00,
248                N::LightBlue => 0x5c5cff,
249                N::LightMagenta => 0xff00ff,
250                N::LightCyan => 0x00ffff,
251                N::White => 0xffffff,
252            },
253            Color::Indexed(idx) => indexed_to_rgb_u32(idx),
254        }
255    }
256}
257
258/// Map an xterm 256-colour index to a packed `0xRRGGBB` value.
259/// - 0..=15: ANSI base colors (matches [`Color::Named`] mapping)
260/// - 16..=231: 6×6×6 cube; each channel steps through
261///   `[0, 95, 135, 175, 215, 255]`
262/// - 232..=255: 24-step grayscale ramp from `0x080808` to
263///   `0xeeeeee` in `+10` increments
264fn indexed_to_rgb_u32(idx: u8) -> u32 {
265    if idx < 16 {
266        let names = [
267            NamedColor::Black,
268            NamedColor::Red,
269            NamedColor::Green,
270            NamedColor::Yellow,
271            NamedColor::Blue,
272            NamedColor::Magenta,
273            NamedColor::Cyan,
274            NamedColor::Gray,
275            NamedColor::DarkGray,
276            NamedColor::LightRed,
277            NamedColor::LightGreen,
278            NamedColor::LightYellow,
279            NamedColor::LightBlue,
280            NamedColor::LightMagenta,
281            NamedColor::LightCyan,
282            NamedColor::White,
283        ];
284        Color::Named(names[idx as usize]).to_rgb_u32(0)
285    } else if idx < 232 {
286        const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
287        let n = idx - 16;
288        let r = STEPS[(n / 36) as usize];
289        let g = STEPS[((n / 6) % 6) as usize];
290        let b = STEPS[(n % 6) as usize];
291        ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
292    } else {
293        let level = 8 + 10 * (idx - 232) as u32;
294        (level << 16) | (level << 8) | level
295    }
296}
297
298/// The 16 named ANSI colors. Order matches ratatui's
299/// `Color::Black..White` enumeration so the adapter is a
300/// straightforward variant-by-variant match.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
302pub enum NamedColor {
303    Black,
304    Red,
305    Green,
306    Yellow,
307    Blue,
308    Magenta,
309    Cyan,
310    Gray,
311    DarkGray,
312    LightRed,
313    LightGreen,
314    LightYellow,
315    LightBlue,
316    LightMagenta,
317    LightCyan,
318    White,
319}
320
321/// Parse a user-typed color name into a [`Color`]. Accepts the 16
322/// ANSI names (lowercase + dark-prefixed variants), `default` /
323/// `reset` for terminal-default, and 6-digit hex (`#cba6f7` or
324/// `cba6f7`, case-insensitive) → [`Color::Rgb`]. T.9.c: hex unblocks
325/// a theme/`:set ui.*` author writing a one-off truecolor without a
326/// palette entry. A `#`-prefixed string that is NOT exactly 6 hex
327/// digits, or any other unknown word, returns the `unknown color`
328/// error rather than guessing.
329pub fn parse_color(s: &str) -> Result<Color, String> {
330    Ok(match s.to_ascii_lowercase().as_str() {
331        "default" | "reset" => Color::Default,
332        "black" => Color::Named(NamedColor::Black),
333        "red" => Color::Named(NamedColor::Red),
334        "green" => Color::Named(NamedColor::Green),
335        "yellow" => Color::Named(NamedColor::Yellow),
336        "blue" => Color::Named(NamedColor::Blue),
337        "magenta" => Color::Named(NamedColor::Magenta),
338        "cyan" => Color::Named(NamedColor::Cyan),
339        "gray" | "grey" | "white" => Color::Named(NamedColor::Gray),
340        "darkgray" | "darkgrey" => Color::Named(NamedColor::DarkGray),
341        "lightred" => Color::Named(NamedColor::LightRed),
342        "lightgreen" => Color::Named(NamedColor::LightGreen),
343        "lightyellow" => Color::Named(NamedColor::LightYellow),
344        "lightblue" => Color::Named(NamedColor::LightBlue),
345        "lightmagenta" => Color::Named(NamedColor::LightMagenta),
346        "lightcyan" => Color::Named(NamedColor::LightCyan),
347        other => return parse_hex_color(other).ok_or_else(|| format!("unknown color `{other}`")),
348    })
349}
350
351/// Parse a 6-digit hex color (`#cba6f7` or `cba6f7`). The leading `#`
352/// is optional; the remaining text must be exactly 6 ASCII hex digits.
353/// `None` for any other shape — the caller maps that to the
354/// `unknown color` error so a malformed hex never silently degrades.
355fn parse_hex_color(s: &str) -> Option<Color> {
356    let hex = s.strip_prefix('#').unwrap_or(s);
357    if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
358        return None;
359    }
360    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
361    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
362    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
363    Some(Color::Rgb(r, g, b))
364}
365
366#[cfg(test)]
367mod tests {
368    #![allow(clippy::unwrap_used)]
369    use super::*;
370
371    #[test]
372    fn parse_color_named() {
373        assert_eq!(parse_color("red").unwrap(), Color::Named(NamedColor::Red));
374        assert_eq!(
375            parse_color("DarkGray").unwrap(),
376            Color::Named(NamedColor::DarkGray)
377        );
378        assert_eq!(parse_color("default").unwrap(), Color::Default);
379    }
380
381    #[test]
382    fn rgb_to_u32_packs_24_bit() {
383        // 0xRRGGBB ordering. 0xff0000 = red, 0x00ff00 = green,
384        // 0x0000ff = blue.
385        assert_eq!(Color::Rgb(0xff, 0x00, 0x00).to_rgb_u32(0), 0xff0000);
386        assert_eq!(Color::Rgb(0x00, 0xff, 0x00).to_rgb_u32(0), 0x00ff00);
387        assert_eq!(Color::Rgb(0x00, 0x00, 0xff).to_rgb_u32(0), 0x0000ff);
388        assert_eq!(Color::Rgb(0x12, 0x34, 0x56).to_rgb_u32(0), 0x123456);
389    }
390
391    #[test]
392    fn default_color_returns_fallback() {
393        // `Color::Default` means "use the terminal / window
394        // default channel" — there's no truecolour answer, so we
395        // hand back the caller's chosen fallback.
396        assert_eq!(Color::Default.to_rgb_u32(0xdeadbe), 0xdeadbe);
397        assert_eq!(Color::Default.to_rgb_u32(0), 0);
398    }
399
400    #[test]
401    fn named_red_canonical_ansi_value() {
402        // The 16 named ANSI colors map to standard xterm RGB.
403        // Red == 0xcd0000 in the canonical xterm palette.
404        assert_eq!(Color::Named(NamedColor::Red).to_rgb_u32(0), 0xcd0000);
405        assert_eq!(Color::Named(NamedColor::White).to_rgb_u32(0), 0xffffff);
406        assert_eq!(Color::Named(NamedColor::Black).to_rgb_u32(0), 0x000000);
407    }
408
409    #[test]
410    fn indexed_below_16_matches_named() {
411        // Indexed 0..=15 must agree with their Named equivalents
412        // (callers should not see a discontinuity between the
413        // 16-color named palette and the indexed-256 path).
414        assert_eq!(
415            Color::Indexed(1).to_rgb_u32(0),
416            Color::Named(NamedColor::Red).to_rgb_u32(0)
417        );
418        assert_eq!(
419            Color::Indexed(15).to_rgb_u32(0),
420            Color::Named(NamedColor::White).to_rgb_u32(0)
421        );
422    }
423
424    #[test]
425    fn indexed_cube_corner_pure_black() {
426        // Index 16 is the start of the 6×6×6 colour cube — pure
427        // (0,0,0) black.
428        assert_eq!(Color::Indexed(16).to_rgb_u32(0), 0x000000);
429    }
430
431    #[test]
432    fn indexed_cube_corner_pure_white() {
433        // Index 231 is the end of the cube — (255,255,255) white.
434        assert_eq!(Color::Indexed(231).to_rgb_u32(0), 0xffffff);
435    }
436
437    #[test]
438    fn indexed_grayscale_ramp() {
439        // 232..=255 is a 24-step grey ramp from 0x080808 to
440        // 0xeeeeee in +10 increments.
441        assert_eq!(Color::Indexed(232).to_rgb_u32(0), 0x080808);
442        assert_eq!(Color::Indexed(255).to_rgb_u32(0), 0xeeeeee);
443    }
444
445    #[test]
446    fn parse_color_unknown_errors() {
447        assert!(parse_color("rainbow").is_err());
448    }
449
450    #[test]
451    fn parse_color_hex_with_and_without_hash() {
452        // T.9.c: `#cba6f7` and `cba6f7` both parse to the same RGB.
453        assert_eq!(
454            parse_color("#cba6f7").unwrap(),
455            Color::Rgb(0xcb, 0xa6, 0xf7)
456        );
457        assert_eq!(parse_color("cba6f7").unwrap(), Color::Rgb(0xcb, 0xa6, 0xf7));
458        // Case-insensitive (parse lowercases first).
459        assert_eq!(
460            parse_color("#CBA6F7").unwrap(),
461            Color::Rgb(0xcb, 0xa6, 0xf7)
462        );
463    }
464
465    #[test]
466    fn parse_color_invalid_hex_errors() {
467        // Non-hex digits, wrong length, and a bare `#` all error
468        // rather than silently degrading.
469        assert!(parse_color("#xyz").is_err());
470        assert!(parse_color("#cba6f").is_err()); // 5 digits
471        assert!(parse_color("#cba6f7a").is_err()); // 7 digits
472        assert!(parse_color("#").is_err());
473        assert!(parse_color("zzzzzz").is_err()); // 6 non-hex chars
474    }
475
476    // ---- T.1: rich-vocabulary attribute types ----
477
478    #[test]
479    fn font_scale_roundtrips_through_fixed_point() {
480        assert_eq!(FontScale::from_ratio(1.6), FontScale(160));
481        assert_eq!(FontScale::ONE.as_ratio(), 1.0);
482        assert_eq!(FontScale::from_ratio(1.6).as_ratio(), 1.6);
483    }
484
485    #[test]
486    fn style_stays_hashable_with_rich_vocab() {
487        // Load-bearing: Style is folded into the cell-matrix version
488        // hash. Adding the rich-vocab fields must not break Hash/Eq.
489        use std::collections::hash_map::DefaultHasher;
490        use std::hash::{Hash, Hasher};
491        let s = Style::empty()
492            .fg(Color::Rgb(1, 2, 3))
493            .bold()
494            .scale(FontScale::from_ratio(1.6))
495            .weight(Weight::SemiBold)
496            .family(FamilyId(7));
497        let mut h = DefaultHasher::new();
498        s.hash(&mut h);
499        let _ = h.finish();
500        assert_eq!(s, s);
501    }
502
503    #[test]
504    fn empty_style_has_no_rich_attrs() {
505        let s = Style::empty();
506        assert_eq!(s.scale, None);
507        assert_eq!(s.family, None);
508        assert_eq!(s.weight, None);
509    }
510}