atomcode_tuix/highlight/theme.rs
1// crates/atomcode-tuix/src/highlight/theme.rs
2//
3// Theme-aware colour palette for markdown rendering + syntect code-block
4// highlight. Two variants:
5//
6// - `dark` (default): legacy palette tuned for dark terminal backgrounds
7// (≈ #1E1E1E). Light, washed-out tones.
8// - `light` : darker, more saturated tones hitting ≥ 4.5:1 WCAG
9// AA contrast against `#FFFFFF`.
10//
11// The active variant is selected at startup from `Config::ui.theme` via
12// `set_theme_mode()`; readers go through the small accessor fns below.
13// Constants that don't change between themes (RESET, bold/italic SGR
14// attribute toggles, muted SGR 90) stay as plain `pub const &str`.
15//
16// VARIABLE and PUNCTUATION are deliberately empty: they're the majority
17// of source-code characters, and painting them would make the screen
18// "flicker." Caller's mapping logic must skip the SGR wrap when the
19// colour string is empty (otherwise an unmatched RESET would clobber
20// any previously open SGR).
21
22use std::sync::atomic::{AtomicU8, Ordering};
23
24const MODE_DARK: u8 = 0;
25const MODE_LIGHT: u8 = 1;
26
27/// Runtime theme selector. Updated once at startup by the TUIX entry
28/// point after reading `Config::ui.theme`; readers see eventual
29/// consistency via `Relaxed` ordering.
30static MODE: AtomicU8 = AtomicU8::new(MODE_DARK);
31
32/// Switch the palette. Idempotent. Call once during startup before the
33/// first markdown / highlight emission.
34pub fn set_theme_mode(light: bool) {
35 MODE.store(if light { MODE_LIGHT } else { MODE_DARK }, Ordering::Relaxed);
36}
37
38#[inline]
39fn is_light() -> bool {
40 MODE.load(Ordering::Relaxed) == MODE_LIGHT
41}
42
43/// Sibling-module accessor: `highlight/mod.rs` reads this to pick the
44/// right cached syntect Theme. Exposed as a small named fn rather than
45/// making `is_light` pub so the rest of the crate can't accidentally
46/// gate behaviour on the mode bit (the right entry point is the
47/// per-token accessors).
48#[inline]
49pub(super) fn is_light_for_highlight() -> bool {
50 is_light()
51}
52
53/// `render/alt_screen.rs` reads this to swap the session-name pill SGR
54/// (reverse + bright cyan on dark; bold + standard magenta on light).
55/// Named so the call site documents intent; behaviourally identical to
56/// `is_light_for_highlight`.
57#[inline]
58pub fn is_light_for_render() -> bool {
59 is_light()
60}
61
62// ── Code-block (syntect) token colours ───────────────────────────────
63//
64// Truecolor SGRs are written-out RGB values that the terminal cannot
65// remap; both palettes must independently hit the contrast bar against
66// their target background.
67
68/// `dark`: soft purple `#C678DD`. `light`: very dark violet `#4A0072`
69/// (≥ 13:1 on white — earlier `#7B1FA2` at 8.7:1 read soft on Mac
70/// Terminal where colours render less crisp than iTerm2).
71pub fn keyword() -> &'static str {
72 if is_light() { "\x1b[38;2;74;0;114m" } else { "\x1b[38;2;198;120;221m" }
73}
74
75/// `dark`: green `#98C379`. `light`: dark green `#006400` (≥ 13:1 —
76/// greens read soft at any given luminance, so light pushes past the
77/// other tokens' contrast budget to compensate).
78pub fn string() -> &'static str {
79 if is_light() { "\x1b[38;2;0;100;0m" } else { "\x1b[38;2;152;195;121m" }
80}
81
82/// `dark`: amber `#D19A66`. `light`: dark chestnut `#663300` (≥ 11:1).
83pub fn number() -> &'static str {
84 if is_light() { "\x1b[38;2;102;51;0m" } else { "\x1b[38;2;209;154;102m" }
85}
86
87/// `dark`: slate gray `#7C8499` + italic. `light`: slate `#4A5060` +
88/// italic — kept moderately desaturated because comments should read
89/// "secondary" relative to the code, not "main attraction."
90pub fn comment() -> &'static str {
91 if is_light() { "\x1b[3;38;2;74;80;96m" } else { "\x1b[3;38;2;124;132;153m" }
92}
93
94/// `dark`: blue `#61AFEF`. `light`: very dark navy `#002171` (≥ 14:1 —
95/// earlier `#0D47A1` at 8.8:1 read "ok but soft"; this is also where
96/// the original `fn main` screenshot regression lived, old `#61AFEF`
97/// at 2.04:1 made `main` invisible).
98pub fn function() -> &'static str {
99 if is_light() { "\x1b[38;2;0;33;113m" } else { "\x1b[38;2;97;175;239m" }
100}
101
102/// `dark`: sand `#E5C07B`. `light`: dark walnut `#5B3A00` (≥ 11:1) —
103/// distinct hue from `number`'s chestnut so type names don't visually
104/// collide with literals on a line like `let x: U32 = 42`.
105pub fn type_color() -> &'static str {
106 if is_light() { "\x1b[38;2;91;58;0m" } else { "\x1b[38;2;229;192;123m" }
107}
108
109/// Both palettes intentionally use terminal default fg.
110pub fn variable() -> &'static str { "" }
111
112/// Both palettes intentionally use terminal default fg.
113pub fn punctuation() -> &'static str { "" }
114
115/// Closes color + italic. Use after every wrapped token span.
116/// SGR 23 = italic off, SGR 39 = default foreground.
117pub const RESET: &str = "\x1b[23;39m";
118
119// ── Markdown inline element colours ──────────────────────────────────
120
121/// Heading H1-H3.
122/// `dark`: bold + bright cyan (SGR 1;96, matches `Palette::ACCENT`).
123/// `light`: bold + bright blue (SGR 1;94) — bright cyan renders too pale
124/// on white in most light-theme terminal profiles; blue still maps to a
125/// dark, readable variant on light profiles.
126pub fn md_heading_open() -> &'static str {
127 if is_light() { "\x1b[1;34m" } else { "\x1b[1;96m" }
128}
129
130/// Close heading: bold off + fg default (SGR 22;39). Theme-invariant.
131pub const MD_HEADING_CLOSE: &str = "\x1b[22;39m";
132
133/// Inline code.
134/// `dark`: bold + bright cyan (matches headings).
135/// `light`: bold + standard magenta (SGR 1;35) — distinct from headings,
136/// terminal profiles map 35 to a dark magenta that's readable on white.
137pub fn md_inline_code_open() -> &'static str {
138 if is_light() { "\x1b[1;35m" } else { "\x1b[1;96m" }
139}
140
141/// Close inline code: bold off + fg default. Theme-invariant.
142pub const MD_INLINE_CODE_CLOSE: &str = "\x1b[22;39m";
143
144/// Bold text: SGR 1 (bold on). Theme-invariant — bold is an attribute,
145/// not a colour.
146pub const MD_BOLD_OPEN: &str = "\x1b[1m";
147pub const MD_BOLD_CLOSE: &str = "\x1b[22m";
148
149/// Italic text: SGR 3 (italic on). Theme-invariant.
150pub const MD_ITALIC_OPEN: &str = "\x1b[3m";
151pub const MD_ITALIC_CLOSE: &str = "\x1b[23m";
152
153/// Muted / structural chrome (list markers, table borders): bright
154/// black / dark grey (SGR 90). The terminal's profile maps this to a
155/// shade with adequate contrast on either background — keep as constant.
156pub const MD_MUTED_OPEN: &str = "\x1b[90m";
157pub const MD_MUTED_CLOSE: &str = "\x1b[39m";
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use std::sync::Mutex;
163
164 // Guard around theme-switching tests so they don't race each other
165 // (the static `MODE` is per-process). Each test takes the lock,
166 // switches, asserts, switches back.
167 static THEME_LOCK: Mutex<()> = Mutex::new(());
168
169 fn with_dark<F: FnOnce()>(f: F) {
170 let _g = THEME_LOCK.lock().unwrap();
171 set_theme_mode(false);
172 f();
173 set_theme_mode(false); // restore default
174 }
175
176 fn with_light<F: FnOnce()>(f: F) {
177 let _g = THEME_LOCK.lock().unwrap();
178 set_theme_mode(true);
179 f();
180 set_theme_mode(false); // restore default
181 }
182
183 #[test]
184 fn dark_keyword_is_legacy_soft_purple() {
185 with_dark(|| assert_eq!(keyword(), "\x1b[38;2;198;120;221m"));
186 }
187
188 #[test]
189 fn light_keyword_is_very_dark_violet() {
190 // Bumped from #7B1FA2 (8.7:1) to #4A0072 (≥ 13:1) after Mac
191 // Terminal feedback that the earlier value read soft.
192 with_light(|| assert_eq!(keyword(), "\x1b[38;2;74;0;114m"));
193 }
194
195 #[test]
196 fn dark_function_is_legacy_blue() {
197 with_dark(|| assert_eq!(function(), "\x1b[38;2;97;175;239m"));
198 }
199
200 #[test]
201 fn light_function_is_very_dark_navy() {
202 // Earlier failure mode: legacy `#61AFEF` had 2.04:1 contrast on
203 // white and `main` vanished. First fix was `#0D47A1` (8.8:1)
204 // which worked but still read "soft" on Mac Terminal.
205 // Current: `#002171` at ≥ 14:1.
206 with_light(|| assert_eq!(function(), "\x1b[38;2;0;33;113m"));
207 }
208
209 #[test]
210 fn variable_and_punctuation_stay_empty_in_both_palettes() {
211 with_dark(|| {
212 assert_eq!(variable(), "");
213 assert_eq!(punctuation(), "");
214 });
215 with_light(|| {
216 assert_eq!(variable(), "");
217 assert_eq!(punctuation(), "");
218 });
219 }
220
221 #[test]
222 fn comment_includes_italic_attr_in_both_palettes() {
223 with_dark(|| assert!(comment().starts_with("\x1b[3;38;2;"),
224 "dark comment must lead with SGR 3 + truecolor"));
225 with_light(|| assert!(comment().starts_with("\x1b[3;38;2;"),
226 "light comment must lead with SGR 3 + truecolor"));
227 }
228
229 #[test]
230 fn reset_closes_italic_and_fg() {
231 assert_eq!(RESET, "\x1b[23;39m");
232 }
233
234 #[test]
235 fn dark_md_heading_is_bold_bright_cyan() {
236 with_dark(|| assert_eq!(md_heading_open(), "\x1b[1;96m"));
237 }
238
239 #[test]
240 fn light_md_heading_is_bold_blue() {
241 with_light(|| assert_eq!(md_heading_open(), "\x1b[1;34m"));
242 }
243
244 #[test]
245 fn dark_md_inline_code_is_bold_bright_cyan() {
246 with_dark(|| assert_eq!(md_inline_code_open(), "\x1b[1;96m"));
247 }
248
249 #[test]
250 fn light_md_inline_code_is_bold_magenta() {
251 with_light(|| assert_eq!(md_inline_code_open(), "\x1b[1;35m"));
252 }
253
254 #[test]
255 fn close_codes_are_theme_invariant() {
256 // Close codes only manipulate SGR attributes (bold off / italic
257 // off / fg default), never set a colour — should be identical
258 // regardless of theme.
259 assert_eq!(MD_HEADING_CLOSE, "\x1b[22;39m");
260 assert_eq!(MD_INLINE_CODE_CLOSE, "\x1b[22;39m");
261 assert_eq!(MD_BOLD_CLOSE, "\x1b[22m");
262 assert_eq!(MD_ITALIC_CLOSE, "\x1b[23m");
263 assert_eq!(MD_MUTED_CLOSE, "\x1b[39m");
264 }
265}