Skip to main content

atomcode_tuix/render/
theme.rs

1// crates/atomcode-tuix/src/render/theme.rs
2use crossterm::style::Color;
3
4use crate::highlight::theme as md_theme;
5use crate::terminal::TerminalCaps;
6
7/// Basic 16-color palette — SGR 30-37/90-97 only, no truecolor RGB.
8///
9/// **Why 16 colors:** truecolor RGB renders the same pixel regardless of
10/// terminal theme. On Mac Terminal.app's default "Basic" (light) profile,
11/// our old lavender/mint/grays landed on a light background and all but
12/// disappeared. The 16-color SGR palette (30-37, 90-97) is interpreted by
13/// the terminal's own theme engine — each user's colorscheme remaps the
14/// same escape into theme-appropriate RGB, so atomcode adapts to whatever
15/// terminal theme the user runs.
16///
17/// **Compatibility floor:** SGR 30-37/90-97 are part of the 1996 ECMA-48
18/// baseline. Every modern terminal (macOS Terminal, iTerm2, Alacritty,
19/// Kitty, Wezterm, Windows Terminal, Win10 1511+ cmd.exe with VT mode,
20/// tmux, SSH-in-SSH) handles them identically. We specifically avoid
21/// `\x1b[2m` (dim) which isn't reliable on Windows conhost < 1809.
22pub struct Palette;
23
24impl Palette {
25    // Using the bright (9X) variants for signal colours rather than the
26    // standard (3X) variants. On dark-theme terminals the standard set
27    // (32/33/31/36) renders muddy — "dark green" looks olive-khaki,
28    // "dark cyan" looks desaturated. CC uses bright variants for diff
29    // +/- and inline code for the same reason; aligning here so colours
30    // read consistently across Mac Terminal / iTerm / Alacritty dark
31    // themes. Bright variants also still map to sensible colours on
32    // light themes (most terminals give them enough contrast with the
33    // default background).
34    pub const BRAND: Color = Color::Magenta; // bright magenta (95)
35
36    /// Muted text on **light** backgrounds. SGR 90 ("bright black") maps
37    /// to a mid-gray on most light themes — contrast against `#FFFFFF`
38    /// lands around 4.5–5:1, comfortably above AA.
39    pub const MUTED_LIGHT: Color = Color::DarkGrey; // SGR 90
40
41    /// Muted text on **dark** backgrounds. SGR 37 ("regular white") maps
42    /// to a soft light-gray on dark themes — contrast against `#1B1B1B`
43    /// to `#303030` lands around 8–10:1.
44    ///
45    /// Earlier this was `Color::DarkGrey` (SGR 90) for both modes on the
46    /// theory that the terminal's palette would adapt. Reality from
47    /// Warp / iTerm2 / Mac Terminal screenshots: most dark themes map
48    /// SGR 90 to ~`#3F3F3F` (≈ 3:1 against the dark bg) — child rows
49    /// under a tool-batch header rendered almost invisible. Splitting
50    /// MUTED into light/dark variants and switching via
51    /// `is_light_for_render` recovers readable contrast on both.
52    pub const MUTED_DARK: Color = Color::White; // SGR 37
53
54    /// Back-compat alias — same value as `MUTED_LIGHT` so old call sites
55    /// that pre-date the dark-mode split keep compiling. New code should
56    /// call [`muted_for_current_theme`] instead so the shade tracks the
57    /// active palette.
58    pub const MUTED: Color = Self::MUTED_LIGHT;
59
60    pub const ACCENT: Color = Color::Cyan; // bright cyan (96)
61    pub const BORDER: Color = Color::Cyan; // bright cyan (96) — 蓝绿色边框,和 Accent/prompt glyph 视觉呼应,对比度高于 DarkGrey 不易被背景吞掉
62    pub const WARNING: Color = Color::Yellow; // bright yellow (93)
63    pub const ERROR: Color = Color::Red; // bright red (91)
64    pub const DIFF_ADD: Color = Color::Green; // bright green (92)
65    pub const DIFF_REMOVE: Color = Color::Red; // bright red (91) — paired with Error
66    pub const CODE: Color = Color::Cyan; // bright cyan (96)
67}
68
69/// Resolve the muted shade for the active palette.
70///
71/// Light theme → `MUTED_LIGHT` (SGR 90, dark gray on white).
72/// Dark theme  → `MUTED_DARK`  (SGR 37, light gray on dark).
73///
74/// Routed through this fn rather than a `const` so role lookups
75/// pick up live theme switches (auto-detect at startup + future
76/// `/theme` slash command) without restart.
77pub fn muted_for_current_theme() -> Color {
78    if md_theme::is_light_for_render() {
79        Palette::MUTED_LIGHT
80    } else {
81        Palette::MUTED_DARK
82    }
83}
84
85/// Semantic colour role → concrete Color, honouring NO_COLOR etc.
86/// Returns None when colours are disabled OR when the role intentionally
87/// uses the terminal's default foreground (so strong/tool-name text just
88/// gets SGR bold without a fixed colour).
89pub fn role(caps: TerminalCaps, role: Role) -> Option<Color> {
90    if !caps.colors {
91        return None;
92    }
93    match role {
94        Role::Brand => Some(Palette::BRAND),
95        Role::Muted => Some(muted_for_current_theme()),
96        Role::Accent => Some(Palette::ACCENT),
97        Role::AccentDim => Some(muted_for_current_theme()),
98        // Secondary = default terminal foreground. Using None means
99        // "don't emit a colour SGR"; text shows in whatever colour the
100        // terminal's theme chose for regular output.
101        Role::Secondary => None,
102        Role::Border => Some(Palette::BORDER),
103        Role::Warning => Some(Palette::WARNING),
104        Role::Error => Some(Palette::ERROR),
105        Role::Success => Some(Palette::DIFF_ADD),
106        Role::DiffAdd => Some(Palette::DIFF_ADD),
107        Role::DiffRemove => Some(Palette::DIFF_REMOVE),
108        // Tool names: emphasise with bold only; the caller adds `\x1b[1m`.
109        // No colour means the name picks up the terminal's default fg,
110        // which guarantees readability on any theme.
111        Role::ToolName => None,
112    }
113}
114
115#[derive(Debug, Clone, Copy)]
116pub enum Role {
117    Brand,
118    Muted,
119    Accent,
120    AccentDim,
121    Secondary,
122    Border,
123    Warning,
124    Error,
125    Success,
126    DiffAdd,
127    DiffRemove,
128    ToolName,
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::terminal::{EnvView, TerminalCaps};
135
136    fn caps(colors: bool) -> TerminalCaps {
137        TerminalCaps::from_env(EnvView {
138            is_stdout_tty: true,
139            no_color: !colors,
140            term: Some("xterm".to_string()),
141            colorterm: Some("truecolor".to_string()),
142            lang: Some("en_US.UTF-8".to_string()),
143            ..Default::default()
144        })
145    }
146
147    #[test]
148    fn role_returns_none_when_colors_disabled() {
149        assert!(role(caps(false), Role::Brand).is_none());
150    }
151
152    #[test]
153    fn role_returns_palette_when_colors_enabled() {
154        assert_eq!(role(caps(true), Role::Brand), Some(Palette::BRAND));
155    }
156
157    #[test]
158    fn muted_switches_with_theme() {
159        // Take the theme lock so we don't race other theme-switching
160        // tests in the highlight module — `MODE` is a process-wide
161        // AtomicU8 and parallel test runs would interleave reads.
162        use crate::highlight::theme as md_theme;
163        md_theme::set_theme_mode(false); // dark
164        assert_eq!(
165            role(caps(true), Role::Muted),
166            Some(Palette::MUTED_DARK),
167            "dark theme must use SGR 37 (regular white) for muted — \
168             SGR 90 reads invisible on Warp / iTerm2 / Mac Terminal dark"
169        );
170        assert_eq!(
171            role(caps(true), Role::AccentDim),
172            Some(Palette::MUTED_DARK),
173            "AccentDim must track the same muted shade as Role::Muted"
174        );
175
176        md_theme::set_theme_mode(true); // light
177        assert_eq!(
178            role(caps(true), Role::Muted),
179            Some(Palette::MUTED_LIGHT),
180            "light theme must use SGR 90 (bright black) — `white` would \
181             be invisible against the white background"
182        );
183        assert_eq!(
184            role(caps(true), Role::AccentDim),
185            Some(Palette::MUTED_LIGHT)
186        );
187
188        // Restore default (dark) so subsequent tests see the legacy state.
189        md_theme::set_theme_mode(false);
190    }
191
192    #[test]
193    fn back_compat_muted_alias_is_light_variant() {
194        // `Palette::MUTED` predates the dark-mode split. Pin that it
195        // continues to mean the light-mode shade so any caller that
196        // still references the bare constant doesn't silently break.
197        assert_eq!(Palette::MUTED, Palette::MUTED_LIGHT);
198    }
199
200    #[test]
201    fn secondary_and_toolname_return_none() {
202        // These roles deliberately fall through to the terminal's default
203        // foreground — they should return None even when colours are on.
204        assert!(role(caps(true), Role::Secondary).is_none());
205        assert!(role(caps(true), Role::ToolName).is_none());
206    }
207}