1use std::sync::Mutex;
6
7#[derive(Debug, Clone)]
16pub struct ThemeColors {
17 pub text: Color,
19 pub muted: Color,
21 pub dim: Color,
23 pub accent: Color,
25 pub error: Color,
27 pub success: Color,
29 pub warning: Color,
31 pub info: Color,
33 pub background: Color,
35 pub surface: Color,
37 pub border: Color,
39 pub border_accent: Color,
41 pub border_muted: Color,
43 pub selection: Color,
45 pub cursor: Color,
47 pub thinking_text: Color,
49
50 pub md_heading: Color,
53 pub md_link: Color,
55 pub md_link_url: Color,
57 pub md_code: Color,
59 pub md_code_bg: Color,
62 pub md_code_block: Color,
64 pub md_code_block_bg: Color,
66 pub md_code_block_border: Color,
68 pub md_quote: Color,
70 pub md_quote_border: Color,
72 pub md_hr: Color,
74 pub md_list_bullet: Color,
76
77 pub tool_pending_bg: Color,
80 pub tool_success_bg: Color,
82 pub tool_error_bg: Color,
84 pub tool_title: Color,
86 pub tool_output: Color,
88 pub bash_mode: Color,
90
91 pub custom_message_bg: Color,
94 pub custom_message_label: Color,
96 pub custom_message_text: Color,
98
99 pub tool_diff_added: Color,
102 pub tool_diff_removed: Color,
104 pub tool_diff_context: Color,
106}
107
108impl Default for ThemeColors {
109 fn default() -> Self {
110 Self {
117 text: Color::Default,
118 muted: Color::Ansi256(244), dim: Color::Ansi256(242), accent: Color::Ansi256(108), error: Color::Ansi256(131), success: Color::Ansi256(107), warning: Color::Ansi256(226), info: Color::Ansi256(81), background: Color::Default,
126 surface: Color::Ansi256(236), border: Color::Ansi256(67), border_accent: Color::Ansi256(45), border_muted: Color::Ansi256(239), selection: Color::Ansi256(60), cursor: Color::Ansi256(81),
132 thinking_text: Color::Ansi256(244), md_heading: Color::Ansi256(179), md_link: Color::Ansi256(110), md_link_url: Color::Ansi256(242), md_code: Color::Rgb(138, 190, 183), md_code_bg: Color::Rgb(48, 52, 59), md_code_block: Color::Rgb(212, 212, 212), md_code_block_bg: Color::Rgb(40, 44, 52), md_code_block_border: Color::Ansi256(244), md_quote: Color::Ansi256(244), md_quote_border: Color::Ansi256(244),
145 md_hr: Color::Ansi256(244),
146 md_list_bullet: Color::Ansi256(108), tool_pending_bg: Color::Rgb(40, 44, 52),
153 tool_success_bg: Color::Rgb(38, 48, 52), tool_error_bg: Color::Rgb(55, 40, 45), tool_title: Color::Default,
156 tool_output: Color::Ansi256(244),
157 bash_mode: Color::Ansi256(107), custom_message_bg: Color::Rgb(45, 40, 56),
162 custom_message_label: Color::Rgb(149, 117, 205),
163 custom_message_text: Color::Default,
164
165 tool_diff_added: Color::Ansi256(107), tool_diff_removed: Color::Ansi256(131), tool_diff_context: Color::Ansi256(244), }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Color {
176 Default,
178 Ansi16(u8),
180 Ansi256(u8),
182 Rgb(u8, u8, u8),
184}
185
186impl Color {
187 pub fn to_fg(&self) -> String {
189 match self {
190 Color::Default => "\x1b[39m".to_string(),
191 Color::Ansi16(c) => format!("\x1b[{}m", c),
192 Color::Ansi256(c) => format!("\x1b[38;5;{}m", c),
193 Color::Rgb(r, g, b) => format!("\x1b[38;2;{};{};{}m", r, g, b),
194 }
195 }
196
197 pub fn to_bg(&self) -> String {
199 match self {
200 Color::Default => "\x1b[49m".to_string(),
201 Color::Ansi16(c) => format!("\x1b[{}m", c + 10),
202 Color::Ansi256(c) => format!("\x1b[48;5;{}m", c),
203 Color::Rgb(r, g, b) => format!("\x1b[48;2;{};{};{}m", r, g, b),
204 }
205 }
206
207 pub fn fg(&self, text: &str) -> String {
209 format!("{}{}\x1b[0m", self.to_fg(), text)
210 }
211
212 pub fn bg(&self, text: &str) -> String {
214 let bg = self.to_bg();
215 let nested = text.replace("\x1b[0m", &format!("\x1b[0m{bg}"));
220 format!("{bg}{nested}\x1b[0m")
221 }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub struct ThemeSpacing {
227 pub tool_panel_padding_x: usize,
229 pub tool_panel_padding_y: usize,
231}
232
233impl Default for ThemeSpacing {
234 fn default() -> Self {
235 Self {
236 tool_panel_padding_x: 2,
237 tool_panel_padding_y: 1,
238 }
239 }
240}
241
242#[derive(Debug, Clone)]
244pub struct Theme {
245 pub colors: ThemeColors,
247 pub spacing: ThemeSpacing,
249 pub border_style: BorderStyle,
251 pub corner_style: CornerStyle,
253}
254
255impl Default for Theme {
256 fn default() -> Self {
257 Self {
258 colors: ThemeColors::default(),
259 spacing: ThemeSpacing::default(),
260 border_style: BorderStyle::Rounded,
261 corner_style: CornerStyle::Rounded,
262 }
263 }
264}
265
266#[derive(Debug, Clone, Copy, Default)]
268pub enum BorderStyle {
269 #[default]
270 Rounded,
271 Sharp,
272 Double,
273 Thick,
274 None,
275}
276
277impl BorderStyle {
278 pub fn chars(&self) -> BorderChars {
280 match self {
281 BorderStyle::Rounded => BorderChars {
282 top_left: "╭",
283 top_right: "╮",
284 bottom_left: "╰",
285 bottom_right: "╯",
286 horizontal: "─",
287 vertical: "│",
288 },
289 BorderStyle::Sharp => BorderChars {
290 top_left: "┌",
291 top_right: "┐",
292 bottom_left: "└",
293 bottom_right: "┘",
294 horizontal: "─",
295 vertical: "│",
296 },
297 BorderStyle::Double => BorderChars {
298 top_left: "╔",
299 top_right: "╗",
300 bottom_left: "╚",
301 bottom_right: "╝",
302 horizontal: "═",
303 vertical: "║",
304 },
305 BorderStyle::Thick => BorderChars {
306 top_left: "┏",
307 top_right: "┓",
308 bottom_left: "┗",
309 bottom_right: "┛",
310 horizontal: "━",
311 vertical: "┃",
312 },
313 BorderStyle::None => BorderChars {
314 top_left: " ",
315 top_right: " ",
316 bottom_left: " ",
317 bottom_right: " ",
318 horizontal: " ",
319 vertical: " ",
320 },
321 }
322 }
323}
324
325pub struct BorderChars {
327 pub top_left: &'static str,
328 pub top_right: &'static str,
329 pub bottom_left: &'static str,
330 pub bottom_right: &'static str,
331 pub horizontal: &'static str,
332 pub vertical: &'static str,
333}
334
335#[derive(Debug, Clone, Copy, Default)]
337pub enum CornerStyle {
338 #[default]
339 Rounded,
340 Sharp,
341}
342
343pub struct ThemeManager {
345 current: Mutex<Theme>,
346}
347
348static THEME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
355
356pub fn theme_revision() -> u64 {
358 THEME_REVISION.load(std::sync::atomic::Ordering::Acquire)
359}
360
361impl ThemeManager {
362 pub fn new() -> Self {
364 Self {
365 current: Mutex::new(Theme::default()),
366 }
367 }
368
369 pub fn get(&self) -> Theme {
371 self.current.lock().map(|t| t.clone()).unwrap_or_default()
372 }
373
374 pub fn set(&self, theme: Theme) {
376 if let Ok(mut current) = self.current.lock() {
377 *current = theme;
378 }
379 THEME_REVISION.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
380 }
381
382 pub fn apply_preset(&self, preset: ThemePreset) {
384 self.set(theme_for_preset(preset));
385 }
386}
387
388fn theme_for_preset(preset: ThemePreset) -> Theme {
393 match preset {
394 ThemePreset::Dark => Theme::default(),
395 ThemePreset::Light => {
396 let mut colors = ThemeColors::default();
397 colors.text = Color::Default;
398 colors.muted = Color::Ansi256(241); colors.dim = Color::Ansi256(243); colors.accent = Color::Ansi256(66); colors.error = Color::Ansi256(131); colors.success = Color::Ansi256(65); colors.warning = Color::Ansi256(136); colors.info = Color::Ansi256(67); colors.background = Color::Default;
406 colors.surface = Color::Ansi256(254); colors.border = Color::Ansi256(67); colors.border_accent = Color::Ansi256(66); colors.border_muted = Color::Ansi256(249); colors.selection = Color::Ansi256(189); colors.cursor = Color::Ansi256(67);
412 colors.thinking_text = Color::Ansi256(241);
413 colors.md_heading = Color::Ansi256(136); colors.md_link = Color::Ansi256(67); colors.md_link_url = Color::Ansi256(243);
416 colors.md_code = Color::Rgb(90, 128, 128); colors.md_code_bg = Color::Rgb(232, 234, 237);
418 colors.md_code_block = Color::Rgb(31, 35, 40);
419 colors.md_code_block_bg = Color::Rgb(246, 248, 250); colors.md_code_block_border = Color::Ansi256(241);
421 colors.md_quote = Color::Ansi256(241);
422 colors.md_quote_border = Color::Ansi256(241);
423 colors.md_hr = Color::Ansi256(241);
424 colors.md_list_bullet = Color::Ansi256(65); colors.tool_pending_bg = Color::Ansi256(189);
426 colors.tool_success_bg = Color::Ansi256(151);
427 colors.tool_error_bg = Color::Ansi256(181);
428 colors.tool_title = Color::Default;
429 colors.tool_output = Color::Ansi256(241);
430 colors.bash_mode = Color::Ansi256(65);
431 colors.custom_message_bg = Color::Rgb(237, 231, 246);
434 colors.custom_message_label = Color::Rgb(126, 87, 194);
435 colors.custom_message_text = Color::Ansi256(237);
436 colors.tool_diff_added = Color::Ansi256(65);
437 colors.tool_diff_removed = Color::Ansi256(131);
438 colors.tool_diff_context = Color::Ansi256(241);
439 Theme {
440 colors,
441 ..Default::default()
442 }
443 }
444 ThemePreset::Monochrome => {
445 let mut colors = ThemeColors::default();
446 for f in [
447 &mut colors.accent,
448 &mut colors.border_accent,
449 &mut colors.md_code,
450 &mut colors.md_list_bullet,
451 &mut colors.bash_mode,
452 ] {
453 *f = Color::Ansi256(15);
454 }
455 colors.muted = Color::Ansi256(244);
456 colors.dim = Color::Ansi256(242);
457 colors.error = Color::Ansi256(9);
458 colors.success = Color::Ansi256(15);
459 colors.warning = Color::Ansi256(15);
460 colors.info = Color::Ansi256(15);
461 colors.surface = Color::Ansi256(236);
462 colors.border = Color::Ansi256(244);
463 colors.border_muted = Color::Ansi256(240);
464 colors.selection = Color::Ansi256(244);
465 colors.cursor = Color::Ansi256(15);
466 colors.thinking_text = Color::Ansi256(244);
467 colors.md_heading = Color::Ansi256(15);
468 colors.md_link = Color::Ansi256(15);
469 colors.md_link_url = Color::Ansi256(242);
470 colors.md_code_bg = Color::Ansi256(237);
471 colors.md_code_block = Color::Ansi256(15);
472 colors.md_code_block_bg = Color::Ansi256(235);
473 colors.md_code_block_border = Color::Ansi256(244);
474 colors.md_quote = Color::Ansi256(244);
475 colors.md_quote_border = Color::Ansi256(244);
476 colors.md_hr = Color::Ansi256(244);
477 colors.custom_message_bg = Color::Ansi256(236);
478 colors.custom_message_label = Color::Ansi256(15);
479 colors.custom_message_text = Color::Ansi256(244);
480 colors.tool_title = Color::Default;
481 colors.tool_output = Color::Ansi256(244);
482 colors.tool_diff_added = Color::Ansi256(15);
483 colors.tool_diff_removed = Color::Ansi256(9);
484 colors.tool_diff_context = Color::Ansi256(244);
485 Theme {
486 colors,
487 ..Default::default()
488 }
489 }
490 }
491}
492
493impl Default for ThemeManager {
494 fn default() -> Self {
495 Self::new()
496 }
497}
498
499#[derive(Debug, Clone, Copy)]
501pub enum ThemePreset {
502 Dark,
503 Light,
504 Monochrome,
505}
506
507pub fn theme() -> Theme {
509 global_theme_manager().get()
510}
511
512pub fn apply_theme_preset(preset: ThemePreset) {
517 global_theme_manager().apply_preset(preset);
518}
519
520pub fn global_theme_manager() -> &'static ThemeManager {
522 THEME_MANAGER.get_or_init(ThemeManager::new)
523}
524
525static THEME_MANAGER: std::sync::OnceLock<ThemeManager> = std::sync::OnceLock::new();
527
528pub fn themed_text(text: &str, color_fn: fn(&ThemeColors) -> Color) -> String {
530 let theme = theme();
531 let color = color_fn(&theme.colors);
532 color.fg(text)
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn test_color_ansi() {
541 let color = Color::Ansi256(196);
542 let text = color.fg("Hello");
543 assert!(text.contains("Hello"));
544 }
545
546 #[test]
547 fn test_theme_manager() {
548 let manager = ThemeManager::new();
549 manager.apply_preset(ThemePreset::Dark);
550 let theme = manager.get();
551 assert!(matches!(theme.colors.accent, Color::Ansi256(108)));
552 }
553}