Skip to main content

linesmith_core/theme/
mod.rs

1//! Role-based theme system. Segments declare semantic roles; themes map
2//! roles to colors; the render path emits ANSI SGR around each segment
3//! based on the terminal's detected capability. Full contract in
4//! `docs/specs/theming.md`.
5//!
6//! Built-ins compiled in: `default` (Palette16, terminal-default
7//! anchor), `minimal` (decoration-only), the four Catppuccin flavors,
8//! plus Dracula, Nord, Gruvbox, Tokyo Night, and Rose Pine. User
9//! themes load from `~/.config/linesmith/themes/*.toml` via
10//! [`user::ThemeRegistry`].
11
12use std::fmt::Write;
13
14mod catppuccin;
15mod default;
16mod dracula;
17mod gruvbox;
18mod minimal;
19mod nord;
20mod rose_pine;
21pub mod style_syntax;
22mod tokyo_night;
23pub mod user;
24
25pub use style_syntax::{parse_style, StyleParseError};
26pub use user::{RegisteredTheme, ThemeRegistry, ThemeSource};
27
28/// Semantic color slot a segment targets. Themes map every role to a
29/// concrete color; segments never reference hex values directly.
30/// Variants are ordered to match the 16-slot role array themes store.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Role {
33    // Base roles — always present in every theme.
34    Foreground = 0,
35    Background = 1,
36    Muted = 2,
37    Primary = 3,
38    Accent = 4,
39    Success = 5,
40    Warning = 6,
41    Error = 7,
42    Info = 8,
43    // Extended roles — themes may omit them; `Theme::color` falls back
44    // to the base role named in the spec's extended-role table.
45    SuccessDim = 9,
46    WarningDim = 10,
47    ErrorDim = 11,
48    PrimaryDim = 12,
49    AccentDim = 13,
50    Surface = 14,
51    Border = 15,
52}
53
54// Compile-time guard: the last variant's discriminant must equal
55// `COUNT - 1` so theme color arrays stay in lockstep with the enum.
56// If a new variant is added without bumping `COUNT`, this trips.
57const _: () = assert!(Role::Border as usize == Role::COUNT - 1);
58
59impl Role {
60    /// Role count; the discriminants cover `0..ROLE_COUNT` densely so
61    /// themes store colors in a fixed-size array.
62    pub const COUNT: usize = 16;
63
64    /// The base role this role falls back to when a theme leaves the
65    /// extended slot unset. Base roles fall back to themselves.
66    #[must_use]
67    pub fn fallback(self) -> Role {
68        match self {
69            Self::SuccessDim => Self::Success,
70            Self::WarningDim => Self::Warning,
71            Self::ErrorDim => Self::Error,
72            Self::PrimaryDim => Self::Primary,
73            Self::AccentDim => Self::Accent,
74            Self::Surface => Self::Background,
75            Self::Border => Self::Muted,
76            other => other,
77        }
78    }
79}
80
81/// Standard 16-color ANSI palette. Stored as the foreground base (30..=37
82/// for normal, 90..=97 for bright); we add offsets at emit time.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum AnsiColor {
85    Black = 0,
86    Red = 1,
87    Green = 2,
88    Yellow = 3,
89    Blue = 4,
90    Magenta = 5,
91    Cyan = 6,
92    White = 7,
93    BrightBlack = 8,
94    BrightRed = 9,
95    BrightGreen = 10,
96    BrightYellow = 11,
97    BrightBlue = 12,
98    BrightMagenta = 13,
99    BrightCyan = 14,
100    BrightWhite = 15,
101}
102
103/// Concrete color value a theme resolves a role to.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum Color {
107    TrueColor {
108        r: u8,
109        g: u8,
110        b: u8,
111    },
112    Palette256(u8),
113    Palette16(AnsiColor),
114    /// Explicit "no color" — the role renders plain, even if the
115    /// terminal supports color. Used by the `minimal` theme to force
116    /// decoration-only styling everywhere.
117    NoColor,
118}
119
120impl Color {
121    /// Downgrade this color to the richest form the terminal supports.
122    /// `NoColor` is preserved; `Palette16` is already the lowest tier
123    /// and passes through.
124    #[must_use]
125    pub fn downgrade(self, cap: Capability) -> Color {
126        match (self, cap) {
127            (_, Capability::None) => Color::NoColor,
128            (Color::NoColor, _) => Color::NoColor,
129            // Truecolor input.
130            (Color::TrueColor { r, g, b }, Capability::Palette256) => {
131                Color::Palette256(rgb_to_256(r, g, b))
132            }
133            (Color::TrueColor { r, g, b }, Capability::Palette16) => {
134                Color::Palette16(rgb_to_ansi16(r, g, b))
135            }
136            // Palette256 input.
137            (Color::Palette256(n), Capability::Palette16) => {
138                Color::Palette16(palette256_to_ansi16(n))
139            }
140            // Same or richer capability: pass through.
141            (c, _) => c,
142        }
143    }
144}
145
146/// Text decorations a segment may layer over its theme color. `role`
147/// is the role the segment wants; `fg` is an explicit color override
148/// that wins over `role`. `hyperlink` carries an OSC 8 URL that
149/// [`crate::layout::runs_to_ansi`] wraps around the run's text when
150/// the terminal advertises hyperlink support. `bg` lives in the spec
151/// but isn't wired through.
152///
153/// Not `Copy` — `hyperlink` carries an owned `String`.
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
155#[non_exhaustive]
156pub struct Style {
157    pub role: Option<Role>,
158    pub fg: Option<Color>,
159    pub bold: bool,
160    pub italic: bool,
161    pub underline: bool,
162    pub dim: bool,
163    pub hyperlink: Option<String>,
164}
165
166impl Style {
167    /// Shorthand for a style that only carries a role.
168    #[must_use]
169    pub const fn role(role: Role) -> Self {
170        Self {
171            role: Some(role),
172            fg: None,
173            bold: false,
174            italic: false,
175            underline: false,
176            dim: false,
177            hyperlink: None,
178        }
179    }
180
181    /// Chainable: attach an OSC 8 URL to this style. The link emits
182    /// only when the terminal advertises hyperlink support; capable
183    /// terminals render the run as a clickable link to `url`. An
184    /// empty `url` folds to `None`, matching the plugin-output path
185    /// — emitting `\x1b]8;;\x1b\\` would wrap text in a link to
186    /// nothing.
187    #[must_use]
188    pub fn with_hyperlink(mut self, url: impl Into<String>) -> Self {
189        let url = url.into();
190        self.hyperlink = if url.is_empty() { None } else { Some(url) };
191        self
192    }
193}
194
195/// Text + style emitted by the layout engine. The flat run sequence is
196/// what [`crate::layout::render_to_runs`] returns: one run per segment
197/// plus one run per non-empty inter-segment separator. Consumers map
198/// runs to their target surface — ANSI SGR for terminal stdout,
199/// ratatui `Span` for the TUI preview pane — without re-parsing
200/// escape sequences.
201///
202/// Fields are `pub(crate)` so external callers go through
203/// [`StyledRun::new`] and the accessors; this mirrors
204/// [`crate::segments::RenderedSegment`] and keeps room to enforce
205/// text-content invariants later without a SemVer break.
206#[derive(Debug, Clone, PartialEq, Eq)]
207#[non_exhaustive]
208pub struct StyledRun {
209    pub(crate) text: String,
210    pub(crate) style: Style,
211}
212
213impl StyledRun {
214    /// Build a run from `text` and `style`. Out-of-tree consumers of
215    /// [`crate::layout::runs_to_ansi`] go through this so future
216    /// invariants (escape-sequence rejection, width caching) can
217    /// land without breaking them.
218    ///
219    /// Today the constructor performs no validation; sanitizing
220    /// untrusted text is the segment's responsibility (see
221    /// [`crate::segments::RenderedSegment::new`]).
222    #[must_use]
223    pub fn new(text: impl Into<String>, style: Style) -> Self {
224        Self {
225            text: text.into(),
226            style,
227        }
228    }
229
230    /// The run's text. Map directly to a target surface (ratatui
231    /// `Span`, ANSI SGR pair, etc.).
232    #[must_use]
233    pub fn text(&self) -> &str {
234        &self.text
235    }
236
237    /// The run's style. Returned by reference; `Style` carries an
238    /// owned `hyperlink` URL so it is no longer `Copy`.
239    #[must_use]
240    pub fn style(&self) -> &Style {
241        &self.style
242    }
243}
244
245/// Terminal color capability detected from the environment. Truecolor
246/// is preferred when available; `None` strips all color and keeps only
247/// decorations.
248///
249/// Variants are ordered richest→poorest so `cap >= Capability::Palette256`
250/// reads "at least 256 colors." Downgrade logic can branch on ordering
251/// rather than re-matching the ladder each time.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
253pub enum Capability {
254    None,
255    Palette16,
256    Palette256,
257    TrueColor,
258}
259
260impl Capability {
261    /// Detect honoring `NO_COLOR` (per no-color.org). Kept for callers
262    /// that want a single-shot detect without threading the env through
263    /// a precedence chain.
264    #[must_use]
265    pub fn detect() -> Self {
266        if std::env::var_os("NO_COLOR").is_some() {
267            return Self::None;
268        }
269        Self::from_terminal()
270    }
271
272    /// Raw terminal capability from `supports-color`, ignoring
273    /// `NO_COLOR`. The color-policy precedence chain uses this so a
274    /// `--force-color` flag can outrank the env var. Returns `None`
275    /// when stdout isn't a TTY so `color = "auto"` stays plain-text
276    /// under pipes and redirects.
277    #[must_use]
278    pub fn from_terminal() -> Self {
279        match supports_color::on(supports_color::Stream::Stdout) {
280            Some(c) if c.has_16m => Self::TrueColor,
281            Some(c) if c.has_256 => Self::Palette256,
282            Some(_) => Self::Palette16,
283            None => Self::None,
284        }
285    }
286
287    /// Env-only capability probe from the `COLORTERM` and `TERM`
288    /// values carried on `CliEnv`. Used by the force-color path to
289    /// recover a tier when `supports-color` gives up (e.g. piped
290    /// stdout under Claude Code). Never reads the live process env;
291    /// callers must snapshot into `CliEnv::from_process` first so
292    /// tests and embedders stay hermetic.
293    ///
294    /// Returns `None` for `TERM=dumb`, empty `TERM`, or no `TERM` at
295    /// all.
296    #[must_use]
297    pub fn from_env_vars(colorterm: Option<&str>, term: Option<&str>) -> Self {
298        if let Some(c) = colorterm {
299            if c.eq_ignore_ascii_case("truecolor") || c.eq_ignore_ascii_case("24bit") {
300                return Self::TrueColor;
301            }
302        }
303        match term.map(str::trim) {
304            None | Some("") => Self::None,
305            Some(t) if t.eq_ignore_ascii_case("dumb") => Self::None,
306            Some(t) if t.contains("256color") => Self::Palette256,
307            _ => Self::Palette16,
308        }
309    }
310
311    /// Combine a TTY probe (from `supports-color`) and an env probe
312    /// (from `COLORTERM` / `TERM`) into a capability under force-color
313    /// intent. Picks the richer of the two, flooring at `Palette16`
314    /// so an explicit `color = "always"` / `--force-color` / `FORCE_COLOR`
315    /// never collapses to no-color. The `Palette16` floor overrides
316    /// `TERM=dumb` — the user explicitly asked for color, so a dumb
317    /// terminal signal loses.
318    #[must_use]
319    pub fn force_from(tty: Self, env: Self) -> Self {
320        tty.max(env).max(Self::Palette16)
321    }
322}
323
324/// Named color palette keyed by [`Role`]. `colors[role as usize]` is
325/// either `Some(color)` when the theme defines it or `None` so
326/// [`Theme::color`] falls back via [`Role::fallback`]. Fields stay
327/// crate-private so callers can't mint themes with arbitrary names or
328/// bypass the fallback contract; user-authored themes will go through
329/// a validated constructor when TOML loading lands.
330#[derive(Debug, Clone)]
331pub struct Theme {
332    name: &'static str,
333    colors: [Option<Color>; Role::COUNT],
334}
335
336impl Theme {
337    /// Name this theme is registered under. Callers use this to surface
338    /// a `--list-themes`-style view or to echo the active theme in
339    /// diagnostics.
340    #[must_use]
341    pub fn name(&self) -> &'static str {
342        self.name
343    }
344
345    /// Crate-internal constructor for themes loaded from user files.
346    /// Keeps the private-fields contract intact (so external callers
347    /// can't mint impostor themes) while letting the `user` loader
348    /// produce themes from parsed TOML.
349    #[must_use]
350    pub(super) fn from_user_parts(
351        name: &'static str,
352        colors: [Option<Color>; Role::COUNT],
353    ) -> Self {
354        Self { name, colors }
355    }
356
357    /// Resolve a role to its color, following the extended-role
358    /// fallback chain. Returns `Color::NoColor` when nothing in the
359    /// chain maps — which in practice only happens for the `minimal`
360    /// theme.
361    #[must_use]
362    pub fn color(&self, role: Role) -> Color {
363        let mut current = role;
364        loop {
365            if let Some(c) = self.colors[current as usize] {
366                return c;
367            }
368            let next = current.fallback();
369            if next == current {
370                return Color::NoColor;
371            }
372            current = next;
373        }
374    }
375}
376
377/// Built-in themes. `default` fully populates base roles so
378/// `Theme::color`'s fallback loop never returns `NoColor` for it;
379/// `minimal` leaves every role unset so everything falls through to
380/// `NoColor`, forcing decoration-only output. The rest are popular
381/// curated palettes shipped per the v0.1 vision (#3 — preset
382/// onboarding).
383const BUILTIN_THEMES: &[Theme] = &[
384    default::DEFAULT,
385    minimal::MINIMAL,
386    catppuccin::LATTE,
387    catppuccin::FRAPPE,
388    catppuccin::MACCHIATO,
389    catppuccin::MOCHA,
390    dracula::DRACULA,
391    nord::NORD,
392    gruvbox::GRUVBOX,
393    tokyo_night::TOKYO_NIGHT,
394    rose_pine::ROSE_PINE,
395];
396
397/// Look up a built-in theme by name. Unknown names return `None` so
398/// callers can warn and fall back to `default`.
399#[must_use]
400pub fn built_in(name: &str) -> Option<&'static Theme> {
401    BUILTIN_THEMES.iter().find(|t| t.name == name)
402}
403
404/// The default theme when config names none (or names an unknown one).
405/// Guaranteed to exist.
406#[must_use]
407pub fn default_theme() -> &'static Theme {
408    built_in("default").expect("`default` theme is always compiled in")
409}
410
411/// Names of all compiled-in themes, in registration order.
412pub fn builtin_names() -> impl Iterator<Item = &'static str> {
413    BUILTIN_THEMES.iter().map(|t| t.name)
414}
415
416/// Emit the ANSI SGR prefix for `style` under `cap`. Returns an empty
417/// string when the style is plain or the capability is `None` with no
418/// decorations.
419#[must_use]
420pub fn sgr_open(style: &Style, theme: &Theme, cap: Capability) -> String {
421    let mut params = Vec::<u16>::with_capacity(4);
422    if style.bold {
423        params.push(1);
424    }
425    if style.dim {
426        params.push(2);
427    }
428    if style.italic {
429        params.push(3);
430    }
431    if style.underline {
432        params.push(4);
433    }
434    let fg = resolve_fg(style, theme, cap);
435    emit_fg_params(fg, &mut params);
436    if params.is_empty() {
437        return String::new();
438    }
439    let mut out = String::from("\x1b[");
440    for (i, p) in params.iter().enumerate() {
441        if i > 0 {
442            out.push(';');
443        }
444        let _ = write!(out, "{p}");
445    }
446    out.push('m');
447    out
448}
449
450/// SGR reset. Emitted after every styled span so color and decorations
451/// don't leak across segment boundaries.
452#[must_use]
453pub const fn sgr_reset() -> &'static str {
454    "\x1b[0m"
455}
456
457/// Resolve the effective foreground color for a style: explicit `fg`
458/// wins over `role`; `role` is looked up in the theme and downgraded
459/// to the terminal's capability; absence → `NoColor`.
460fn resolve_fg(style: &Style, theme: &Theme, cap: Capability) -> Color {
461    let raw = style
462        .fg
463        .or_else(|| style.role.map(|r| theme.color(r)))
464        .unwrap_or(Color::NoColor);
465    raw.downgrade(cap)
466}
467
468/// Push the SGR params (38;2;r;g;b / 38;5;n / 30–37 / 90–97) for a
469/// foreground color. `NoColor` pushes nothing.
470fn emit_fg_params(color: Color, out: &mut Vec<u16>) {
471    match color {
472        Color::NoColor => {}
473        Color::Palette16(ansi) => {
474            let code = ansi_to_sgr_fg(ansi);
475            out.push(code);
476        }
477        Color::Palette256(n) => {
478            out.extend_from_slice(&[38, 5, u16::from(n)]);
479        }
480        Color::TrueColor { r, g, b } => {
481            out.extend_from_slice(&[38, 2, u16::from(r), u16::from(g), u16::from(b)]);
482        }
483    }
484}
485
486fn ansi_to_sgr_fg(c: AnsiColor) -> u16 {
487    let n = c as u8;
488    if n < 8 {
489        30 + u16::from(n)
490    } else {
491        // 90..=97 for bright variants; n is 8..=15.
492        90 + u16::from(n - 8)
493    }
494}
495
496// --- Downgrade algorithms ---
497
498/// Map an RGB triple to the nearest xterm-256 index. Uses the standard
499/// 6×6×6 color cube (16..=231) plus the 24-step grayscale ramp
500/// (232..=255). Picks grayscale when r≈g≈b to avoid muddy cube greys.
501fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
502    if r == g && g == b {
503        if r < 8 {
504            return 16;
505        }
506        // `238` is the last channel value whose mapped ramp index
507        // stays within `232..=255`: `232 + (238-8)/10 = 255`. Anything
508        // higher would overflow the ramp, so map it to palette-white.
509        if r > 238 {
510            return 231;
511        }
512        return 232 + (r - 8) / 10;
513    }
514    let r6 = scale_to_cube(r);
515    let g6 = scale_to_cube(g);
516    let b6 = scale_to_cube(b);
517    16 + 36 * r6 + 6 * g6 + b6
518}
519
520fn scale_to_cube(channel: u8) -> u8 {
521    // xterm cube levels: 0, 95, 135, 175, 215, 255.
522    const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
523    let mut best = 0u8;
524    let mut best_dist = u16::MAX;
525    for (i, &level) in LEVELS.iter().enumerate() {
526        let d = u16::from(channel.abs_diff(level));
527        if d < best_dist {
528            best_dist = d;
529            best = u8::try_from(i).expect("cube index fits in u8");
530        }
531    }
532    best
533}
534
535/// Map an RGB triple to the nearest 16-color ANSI slot via luminance
536/// plus primary-channel heuristic. This is coarse on purpose: themes
537/// that care about 16-color output are expected to declare Palette16
538/// values directly; this path exists for truecolor-only themes
539/// running under a legacy terminal.
540fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> AnsiColor {
541    let max = r.max(g).max(b);
542    let min = r.min(g).min(b);
543    let bright = max >= 128;
544    // Achromatic: pick from black / grey / white by luminance.
545    if max - min < 32 {
546        return match max {
547            0..=42 => AnsiColor::Black,
548            43..=170 => {
549                if bright {
550                    AnsiColor::BrightBlack
551                } else {
552                    AnsiColor::White
553                }
554            }
555            _ => AnsiColor::BrightWhite,
556        };
557    }
558    // Dominant channel picks the hue.
559    let dominant_r = r >= g && r >= b;
560    let dominant_g = g >= r && g >= b;
561    let dominant_b = b >= r && b >= g;
562    let r_hi = r >= 128;
563    let g_hi = g >= 128;
564    let b_hi = b >= 128;
565    match (dominant_r, dominant_g, dominant_b, r_hi, g_hi, b_hi) {
566        (true, _, _, _, true, _) => {
567            if bright {
568                AnsiColor::BrightYellow
569            } else {
570                AnsiColor::Yellow
571            }
572        }
573        (true, _, _, _, _, true) => {
574            if bright {
575                AnsiColor::BrightMagenta
576            } else {
577                AnsiColor::Magenta
578            }
579        }
580        (_, true, _, _, _, true) => {
581            if bright {
582                AnsiColor::BrightCyan
583            } else {
584                AnsiColor::Cyan
585            }
586        }
587        (_, true, _, true, _, _) => {
588            if bright {
589                AnsiColor::BrightYellow
590            } else {
591                AnsiColor::Yellow
592            }
593        }
594        (true, _, _, _, _, _) => {
595            if bright {
596                AnsiColor::BrightRed
597            } else {
598                AnsiColor::Red
599            }
600        }
601        (_, true, _, _, _, _) => {
602            if bright {
603                AnsiColor::BrightGreen
604            } else {
605                AnsiColor::Green
606            }
607        }
608        (_, _, true, _, _, _) => {
609            if bright {
610                AnsiColor::BrightBlue
611            } else {
612                AnsiColor::Blue
613            }
614        }
615        _ => AnsiColor::White,
616    }
617}
618
619/// Route a Palette256 entry down to the nearest 16-color slot. For
620/// indices 0..=15 the mapping is identity; for the cube we decompose
621/// back to approximate RGB and hand off to [`rgb_to_ansi16`].
622fn palette256_to_ansi16(n: u8) -> AnsiColor {
623    if n < 16 {
624        return match n {
625            0 => AnsiColor::Black,
626            1 => AnsiColor::Red,
627            2 => AnsiColor::Green,
628            3 => AnsiColor::Yellow,
629            4 => AnsiColor::Blue,
630            5 => AnsiColor::Magenta,
631            6 => AnsiColor::Cyan,
632            7 => AnsiColor::White,
633            8 => AnsiColor::BrightBlack,
634            9 => AnsiColor::BrightRed,
635            10 => AnsiColor::BrightGreen,
636            11 => AnsiColor::BrightYellow,
637            12 => AnsiColor::BrightBlue,
638            13 => AnsiColor::BrightMagenta,
639            14 => AnsiColor::BrightCyan,
640            _ => AnsiColor::BrightWhite,
641        };
642    }
643    if n >= 232 {
644        // Grayscale ramp: map to bright/dim black or white by index.
645        let step = n - 232;
646        return match step {
647            0..=7 => AnsiColor::Black,
648            8..=15 => AnsiColor::BrightBlack,
649            16..=19 => AnsiColor::White,
650            _ => AnsiColor::BrightWhite,
651        };
652    }
653    // Color cube: decompose (n-16) into r6/g6/b6, expand to approx RGB.
654    const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
655    let c = n - 16;
656    let r6 = usize::from(c / 36);
657    let g6 = usize::from((c / 6) % 6);
658    let b6 = usize::from(c % 6);
659    rgb_to_ansi16(LEVELS[r6], LEVELS[g6], LEVELS[b6])
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    // --- Role ---
667
668    #[test]
669    fn role_count_matches_enum_discriminant_range() {
670        assert_eq!(Role::Foreground as usize, 0);
671        assert_eq!(Role::Border as usize, Role::COUNT - 1);
672    }
673
674    #[test]
675    fn extended_role_fallbacks_match_spec_table() {
676        assert_eq!(Role::SuccessDim.fallback(), Role::Success);
677        assert_eq!(Role::WarningDim.fallback(), Role::Warning);
678        assert_eq!(Role::ErrorDim.fallback(), Role::Error);
679        assert_eq!(Role::PrimaryDim.fallback(), Role::Primary);
680        assert_eq!(Role::AccentDim.fallback(), Role::Accent);
681        assert_eq!(Role::Surface.fallback(), Role::Background);
682        assert_eq!(Role::Border.fallback(), Role::Muted);
683    }
684
685    #[test]
686    fn base_roles_fall_back_to_themselves() {
687        for role in [
688            Role::Foreground,
689            Role::Background,
690            Role::Muted,
691            Role::Primary,
692            Role::Accent,
693            Role::Success,
694            Role::Warning,
695            Role::Error,
696            Role::Info,
697        ] {
698            assert_eq!(role.fallback(), role);
699        }
700    }
701
702    // --- Theme::color ---
703
704    #[test]
705    fn default_theme_maps_every_base_role() {
706        let t = built_in("default").expect("default exists");
707        for role in [
708            Role::Foreground,
709            Role::Muted,
710            Role::Primary,
711            Role::Accent,
712            Role::Success,
713            Role::Warning,
714            Role::Error,
715            Role::Info,
716        ] {
717            let c = t.color(role);
718            assert!(
719                !matches!(c, Color::NoColor),
720                "default theme left {role:?} as NoColor",
721            );
722        }
723    }
724
725    #[test]
726    fn default_theme_extended_roles_fall_back_to_base() {
727        let t = built_in("default").expect("default exists");
728        assert_eq!(t.color(Role::SuccessDim), t.color(Role::Success));
729        assert_eq!(t.color(Role::PrimaryDim), t.color(Role::Primary));
730        assert_eq!(t.color(Role::Border), t.color(Role::Muted));
731    }
732
733    #[test]
734    fn minimal_theme_returns_no_color_for_every_role() {
735        let t = built_in("minimal").expect("minimal exists");
736        for role in [
737            Role::Foreground,
738            Role::Primary,
739            Role::Warning,
740            Role::SuccessDim,
741        ] {
742            assert_eq!(t.color(role), Color::NoColor);
743        }
744    }
745
746    #[test]
747    fn unknown_theme_name_returns_none() {
748        assert!(built_in("nope").is_none());
749    }
750
751    #[test]
752    fn default_theme_always_available() {
753        assert_eq!(default_theme().name, "default");
754    }
755
756    #[test]
757    fn builtin_names_lists_default_and_minimal() {
758        let names: Vec<&str> = builtin_names().collect();
759        assert!(names.contains(&"default"));
760        assert!(names.contains(&"minimal"));
761    }
762
763    #[test]
764    fn builtin_names_lists_all_curated_presets() {
765        // Pin the v0.1 preset pack: a regression that drops one of
766        // these from BUILTIN_THEMES would hide a user-visible theme
767        // from `linesmith themes list` and the future TUI picker.
768        // The count check ratchets — adding a new theme must update
769        // both the membership list and the count, forcing deliberate
770        // review.
771        let names: Vec<&str> = builtin_names().collect();
772        for theme in ["dracula", "nord", "gruvbox", "tokyo-night", "rose-pine"] {
773            assert!(names.contains(&theme), "missing {theme} in builtin_names");
774        }
775        assert_eq!(
776            builtin_names().count(),
777            11,
778            "BUILTIN_THEMES count drift: default + minimal + 4 catppuccin + 5 curated = 11"
779        );
780    }
781
782    #[test]
783    fn every_curated_preset_maps_every_base_role() {
784        // Contract: no curated preset leaves a base role unmapped. A
785        // typo dropping `c[Role::Warning as usize] = ...` from any
786        // theme module would silently leave `NoColor` in production
787        // for that role; this test catches it. Mirrors
788        // `catppuccin::tests::every_flavor_maps_every_base_role`.
789        for name in ["dracula", "nord", "gruvbox", "tokyo-night", "rose-pine"] {
790            let t = built_in(name).expect(name);
791            for role in [
792                Role::Foreground,
793                Role::Background,
794                Role::Muted,
795                Role::Primary,
796                Role::Accent,
797                Role::Success,
798                Role::Warning,
799                Role::Error,
800                Role::Info,
801            ] {
802                assert!(
803                    !matches!(t.color(role), Color::NoColor),
804                    "{name} left {role:?} as NoColor"
805                );
806            }
807        }
808    }
809
810    // --- Downgrade ---
811
812    #[test]
813    fn downgrade_strips_color_under_no_capability() {
814        assert_eq!(
815            Color::TrueColor { r: 255, g: 0, b: 0 }.downgrade(Capability::None),
816            Color::NoColor
817        );
818        assert_eq!(
819            Color::Palette16(AnsiColor::Red).downgrade(Capability::None),
820            Color::NoColor
821        );
822    }
823
824    #[test]
825    fn downgrade_preserves_matching_or_richer_capability() {
826        let c = Color::Palette16(AnsiColor::Green);
827        assert_eq!(c.downgrade(Capability::Palette16), c);
828        assert_eq!(c.downgrade(Capability::Palette256), c);
829        assert_eq!(c.downgrade(Capability::TrueColor), c);
830    }
831
832    #[test]
833    fn downgrade_truecolor_to_256_grayscale_uses_gray_ramp() {
834        // Mid-grey should land in the 232..=255 ramp, not the cube.
835        let g = Color::TrueColor {
836            r: 128,
837            g: 128,
838            b: 128,
839        };
840        match g.downgrade(Capability::Palette256) {
841            Color::Palette256(n) => assert!((232..=255).contains(&n), "got {n}"),
842            other => panic!("expected Palette256, got {other:?}"),
843        }
844    }
845
846    #[test]
847    fn downgrade_truecolor_near_white_grayscale_saturates_without_overflow() {
848        // Regression: the ramp formula `232 + (r-8)/10` overflows u8 at
849        // r >= 239 (`(239-8)/10 = 23` is the last safe index mapping
850        // to 255). Channels above 238 must saturate to 231, not panic.
851        for v in [239u8, 240, 248, 249, 255] {
852            let c = Color::TrueColor { r: v, g: v, b: v };
853            match c.downgrade(Capability::Palette256) {
854                Color::Palette256(n) => assert!(
855                    (232..=255).contains(&n) || n == 231,
856                    "r={v}: n={n} out of ramp/white range"
857                ),
858                other => panic!("expected Palette256, got {other:?}"),
859            }
860        }
861    }
862
863    #[test]
864    fn downgrade_truecolor_to_256_uses_cube_for_color() {
865        // Pure red: (255, 0, 0). r6=5, g6=0, b6=0 → 16 + 180 = 196.
866        let red = Color::TrueColor { r: 255, g: 0, b: 0 };
867        assert_eq!(
868            red.downgrade(Capability::Palette256),
869            Color::Palette256(196)
870        );
871        // Pure green: r6=0, g6=5, b6=0 → 16 + 30 = 46.
872        let green = Color::TrueColor { r: 0, g: 255, b: 0 };
873        assert_eq!(
874            green.downgrade(Capability::Palette256),
875            Color::Palette256(46)
876        );
877        // Pure blue: r6=0, g6=0, b6=5 → 16 + 5 = 21.
878        let blue = Color::TrueColor { r: 0, g: 0, b: 255 };
879        assert_eq!(
880            blue.downgrade(Capability::Palette256),
881            Color::Palette256(21)
882        );
883    }
884
885    #[test]
886    fn downgrade_palette256_cube_to_16_picks_a_color() {
887        // Index 196 is pure red in the cube; must route to a red slot.
888        match Color::Palette256(196).downgrade(Capability::Palette16) {
889            Color::Palette16(ac) => assert!(
890                matches!(ac, AnsiColor::Red | AnsiColor::BrightRed),
891                "got {ac:?}"
892            ),
893            other => panic!("expected Palette16, got {other:?}"),
894        }
895    }
896
897    #[test]
898    fn downgrade_palette256_low_16_passes_through_identity() {
899        // Indices 0..=15 map to their AnsiColor counterparts.
900        assert_eq!(
901            Color::Palette256(1).downgrade(Capability::Palette16),
902            Color::Palette16(AnsiColor::Red),
903        );
904        assert_eq!(
905            Color::Palette256(15).downgrade(Capability::Palette16),
906            Color::Palette16(AnsiColor::BrightWhite),
907        );
908    }
909
910    #[test]
911    fn downgrade_palette256_grayscale_routes_to_blacks_or_whites() {
912        // Index 232 is darkest grey → Black; 255 is lightest → BrightWhite.
913        assert_eq!(
914            Color::Palette256(232).downgrade(Capability::Palette16),
915            Color::Palette16(AnsiColor::Black),
916        );
917        assert_eq!(
918            Color::Palette256(255).downgrade(Capability::Palette16),
919            Color::Palette16(AnsiColor::BrightWhite),
920        );
921    }
922
923    #[test]
924    fn downgrade_truecolor_red_to_16_picks_red_family() {
925        let red = Color::TrueColor {
926            r: 200,
927            g: 30,
928            b: 30,
929        };
930        match red.downgrade(Capability::Palette16) {
931            Color::Palette16(ac) => assert!(
932                matches!(ac, AnsiColor::Red | AnsiColor::BrightRed),
933                "got {ac:?}"
934            ),
935            other => panic!("expected Palette16, got {other:?}"),
936        }
937    }
938
939    // --- SGR emission ---
940
941    #[test]
942    fn sgr_open_plain_style_emits_nothing() {
943        let s = Style::default();
944        let t = default_theme();
945        assert_eq!(sgr_open(&s, t, Capability::TrueColor), "");
946    }
947
948    #[test]
949    fn sgr_open_bold_only_emits_sgr1() {
950        let s = Style {
951            bold: true,
952            ..Style::default()
953        };
954        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[1m");
955    }
956
957    #[test]
958    fn sgr_open_role_under_palette16_emits_ansi_code() {
959        let s = Style::role(Role::Success);
960        let out = sgr_open(&s, default_theme(), Capability::Palette16);
961        // Default theme maps Success → BrightGreen (SGR 92).
962        assert_eq!(out, "\x1b[92m");
963    }
964
965    #[test]
966    fn sgr_open_role_under_no_capability_drops_color_keeps_decoration() {
967        let s = Style {
968            role: Some(Role::Error),
969            bold: true,
970            ..Style::default()
971        };
972        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[1m");
973    }
974
975    #[test]
976    fn sgr_open_explicit_fg_wins_over_role() {
977        let s = Style {
978            role: Some(Role::Warning),
979            fg: Some(Color::Palette16(AnsiColor::Blue)),
980            ..Style::default()
981        };
982        // Blue is SGR 34; Warning would have been 93 (BrightYellow).
983        assert_eq!(
984            sgr_open(&s, default_theme(), Capability::Palette16),
985            "\x1b[34m"
986        );
987    }
988
989    #[test]
990    fn sgr_open_truecolor_fg_emits_38_2_sequence() {
991        let s = Style {
992            fg: Some(Color::TrueColor {
993                r: 12,
994                g: 34,
995                b: 56,
996            }),
997            ..Style::default()
998        };
999        assert_eq!(
1000            sgr_open(&s, default_theme(), Capability::TrueColor),
1001            "\x1b[38;2;12;34;56m"
1002        );
1003    }
1004
1005    #[test]
1006    fn sgr_open_combines_decorations_and_color() {
1007        let s = Style {
1008            role: Some(Role::Primary),
1009            bold: true,
1010            italic: true,
1011            ..Style::default()
1012        };
1013        // SGR params emit in order: bold(1), italic(3), color(95 = BrightMagenta).
1014        assert_eq!(
1015            sgr_open(&s, default_theme(), Capability::Palette16),
1016            "\x1b[1;3;95m"
1017        );
1018    }
1019
1020    #[test]
1021    fn sgr_open_dim_only_emits_sgr2() {
1022        let s = Style {
1023            dim: true,
1024            ..Style::default()
1025        };
1026        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[2m");
1027    }
1028
1029    #[test]
1030    fn sgr_open_underline_only_emits_sgr4() {
1031        let s = Style {
1032            underline: true,
1033            ..Style::default()
1034        };
1035        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[4m");
1036    }
1037
1038    #[test]
1039    fn sgr_open_full_decoration_stack_keeps_order() {
1040        let s = Style {
1041            role: Some(Role::Primary),
1042            bold: true,
1043            dim: true,
1044            italic: true,
1045            underline: true,
1046            ..Style::default()
1047        };
1048        // Order: bold(1), dim(2), italic(3), underline(4), color(95).
1049        assert_eq!(
1050            sgr_open(&s, default_theme(), Capability::Palette16),
1051            "\x1b[1;2;3;4;95m"
1052        );
1053    }
1054
1055    #[test]
1056    fn sgr_reset_is_stable_and_short() {
1057        assert_eq!(sgr_reset(), "\x1b[0m");
1058    }
1059
1060    #[test]
1061    fn minimal_theme_emits_only_decorations() {
1062        let s = Style {
1063            role: Some(Role::Success),
1064            bold: true,
1065            ..Style::default()
1066        };
1067        let t = built_in("minimal").expect("minimal");
1068        assert_eq!(sgr_open(&s, t, Capability::TrueColor), "\x1b[1m");
1069    }
1070
1071    // --- env-fallback capability probe ---
1072
1073    #[test]
1074    fn from_env_vars_prefers_colorterm_truecolor() {
1075        assert_eq!(
1076            Capability::from_env_vars(Some("truecolor"), Some("xterm")),
1077            Capability::TrueColor
1078        );
1079        assert_eq!(
1080            Capability::from_env_vars(Some("24bit"), Some("xterm")),
1081            Capability::TrueColor
1082        );
1083        assert_eq!(
1084            Capability::from_env_vars(Some("TRUECOLOR"), None),
1085            Capability::TrueColor
1086        );
1087    }
1088
1089    #[test]
1090    fn from_env_vars_falls_back_to_term_256color() {
1091        assert_eq!(
1092            Capability::from_env_vars(None, Some("xterm-256color")),
1093            Capability::Palette256
1094        );
1095        assert_eq!(
1096            Capability::from_env_vars(None, Some("tmux-256color")),
1097            Capability::Palette256
1098        );
1099    }
1100
1101    #[test]
1102    fn from_env_vars_unknown_term_is_palette16() {
1103        assert_eq!(
1104            Capability::from_env_vars(None, Some("xterm")),
1105            Capability::Palette16
1106        );
1107        assert_eq!(
1108            Capability::from_env_vars(None, Some("vt100")),
1109            Capability::Palette16
1110        );
1111    }
1112
1113    #[test]
1114    fn from_env_vars_dumb_or_missing_term_is_none() {
1115        assert_eq!(
1116            Capability::from_env_vars(None, Some("dumb")),
1117            Capability::None
1118        );
1119        // Case-insensitive on TERM=dumb to mirror the COLORTERM check.
1120        assert_eq!(
1121            Capability::from_env_vars(None, Some("DUMB")),
1122            Capability::None
1123        );
1124        assert_eq!(
1125            Capability::from_env_vars(None, Some("  dumb  ")),
1126            Capability::None
1127        );
1128        assert_eq!(Capability::from_env_vars(None, Some("")), Capability::None);
1129        assert_eq!(Capability::from_env_vars(None, None), Capability::None);
1130    }
1131
1132    #[test]
1133    fn from_env_vars_colorterm_truecolor_overrides_term_dumb() {
1134        // COLORTERM=truecolor is the stronger signal and wins over TERM=dumb.
1135        assert_eq!(
1136            Capability::from_env_vars(Some("truecolor"), Some("dumb")),
1137            Capability::TrueColor
1138        );
1139    }
1140
1141    // --- force-color combinator ---
1142
1143    #[test]
1144    fn force_from_picks_max_of_both_inputs() {
1145        assert_eq!(
1146            Capability::force_from(Capability::Palette256, Capability::TrueColor),
1147            Capability::TrueColor
1148        );
1149        assert_eq!(
1150            Capability::force_from(Capability::TrueColor, Capability::Palette256),
1151            Capability::TrueColor
1152        );
1153        assert_eq!(
1154            Capability::force_from(Capability::Palette16, Capability::Palette256),
1155            Capability::Palette256
1156        );
1157    }
1158
1159    #[test]
1160    fn force_from_truecolor_from_either_side_wins() {
1161        assert_eq!(
1162            Capability::force_from(Capability::None, Capability::TrueColor),
1163            Capability::TrueColor
1164        );
1165        assert_eq!(
1166            Capability::force_from(Capability::TrueColor, Capability::None),
1167            Capability::TrueColor
1168        );
1169    }
1170
1171    #[test]
1172    fn force_from_floors_at_palette16_when_both_inputs_are_none() {
1173        // Force-color intent must emit something; the user opted in
1174        // explicitly via --force-color or `color = "always"`.
1175        assert_eq!(
1176            Capability::force_from(Capability::None, Capability::None),
1177            Capability::Palette16
1178        );
1179    }
1180
1181    #[test]
1182    fn force_from_floor_overrides_dumb_term_when_env_probe_returns_none() {
1183        // Even when TERM=dumb made the env probe return None, force-color
1184        // intent wins — the user knows what they're doing.
1185        let env = Capability::from_env_vars(None, Some("dumb"));
1186        assert_eq!(env, Capability::None);
1187        assert_eq!(
1188            Capability::force_from(Capability::None, env),
1189            Capability::Palette16
1190        );
1191    }
1192
1193    #[test]
1194    fn from_env_vars_colorterm_garbage_falls_through_to_term() {
1195        // COLORTERM=yes (some terminals set this as a boolean hint) does
1196        // not claim truecolor; fall through to the TERM probe.
1197        assert_eq!(
1198            Capability::from_env_vars(Some("yes"), Some("xterm-256color")),
1199            Capability::Palette256
1200        );
1201    }
1202}