Skip to main content

claude_code_statusline_core/
style.rs

1//! ANSI color and text styling utilities
2//!
3//! This module provides functions for applying ANSI escape codes to
4//! terminal text, enabling colored and styled output in the status line.
5
6/// Applies ANSI styling to text for terminal display
7///
8/// Takes a text string and a style specification, returning the text
9/// wrapped in appropriate ANSI escape codes.
10///
11/// # Arguments
12///
13/// * `text` - The text to style
14/// * `style` - Space-separated style tokens
15///
16/// # Supported Style Tokens
17///
18/// Text styles:
19/// - `bold` - Bold text
20/// - `italic` - Italic text
21/// - `underline` - Underlined text
22///
23/// Colors:
24/// - `black`, `red`, `green`, `yellow`
25/// - `blue`, `magenta`, `cyan`, `white`
26///
27/// # Examples
28///
29/// ```
30/// use claude_code_statusline_core::style::apply_style;
31///
32/// let styled = apply_style("Hello", "bold red");
33/// // Returns: "\x1b[1;31mHello\x1b[0m"
34///
35/// let multi = apply_style("World", "bold italic blue");
36/// // Returns: "\x1b[1;3;34mWorld\x1b[0m"
37/// ```
38///
39/// # Notes
40///
41/// - Unknown tokens are silently ignored
42/// - If no valid tokens are found, returns the original text
43/// - Multiple styles can be combined (e.g., "bold red underline")
44pub fn apply_style(text: &str, style: &str) -> String {
45    #[derive(Clone, Copy)]
46    enum ColorSpec {
47        NamedNormal(u8), // 30..=37 (FG) / 40..=47 (BG) base offset will be applied
48        NamedBright(u8), // 90..=97 / 100..=107 (store 0..=7)
49        Index(u8),       // 0..=255
50        Rgb(u8, u8, u8), // truecolor
51        NoneSet,         // explicit none
52    }
53
54    fn parse_named(name: &str) -> Option<u8> {
55        match name {
56            "black" => Some(0),
57            "red" => Some(1),
58            "green" => Some(2),
59            "yellow" => Some(3),
60            "blue" => Some(4),
61            "magenta" => Some(5),
62            "cyan" => Some(6),
63            "white" => Some(7),
64            _ => None,
65        }
66    }
67
68    // Heuristics to decide if the terminal supports truecolor. This keeps
69    // behavior consistent across environments where 24-bit colors are not
70    // fully supported and avoids foreground/background mismatch when a host
71    // silently downgrades one channel differently from the other.
72    fn supports_truecolor() -> bool {
73        // Explicit override for tests or user preference
74        if std::env::var("CCS_TRUECOLOR")
75            .map(|v| v == "1")
76            .unwrap_or(false)
77        {
78            return true;
79        }
80        if let Ok(v) = std::env::var("COLORTERM") {
81            let v = v.to_lowercase();
82            if v.contains("truecolor") || v.contains("24bit") {
83                return true;
84            }
85        }
86        if let Ok(t) = std::env::var("TERM") {
87            let t = t.to_lowercase();
88            if t.contains("direct") || t.contains("truecolor") {
89                return true;
90            }
91        }
92        false
93    }
94
95    // Convert an RGB color to the nearest ANSI 256-color index.
96    // Algorithm: prefer xterm 6x6x6 color cube (16..231) and fall back to
97    // grayscale ramp (232..255) when r≈g≈b. This mirrors common mappers.
98    fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
99        // If it's close to gray, map to grayscale range for better fidelity
100        let rg = r as i32 - g as i32;
101        let rb = r as i32 - b as i32;
102        let gb = g as i32 - b as i32;
103        let is_grayish = rg.abs() < 10 && rb.abs() < 10 && gb.abs() < 10;
104        if is_grayish {
105            // 24 grays, 8..238 step ~10
106            let gray = ((r as u16 + g as u16 + b as u16) / 3) as u8;
107            if gray < 8 {
108                return 16; // nearest to black
109            }
110            if gray > 238 {
111                return 231; // nearest to white from color cube
112            }
113            return 232 + ((gray as u16 - 8) / 10) as u8;
114        }
115        // Quantize each channel to 0..5 then map into 6x6x6 cube
116        let to_6 = |v: u8| -> u8 { ((v as u16 * 5 + 127) / 255) as u8 };
117        let r6 = to_6(r);
118        let g6 = to_6(g);
119        let b6 = to_6(b);
120        16 + 36 * r6 + 6 * g6 + b6
121    }
122
123    fn parse_color_spec(spec: &str) -> Option<ColorSpec> {
124        let s = spec.to_lowercase();
125        if s == "none" {
126            return Some(ColorSpec::NoneSet);
127        }
128        if let Some(hex) = s.strip_prefix('#') {
129            if hex.len() == 6 {
130                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
131                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
132                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
133                return Some(ColorSpec::Rgb(r, g, b));
134            }
135        }
136        if s.chars().all(|c| c.is_ascii_digit()) {
137            if let Ok(n) = s.parse::<u16>() {
138                if n <= 255 {
139                    return Some(ColorSpec::Index(n as u8));
140                }
141            }
142        }
143        if let Some(n) = s.strip_prefix("bright-") {
144            if let Some(idx) = parse_named(n) {
145                return Some(ColorSpec::NamedBright(idx));
146            }
147        }
148        if let Some(idx) = parse_named(&s) {
149            return Some(ColorSpec::NamedNormal(idx));
150        }
151        None
152    }
153
154    // Modifiers
155    let mut bold = false;
156    let mut italic = false;
157    let mut underline = false;
158
159    // Color channels: last one wins
160    let mut fg: Option<ColorSpec> = None;
161    let mut bg: Option<ColorSpec> = None;
162
163    for token in style.split_whitespace() {
164        let t = token.to_lowercase();
165        match t.as_str() {
166            "bold" => {
167                bold = true;
168                continue;
169            }
170            "italic" => {
171                italic = true;
172                continue;
173            }
174            "underline" => {
175                underline = true;
176                continue;
177            }
178            _ => {}
179        }
180
181        if let Some(rest) = t.strip_prefix("fg:") {
182            fg = parse_color_spec(rest);
183            continue;
184        }
185        if let Some(rest) = t.strip_prefix("bg:") {
186            bg = parse_color_spec(rest);
187            continue;
188        }
189
190        // Bare color spec is treated as foreground
191        if let Some(cs) = parse_color_spec(&t) {
192            fg = Some(cs);
193        } else {
194            // Unknown token: ignore
195        }
196    }
197
198    let mut codes: Vec<String> = Vec::with_capacity(5);
199    if bold {
200        codes.push("1".to_string());
201    }
202    if italic {
203        codes.push("3".to_string());
204    }
205    if underline {
206        codes.push("4".to_string());
207    }
208
209    if let Some(c) = fg {
210        match c {
211            ColorSpec::NamedNormal(idx) => codes.push((30 + idx).to_string()),
212            ColorSpec::NamedBright(idx) => codes.push((90 + idx).to_string()),
213            ColorSpec::Index(n) => codes.push(format!("38;5;{n}")),
214            ColorSpec::Rgb(r, g, b) => {
215                if supports_truecolor() {
216                    codes.push(format!("38;2;{r};{g};{b}"));
217                } else {
218                    let n = rgb_to_ansi256(r, g, b);
219                    codes.push(format!("38;5;{n}"));
220                }
221            }
222            ColorSpec::NoneSet => {}
223        }
224    }
225    if let Some(c) = bg {
226        match c {
227            ColorSpec::NamedNormal(idx) => codes.push((40 + idx).to_string()),
228            ColorSpec::NamedBright(idx) => codes.push((100 + idx).to_string()),
229            ColorSpec::Index(n) => codes.push(format!("48;5;{n}")),
230            ColorSpec::Rgb(r, g, b) => {
231                if supports_truecolor() {
232                    codes.push(format!("48;2;{r};{g};{b}"));
233                } else {
234                    let n = rgb_to_ansi256(r, g, b);
235                    codes.push(format!("48;5;{n}"));
236                }
237            }
238            ColorSpec::NoneSet => {}
239        }
240    }
241
242    if codes.is_empty() {
243        return text.to_string();
244    }
245    let sgr = codes.join(";");
246    format!("\x1b[{sgr}m{text}\x1b[0m")
247}
248
249/// Render a simple module-local format string that can contain variable tokens
250/// like `$path`, `$model`, `$symbol`, `$branch` and optional bracket-style
251/// annotations: `[$content]($style)`.
252///
253/// - Variables inside the bracket content are substituted first.
254/// - The style inside parentheses can be a literal (e.g. "bold yellow") or
255///   `$style` which resolves to `default_style`.
256/// - If there is no bracket-style annotation, the variables are substituted and
257///   returned as-is.
258pub fn render_with_style_template(
259    format: &str,
260    tokens: &std::collections::HashMap<&str, String>,
261    default_style: &str,
262) -> String {
263    // First, replace known tokens except "$style" using deterministic,
264    // longest-key-first ordering to avoid overlaps (e.g., $git vs $git_branch).
265    let mut replaced = String::from(format);
266    let mut keys: Vec<&str> = tokens.keys().copied().filter(|k| *k != "style").collect();
267    // Sort by descending length so longer tokens are substituted first
268    keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
269    for k in keys {
270        if let Some(v) = tokens.get(k) {
271            let needle = format!("${k}");
272            replaced = replaced.replace(&needle, v);
273        }
274    }
275
276    // Robust pass to process [text](style) while ignoring ANSI escape
277    // sequences already present in the string (e.g., from substituted
278    // module outputs). We skip any ESC[..terminator sequences to avoid
279    // misinterpreting the '[' in "\x1b[" as a text-group opener.
280    let bytes = replaced.as_bytes();
281    let mut i = 0;
282    let len = bytes.len();
283    let mut out = String::with_capacity(len + 16);
284    // Start index of the current literal chunk to be copied as-is
285    let mut seg_start = 0usize;
286
287    while i < len {
288        let b = bytes[i];
289        if b == 0x1b {
290            // ESC: copy SGR/CSI sequence verbatim
291            let start = i;
292            i += 1; // Skip ESC
293            if i < len && bytes[i] == b'[' {
294                i += 1;
295                while i < len {
296                    let bb = bytes[i];
297                    if (0x40..=0x7E).contains(&bb) {
298                        i += 1; // include terminator
299                        break;
300                    }
301                    i += 1;
302                }
303            }
304            // flush preceding literal then CSI
305            if seg_start < start {
306                out.push_str(&replaced[seg_start..start]);
307            }
308            out.push_str(&replaced[start..i]);
309            seg_start = i;
310            continue;
311        }
312
313        if b == b'[' {
314            // Potential text group
315            // Flush any preceding literal chunk
316            if seg_start < i {
317                out.push_str(&replaced[seg_start..i]);
318            }
319            let mut j = i + 1;
320            while j < len && bytes[j] != b']' {
321                j += 1;
322            }
323            if j < len && j + 1 < len && bytes[j + 1] == b'(' {
324                // Find right parenthesis
325                let mut k = j + 2;
326                while k < len && bytes[k] != b')' {
327                    k += 1;
328                }
329                if k < len {
330                    let inner = &replaced[i + 1..j];
331                    let style_spec = &replaced[j + 2..k];
332                    let style_to_use = if style_spec == "$style" {
333                        default_style
334                    } else {
335                        style_spec
336                    };
337                    out.push_str(&apply_style(inner, style_to_use));
338                    i = k + 1;
339                    seg_start = i;
340                    continue;
341                }
342            }
343
344            // Fallback: literal '['
345            out.push('[');
346            i += 1;
347            seg_start = i;
348            continue;
349        }
350
351        // Regular byte; advance. We'll copy in bulk using seg_start when needed.
352        i += 1;
353    }
354    // Flush remaining literal
355    if seg_start < len {
356        out.push_str(&replaced[seg_start..len]);
357    }
358    out
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn applies_bold_yellow() {
367        let s = apply_style("X", "bold yellow");
368        assert!(s.starts_with("\u{1b}[") && s.contains("1;33") && s.ends_with("\u{1b}[0m"));
369        assert!(s.contains('X'));
370    }
371
372    #[test]
373    fn ignores_unknown_tokens() {
374        assert_eq!(apply_style("X", "unknown"), "X");
375    }
376
377    #[test]
378    fn mixed_known_and_unknown_tokens_are_stable() {
379        // Unknown tokens should be ignored, known tokens applied
380        let s = apply_style("Y", "bold sparkly yellow foo");
381        // Should include ANSI for bold (1) and yellow (33)
382        assert!(s.starts_with("\u{1b}["));
383        assert!(s.contains("1;33") || s.contains("33;1"));
384        assert!(s.ends_with("\u{1b}[0m"));
385        assert!(s.contains('Y'));
386    }
387
388    #[test]
389    fn renders_bracket_style_template() {
390        use std::collections::HashMap;
391        let mut tokens = HashMap::new();
392        tokens.insert("path", String::from("~/proj"));
393        let out = render_with_style_template("[$path]($style)", &tokens, "bold blue");
394        assert!(out.contains("~/proj"));
395        assert!(out.starts_with("\u{1b}["));
396        assert!(out.ends_with("\u{1b}[0m"));
397    }
398
399    #[test]
400    fn ignores_ansi_sequences_when_parsing_text_groups() {
401        use std::collections::HashMap;
402        // Pre-styled token (simulating a module output already ANSI-wrapped)
403        let styled = apply_style("X", "fg:#ff0000");
404        let mut tokens = HashMap::new();
405        tokens.insert("t", styled);
406        // Surrounding group should be styled, but the existing ANSI inside $t
407        // must not confuse the parser.
408        let s = render_with_style_template("[](bg:#003366)$t", &tokens, "");
409        // After stripping ANSI, we should see the glyph and the token text only.
410        let plain = String::from_utf8(strip_ansi_escapes::strip(s)).unwrap();
411        assert_eq!(plain, "X");
412    }
413
414    // New spec tests for fg:/bg: and extended colors
415
416    #[test]
417    fn style_named_fg_bg() {
418        let s = apply_style("X", "bold fg:green bg:black");
419        assert!(s.starts_with("\u{1b}["));
420        // Contains bold(1), fg green(32), bg black(40) in any order
421        assert!(s.contains("1"));
422        assert!(s.contains("32"));
423        assert!(s.contains("40"));
424        assert!(s.ends_with("\u{1b}[0m"));
425    }
426
427    #[test]
428    fn style_bright_named() {
429        let s = apply_style("X", "bright-yellow bg:bright-blue");
430        // bright yellow = 93, bright blue background = 104
431        assert!(s.contains("93"));
432        assert!(s.contains("104"));
433    }
434
435    #[test]
436    fn style_8bit_indexes() {
437        let s = apply_style("X", "fg:196 bg:238");
438        assert!(s.contains("38;5;196"));
439        assert!(s.contains("48;5;238"));
440    }
441
442    #[test]
443    fn style_hex_truecolor() {
444        let s = apply_style("X", "fg:#bf5700 bg:#003366");
445        // Accept either truecolor or ANSI-256 downgraded output depending on env
446        assert!(s.contains("38;2;191;87;0") || s.contains("38;5;"));
447        assert!(s.contains("48;2;0;51;102") || s.contains("48;5;"));
448    }
449
450    #[test]
451    fn style_bare_color_equivalence() {
452        let s1 = apply_style("X", "yellow");
453        let s2 = apply_style("X", "fg:yellow");
454        assert_eq!(s1, s2);
455    }
456
457    #[test]
458    fn style_unknown_tokens_stability() {
459        let s = apply_style("X", "bold sparkle fg:green foo");
460        assert!(s.contains("1"));
461        assert!(s.contains("32") || s.contains("38;2;") || s.contains("38;5;"));
462        // Should not introduce 38; for fg if using named mapping 32; but mainly ensure still wrapped
463        assert!(s.starts_with("\u{1b}[") && s.ends_with("\u{1b}[0m"));
464    }
465
466    #[test]
467    fn token_substitution_uses_longest_key_first() {
468        use std::collections::HashMap;
469        // Simulate overlapping token names like $git and $git_branch
470        let mut tokens = HashMap::new();
471        tokens.insert("git", String::from("G"));
472        tokens.insert("git_branch", String::from("BR"));
473
474        let out = render_with_style_template("$git_branch $git", &tokens, "");
475        // Expect both tokens fully replaced without partial corruption
476        assert_eq!(out, "BR G");
477        assert!(!out.contains("_branch"));
478    }
479
480    #[test]
481    fn style_none_handling() {
482        let s = apply_style("X", "fg:none italic");
483        assert!(s.contains("3"));
484        assert!(!s.contains("38;"));
485    }
486
487    #[test]
488    fn rgb_foreground_background_downgrade_is_consistent() {
489        // Ensure that when truecolor is not detected, the same RGB hex
490        // maps to the same ANSI-256 index for both fg and bg.
491        use std::sync::{Mutex, OnceLock};
492        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
493        let _g = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
494        // Force non-truecolor environment
495        unsafe {
496            std::env::remove_var("CCS_TRUECOLOR");
497            std::env::set_var("COLORTERM", "");
498            std::env::set_var("TERM", "xterm-256color");
499        }
500
501        let fg = apply_style("X", "#9A348E");
502        let bg = apply_style("X", "bg:#9A348E");
503        // Extract the 256-color index numbers if present
504        let idx_fg = fg
505            .split("38;5;")
506            .nth(1)
507            .and_then(|s| s.split('m').next())
508            .and_then(|n| n.parse::<u16>().ok());
509        let idx_bg = bg
510            .split("48;5;")
511            .nth(1)
512            .and_then(|s| s.split('m').next())
513            .and_then(|n| n.parse::<u16>().ok());
514        if let (Some(a), Some(b)) = (idx_fg, idx_bg) {
515            assert_eq!(a, b);
516        } else {
517            // In environments with truecolor this test isn't meaningful.
518            // Ensure at least both contain truecolor sequences then.
519            assert!(fg.contains("38;2;") && bg.contains("48;2;"));
520        }
521    }
522}