1use 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#[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 pub fn new() -> Self {
40 Self::default()
41 }
42
43 pub const fn fg(mut self, color: Color) -> Self {
45 self.fg = color;
46 self
47 }
48
49 pub const fn bg(mut self, color: Color) -> Self {
51 self.bg = color;
52 self
53 }
54
55 pub const fn bold(mut self) -> Self {
57 self.bold = true;
58 self
59 }
60
61 pub const fn italic(mut self) -> Self {
63 self.italic = true;
64 self
65 }
66
67 pub const fn underlined(mut self) -> Self {
69 self.underlined = true;
70 self
71 }
72
73 pub const fn with_bg(mut self, color: Color) -> Self {
75 self.bg = color;
76 self
77 }
78}
79
80#[derive(Clone, Debug, Eq, PartialEq, Default)]
82pub struct Span {
83 pub text: String,
84 pub style: CellStyle,
85}
86
87#[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
216pub 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 pub fn plain(text: impl Into<String>) -> Self {
232 Span { text: text.into(), style: CellStyle::default() }
233 }
234
235 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
253fn 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
279impl 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}