1use std::borrow::Cow;
7
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span, Text};
10use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
11
12pub type BubbleTheme = Theme;
14
15#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
17pub struct Symbols {
18 pub bullet: &'static str,
20 pub selected: &'static str,
22 pub check: &'static str,
24 pub cross: &'static str,
26 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#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
44pub struct Palette {
45 pub foreground: Color,
47 pub muted: Color,
49 pub accent: Color,
51 pub success: Color,
53 pub warning: Color,
55 pub error: Color,
57 pub border: Color,
59 pub focused_border: Color,
61 pub selected_background: Color,
63}
64
65impl Palette {
66 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#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
92pub struct Theme {
93 pub palette: Palette,
95 pub symbols: Symbols,
97 pub text: Style,
99 pub muted: Style,
101 pub accent: Style,
103 pub success: Style,
105 pub warning: Style,
107 pub error: Style,
109 pub border: Style,
111 pub focused_border: Style,
113 pub title: Style,
115 pub selected: Style,
117 pub help_key: Style,
119 pub help_desc: Style,
121}
122
123impl Theme {
124 #[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 #[must_use]
150 pub fn block<'a>(&self) -> Block<'a> {
151 self.block_with_focus(false)
152 }
153
154 #[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 #[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 #[must_use]
181 pub fn modal_block<'a>(&self) -> Block<'a> {
182 self.block_with_focus(true)
183 }
184
185 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}