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, 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        if self.uses_native_terminal() {
94            return;
95        }
96        f.render_widget(Paragraph::new("").style(self.surface_text()), area);
97    }
98
99    pub fn background(&self) -> Style {
100        Style::default().bg(self.theme.background())
101    }
102
103    pub fn surface(&self) -> Style {
104        if self.uses_native_terminal() {
105            Style::default()
106        } else {
107            Style::default().bg(self.theme.surface())
108        }
109    }
110
111    fn surface_text(&self) -> Style {
112        if self.uses_native_terminal() {
113            Style::default()
114        } else {
115            self.surface().fg(self.theme.text())
116        }
117    }
118
119    pub fn text(&self) -> Style {
120        if self.uses_native_terminal() {
121            Style::default()
122        } else {
123            Style::default().fg(self.theme.text())
124        }
125    }
126
127    pub fn stripe(&self) -> Style {
128        if self.uses_native_terminal() {
129            self.text()
130        } else {
131            Style::default()
132                .fg(self.theme.text())
133                .bg(self.theme.stripe())
134        }
135    }
136
137    pub fn border(&self) -> Style {
138        if self.uses_native_terminal() {
139            Style::default().fg(self.theme.border())
140        } else {
141            // Borders sit on the canvas, not the panel surface, so edges stay visible.
142            Style::default()
143                .fg(self.theme.text_dim())
144                .bg(self.theme.background())
145        }
146    }
147
148    pub fn border_accent(&self) -> Style {
149        let mut style = Style::default().fg(self.theme.accent());
150        if self.has_immersive_background() {
151            style = style.bg(self.theme.background());
152        }
153        style
154    }
155
156    pub fn selection(&self) -> Style {
157        if self.uses_native_terminal() {
158            Style::default()
159                .fg(self.theme.accent())
160                .add_modifier(Modifier::BOLD)
161        } else {
162            Style::default()
163                .fg(self.theme.accent())
164                .bg(self.theme.stripe())
165                .add_modifier(Modifier::BOLD)
166        }
167    }
168
169    /// Style for a table/list row: selected, zebra odd, or default.
170    pub fn row(&self, index: usize, selected: bool) -> Style {
171        if selected {
172            self.selection()
173        } else if self.uses_native_terminal() || index.is_multiple_of(2) {
174            self.text()
175        } else {
176            self.stripe()
177        }
178    }
179
180    pub fn label(&self) -> Style {
181        Style::default().fg(self.theme.info())
182    }
183
184    pub fn success(&self) -> Style {
185        Style::default().fg(self.theme.success())
186    }
187
188    pub fn error(&self) -> Style {
189        Style::default().fg(self.theme.error())
190    }
191
192    pub fn warning(&self) -> Style {
193        Style::default().fg(self.theme.warning())
194    }
195
196    pub fn muted(&self) -> Style {
197        Style::default().fg(self.theme.text_dim())
198    }
199
200    pub fn primary_text(&self) -> Style {
201        if self.uses_native_terminal() {
202            Style::default().add_modifier(Modifier::BOLD)
203        } else {
204            Style::default().fg(self.theme.text_bright())
205        }
206    }
207
208    pub fn border_focus(&self) -> Style {
209        self.border_accent()
210    }
211
212    pub fn footer_hint(&self) -> Style {
213        Style::default().fg(self.theme.text_dim())
214    }
215
216    /// Bordered panel with themed surface fill.
217    pub fn panel_block<'b>(&self, title: impl Into<Line<'b>>) -> Block<'b> {
218        let border_type = if self.uses_native_terminal() {
219            BorderType::Plain
220        } else {
221            BorderType::Rounded
222        };
223        let mut block = Block::default()
224            .title(title)
225            .borders(Borders::ALL)
226            .border_type(border_type)
227            .border_style(self.border())
228            .title_style(
229                Style::default()
230                    .fg(self.theme.accent())
231                    .add_modifier(Modifier::BOLD),
232            );
233        if !self.uses_native_terminal() {
234            block = block.style(self.surface());
235        }
236        block
237    }
238
239    /// Bordered panel without a title.
240    pub fn panel_block_untitled(&self) -> Block<'_> {
241        let border_type = if self.uses_native_terminal() {
242            BorderType::Plain
243        } else {
244            BorderType::Rounded
245        };
246        let mut block = Block::default()
247            .borders(Borders::ALL)
248            .border_type(border_type)
249            .border_style(self.border());
250        if !self.uses_native_terminal() {
251            block = block.style(self.surface());
252        }
253        block
254    }
255
256    /// Header strip with bottom border only.
257    pub fn header_block(&self) -> Block<'_> {
258        let mut block = Block::default()
259            .borders(Borders::BOTTOM)
260            .border_type(BorderType::Plain)
261            .border_style(self.border());
262        if !self.uses_native_terminal() {
263            block = block.style(self.surface());
264        }
265        block
266    }
267
268    pub fn color_success(&self) -> Color {
269        self.theme.success()
270    }
271
272    pub fn color_error(&self) -> Color {
273        self.theme.error()
274    }
275
276    pub fn color_warning(&self) -> Color {
277        self.theme.warning()
278    }
279
280    pub fn color_info(&self) -> Color {
281        self.theme.info()
282    }
283
284    pub fn tone(&self, tone: MessageTone) -> Style {
285        match tone {
286            MessageTone::Success => self.success(),
287            MessageTone::Error => self.error(),
288            MessageTone::Warning => self.warning(),
289            MessageTone::Info => self.label(),
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn resolve_unknown_falls_back_to_terminal() {
300        std::env::remove_var("NO_COLOR");
301        let theme = resolve_theme_or_default("not-a-theme");
302        assert_eq!(theme.id(), "terminal");
303    }
304
305    #[test]
306    fn dracula_has_immersive_background_and_selection_contrast() {
307        std::env::remove_var("NO_COLOR");
308        let theme = resolve_theme_or_default("dracula");
309        let styles = RommStyles::new(theme.as_ref());
310        assert!(styles.has_immersive_background());
311        assert_ne!(styles.selection().fg, None);
312        assert_ne!(styles.selection().bg, None);
313    }
314
315    #[test]
316    fn terminal_theme_respects_native_terminal_colors() {
317        std::env::remove_var("NO_COLOR");
318        let theme = resolve_theme_or_default("terminal");
319        let styles = RommStyles::new(theme.as_ref());
320        assert!(styles.uses_native_terminal());
321        assert_eq!(styles.surface().bg, None);
322        assert_eq!(styles.selection().bg, None);
323        assert_eq!(styles.text().fg, None);
324    }
325
326    #[test]
327    fn dracula_border_contrasts_with_surface() {
328        std::env::remove_var("NO_COLOR");
329        let theme = resolve_theme_or_default("dracula");
330        let styles = RommStyles::new(theme.as_ref());
331        let border = styles.border();
332        assert_eq!(border.bg, Some(theme.background()));
333        assert_ne!(border.fg, Some(theme.surface()));
334    }
335}