Skip to main content

gw_render/
style.rs

1//! Fixed colors, OSC 8 links, and escape-aware width measuring for static output.
2
3use unicode_width::UnicodeWidthChar;
4
5pub const GREEN: &str = "32";
6pub const RED: &str = "31";
7pub const YELLOW: &str = "33";
8pub const DIM: &str = "2";
9pub const BOLD: &str = "1";
10pub const BRANCH: &str = "36";
11pub const BRANCH_BOLD: &str = "1;36";
12pub const UPSTREAM: &str = "35";
13pub const LINK: &str = "4;34";
14
15/// True when `NO_COLOR` is set to a non-empty value.
16pub fn no_color_env() -> bool {
17    std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
18}
19
20/// Wraps `s` in an SGR sequence, or returns it unchanged when `color` is false.
21pub fn paint(s: &str, sgr: &str, color: bool) -> String {
22    if color && !s.is_empty() { format!("\x1b[{sgr}m{s}\x1b[0m") } else { s.to_string() }
23}
24
25/// OSC 8 hyperlink. Emitted even without color: links are not styling.
26pub fn link(url: &str, text: &str) -> String {
27    format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
28}
29
30/// Makes untrusted text (file names, server messages) inert: control chars (C0, DEL, C1)
31/// become `\xNN`, tab becomes a space. Prevents terminal escape injection.
32pub fn sanitize(s: &str) -> String {
33    let mut out = String::with_capacity(s.len());
34    for c in s.chars() {
35        match c {
36            '\t' => out.push(' '),
37            c if c.is_control() => out += &format!("\\x{:02x}", c as u32),
38            c => out.push(c),
39        }
40    }
41    out
42}
43
44/// Removes CSI (`ESC [ ... final`) and OSC (`ESC ] ... ST|BEL`) sequences.
45pub fn strip_escapes(s: &str) -> String {
46    let mut out = String::with_capacity(s.len());
47    let mut it = s.chars().peekable();
48    while let Some(c) = it.next() {
49        if c != '\x1b' {
50            out.push(c);
51            continue;
52        }
53        match it.next() {
54            Some('[') => {
55                for c in it.by_ref() {
56                    if ('\x40'..='\x7e').contains(&c) {
57                        break;
58                    }
59                }
60            }
61            Some(']') => {
62                while let Some(c) = it.next() {
63                    if c == '\x07' || (c == '\x1b' && it.next_if_eq(&'\\').is_some()) {
64                        break;
65                    }
66                }
67            }
68            _ => {}
69        }
70    }
71    out
72}
73
74/// Display width of the visible text, ignoring escapes.
75pub fn visible_width(s: &str) -> usize {
76    unicode_width::UnicodeWidthStr::width(strip_escapes(s).as_str())
77}
78
79/// Cuts plain text to at most `max` columns, ending with `…` when cut.
80pub fn truncate(s: &str, max: usize) -> String {
81    if unicode_width::UnicodeWidthStr::width(s) <= max {
82        return s.to_string();
83    }
84    let mut out = String::new();
85    let mut w = 0;
86    for c in s.chars() {
87        let cw = c.width().unwrap_or(0);
88        if w + cw + 1 > max {
89            break;
90        }
91        out.push(c);
92        w += cw;
93    }
94    if max > 0 {
95        out.push('…');
96    }
97    out
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn no_color_has_no_sgr_but_keeps_links() {
106        let s = format!("{} {}", paint("x", GREEN, false), link("https://a.b", "a.b"));
107        assert!(!s.contains("\x1b["));
108        assert!(s.contains("\x1b]8;;https://a.b\x1b\\"));
109    }
110
111    #[test]
112    fn sanitize_controls() {
113        assert_eq!(sanitize("a\x1b]8;;x\x07b\u{9b}c\td"), "a\\x1b]8;;x\\x07b\\x9bc d");
114        assert_eq!(sanitize("ünï"), "ünï");
115    }
116
117    #[test]
118    fn width_ignores_escapes() {
119        let s = format!("{}─{}", paint("ab", BOLD, true), link("https://x.y/zz", "x…"));
120        assert_eq!(visible_width(&s), 5);
121        assert_eq!(truncate("abcdef", 4), "abc…");
122        assert_eq!(truncate("abc", 4), "abc");
123    }
124}