Skip to main content

rpi_tui/
theme.rs

1//! Theme system for TUI customization.
2//!
3//! Provides color schemes and styling options.
4
5use std::sync::Mutex;
6
7/// Theme colors.
8///
9/// Mirrors the pi `ThemeJson.colors` tokens (see
10/// `.reference/pi/.../theme/dark.json`). The semantic core (`muted`, `accent`,
11/// `error`, `success`, `warning`, `border`, `surface`) drives most components;
12/// the `md_*` / `tool_*` / `thinking_*` fields correspond 1:1 to the pi md* /
13/// toolDiff* / thinking-level tokens and let the markdown renderer and tool
14/// blocks match pi's palette instead of hardcoded `Ansi256`/`fg_256` literals.
15#[derive(Debug, Clone)]
16pub struct ThemeColors {
17    /// Primary text color.
18    pub text: Color,
19    /// Muted/dimmed text color (pi `muted`).
20    pub muted: Color,
21    /// Dimmer-than-muted color (pi `dim`).
22    pub dim: Color,
23    /// Accent/highlight color (pi `accent`, e.g. teal-ish).
24    pub accent: Color,
25    /// Error color.
26    pub error: Color,
27    /// Success color.
28    pub success: Color,
29    /// Warning color.
30    pub warning: Color,
31    /// Info color.
32    pub info: Color,
33    /// Background color.
34    pub background: Color,
35    /// Primary surface color (pi `userMessageBg`).
36    pub surface: Color,
37    /// Border color (pi `border`).
38    pub border: Color,
39    /// Accent border color (pi `borderAccent`).
40    pub border_accent: Color,
41    /// Muted border color (pi `borderMuted`).
42    pub border_muted: Color,
43    /// Selection background color.
44    pub selection: Color,
45    /// Cursor color.
46    pub cursor: Color,
47    /// Text color for thinking/reasoning blocks (pi `thinkingText`).
48    pub thinking_text: Color,
49
50    // -- Markdown syntax tokens (pi md*) --
51    /// Heading color (pi `mdHeading`, gold on dark).
52    pub md_heading: Color,
53    /// Inline link color (pi `mdLink`).
54    pub md_link: Color,
55    /// Link URL color (pi `mdLinkUrl`).
56    pub md_link_url: Color,
57    /// Inline `code` color (pi `mdCode`).
58    pub md_code: Color,
59    /// Subtle inline-code background. This Rust extension keeps code readable
60    /// without applying a card background to the whole assistant message.
61    pub md_code_bg: Color,
62    /// Code-block body color (pi `mdCodeBlock`).
63    pub md_code_block: Color,
64    /// Low-contrast code-block surface (One Dark inspired).
65    pub md_code_block_bg: Color,
66    /// Code-block fence/border color (pi `mdCodeBlockBorder`).
67    pub md_code_block_border: Color,
68    /// Blockquote body color (pi `mdQuote`).
69    pub md_quote: Color,
70    /// Blockquote border color (pi `mdQuoteBorder`).
71    pub md_quote_border: Color,
72    /// Horizontal rule color (pi `mdHr`).
73    pub md_hr: Color,
74    /// List bullet color (pi `mdListBullet`).
75    pub md_list_bullet: Color,
76
77    // -- Tool execution backgrounds (pi tool*Bg / toolTitle / toolOutput) --
78    /// Pending-tool background.
79    pub tool_pending_bg: Color,
80    /// Successful-tool background.
81    pub tool_success_bg: Color,
82    /// Failed-tool background.
83    pub tool_error_bg: Color,
84    /// Tool title text color.
85    pub tool_title: Color,
86    /// Tool output text color.
87    pub tool_output: Color,
88    /// Bash mode accent color (pi `bashMode`).
89    pub bash_mode: Color,
90
91    // -- Custom messages / skill invocation (pi customMessage*) --
92    /// Skill-invocation / custom-message background (pi `customMessageBg`).
93    pub custom_message_bg: Color,
94    /// Skill-invocation `[skill]` label color (pi `customMessageLabel`).
95    pub custom_message_label: Color,
96    /// Skill-invocation body text color (pi `customMessageText`).
97    pub custom_message_text: Color,
98
99    // -- Tool diffs (pi toolDiff*) --
100    /// Added-line color.
101    pub tool_diff_added: Color,
102    /// Removed-line color.
103    pub tool_diff_removed: Color,
104    /// Context-line color.
105    pub tool_diff_context: Color,
106}
107
108impl Default for ThemeColors {
109    fn default() -> Self {
110        // Defaults mirror the pi `dark.json` palette (`accent` teal, the
111        // `#f0c674` gold heading, `#b5bd68` green code blocks, and the
112        // `#282832`/`#283228`/`#3c2828` tool backgrounds). Exact pi hexes are
113        // approximated by their nearest ANSI-256 index so 8/256-color
114        // terminals reproduce the same relationships; truecolor rendering is
115        // handled per-`Color` variant at emit time.
116        Self {
117            text: Color::Default,
118            muted: Color::Ansi256(244),   // gray (#808080-ish)
119            dim: Color::Ansi256(242),     // dimGray (#666666-ish)
120            accent: Color::Ansi256(108),  // accent teal (#8abeb7) ≈ 108
121            error: Color::Ansi256(131),   // red (#cc6666) ≈ 131
122            success: Color::Ansi256(107), // green (#b5bd68) ≈ 107
123            warning: Color::Ansi256(226), // yellow
124            info: Color::Ansi256(81),     // cyan
125            background: Color::Default,
126            surface: Color::Ansi256(236), // userMessageBg (#343541) ≈ 236
127            border: Color::Ansi256(67),   // border blue (#5f87ff) ≈ 67
128            border_accent: Color::Ansi256(45), // borderAccent cyan (#00d7ff) ≈ 45
129            border_muted: Color::Ansi256(239), // borderMuted darkGray (#505050) ≈ 239
130            selection: Color::Ansi256(60), // selectedBg (#3a3a4a) ≈ 60
131            cursor: Color::Ansi256(81),
132            thinking_text: Color::Ansi256(244), // thinkingText gray
133
134            // Markdown
135            md_heading: Color::Ansi256(179),  // gold (#f0c674) ≈ 179
136            md_link: Color::Ansi256(110),     // blue (#81a2be) ≈ 110
137            md_link_url: Color::Ansi256(242), // dimGray
138            md_code: Color::Rgb(138, 190, 183), // upstream accent #8abeb7
139            md_code_bg: Color::Rgb(48, 52, 59), // subtle inline chip
140            md_code_block: Color::Rgb(212, 212, 212), // neutral code text
141            md_code_block_bg: Color::Rgb(40, 44, 52), // One Dark #282c34
142            md_code_block_border: Color::Ansi256(244), // gray
143            md_quote: Color::Ansi256(244),    // gray
144            md_quote_border: Color::Ansi256(244),
145            md_hr: Color::Ansi256(244),
146            md_list_bullet: Color::Ansi256(108), // accent
147
148            // Tool blocks
149            // Keep status backgrounds close to the terminal surface. Status is
150            // communicated by the icon/foreground; a full saturated green
151            // panel makes successful tools dominate the transcript.
152            tool_pending_bg: Color::Rgb(40, 44, 52),
153            tool_success_bg: Color::Rgb(38, 48, 52), // subtle blue/teal tint
154            tool_error_bg: Color::Rgb(55, 40, 45),   // subtle red tint
155            tool_title: Color::Default,
156            tool_output: Color::Ansi256(244),
157            bash_mode: Color::Ansi256(107), // green
158
159            // Custom messages / skill invocation (pi dark.json):
160            // customMsgBg #2d2838, customMessageLabel #9575cd, text #d4d4d4.
161            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 diffs
166            tool_diff_added: Color::Ansi256(107),   // green
167            tool_diff_removed: Color::Ansi256(131), // red
168            tool_diff_context: Color::Ansi256(244), // gray
169        }
170    }
171}
172
173/// Color representation.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Color {
176    /// Default terminal color.
177    Default,
178    /// ANSI 16-color palette.
179    Ansi16(u8),
180    /// ANSI 256-color palette.
181    Ansi256(u8),
182    /// RGB true color.
183    Rgb(u8, u8, u8),
184}
185
186impl Color {
187    /// Convert to ANSI foreground escape sequence.
188    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    /// Convert to ANSI background escape sequence.
198    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    /// Apply color to text (foreground).
208    pub fn fg(&self, text: &str) -> String {
209        format!("{}{}\x1b[0m", self.to_fg(), text)
210    }
211
212    /// Apply color to text (background).
213    pub fn bg(&self, text: &str) -> String {
214        let bg = self.to_bg();
215        // Foreground helpers deliberately terminate with SGR 0. When such a
216        // styled span is nested in a panel background, that reset also clears
217        // the background and used to leave the rest of tool/user rows patchy.
218        // Re-apply this background after every nested reset.
219        let nested = text.replace("\x1b[0m", &format!("\x1b[0m{bg}"));
220        format!("{bg}{nested}\x1b[0m")
221    }
222}
223
224/// Theme settings.
225#[derive(Debug, Clone)]
226pub struct Theme {
227    /// Color scheme.
228    pub colors: ThemeColors,
229    /// Border style.
230    pub border_style: BorderStyle,
231    /// Corner style.
232    pub corner_style: CornerStyle,
233}
234
235impl Default for Theme {
236    fn default() -> Self {
237        Self {
238            colors: ThemeColors::default(),
239            border_style: BorderStyle::Rounded,
240            corner_style: CornerStyle::Rounded,
241        }
242    }
243}
244
245/// Border style for boxes.
246#[derive(Debug, Clone, Copy, Default)]
247pub enum BorderStyle {
248    #[default]
249    Rounded,
250    Sharp,
251    Double,
252    Thick,
253    None,
254}
255
256impl BorderStyle {
257    /// Get the border characters.
258    pub fn chars(&self) -> BorderChars {
259        match self {
260            BorderStyle::Rounded => BorderChars {
261                top_left: "╭",
262                top_right: "╮",
263                bottom_left: "╰",
264                bottom_right: "╯",
265                horizontal: "─",
266                vertical: "│",
267            },
268            BorderStyle::Sharp => BorderChars {
269                top_left: "┌",
270                top_right: "┐",
271                bottom_left: "└",
272                bottom_right: "┘",
273                horizontal: "─",
274                vertical: "│",
275            },
276            BorderStyle::Double => BorderChars {
277                top_left: "╔",
278                top_right: "╗",
279                bottom_left: "╚",
280                bottom_right: "╝",
281                horizontal: "═",
282                vertical: "║",
283            },
284            BorderStyle::Thick => BorderChars {
285                top_left: "┏",
286                top_right: "┓",
287                bottom_left: "┗",
288                bottom_right: "┛",
289                horizontal: "━",
290                vertical: "┃",
291            },
292            BorderStyle::None => BorderChars {
293                top_left: " ",
294                top_right: " ",
295                bottom_left: " ",
296                bottom_right: " ",
297                horizontal: " ",
298                vertical: " ",
299            },
300        }
301    }
302}
303
304/// Border characters.
305pub struct BorderChars {
306    pub top_left: &'static str,
307    pub top_right: &'static str,
308    pub bottom_left: &'static str,
309    pub bottom_right: &'static str,
310    pub horizontal: &'static str,
311    pub vertical: &'static str,
312}
313
314/// Corner style.
315#[derive(Debug, Clone, Copy, Default)]
316pub enum CornerStyle {
317    #[default]
318    Rounded,
319    Sharp,
320}
321
322/// Global theme manager.
323pub struct ThemeManager {
324    current: Mutex<Theme>,
325}
326
327/// Monotonic revision bumped on every theme mutation.
328///
329/// Components that memoize rendered output (which bakes in theme colors) key
330/// their cache on this value, so a live theme switch invalidates them without
331/// every host having to walk the tree and call `invalidate` (upstream's
332/// `theme-controller.ts` does both: swap the palette and `ui.invalidate()`).
333static THEME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
334
335/// Current theme revision. Cache the value at build time and compare on render.
336pub fn theme_revision() -> u64 {
337    THEME_REVISION.load(std::sync::atomic::Ordering::Acquire)
338}
339
340impl ThemeManager {
341    /// Create a new theme manager.
342    pub fn new() -> Self {
343        Self {
344            current: Mutex::new(Theme::default()),
345        }
346    }
347
348    /// Get the current theme.
349    pub fn get(&self) -> Theme {
350        self.current.lock().map(|t| t.clone()).unwrap_or_default()
351    }
352
353    /// Set the theme.
354    pub fn set(&self, theme: Theme) {
355        if let Ok(mut current) = self.current.lock() {
356            *current = theme;
357        }
358        THEME_REVISION.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
359    }
360
361    /// Apply a preset theme to this manager.
362    pub fn apply_preset(&self, preset: ThemePreset) {
363        self.set(theme_for_preset(preset));
364    }
365}
366
367/// Build a complete theme for a preset.
368///
369/// Kept separate from [`ThemeManager::apply_preset`] so the process-wide theme
370/// used by components can be updated without maintaining a second palette.
371fn theme_for_preset(preset: ThemePreset) -> Theme {
372    match preset {
373        ThemePreset::Dark => Theme::default(),
374        ThemePreset::Light => {
375            let mut colors = ThemeColors::default();
376            colors.text = Color::Default;
377            colors.muted = Color::Ansi256(241); // mediumGray (#6c6c6c)
378            colors.dim = Color::Ansi256(243); // dimGray (#767676)
379            colors.accent = Color::Ansi256(66); // teal (#5a8080)
380            colors.error = Color::Ansi256(131); // red (#aa5555)
381            colors.success = Color::Ansi256(65); // green (#588458)
382            colors.warning = Color::Ansi256(136); // yellow (#9a7326)
383            colors.info = Color::Ansi256(67); // blue
384            colors.background = Color::Default;
385            colors.surface = Color::Ansi256(254); // userMsgBg (#e8e8e8)
386            colors.border = Color::Ansi256(67); // blue (#547da7)
387            colors.border_accent = Color::Ansi256(66); // teal
388            colors.border_muted = Color::Ansi256(249); // lightGray (#b0b0b0)
389            colors.selection = Color::Ansi256(189); // selectedBg (#d0d0e0)
390            colors.cursor = Color::Ansi256(67);
391            colors.thinking_text = Color::Ansi256(241);
392            colors.md_heading = Color::Ansi256(136); // yellow
393            colors.md_link = Color::Ansi256(67); // blue
394            colors.md_link_url = Color::Ansi256(243);
395            colors.md_code = Color::Rgb(90, 128, 128); // native light teal
396            colors.md_code_bg = Color::Rgb(232, 234, 237);
397            colors.md_code_block = Color::Rgb(31, 35, 40);
398            colors.md_code_block_bg = Color::Rgb(246, 248, 250); // GitHub light code
399            colors.md_code_block_border = Color::Ansi256(241);
400            colors.md_quote = Color::Ansi256(241);
401            colors.md_quote_border = Color::Ansi256(241);
402            colors.md_hr = Color::Ansi256(241);
403            colors.md_list_bullet = Color::Ansi256(65); // green
404            colors.tool_pending_bg = Color::Ansi256(189);
405            colors.tool_success_bg = Color::Ansi256(151);
406            colors.tool_error_bg = Color::Ansi256(181);
407            colors.tool_title = Color::Default;
408            colors.tool_output = Color::Ansi256(241);
409            colors.bash_mode = Color::Ansi256(65);
410            // Custom messages / skill invocation (pi light.json):
411            // customMsgBg #ede7f6, customMessageLabel #7e57c2, text #1f2328.
412            colors.custom_message_bg = Color::Rgb(237, 231, 246);
413            colors.custom_message_label = Color::Rgb(126, 87, 194);
414            colors.custom_message_text = Color::Ansi256(237);
415            colors.tool_diff_added = Color::Ansi256(65);
416            colors.tool_diff_removed = Color::Ansi256(131);
417            colors.tool_diff_context = Color::Ansi256(241);
418            Theme {
419                colors,
420                ..Default::default()
421            }
422        }
423        ThemePreset::Monochrome => {
424            let mut colors = ThemeColors::default();
425            for f in [
426                &mut colors.accent,
427                &mut colors.border_accent,
428                &mut colors.md_code,
429                &mut colors.md_list_bullet,
430                &mut colors.bash_mode,
431            ] {
432                *f = Color::Ansi256(15);
433            }
434            colors.muted = Color::Ansi256(244);
435            colors.dim = Color::Ansi256(242);
436            colors.error = Color::Ansi256(9);
437            colors.success = Color::Ansi256(15);
438            colors.warning = Color::Ansi256(15);
439            colors.info = Color::Ansi256(15);
440            colors.surface = Color::Ansi256(236);
441            colors.border = Color::Ansi256(244);
442            colors.border_muted = Color::Ansi256(240);
443            colors.selection = Color::Ansi256(244);
444            colors.cursor = Color::Ansi256(15);
445            colors.thinking_text = Color::Ansi256(244);
446            colors.md_heading = Color::Ansi256(15);
447            colors.md_link = Color::Ansi256(15);
448            colors.md_link_url = Color::Ansi256(242);
449            colors.md_code_bg = Color::Ansi256(237);
450            colors.md_code_block = Color::Ansi256(15);
451            colors.md_code_block_bg = Color::Ansi256(235);
452            colors.md_code_block_border = Color::Ansi256(244);
453            colors.md_quote = Color::Ansi256(244);
454            colors.md_quote_border = Color::Ansi256(244);
455            colors.md_hr = Color::Ansi256(244);
456            colors.custom_message_bg = Color::Ansi256(236);
457            colors.custom_message_label = Color::Ansi256(15);
458            colors.custom_message_text = Color::Ansi256(244);
459            colors.tool_title = Color::Default;
460            colors.tool_output = Color::Ansi256(244);
461            colors.tool_diff_added = Color::Ansi256(15);
462            colors.tool_diff_removed = Color::Ansi256(9);
463            colors.tool_diff_context = Color::Ansi256(244);
464            Theme {
465                colors,
466                ..Default::default()
467            }
468        }
469    }
470}
471
472impl Default for ThemeManager {
473    fn default() -> Self {
474        Self::new()
475    }
476}
477
478/// Theme presets.
479#[derive(Debug, Clone, Copy)]
480pub enum ThemePreset {
481    Dark,
482    Light,
483    Monochrome,
484}
485
486/// Global theme helper functions.
487pub fn theme() -> Theme {
488    global_theme_manager().get()
489}
490
491/// Apply a preset to the process-wide theme read by every component.
492///
493/// Creating a standalone [`ThemeManager`] does not affect components that call
494/// [`theme`]. Hosts should use this function for live theme switching.
495pub fn apply_theme_preset(preset: ThemePreset) {
496    global_theme_manager().apply_preset(preset);
497}
498
499/// Return the process-wide theme manager.
500pub fn global_theme_manager() -> &'static ThemeManager {
501    THEME_MANAGER.get_or_init(ThemeManager::new)
502}
503
504/// Global theme manager.
505static THEME_MANAGER: std::sync::OnceLock<ThemeManager> = std::sync::OnceLock::new();
506
507/// Apply color from theme.
508pub fn themed_text(text: &str, color_fn: fn(&ThemeColors) -> Color) -> String {
509    let theme = theme();
510    let color = color_fn(&theme.colors);
511    color.fg(text)
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_color_ansi() {
520        let color = Color::Ansi256(196);
521        let text = color.fg("Hello");
522        assert!(text.contains("Hello"));
523    }
524
525    #[test]
526    fn test_theme_manager() {
527        let manager = ThemeManager::new();
528        manager.apply_preset(ThemePreset::Dark);
529        let theme = manager.get();
530        assert!(matches!(theme.colors.accent, Color::Ansi256(108)));
531    }
532}