1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum MessageTone {
15 Success,
16 Error,
17 Warning,
18 Info,
19}
20
21pub 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
34pub fn theme_display_name(id: &str) -> String {
36 resolve_theme_or_default(id).name().to_string()
37}
38
39pub 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
49pub 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
60pub 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 pub fn has_immersive_background(&self) -> bool {
76 !matches!(self.theme.background(), Color::Reset)
77 }
78
79 pub fn uses_native_terminal(&self) -> bool {
81 !self.has_immersive_background()
82 }
83
84 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 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 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 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 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 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 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}