Skip to main content

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}