Skip to main content

thndrs_lib/cli/renderer/
style.rs

1//! Renderer style primitives.
2//!
3//! [`CellStyle`] and [`Span`] are renderer-owned because wrapping, padding,
4//! truncation, cursor calculation, and snapshots operate on them directly.
5//! Color is Crossterm's color type; keeping a separate mirror enum adds
6//! conversion code without buying backend independence today.
7
8use std::fmt;
9use std::sync::atomic::{AtomicU8, Ordering};
10
11pub use crossterm::style::Color;
12
13use crate::cli::Theme;
14
15const SPINNER_FRAME_INTERVAL_MS: u64 = 66;
16
17/// Style attributes applied to a cell's text and background.
18///
19/// Mirrors the subset of ANSI attributes `thndrs` uses today: foreground and
20/// background color plus bold, italic, underlined, and dim modifiers.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub struct CellStyle {
23    pub fg: Color,
24    pub bg: Color,
25    pub bold: bool,
26    pub italic: bool,
27    pub underlined: bool,
28    pub dim: bool,
29}
30
31impl Default for CellStyle {
32    fn default() -> Self {
33        CellStyle { fg: Color::Reset, bg: Color::Reset, bold: false, italic: false, underlined: false, dim: false }
34    }
35}
36
37impl CellStyle {
38    /// Create a default (reset) style.
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Set the foreground color.
44    pub const fn fg(mut self, color: Color) -> Self {
45        self.fg = color;
46        self
47    }
48
49    /// Set the background color.
50    pub const fn bg(mut self, color: Color) -> Self {
51        self.bg = color;
52        self
53    }
54
55    /// Enable bold.
56    pub const fn bold(mut self) -> Self {
57        self.bold = true;
58        self
59    }
60
61    /// Enable italic.
62    pub const fn italic(mut self) -> Self {
63        self.italic = true;
64        self
65    }
66
67    /// Enable underline.
68    pub const fn underlined(mut self) -> Self {
69        self.underlined = true;
70        self
71    }
72
73    /// Return a copy with the background color changed.
74    pub const fn with_bg(mut self, color: Color) -> Self {
75        self.bg = color;
76        self
77    }
78}
79
80/// A styled run of text.
81#[derive(Clone, Debug, Eq, PartialEq, Default)]
82pub struct Span {
83    pub text: String,
84    pub style: CellStyle,
85}
86
87/// Theme palette expressed in renderer-native colors.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct Palette {
90    pub accent: Color,
91    pub panel_bg: Color,
92    pub surface0: Color,
93    pub surface1: Color,
94    pub surface_dim: Color,
95    pub overlay0: Color,
96    pub overlay1: Color,
97    pub text: Color,
98    pub subtext0: Color,
99    pub mauve: Color,
100    pub pink: Color,
101    pub green: Color,
102    pub yellow: Color,
103    pub red: Color,
104    pub blue: Color,
105    pub teal: Color,
106    pub peach: Color,
107}
108
109static CURRENT_THEME: AtomicU8 = AtomicU8::new(0);
110
111pub const ICEBERG_DARK: Palette = Palette {
112    accent: Color::Rgb { r: 132, g: 160, b: 198 },
113    panel_bg: Color::Rgb { r: 22, g: 24, b: 33 },
114    surface0: Color::Rgb { r: 30, g: 33, b: 50 },
115    surface1: Color::Rgb { r: 39, g: 44, b: 66 },
116    surface_dim: Color::Rgb { r: 15, g: 17, b: 23 },
117    overlay0: Color::Rgb { r: 68, g: 75, b: 113 },
118    overlay1: Color::Rgb { r: 107, g: 112, b: 137 },
119    text: Color::Rgb { r: 198, g: 200, b: 209 },
120    subtext0: Color::Rgb { r: 129, g: 133, b: 150 },
121    mauve: Color::Rgb { r: 160, g: 147, b: 199 },
122    pink: Color::Rgb { r: 242, g: 101, b: 181 },
123    green: Color::Rgb { r: 180, g: 190, b: 130 },
124    yellow: Color::Rgb { r: 226, g: 164, b: 120 },
125    red: Color::Rgb { r: 226, g: 120, b: 120 },
126    blue: Color::Rgb { r: 132, g: 160, b: 198 },
127    teal: Color::Rgb { r: 137, g: 184, b: 194 },
128    peach: Color::Rgb { r: 226, g: 164, b: 120 },
129};
130
131pub const ELDRITCH_MINIMAL: Palette = Palette {
132    accent: Color::Rgb { r: 55, g: 244, b: 153 },
133    panel_bg: Color::Rgb { r: 23, g: 25, b: 40 },
134    surface0: Color::Rgb { r: 33, g: 35, b: 55 },
135    surface1: Color::Rgb { r: 41, g: 46, b: 66 },
136    surface_dim: Color::Rgb { r: 23, g: 25, b: 40 },
137    overlay0: Color::Rgb { r: 59, g: 66, b: 97 },
138    overlay1: Color::Rgb { r: 100, g: 115, b: 183 },
139    text: Color::Rgb { r: 235, g: 250, b: 250 },
140    subtext0: Color::Rgb { r: 171, g: 180, b: 218 },
141    mauve: Color::Rgb { r: 164, g: 140, b: 242 },
142    pink: Color::Rgb { r: 242, g: 101, b: 181 },
143    green: Color::Rgb { r: 55, g: 244, b: 153 },
144    yellow: Color::Rgb { r: 241, g: 252, b: 121 },
145    red: Color::Rgb { r: 241, g: 108, b: 117 },
146    blue: Color::Rgb { r: 4, g: 209, b: 249 },
147    teal: Color::Rgb { r: 4, g: 209, b: 249 },
148    peach: Color::Rgb { r: 247, g: 198, b: 127 },
149};
150
151pub const CATPPUCCIN_MOCHA: Palette = Palette {
152    accent: Color::Rgb { r: 203, g: 166, b: 247 },
153    panel_bg: Color::Rgb { r: 30, g: 30, b: 46 },
154    surface0: Color::Rgb { r: 49, g: 50, b: 68 },
155    surface1: Color::Rgb { r: 69, g: 71, b: 90 },
156    surface_dim: Color::Rgb { r: 24, g: 24, b: 37 },
157    overlay0: Color::Rgb { r: 108, g: 112, b: 134 },
158    overlay1: Color::Rgb { r: 127, g: 132, b: 156 },
159    text: Color::Rgb { r: 205, g: 214, b: 244 },
160    subtext0: Color::Rgb { r: 166, g: 173, b: 200 },
161    mauve: Color::Rgb { r: 203, g: 166, b: 247 },
162    pink: Color::Rgb { r: 245, g: 194, b: 231 },
163    green: Color::Rgb { r: 166, g: 227, b: 161 },
164    yellow: Color::Rgb { r: 249, g: 226, b: 175 },
165    red: Color::Rgb { r: 243, g: 139, b: 168 },
166    blue: Color::Rgb { r: 137, g: 180, b: 250 },
167    teal: Color::Rgb { r: 148, g: 226, b: 213 },
168    peach: Color::Rgb { r: 250, g: 179, b: 135 },
169};
170
171impl Theme {
172    fn renderer_palette(self) -> Palette {
173        match self {
174            Theme::EldritchMinimal => ELDRITCH_MINIMAL,
175            Theme::IcebergDark => ICEBERG_DARK,
176            Theme::CatppuccinMocha => CATPPUCCIN_MOCHA,
177        }
178    }
179}
180
181pub fn set_theme(theme: Theme) {
182    CURRENT_THEME.store(theme as u8, Ordering::Relaxed);
183}
184
185pub fn palette() -> Palette {
186    match CURRENT_THEME.load(Ordering::Relaxed) {
187        1 => Theme::IcebergDark.renderer_palette(),
188        2 => Theme::CatppuccinMocha.renderer_palette(),
189        _ => Theme::EldritchMinimal.renderer_palette(),
190    }
191}
192
193pub fn status_color(label: &str) -> Color {
194    let p = palette();
195    match label {
196        "idle" => p.overlay0,
197        "done" => p.green,
198        "sending" | "thinking" | "working" | "running tool" | "stopping" => p.peach,
199        "cancelled" => p.teal,
200        "failed" => p.red,
201        _ => p.overlay0,
202    }
203}
204
205pub fn status_icon(label: &str, tick: u64) -> &'static str {
206    match label {
207        "sending" | "thinking" | "working" | "running tool" | "stopping" => spinner_frame(tick),
208        "done" => "✓",
209        "failed" => "✕",
210        "cancelled" => "○",
211        "idle" => "·",
212        _ => "·",
213    }
214}
215
216/// Convert UI ticks to a spinner frame that advances about every 66 milliseconds.
217///
218/// The event/render loop may run faster than the spinner so streaming output
219/// stays responsive without making the activity affordance visually frantic.
220pub fn spinner_tick(ui_tick: u64, tick_rate_ms: u64) -> u64 {
221    ui_tick.saturating_mul(tick_rate_ms.max(1)) / SPINNER_FRAME_INTERVAL_MS
222}
223
224pub fn spinner_frame(tick: u64) -> &'static str {
225    const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
226    FRAMES[(tick as usize) % FRAMES.len()]
227}
228
229impl Span {
230    /// Create a plain (unstyled) span.
231    pub fn plain(text: impl Into<String>) -> Self {
232        Span { text: text.into(), style: CellStyle::default() }
233    }
234
235    /// Create a styled span.
236    pub fn styled(text: impl Into<String>, style: CellStyle) -> Self {
237        Span { text: text.into(), style }
238    }
239}
240
241impl From<&str> for Span {
242    fn from(s: &str) -> Self {
243        Span::plain(s)
244    }
245}
246
247impl From<String> for Span {
248    fn from(s: String) -> Self {
249        Span::plain(s)
250    }
251}
252
253/// Compact, human-readable representation of a [`Color`] for snapshot
254/// debug output.
255fn color_debug(color: Color) -> String {
256    match color {
257        Color::Reset => "-".to_string(),
258        Color::Black => "black".to_string(),
259        Color::DarkRed => "darkred".to_string(),
260        Color::DarkGreen => "darkgreen".to_string(),
261        Color::DarkYellow => "darkyellow".to_string(),
262        Color::DarkBlue => "darkblue".to_string(),
263        Color::DarkMagenta => "darkmagenta".to_string(),
264        Color::DarkCyan => "darkcyan".to_string(),
265        Color::Grey => "grey".to_string(),
266        Color::DarkGrey => "darkgrey".to_string(),
267        Color::Red => "red".to_string(),
268        Color::Green => "green".to_string(),
269        Color::Yellow => "yellow".to_string(),
270        Color::Blue => "blue".to_string(),
271        Color::Magenta => "magenta".to_string(),
272        Color::Cyan => "cyan".to_string(),
273        Color::White => "white".to_string(),
274        Color::Rgb { r, g, b } => format!("#{r:02x}{g:02x}{b:02x}"),
275        Color::AnsiValue(value) => format!("ansi{value}"),
276    }
277}
278
279/// Render a [`CellStyle`] as a compact attribute string for snapshots.
280///
281/// Produces strings like `fg=#aabbcc bg=#112233 biu` where the trailing letters
282/// are `b`old, `i`talic, `u`nderlined, `d`im. A fully default style renders as
283/// the single letter `-`.
284impl fmt::Display for CellStyle {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        let mut parts: Vec<String> = Vec::new();
287        if self.fg != Color::Reset {
288            parts.push(format!("fg={}", color_debug(self.fg)));
289        }
290        if self.bg != Color::Reset {
291            parts.push(format!("bg={}", color_debug(self.bg)));
292        }
293        let mut attrs = String::new();
294        if self.bold {
295            attrs.push('b');
296        }
297        if self.italic {
298            attrs.push('i');
299        }
300        if self.underlined {
301            attrs.push('u');
302        }
303        if self.dim {
304            attrs.push('d');
305        }
306        if !attrs.is_empty() {
307            parts.push(attrs);
308        }
309        if parts.is_empty() {
310            return f.write_str("-");
311        }
312        f.write_str(&parts.join(" "))
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn default_style_is_reset() {
322        let s = CellStyle::default();
323        assert_eq!(s.fg, Color::Reset);
324        assert_eq!(s.bg, Color::Reset);
325        assert!(!s.bold);
326        assert!(!s.italic);
327        assert!(!s.underlined);
328        assert!(!s.dim);
329    }
330
331    #[test]
332    fn builder_sets_attributes() {
333        let s = CellStyle::new()
334            .fg(Color::Red)
335            .bg(Color::Blue)
336            .bold()
337            .italic()
338            .underlined();
339        assert_eq!(s.fg, Color::Red);
340        assert_eq!(s.bg, Color::Blue);
341        assert!(s.bold && s.italic && s.underlined);
342    }
343
344    #[test]
345    fn display_default_style_is_dash() {
346        assert_eq!(CellStyle::default().to_string(), "-");
347    }
348
349    #[test]
350    fn display_shows_fg_bg_and_attrs() {
351        let s = CellStyle::new().fg(Color::Red).bg(Color::Blue).bold().italic();
352        assert_eq!(s.to_string(), "fg=red bg=blue bi");
353    }
354
355    #[test]
356    fn display_rgb_color_hex() {
357        let s = CellStyle::new().fg(Color::Rgb { r: 0xaa, g: 0xbb, b: 0xcc });
358        assert_eq!(s.to_string(), "fg=#aabbcc");
359    }
360
361    #[test]
362    fn spinner_frame_wraps() {
363        assert_eq!(spinner_frame(0), "⠋");
364        assert_eq!(spinner_frame(10), "⠋");
365        assert_eq!(spinner_frame(11), "⠙");
366    }
367
368    #[test]
369    fn spinner_tick_advances_at_a_smooth_66_ms_cadence() {
370        assert_eq!(spinner_tick(0, 33), 0);
371        assert_eq!(spinner_tick(1, 33), 0);
372        assert_eq!(spinner_tick(2, 33), 1);
373        assert_eq!(spinner_tick(1, 100), 1);
374    }
375
376    #[test]
377    fn theme_selects_palette() {
378        assert_eq!(Theme::IcebergDark.renderer_palette(), ICEBERG_DARK);
379        assert_eq!(Theme::EldritchMinimal.renderer_palette(), ELDRITCH_MINIMAL);
380        assert_eq!(Theme::CatppuccinMocha.renderer_palette(), CATPPUCCIN_MOCHA);
381    }
382
383    #[test]
384    fn eldritch_minimal_uses_the_terminal_label_palette() {
385        assert_eq!(ELDRITCH_MINIMAL.teal, Color::Rgb { r: 4, g: 209, b: 249 });
386        assert_eq!(ELDRITCH_MINIMAL.green, Color::Rgb { r: 55, g: 244, b: 153 });
387        assert_eq!(ELDRITCH_MINIMAL.mauve, Color::Rgb { r: 164, g: 140, b: 242 });
388        assert_eq!(ELDRITCH_MINIMAL.pink, Color::Rgb { r: 242, g: 101, b: 181 });
389        assert_eq!(ELDRITCH_MINIMAL.yellow, Color::Rgb { r: 241, g: 252, b: 121 });
390        assert_eq!(ELDRITCH_MINIMAL.peach, Color::Rgb { r: 247, g: 198, b: 127 });
391        assert_eq!(ELDRITCH_MINIMAL.red, Color::Rgb { r: 241, g: 108, b: 117 });
392    }
393}