claude_code_statusline_core/
style.rs1pub fn apply_style(text: &str, style: &str) -> String {
45 #[derive(Clone, Copy)]
46 enum ColorSpec {
47 NamedNormal(u8), NamedBright(u8), Index(u8), Rgb(u8, u8, u8), NoneSet, }
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 fn supports_truecolor() -> bool {
73 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 fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
99 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 let gray = ((r as u16 + g as u16 + b as u16) / 3) as u8;
107 if gray < 8 {
108 return 16; }
110 if gray > 238 {
111 return 231; }
113 return 232 + ((gray as u16 - 8) / 10) as u8;
114 }
115 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 let mut bold = false;
156 let mut italic = false;
157 let mut underline = false;
158
159 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 if let Some(cs) = parse_color_spec(&t) {
192 fg = Some(cs);
193 } else {
194 }
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
249pub fn render_with_style_template(
259 format: &str,
260 tokens: &std::collections::HashMap<&str, String>,
261 default_style: &str,
262) -> String {
263 let mut replaced = String::from(format);
266 let mut keys: Vec<&str> = tokens.keys().copied().filter(|k| *k != "style").collect();
267 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 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 let mut seg_start = 0usize;
286
287 while i < len {
288 let b = bytes[i];
289 if b == 0x1b {
290 let start = i;
292 i += 1; 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; break;
300 }
301 i += 1;
302 }
303 }
304 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 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 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 out.push('[');
346 i += 1;
347 seg_start = i;
348 continue;
349 }
350
351 i += 1;
353 }
354 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 let s = apply_style("Y", "bold sparkly yellow foo");
381 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 let styled = apply_style("X", "fg:#ff0000");
404 let mut tokens = HashMap::new();
405 tokens.insert("t", styled);
406 let s = render_with_style_template("[](bg:#003366)$t", &tokens, "");
409 let plain = String::from_utf8(strip_ansi_escapes::strip(s)).unwrap();
411 assert_eq!(plain, "X");
412 }
413
414 #[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 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 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 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 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 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 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 use std::sync::{Mutex, OnceLock};
492 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
493 let _g = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
494 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 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 assert!(fg.contains("38;2;") && bg.contains("48;2;"));
520 }
521 }
522}