Skip to main content

romm_cli/tui/
theme.rs

1//! Semantic TUI styling backed by ratatui-themekit presets.
2
3use ratatui::layout::Rect;
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::Line;
6use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
7use ratatui::Frame;
8use ratatui_themekit::{available_theme_ids, resolve_theme, Theme};
9
10use crate::config::{default_theme_id, DEFAULT_THEME_ID};
11
12/// Status message severity for themed TUI feedback.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum MessageTone {
15    Success,
16    Error,
17    Warning,
18    Info,
19}
20
21/// Resolve a theme ID, falling back to [`DEFAULT_THEME_ID`] for unknown values.
22pub fn resolve_theme_or_default(id: &str) -> Box<dyn Theme> {
23    if ratatui_themekit::no_color_active() {
24        return resolve_theme(id);
25    }
26    let known = id == "no-color" || available_theme_ids().contains(&id);
27    if !known {
28        tracing::warn!(theme = id, "unknown theme ID, using {DEFAULT_THEME_ID}");
29        return resolve_theme(DEFAULT_THEME_ID);
30    }
31    resolve_theme(id)
32}
33
34/// Human-readable name for a theme ID (uses fallback resolution).
35pub fn theme_display_name(id: &str) -> String {
36    resolve_theme_or_default(id).name().to_string()
37}
38
39/// Cycle to the next built-in theme ID (wraps).
40pub fn next_theme_id(current: &str) -> String {
41    let ids: Vec<&str> = available_theme_ids();
42    if ids.is_empty() {
43        return default_theme_id();
44    }
45    let idx = ids.iter().position(|id| *id == current).unwrap_or(0);
46    ids[(idx + 1) % ids.len()].to_string()
47}
48
49/// Cycle to the previous built-in theme ID (wraps).
50pub fn prev_theme_id(current: &str) -> String {
51    let ids: Vec<&str> = available_theme_ids();
52    if ids.is_empty() {
53        return default_theme_id();
54    }
55    let idx = ids.iter().position(|id| *id == current).unwrap_or(0);
56    let len = ids.len();
57    ids[(idx + len - 1) % len].to_string()
58}
59
60/// App-level semantic styles mapped onto a ratatui-themekit theme.
61pub struct RommStyles<'a> {
62    theme: &'a dyn Theme,
63}
64
65impl<'a> RommStyles<'a> {
66    pub fn new(theme: &'a dyn Theme) -> Self {
67        Self { theme }
68    }
69
70    pub fn theme(&self) -> &dyn Theme {
71        self.theme
72    }
73
74    /// Whether this theme defines a real background (vs terminal default).
75    pub fn has_immersive_background(&self) -> bool {
76        !matches!(self.theme.background(), Color::Reset)
77    }
78
79    /// Use the terminal emulator palette without forced panel/surface fills.
80    pub fn uses_native_terminal(&self) -> bool {
81        !self.has_immersive_background()
82    }
83
84    /// Paint the full frame background when the theme defines one.
85    pub fn fill_background(&self, f: &mut Frame, area: Rect) {
86        if self.has_immersive_background() {
87            f.render_widget(Paragraph::new("").style(self.background()), area);
88        }
89    }
90
91    /// Fill a region (e.g. popup) with the panel surface color.
92    pub fn fill_surface(&self, f: &mut Frame, area: Rect) {
93        f.render_widget(Clear, area);
94        if self.uses_native_terminal() {
95            return;
96        }
97        f.render_widget(Paragraph::new("").style(self.surface_text()), area);
98    }
99
100    pub fn background(&self) -> Style {
101        Style::default().bg(self.theme.background())
102    }
103
104    pub fn surface(&self) -> Style {
105        if self.uses_native_terminal() {
106            Style::default()
107        } else {
108            Style::default().bg(self.theme.surface())
109        }
110    }
111
112    fn surface_text(&self) -> Style {
113        if self.uses_native_terminal() {
114            Style::default()
115        } else {
116            self.surface().fg(self.theme.text())
117        }
118    }
119
120    pub fn text(&self) -> Style {
121        if self.uses_native_terminal() {
122            Style::default()
123        } else {
124            Style::default().fg(self.theme.text())
125        }
126    }
127
128    pub fn stripe(&self) -> Style {
129        if self.uses_native_terminal() {
130            self.text()
131        } else {
132            Style::default()
133                .fg(self.theme.text())
134                .bg(self.theme.stripe())
135        }
136    }
137
138    pub fn border(&self) -> Style {
139        if self.uses_native_terminal() {
140            Style::default().fg(self.theme.border())
141        } else {
142            // Borders sit on the canvas, not the panel surface, so edges stay visible.
143            Style::default()
144                .fg(self.theme.text_dim())
145                .bg(self.theme.background())
146        }
147    }
148
149    pub fn border_accent(&self) -> Style {
150        let mut style = Style::default().fg(self.theme.accent());
151        if self.has_immersive_background() {
152            style = style.bg(self.theme.background());
153        }
154        style
155    }
156
157    pub fn selection(&self) -> Style {
158        if self.uses_native_terminal() {
159            Style::default()
160                .fg(self.theme.accent())
161                .add_modifier(Modifier::BOLD)
162        } else {
163            Style::default()
164                .fg(self.theme.accent())
165                .bg(self.theme.stripe())
166                .add_modifier(Modifier::BOLD)
167        }
168    }
169
170    /// Style for a table/list row: selected, zebra odd, or default.
171    pub fn row(&self, index: usize, selected: bool) -> Style {
172        if selected {
173            self.selection()
174        } else if self.uses_native_terminal() || index.is_multiple_of(2) {
175            self.text()
176        } else {
177            self.stripe()
178        }
179    }
180
181    pub fn label(&self) -> Style {
182        Style::default().fg(self.theme.info())
183    }
184
185    pub fn success(&self) -> Style {
186        Style::default().fg(self.theme.success())
187    }
188
189    pub fn error(&self) -> Style {
190        Style::default().fg(self.theme.error())
191    }
192
193    pub fn warning(&self) -> Style {
194        Style::default().fg(self.theme.warning())
195    }
196
197    pub fn muted(&self) -> Style {
198        Style::default().fg(self.theme.text_dim())
199    }
200
201    pub fn primary_text(&self) -> Style {
202        if self.uses_native_terminal() {
203            Style::default().add_modifier(Modifier::BOLD)
204        } else {
205            Style::default().fg(self.theme.text_bright())
206        }
207    }
208
209    pub fn border_focus(&self) -> Style {
210        self.border_accent()
211    }
212
213    pub fn footer_hint(&self) -> Style {
214        Style::default().fg(self.theme.text_dim())
215    }
216
217    /// Bordered panel with themed surface fill.
218    pub fn panel_block<'b>(&self, title: impl Into<Line<'b>>) -> Block<'b> {
219        let border_type = if self.uses_native_terminal() {
220            BorderType::Plain
221        } else {
222            BorderType::Rounded
223        };
224        let mut block = Block::default()
225            .title(title)
226            .borders(Borders::ALL)
227            .border_type(border_type)
228            .border_style(self.border())
229            .title_style(
230                Style::default()
231                    .fg(self.theme.accent())
232                    .add_modifier(Modifier::BOLD),
233            );
234        if !self.uses_native_terminal() {
235            block = block.style(self.surface());
236        }
237        block
238    }
239
240    /// Bordered panel without a title.
241    pub fn panel_block_untitled(&self) -> Block<'_> {
242        let border_type = if self.uses_native_terminal() {
243            BorderType::Plain
244        } else {
245            BorderType::Rounded
246        };
247        let mut block = Block::default()
248            .borders(Borders::ALL)
249            .border_type(border_type)
250            .border_style(self.border());
251        if !self.uses_native_terminal() {
252            block = block.style(self.surface());
253        }
254        block
255    }
256
257    /// Header strip with bottom border only.
258    pub fn header_block(&self) -> Block<'_> {
259        let mut block = Block::default()
260            .borders(Borders::BOTTOM)
261            .border_type(BorderType::Plain)
262            .border_style(self.border());
263        if !self.uses_native_terminal() {
264            block = block.style(self.surface());
265        }
266        block
267    }
268
269    pub fn color_success(&self) -> Color {
270        self.theme.success()
271    }
272
273    pub fn color_error(&self) -> Color {
274        self.theme.error()
275    }
276
277    pub fn color_warning(&self) -> Color {
278        self.theme.warning()
279    }
280
281    pub fn color_info(&self) -> Color {
282        self.theme.info()
283    }
284
285    pub fn tone(&self, tone: MessageTone) -> Style {
286        match tone {
287            MessageTone::Success => self.success(),
288            MessageTone::Error => self.error(),
289            MessageTone::Warning => self.warning(),
290            MessageTone::Info => self.label(),
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn resolve_unknown_falls_back_to_terminal() {
301        std::env::remove_var("NO_COLOR");
302        let theme = resolve_theme_or_default("not-a-theme");
303        assert_eq!(theme.id(), "terminal");
304    }
305
306    #[test]
307    fn dracula_has_immersive_background_and_selection_contrast() {
308        std::env::remove_var("NO_COLOR");
309        let theme = resolve_theme_or_default("dracula");
310        let styles = RommStyles::new(theme.as_ref());
311        assert!(styles.has_immersive_background());
312        assert_ne!(styles.selection().fg, None);
313        assert_ne!(styles.selection().bg, None);
314    }
315
316    #[test]
317    fn terminal_theme_respects_native_terminal_colors() {
318        std::env::remove_var("NO_COLOR");
319        let theme = resolve_theme_or_default("terminal");
320        let styles = RommStyles::new(theme.as_ref());
321        assert!(styles.uses_native_terminal());
322        assert_eq!(styles.surface().bg, None);
323        assert_eq!(styles.selection().bg, None);
324        assert_eq!(styles.text().fg, None);
325    }
326
327    #[test]
328    fn dracula_border_contrasts_with_surface() {
329        std::env::remove_var("NO_COLOR");
330        let theme = resolve_theme_or_default("dracula");
331        let styles = RommStyles::new(theme.as_ref());
332        let border = styles.border();
333        assert_eq!(border.bg, Some(theme.background()));
334        assert_ne!(border.fg, Some(theme.surface()));
335    }
336}