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}