pub fn apply_style(text: &str, style: &str) -> String {
#[derive(Clone, Copy)]
enum ColorSpec {
NamedNormal(u8), NamedBright(u8), Index(u8), Rgb(u8, u8, u8), NoneSet, }
fn parse_named(name: &str) -> Option<u8> {
match name {
"black" => Some(0),
"red" => Some(1),
"green" => Some(2),
"yellow" => Some(3),
"blue" => Some(4),
"magenta" => Some(5),
"cyan" => Some(6),
"white" => Some(7),
_ => None,
}
}
fn supports_truecolor() -> bool {
if std::env::var("CCS_TRUECOLOR")
.map(|v| v == "1")
.unwrap_or(false)
{
return true;
}
if let Ok(v) = std::env::var("COLORTERM") {
let v = v.to_lowercase();
if v.contains("truecolor") || v.contains("24bit") {
return true;
}
}
if let Ok(t) = std::env::var("TERM") {
let t = t.to_lowercase();
if t.contains("direct") || t.contains("truecolor") {
return true;
}
}
false
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
let rg = r as i32 - g as i32;
let rb = r as i32 - b as i32;
let gb = g as i32 - b as i32;
let is_grayish = rg.abs() < 10 && rb.abs() < 10 && gb.abs() < 10;
if is_grayish {
let gray = ((r as u16 + g as u16 + b as u16) / 3) as u8;
if gray < 8 {
return 16; }
if gray > 238 {
return 231; }
return 232 + ((gray as u16 - 8) / 10) as u8;
}
let to_6 = |v: u8| -> u8 { ((v as u16 * 5 + 127) / 255) as u8 };
let r6 = to_6(r);
let g6 = to_6(g);
let b6 = to_6(b);
16 + 36 * r6 + 6 * g6 + b6
}
fn parse_color_spec(spec: &str) -> Option<ColorSpec> {
let s = spec.to_lowercase();
if s == "none" {
return Some(ColorSpec::NoneSet);
}
if let Some(hex) = s.strip_prefix('#') {
if hex.len() == 6 {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
return Some(ColorSpec::Rgb(r, g, b));
}
}
if s.chars().all(|c| c.is_ascii_digit()) {
if let Ok(n) = s.parse::<u16>() {
if n <= 255 {
return Some(ColorSpec::Index(n as u8));
}
}
}
if let Some(n) = s.strip_prefix("bright-") {
if let Some(idx) = parse_named(n) {
return Some(ColorSpec::NamedBright(idx));
}
}
if let Some(idx) = parse_named(&s) {
return Some(ColorSpec::NamedNormal(idx));
}
None
}
let mut bold = false;
let mut italic = false;
let mut underline = false;
let mut fg: Option<ColorSpec> = None;
let mut bg: Option<ColorSpec> = None;
for token in style.split_whitespace() {
let t = token.to_lowercase();
match t.as_str() {
"bold" => {
bold = true;
continue;
}
"italic" => {
italic = true;
continue;
}
"underline" => {
underline = true;
continue;
}
_ => {}
}
if let Some(rest) = t.strip_prefix("fg:") {
fg = parse_color_spec(rest);
continue;
}
if let Some(rest) = t.strip_prefix("bg:") {
bg = parse_color_spec(rest);
continue;
}
if let Some(cs) = parse_color_spec(&t) {
fg = Some(cs);
} else {
}
}
let mut codes: Vec<String> = Vec::with_capacity(5);
if bold {
codes.push("1".to_string());
}
if italic {
codes.push("3".to_string());
}
if underline {
codes.push("4".to_string());
}
if let Some(c) = fg {
match c {
ColorSpec::NamedNormal(idx) => codes.push((30 + idx).to_string()),
ColorSpec::NamedBright(idx) => codes.push((90 + idx).to_string()),
ColorSpec::Index(n) => codes.push(format!("38;5;{n}")),
ColorSpec::Rgb(r, g, b) => {
if supports_truecolor() {
codes.push(format!("38;2;{r};{g};{b}"));
} else {
let n = rgb_to_ansi256(r, g, b);
codes.push(format!("38;5;{n}"));
}
}
ColorSpec::NoneSet => {}
}
}
if let Some(c) = bg {
match c {
ColorSpec::NamedNormal(idx) => codes.push((40 + idx).to_string()),
ColorSpec::NamedBright(idx) => codes.push((100 + idx).to_string()),
ColorSpec::Index(n) => codes.push(format!("48;5;{n}")),
ColorSpec::Rgb(r, g, b) => {
if supports_truecolor() {
codes.push(format!("48;2;{r};{g};{b}"));
} else {
let n = rgb_to_ansi256(r, g, b);
codes.push(format!("48;5;{n}"));
}
}
ColorSpec::NoneSet => {}
}
}
if codes.is_empty() {
return text.to_string();
}
let sgr = codes.join(";");
format!("\x1b[{sgr}m{text}\x1b[0m")
}
pub fn render_with_style_template(
format: &str,
tokens: &std::collections::HashMap<&str, String>,
default_style: &str,
) -> String {
let mut replaced = String::from(format);
let mut keys: Vec<&str> = tokens.keys().copied().filter(|k| *k != "style").collect();
keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
for k in keys {
if let Some(v) = tokens.get(k) {
let needle = format!("${k}");
replaced = replaced.replace(&needle, v);
}
}
let bytes = replaced.as_bytes();
let mut i = 0;
let len = bytes.len();
let mut out = String::with_capacity(len + 16);
let mut seg_start = 0usize;
while i < len {
let b = bytes[i];
if b == 0x1b {
let start = i;
i += 1; if i < len && bytes[i] == b'[' {
i += 1;
while i < len {
let bb = bytes[i];
if (0x40..=0x7E).contains(&bb) {
i += 1; break;
}
i += 1;
}
}
if seg_start < start {
out.push_str(&replaced[seg_start..start]);
}
out.push_str(&replaced[start..i]);
seg_start = i;
continue;
}
if b == b'[' {
if seg_start < i {
out.push_str(&replaced[seg_start..i]);
}
let mut j = i + 1;
while j < len && bytes[j] != b']' {
j += 1;
}
if j < len && j + 1 < len && bytes[j + 1] == b'(' {
let mut k = j + 2;
while k < len && bytes[k] != b')' {
k += 1;
}
if k < len {
let inner = &replaced[i + 1..j];
let style_spec = &replaced[j + 2..k];
let style_to_use = if style_spec == "$style" {
default_style
} else {
style_spec
};
out.push_str(&apply_style(inner, style_to_use));
i = k + 1;
seg_start = i;
continue;
}
}
out.push('[');
i += 1;
seg_start = i;
continue;
}
i += 1;
}
if seg_start < len {
out.push_str(&replaced[seg_start..len]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applies_bold_yellow() {
let s = apply_style("X", "bold yellow");
assert!(s.starts_with("\u{1b}[") && s.contains("1;33") && s.ends_with("\u{1b}[0m"));
assert!(s.contains('X'));
}
#[test]
fn ignores_unknown_tokens() {
assert_eq!(apply_style("X", "unknown"), "X");
}
#[test]
fn mixed_known_and_unknown_tokens_are_stable() {
let s = apply_style("Y", "bold sparkly yellow foo");
assert!(s.starts_with("\u{1b}["));
assert!(s.contains("1;33") || s.contains("33;1"));
assert!(s.ends_with("\u{1b}[0m"));
assert!(s.contains('Y'));
}
#[test]
fn renders_bracket_style_template() {
use std::collections::HashMap;
let mut tokens = HashMap::new();
tokens.insert("path", String::from("~/proj"));
let out = render_with_style_template("[$path]($style)", &tokens, "bold blue");
assert!(out.contains("~/proj"));
assert!(out.starts_with("\u{1b}["));
assert!(out.ends_with("\u{1b}[0m"));
}
#[test]
fn ignores_ansi_sequences_when_parsing_text_groups() {
use std::collections::HashMap;
let styled = apply_style("X", "fg:#ff0000");
let mut tokens = HashMap::new();
tokens.insert("t", styled);
let s = render_with_style_template("[î‚°](bg:#003366)$t", &tokens, "");
let plain = String::from_utf8(strip_ansi_escapes::strip(s)).unwrap();
assert_eq!(plain, "î‚°X");
}
#[test]
fn style_named_fg_bg() {
let s = apply_style("X", "bold fg:green bg:black");
assert!(s.starts_with("\u{1b}["));
assert!(s.contains("1"));
assert!(s.contains("32"));
assert!(s.contains("40"));
assert!(s.ends_with("\u{1b}[0m"));
}
#[test]
fn style_bright_named() {
let s = apply_style("X", "bright-yellow bg:bright-blue");
assert!(s.contains("93"));
assert!(s.contains("104"));
}
#[test]
fn style_8bit_indexes() {
let s = apply_style("X", "fg:196 bg:238");
assert!(s.contains("38;5;196"));
assert!(s.contains("48;5;238"));
}
#[test]
fn style_hex_truecolor() {
let s = apply_style("X", "fg:#bf5700 bg:#003366");
assert!(s.contains("38;2;191;87;0") || s.contains("38;5;"));
assert!(s.contains("48;2;0;51;102") || s.contains("48;5;"));
}
#[test]
fn style_bare_color_equivalence() {
let s1 = apply_style("X", "yellow");
let s2 = apply_style("X", "fg:yellow");
assert_eq!(s1, s2);
}
#[test]
fn style_unknown_tokens_stability() {
let s = apply_style("X", "bold sparkle fg:green foo");
assert!(s.contains("1"));
assert!(s.contains("32") || s.contains("38;2;") || s.contains("38;5;"));
assert!(s.starts_with("\u{1b}[") && s.ends_with("\u{1b}[0m"));
}
#[test]
fn token_substitution_uses_longest_key_first() {
use std::collections::HashMap;
let mut tokens = HashMap::new();
tokens.insert("git", String::from("G"));
tokens.insert("git_branch", String::from("BR"));
let out = render_with_style_template("$git_branch $git", &tokens, "");
assert_eq!(out, "BR G");
assert!(!out.contains("_branch"));
}
#[test]
fn style_none_handling() {
let s = apply_style("X", "fg:none italic");
assert!(s.contains("3"));
assert!(!s.contains("38;"));
}
#[test]
fn rgb_foreground_background_downgrade_is_consistent() {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let _g = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
unsafe {
std::env::remove_var("CCS_TRUECOLOR");
std::env::set_var("COLORTERM", "");
std::env::set_var("TERM", "xterm-256color");
}
let fg = apply_style("X", "#9A348E");
let bg = apply_style("X", "bg:#9A348E");
let idx_fg = fg
.split("38;5;")
.nth(1)
.and_then(|s| s.split('m').next())
.and_then(|n| n.parse::<u16>().ok());
let idx_bg = bg
.split("48;5;")
.nth(1)
.and_then(|s| s.split('m').next())
.and_then(|n| n.parse::<u16>().ok());
if let (Some(a), Some(b)) = (idx_fg, idx_bg) {
assert_eq!(a, b);
} else {
assert!(fg.contains("38;2;") && bg.contains("48;2;"));
}
}
}