Skip to main content

ratatui_bubbletea_theme/
lib.rs

1//! Charm/Bubble Tea-inspired theme helpers for ratatui.
2//!
3//! This crate is intentionally usable from a normal ratatui event loop. It
4//! does not own rendering, terminal setup, or event handling.
5
6use std::borrow::Cow;
7
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span, Text};
10use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
11
12/// Default theme type for Charm/Bubble Tea-inspired ratatui apps.
13pub type BubbleTheme = Theme;
14
15/// Small set of symbols used by Charm-like terminal UIs.
16#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
17pub struct Symbols {
18    /// Prefix for ordinary list items.
19    pub bullet: &'static str,
20    /// Prefix for selected list items.
21    pub selected: &'static str,
22    /// Success marker.
23    pub check: &'static str,
24    /// Error/failure marker.
25    pub cross: &'static str,
26    /// Separator between compact help bindings.
27    pub help_separator: &'static str,
28}
29
30impl Default for Symbols {
31    fn default() -> Self {
32        Self {
33            bullet: "•",
34            selected: "▸",
35            check: "✓",
36            cross: "✗",
37            help_separator: " • ",
38        }
39    }
40}
41
42/// Color palette behind [`Theme`]'s semantic styles.
43#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
44pub struct Palette {
45    /// Main foreground color.
46    pub foreground: Color,
47    /// Muted text color for hints and secondary copy.
48    pub muted: Color,
49    /// Primary accent color.
50    pub accent: Color,
51    /// Success color.
52    pub success: Color,
53    /// Warning color.
54    pub warning: Color,
55    /// Error color.
56    pub error: Color,
57    /// Normal border color.
58    pub border: Color,
59    /// Focused border color.
60    pub focused_border: Color,
61    /// Selected item background color.
62    pub selected_background: Color,
63}
64
65impl Palette {
66    /// Default Charm-inspired palette.
67    ///
68    /// This is exposed as a constant so applications can reuse color tokens in
69    /// their own `const` style tables, match arms, and module-level defaults
70    /// without duplicating RGB literals.
71    pub const CHARM: Self = Self {
72        foreground: Color::Rgb(230, 230, 230),
73        muted: Color::Rgb(122, 122, 122),
74        accent: Color::Rgb(255, 117, 191),
75        success: Color::Rgb(4, 211, 97),
76        warning: Color::Rgb(255, 193, 7),
77        error: Color::Rgb(255, 83, 112),
78        border: Color::Rgb(92, 92, 92),
79        focused_border: Color::Rgb(123, 201, 255),
80        selected_background: Color::Rgb(48, 48, 48),
81    };
82}
83
84impl Default for Palette {
85    fn default() -> Self {
86        Self::CHARM
87    }
88}
89
90/// Semantic style tokens and widget helpers for a Charm-like ratatui UI.
91#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
92pub struct Theme {
93    /// Source palette used to build semantic styles.
94    pub palette: Palette,
95    /// Shared symbols used by helper methods.
96    pub symbols: Symbols,
97    /// Main text style.
98    pub text: Style,
99    /// Muted secondary text style.
100    pub muted: Style,
101    /// Primary accent style.
102    pub accent: Style,
103    /// Success style.
104    pub success: Style,
105    /// Warning style.
106    pub warning: Style,
107    /// Error style.
108    pub error: Style,
109    /// Normal border style.
110    pub border: Style,
111    /// Focused border style.
112    pub focused_border: Style,
113    /// Block title style.
114    pub title: Style,
115    /// Selected row/item style.
116    pub selected: Style,
117    /// Help key style.
118    pub help_key: Style,
119    /// Help description style.
120    pub help_desc: Style,
121}
122
123impl Theme {
124    /// Builds a theme from a palette and symbol set.
125    #[must_use]
126    pub fn new(palette: Palette, symbols: Symbols) -> Self {
127        Self {
128            palette,
129            symbols,
130            text: Style::new().fg(palette.foreground),
131            muted: Style::new().fg(palette.muted),
132            accent: Style::new().fg(palette.accent),
133            success: Style::new().fg(palette.success),
134            warning: Style::new().fg(palette.warning),
135            error: Style::new().fg(palette.error),
136            border: Style::new().fg(palette.border),
137            focused_border: Style::new().fg(palette.focused_border),
138            title: Style::new().fg(palette.accent).add_modifier(Modifier::BOLD),
139            selected: Style::new()
140                .fg(palette.foreground)
141                .bg(palette.selected_background)
142                .add_modifier(Modifier::BOLD),
143            help_key: Style::new().fg(palette.accent).add_modifier(Modifier::BOLD),
144            help_desc: Style::new().fg(palette.muted),
145        }
146    }
147
148    /// Creates a rounded bordered block using the theme's default border and title styles.
149    #[must_use]
150    pub fn block<'a>(&self) -> Block<'a> {
151        self.block_with_focus(false)
152    }
153
154    /// Creates a rounded bordered block with focus-aware border styling.
155    #[must_use]
156    pub fn block_with_focus<'a>(&self, focused: bool) -> Block<'a> {
157        Block::new()
158            .borders(Borders::ALL)
159            .border_type(BorderType::Rounded)
160            .border_style(if focused {
161                self.focused_border
162            } else {
163                self.border
164            })
165            .title_style(self.title)
166    }
167
168    /// Creates a rounded, titled block using the theme's default title style.
169    #[must_use]
170    pub fn titled_block<'a, T>(&self, title: T) -> Block<'a>
171    where
172        T: Into<Line<'a>>,
173    {
174        self.block().title(title)
175    }
176
177    /// Creates a modal-style block with the focused/accent border style.
178    ///
179    /// This is intended for the common `Clear` + centered-popup pattern.
180    #[must_use]
181    pub fn modal_block<'a>(&self) -> Block<'a> {
182        self.block_with_focus(true)
183    }
184
185    /// Creates a titled modal-style block with the focused/accent border style.
186    #[must_use]
187    pub fn titled_modal_block<'a, T>(&self, title: T) -> Block<'a>
188    where
189        T: Into<Line<'a>>,
190    {
191        self.modal_block().title(title)
192    }
193
194    /// Creates a paragraph using the theme's normal text style.
195    #[must_use]
196    pub fn paragraph<'a, T>(&self, text: T) -> Paragraph<'a>
197    where
198        T: Into<Text<'a>>,
199    {
200        Paragraph::new(text).style(self.text)
201    }
202
203    /// Creates a paragraph with a themed block attached.
204    #[must_use]
205    pub fn paragraph_in_block<'a, T, L>(&self, text: T, title: L) -> Paragraph<'a>
206    where
207        T: Into<Text<'a>>,
208        L: Into<Line<'a>>,
209    {
210        Paragraph::new(text)
211            .style(self.text)
212            .block(self.block().title(title))
213    }
214
215    /// Creates a normal text span.
216    #[must_use]
217    pub fn span<'a, T>(&self, content: T) -> Span<'a>
218    where
219        T: Into<Cow<'a, str>>,
220    {
221        Span::styled(content, self.text)
222    }
223
224    /// Creates a muted text span.
225    #[must_use]
226    pub fn muted<'a, T>(&self, content: T) -> Span<'a>
227    where
228        T: Into<Cow<'a, str>>,
229    {
230        Span::styled(content, self.muted)
231    }
232
233    /// Creates an accent text span.
234    #[must_use]
235    pub fn accent<'a, T>(&self, content: T) -> Span<'a>
236    where
237        T: Into<Cow<'a, str>>,
238    {
239        Span::styled(content, self.accent)
240    }
241
242    /// Creates a success text span.
243    #[must_use]
244    pub fn success<'a, T>(&self, content: T) -> Span<'a>
245    where
246        T: Into<Cow<'a, str>>,
247    {
248        Span::styled(content, self.success)
249    }
250
251    /// Creates a warning text span.
252    #[must_use]
253    pub fn warning<'a, T>(&self, content: T) -> Span<'a>
254    where
255        T: Into<Cow<'a, str>>,
256    {
257        Span::styled(content, self.warning)
258    }
259
260    /// Creates an error text span.
261    #[must_use]
262    pub fn error<'a, T>(&self, content: T) -> Span<'a>
263    where
264        T: Into<Cow<'a, str>>,
265    {
266        Span::styled(content, self.error)
267    }
268
269    /// Creates a title line.
270    #[must_use]
271    pub fn title<'a, T>(&self, content: T) -> Line<'a>
272    where
273        T: Into<Cow<'a, str>>,
274    {
275        Line::styled(content, self.title)
276    }
277
278    /// Creates a line prefixed with the default bullet symbol.
279    #[must_use]
280    pub fn bullet<'a, T>(&self, content: T) -> Line<'a>
281    where
282        T: Into<Cow<'a, str>>,
283    {
284        Line::from(vec![
285            self.muted(self.symbols.bullet),
286            self.span(" "),
287            self.span(content),
288        ])
289    }
290
291    /// Creates a success line prefixed with the default check symbol.
292    #[must_use]
293    pub fn checked<'a, T>(&self, content: T) -> Line<'a>
294    where
295        T: Into<Cow<'a, str>>,
296    {
297        Line::from(vec![
298            self.success(self.symbols.check),
299            self.span(" "),
300            self.span(content),
301        ])
302    }
303
304    /// Creates an error line prefixed with the default cross symbol.
305    #[must_use]
306    pub fn crossed<'a, T>(&self, content: T) -> Line<'a>
307    where
308        T: Into<Cow<'a, str>>,
309    {
310        Line::from(vec![
311            self.error(self.symbols.cross),
312            self.span(" "),
313            self.span(content),
314        ])
315    }
316
317    /// Creates a compact help line from `(key, description)` pairs.
318    #[must_use]
319    pub fn help_line<'a, I, K, D>(&self, bindings: I) -> Line<'a>
320    where
321        I: IntoIterator<Item = (K, D)>,
322        K: Into<Cow<'a, str>>,
323        D: Into<Cow<'a, str>>,
324    {
325        let mut spans = Vec::new();
326
327        for (index, (key, description)) in bindings.into_iter().enumerate() {
328            if index > 0 {
329                spans.push(self.muted(self.symbols.help_separator));
330            }
331
332            spans.push(Span::styled(key, self.help_key));
333            spans.push(self.muted(" "));
334            spans.push(Span::styled(description, self.help_desc));
335        }
336
337        Line::from(spans)
338    }
339}
340
341impl Default for Theme {
342    fn default() -> Self {
343        Self::new(Palette::default(), Symbols::default())
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use ratatui::Terminal;
350    use ratatui::backend::TestBackend;
351    use ratatui::layout::Rect;
352    use ratatui::style::{Color, Modifier, Style};
353
354    use super::{BubbleTheme, Palette, Symbols, Theme};
355
356    #[test]
357    fn default_theme_maps_palette_to_semantic_styles() {
358        let theme = Theme::default();
359        let palette = Palette::default();
360
361        assert_eq!(theme.text, Style::new().fg(palette.foreground));
362        assert_eq!(theme.muted, Style::new().fg(palette.muted));
363        assert_eq!(theme.accent, Style::new().fg(palette.accent));
364        assert_eq!(theme.border, Style::new().fg(palette.border));
365        assert_eq!(
366            theme.focused_border,
367            Style::new().fg(palette.focused_border)
368        );
369        assert_eq!(theme.title.add_modifier, Modifier::BOLD);
370        assert_eq!(theme.selected.bg, Some(palette.selected_background));
371    }
372
373    #[test]
374    fn default_palette_is_charm_const() {
375        const ACCENT: Color = Palette::CHARM.accent;
376
377        assert_eq!(Palette::default(), Palette::CHARM);
378        assert_eq!(ACCENT, Color::Rgb(255, 117, 191));
379    }
380
381    #[test]
382    fn custom_palette_is_reflected_in_styles() {
383        let palette = Palette {
384            foreground: Color::White,
385            muted: Color::DarkGray,
386            accent: Color::Cyan,
387            success: Color::Green,
388            warning: Color::Yellow,
389            error: Color::Red,
390            border: Color::Gray,
391            focused_border: Color::Blue,
392            selected_background: Color::Black,
393        };
394        let theme = Theme::new(palette, Symbols::default());
395
396        assert_eq!(theme.accent.fg, Some(Color::Cyan));
397        assert_eq!(theme.focused_border.fg, Some(Color::Blue));
398        assert_eq!(theme.selected.bg, Some(Color::Black));
399    }
400
401    #[test]
402    fn helper_lines_include_expected_symbols_and_text() {
403        let theme = BubbleTheme::default();
404
405        assert_eq!(theme.bullet("Item").to_string(), "• Item");
406        assert_eq!(theme.checked("Done").to_string(), "✓ Done");
407        assert_eq!(theme.crossed("Failed").to_string(), "✗ Failed");
408    }
409
410    #[test]
411    fn help_line_renders_compact_bindings() {
412        let theme = Theme::default();
413        let line = theme.help_line([("q", "quit"), ("?", "help")]);
414
415        assert_eq!(line.to_string(), "q quit • ? help");
416        assert_eq!(line.spans[0].style, theme.help_key);
417        assert_eq!(line.spans[2].style, theme.help_desc);
418    }
419
420    #[test]
421    fn themed_block_renders_rounded_border_and_title() -> Result<(), Box<dyn std::error::Error>> {
422        let theme = Theme::default();
423        let backend = TestBackend::new(12, 3);
424        let mut terminal = Terminal::new(backend)?;
425
426        terminal.draw(|frame| {
427            frame.render_widget(theme.block().title("Demo"), frame.area());
428        })?;
429
430        let buffer = terminal.backend().buffer();
431        assert_eq!(buffer[(0, 0)].symbol(), "╭");
432        assert_eq!(buffer[(1, 0)].symbol(), "D");
433        assert_eq!(buffer[(4, 0)].symbol(), "o");
434        assert_eq!(buffer[(11, 0)].symbol(), "╮");
435        assert_eq!(buffer[(0, 2)].symbol(), "╰");
436        assert_eq!(buffer[(11, 2)].symbol(), "╯");
437
438        assert_eq!(buffer[(0, 0)].fg, theme.palette.border);
439        assert_eq!(buffer[(1, 0)].fg, theme.palette.accent);
440        assert!(buffer[(1, 0)].modifier.contains(Modifier::BOLD));
441
442        Ok(())
443    }
444
445    #[test]
446    fn focused_block_uses_focused_border_style() -> Result<(), Box<dyn std::error::Error>> {
447        let theme = Theme::default();
448        let backend = TestBackend::new(4, 3);
449        let mut terminal = Terminal::new(backend)?;
450
451        terminal.draw(|frame| {
452            frame.render_widget(theme.block_with_focus(true), frame.area());
453        })?;
454
455        assert_eq!(
456            terminal.backend().buffer()[(0, 0)].fg,
457            theme.palette.focused_border
458        );
459
460        Ok(())
461    }
462
463    #[test]
464    fn titled_block_applies_title_style() -> Result<(), Box<dyn std::error::Error>> {
465        let theme = Theme::default();
466        let backend = TestBackend::new(12, 3);
467        let mut terminal = Terminal::new(backend)?;
468
469        terminal.draw(|frame| {
470            frame.render_widget(theme.titled_block("Demo"), frame.area());
471        })?;
472
473        let buffer = terminal.backend().buffer();
474        assert_eq!(buffer[(1, 0)].symbol(), "D");
475        assert_eq!(buffer[(1, 0)].fg, theme.palette.accent);
476        assert!(buffer[(1, 0)].modifier.contains(Modifier::BOLD));
477
478        Ok(())
479    }
480
481    #[test]
482    fn modal_block_uses_focused_border_style() -> Result<(), Box<dyn std::error::Error>> {
483        let theme = Theme::default();
484        let backend = TestBackend::new(12, 3);
485        let mut terminal = Terminal::new(backend)?;
486
487        terminal.draw(|frame| {
488            frame.render_widget(theme.titled_modal_block("Edit"), frame.area());
489        })?;
490
491        let buffer = terminal.backend().buffer();
492        assert_eq!(buffer[(0, 0)].fg, theme.palette.focused_border);
493        assert_eq!(buffer[(1, 0)].symbol(), "E");
494
495        Ok(())
496    }
497
498    #[test]
499    fn paragraph_helper_applies_text_style() -> Result<(), Box<dyn std::error::Error>> {
500        let theme = Theme::default();
501        let backend = TestBackend::new(5, 1);
502        let mut terminal = Terminal::new(backend)?;
503
504        terminal.draw(|frame| {
505            frame.render_widget(theme.paragraph("Hello"), Rect::new(0, 0, 5, 1));
506        })?;
507
508        let buffer = terminal.backend().buffer();
509        assert_eq!(buffer[(0, 0)].symbol(), "H");
510        assert_eq!(buffer[(4, 0)].symbol(), "o");
511        assert_eq!(buffer[(0, 0)].fg, theme.palette.foreground);
512
513        Ok(())
514    }
515}