Skip to main content

rab/tui/
theme.rs

1/// Composable text style. Builds ANSI escape sequences for foreground,
2/// background, bold, italic, underline, and strikethrough.
3///
4/// Create via builder methods and apply with `apply()`:
5/// ```
6/// let styled = rab::tui::Style::new().bg("\x1b[48;2;52;53;65m".to_string()).bold().apply("hello");
7/// assert!(styled.starts_with("\x1b[48"));
8/// assert!(styled.contains("hello"));
9/// ```
10#[derive(Debug, Clone, Default)]
11pub struct Style {
12    fg: Option<String>,
13    bg: Option<String>,
14    bold: bool,
15    dim: bool,
16    italic: bool,
17    underline: bool,
18    strikethrough: bool,
19    reverse: bool,
20}
21
22impl Style {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Set foreground ANSI escape prefix.
28    pub fn fg(mut self, ansi: impl Into<String>) -> Self {
29        self.fg = Some(ansi.into());
30        self
31    }
32
33    /// Set background ANSI escape prefix.
34    pub fn bg(mut self, ansi: impl Into<String>) -> Self {
35        self.bg = Some(ansi.into());
36        self
37    }
38
39    /// Enable bold.
40    pub fn bold(mut self) -> Self {
41        self.bold = true;
42        self
43    }
44
45    /// Enable dim.
46    pub fn dim(mut self) -> Self {
47        self.dim = true;
48        self
49    }
50
51    /// Enable italic.
52    pub fn italic(mut self) -> Self {
53        self.italic = true;
54        self
55    }
56
57    /// Enable underline.
58    pub fn underline(mut self) -> Self {
59        self.underline = true;
60        self
61    }
62
63    /// Enable strikethrough.
64    pub fn strikethrough(mut self) -> Self {
65        self.strikethrough = true;
66        self
67    }
68
69    /// Enable reverse video.
70    pub fn reverse(mut self) -> Self {
71        self.reverse = true;
72        self
73    }
74
75    /// Apply this style to text, returning ANSI-wrapped string.
76    /// The text is wrapped with opening escape sequences at the start
77    /// and closing sequences at the end.
78    pub fn apply(&self, text: &str) -> String {
79        let mut prefix = String::new();
80        let mut suffix = String::new();
81
82        if let Some(ref fg) = self.fg {
83            prefix.push_str(fg);
84            suffix.push_str("\x1b[39m");
85        }
86        if let Some(ref bg) = self.bg {
87            prefix.push_str(bg);
88            suffix.push_str("\x1b[49m");
89        }
90        if self.bold {
91            prefix.push_str("\x1b[1m");
92            suffix.push_str("\x1b[22m");
93        }
94        if self.italic {
95            prefix.push_str("\x1b[3m");
96            suffix.push_str("\x1b[23m");
97        }
98        if self.underline {
99            prefix.push_str("\x1b[4m");
100            suffix.push_str("\x1b[24m");
101        }
102        if self.dim {
103            prefix.push_str("\x1b[2m");
104            suffix.push_str("\x1b[22m");
105        }
106        if self.strikethrough {
107            prefix.push_str("\x1b[9m");
108            suffix.push_str("\x1b[29m");
109        }
110        if self.reverse {
111            prefix.push_str("\x1b[7m");
112            suffix.push_str("\x1b[27m");
113        }
114
115        format!("{}{}{}", prefix, text, suffix)
116    }
117
118    /// Apply this style to text, padding to `width` visible columns.
119    pub fn apply_padded(&self, text: &str, width: usize) -> String {
120        let styled = self.apply(text);
121        let vw = crate::tui::util::visible_width(&styled);
122        if vw < width {
123            format!("{}{}", styled, " ".repeat(width - vw))
124        } else {
125            styled
126        }
127    }
128
129    /// Check if this style has any foreground color set.
130    pub fn has_fg(&self) -> bool {
131        self.fg.is_some()
132    }
133
134    /// Check if this style has any background color set.
135    pub fn has_bg(&self) -> bool {
136        self.bg.is_some()
137    }
138}
139
140/// Compile-time safe theme color keys.
141/// Each variant corresponds to a named color in the theme JSON files.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
143pub enum ThemeKey {
144    Accent,
145    BashMode,
146    Border,
147    BorderAccent,
148    BorderMuted,
149    CustomMessageBg,
150    CustomMessageLabel,
151    CustomMessageText,
152    Dim,
153    Error,
154    MdCode,
155    MdCodeBlock,
156    MdCodeBlockBorder,
157    MdHeading,
158    MdHr,
159    MdLink,
160    MdLinkUrl,
161    MdListBullet,
162    MdQuote,
163    MdQuoteBorder,
164    Muted,
165    SelectedBg,
166    Success,
167    SyntaxComment,
168    SyntaxFunction,
169    SyntaxKeyword,
170    SyntaxNumber,
171    SyntaxOperator,
172    SyntaxPunctuation,
173    SyntaxString,
174    SyntaxType,
175    SyntaxVariable,
176    Text,
177    ThinkingHigh,
178    ThinkingLow,
179    ThinkingMedium,
180    ThinkingMinimal,
181    ThinkingOff,
182    ThinkingText,
183    ThinkingXhigh,
184    ToolDiffAdded,
185    ToolDiffContext,
186    ToolDiffRemoved,
187    ToolErrorBg,
188    ToolOutput,
189    ToolPendingBg,
190    ToolSuccessBg,
191    ToolTitle,
192    UserMessageBg,
193    UserMessageText,
194    Warning,
195}
196
197impl ThemeKey {
198    /// Return the string key used in theme JSON configuration.
199    pub fn as_str(&self) -> &'static str {
200        match self {
201            Self::Accent => "accent",
202            Self::BashMode => "bashMode",
203            Self::Border => "border",
204            Self::BorderAccent => "borderAccent",
205            Self::BorderMuted => "borderMuted",
206            Self::CustomMessageBg => "customMessageBg",
207            Self::CustomMessageLabel => "customMessageLabel",
208            Self::CustomMessageText => "customMessageText",
209            Self::Dim => "dim",
210            Self::Error => "error",
211            Self::MdCode => "mdCode",
212            Self::MdCodeBlock => "mdCodeBlock",
213            Self::MdCodeBlockBorder => "mdCodeBlockBorder",
214            Self::MdHeading => "mdHeading",
215            Self::MdHr => "mdHr",
216            Self::MdLink => "mdLink",
217            Self::MdLinkUrl => "mdLinkUrl",
218            Self::MdListBullet => "mdListBullet",
219            Self::MdQuote => "mdQuote",
220            Self::MdQuoteBorder => "mdQuoteBorder",
221            Self::Muted => "muted",
222            Self::SelectedBg => "selectedBg",
223            Self::Success => "success",
224            Self::SyntaxComment => "syntaxComment",
225            Self::SyntaxFunction => "syntaxFunction",
226            Self::SyntaxKeyword => "syntaxKeyword",
227            Self::SyntaxNumber => "syntaxNumber",
228            Self::SyntaxOperator => "syntaxOperator",
229            Self::SyntaxPunctuation => "syntaxPunctuation",
230            Self::SyntaxString => "syntaxString",
231            Self::SyntaxType => "syntaxType",
232            Self::SyntaxVariable => "syntaxVariable",
233            Self::Text => "text",
234            Self::ThinkingHigh => "thinkingHigh",
235            Self::ThinkingLow => "thinkingLow",
236            Self::ThinkingMedium => "thinkingMedium",
237            Self::ThinkingMinimal => "thinkingMinimal",
238            Self::ThinkingOff => "thinkingOff",
239            Self::ThinkingText => "thinkingText",
240            Self::ThinkingXhigh => "thinkingXhigh",
241            Self::ToolDiffAdded => "toolDiffAdded",
242            Self::ToolDiffContext => "toolDiffContext",
243            Self::ToolDiffRemoved => "toolDiffRemoved",
244            Self::ToolErrorBg => "toolErrorBg",
245            Self::ToolOutput => "toolOutput",
246            Self::ToolPendingBg => "toolPendingBg",
247            Self::ToolSuccessBg => "toolSuccessBg",
248            Self::ToolTitle => "toolTitle",
249            Self::UserMessageBg => "userMessageBg",
250            Self::UserMessageText => "userMessageText",
251            Self::Warning => "warning",
252        }
253    }
254
255    /// All theme keys, for iteration.
256    pub fn all() -> &'static [ThemeKey] {
257        use ThemeKey::*;
258        &[
259            Accent,
260            BashMode,
261            Border,
262            BorderAccent,
263            BorderMuted,
264            CustomMessageBg,
265            CustomMessageLabel,
266            CustomMessageText,
267            Dim,
268            Error,
269            MdCode,
270            MdCodeBlock,
271            MdCodeBlockBorder,
272            MdHeading,
273            MdHr,
274            MdLink,
275            MdLinkUrl,
276            MdListBullet,
277            MdQuote,
278            MdQuoteBorder,
279            Muted,
280            SelectedBg,
281            Success,
282            SyntaxComment,
283            SyntaxFunction,
284            SyntaxKeyword,
285            SyntaxNumber,
286            SyntaxOperator,
287            SyntaxPunctuation,
288            SyntaxString,
289            SyntaxType,
290            SyntaxVariable,
291            Text,
292            ThinkingHigh,
293            ThinkingLow,
294            ThinkingMedium,
295            ThinkingMinimal,
296            ThinkingOff,
297            ThinkingText,
298            ThinkingXhigh,
299            ToolDiffAdded,
300            ToolDiffContext,
301            ToolDiffRemoved,
302            ToolErrorBg,
303            ToolOutput,
304            ToolPendingBg,
305            ToolSuccessBg,
306            ToolTitle,
307            UserMessageBg,
308            UserMessageText,
309            Warning,
310        ]
311    }
312}
313
314/// Theme trait for components that need color styling.
315///
316/// Implementations provide foreground and background color functions
317/// that take text and return ANSI-styled strings.
318pub trait Theme {
319    /// Apply a foreground color to text.
320    /// `color` is a color name (e.g., "accent", "text", "success", "error", "muted").
321    fn fg(&self, color: &str, text: &str) -> String;
322
323    /// Apply a background color to text.
324    fn bg(&self, color: &str, text: &str) -> String;
325
326    /// Apply bold styling.
327    fn bold(&self, text: &str) -> String;
328
329    /// Apply italic styling (used for thinking blocks, matching pi).
330    fn italic(&self, text: &str) -> String;
331
332    /// Apply reverse/inverse video styling (used for intra-line diff highlighting).
333    fn inverse(&self, text: &str) -> String;
334
335    /// Apply a foreground color from a `ThemeKey`.
336    fn fg_key(&self, key: ThemeKey, text: &str) -> String {
337        self.fg(key.as_str(), text)
338    }
339
340    /// Apply a background color from a `ThemeKey`.
341    fn bg_key(&self, key: ThemeKey, text: &str) -> String {
342        self.bg(key.as_str(), text)
343    }
344
345    /// Return the ANSI escape code for a named color (without text).
346    /// Default implementation returns empty string — override in concrete themes.
347    fn fg_ansi(&self, _color: &str) -> &str {
348        ""
349    }
350
351    /// Return the ANSI escape code for a `ThemeKey` color (without text).
352    fn fg_ansi_key(&self, key: ThemeKey) -> &str {
353        self.fg_ansi(key.as_str())
354    }
355}
356
357/// A no-op theme that returns text unchanged.
358/// Useful for testing components without needing a real theme.
359#[derive(Debug, Clone, Copy, Default)]
360pub struct NoopTheme;
361
362impl Theme for NoopTheme {
363    fn fg(&self, _color: &str, text: &str) -> String {
364        text.to_string()
365    }
366
367    fn bg(&self, _color: &str, text: &str) -> String {
368        text.to_string()
369    }
370
371    fn bold(&self, text: &str) -> String {
372        text.to_string()
373    }
374
375    fn italic(&self, text: &str) -> String {
376        text.to_string()
377    }
378
379    fn inverse(&self, text: &str) -> String {
380        text.to_string()
381    }
382}