1use 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#[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 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 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 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 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 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 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}