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}