1pub mod component;
2pub mod control;
3pub mod theme;
4
5use std::sync::Arc;
6
7use ratatui::{
8 crossterm::event::{Event, KeyCode, KeyEventKind},
9 layout::{Constraint, Flex, Layout},
10 prelude::Backend,
11 style::Style,
12 text::Text,
13 widgets::Block,
14 Frame, Terminal,
15};
16use tui_popup::Popup;
17
18use crate::{
19 app::AppEvent,
20 config::Config,
21 framework::{fingerprint::Fingerprint, info::FrameworkInfo},
22 tui::{
23 component::{
24 footer::FooterComponent, main::MainComponent, title::TitleComponent, Component,
25 },
26 theme::Theme,
27 },
28};
29
30pub struct Tui {
31 pub title: TitleComponent,
32 main: MainComponent,
33 footer: FooterComponent,
34 theme: Theme,
35 error_message: Option<String>,
36 config: Config,
37 tick_interval_ms: u64,
38}
39
40impl Tui {
41 pub fn new(
42 fingerprint: Arc<Fingerprint>,
43 info: &FrameworkInfo,
44 config: Config,
45 ) -> color_eyre::Result<Self> {
46 let theme = Theme::from_variant(config.theme);
47
48 Ok(Self {
49 title: TitleComponent::new(theme.variant),
50 main: MainComponent::new(fingerprint, info),
51 footer: FooterComponent,
52 theme,
53 error_message: None,
54 tick_interval_ms: config.tick_interval_ms,
55 config,
56 })
57 }
58
59 pub fn next_theme(&mut self) {
60 let next_variant = self.config.theme.next();
61 self.theme = Theme::from_variant(next_variant);
62 if let Err(e) = self.config.set_theme(next_variant) {
63 self.set_error(format!("Failed to save theme: {}", e));
64 }
65 }
66
67 pub fn previous_theme(&mut self) {
68 let prev_variant = self.config.theme.previous();
69 self.theme = Theme::from_variant(prev_variant);
70 if let Err(e) = self.config.set_theme(prev_variant) {
71 self.set_error(format!("Failed to save theme: {}", e));
72 }
73 }
74
75 pub fn current_theme_name(&self) -> &'static str {
76 self.config.theme.name()
77 }
78
79 fn increase_tick_interval(&mut self) -> Option<AppEvent> {
80 let new_interval = (self.tick_interval_ms + 100).min(5000);
81 if new_interval != self.tick_interval_ms {
82 self.tick_interval_ms = new_interval;
83 Some(AppEvent::SetTickInterval(new_interval))
84 } else {
85 None
86 }
87 }
88
89 fn decrease_tick_interval(&mut self) -> Option<AppEvent> {
90 let new_interval = self.tick_interval_ms.saturating_sub(100).max(100);
91 if new_interval != self.tick_interval_ms {
92 self.tick_interval_ms = new_interval;
93 Some(AppEvent::SetTickInterval(new_interval))
94 } else {
95 None
96 }
97 }
98
99 pub fn handle_input(&mut self, event: Event) -> color_eyre::Result<Option<AppEvent>> {
100 let top_level_event = match &event {
101 Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
102 KeyCode::Char('q') => Some(AppEvent::Quit),
103 KeyCode::Char('b') => {
104 self.previous_theme();
105 None
106 }
107 KeyCode::Char('n') => {
108 self.next_theme();
109 None
110 }
111 KeyCode::Char('+') | KeyCode::Char('=') => self.increase_tick_interval(),
112 KeyCode::Char('-') => self.decrease_tick_interval(),
113 KeyCode::Esc if self.error_message.is_some() => {
114 self.error_message = None;
115 None
116 }
117 _ => None,
118 },
119 _ => None,
120 };
121
122 match self.error_message {
123 Some(_) => Ok(top_level_event),
124 None => Ok(top_level_event.or(self.main.handle_input(event))),
125 }
126 }
127
128 pub fn render<B: Backend>(
129 &mut self,
130 terminal: &mut Terminal<B>,
131 info: &FrameworkInfo,
132 ) -> color_eyre::Result<()>
133 where
134 B::Error: Send + Sync + 'static,
135 {
136 terminal.draw(|frame| {
137 let block = Block::default().style(
138 Style::default()
139 .bg(self.theme.background)
140 .fg(self.theme.text),
141 );
142 frame.render_widget(block, frame.area());
143
144 let area = frame.area();
145 let [area] = Layout::vertical([Constraint::Max(49)])
146 .flex(Flex::Center)
147 .areas(area);
148 let [area] = Layout::horizontal([Constraint::Max(140)])
149 .flex(Flex::Center)
150 .areas(area);
151
152 let [title_area, main_area, footer_area] =
153 Layout::vertical([Constraint::Max(3), Constraint::Max(44), Constraint::Max(3)])
154 .flex(Flex::Center)
155 .areas(area);
156
157 self.title
159 .set_theme_name(self.current_theme_name().to_string());
160 self.title.render(frame, title_area, &self.theme, info);
161
162 self.main.render(frame, main_area, &self.theme, info);
164
165 self.footer.render(frame, footer_area, &self.theme, info);
167
168 self.render_error_popup(frame);
170 })?;
171
172 Ok(())
173 }
174
175 pub fn set_error(&mut self, message: String) {
176 self.error_message = Some(message);
177 }
178
179 fn render_error_popup(&self, frame: &mut Frame) {
180 if let Some(message) = &self.error_message {
181 let mut text = Text::default();
182 let message = format!(" {} ", message);
183
184 text.push_line("");
185 text.push_line(message.as_str());
186 text.push_line("");
187
188 let popup = Popup::new(text)
189 .title(" Error ")
190 .style((self.theme.indication_warning, self.theme.background))
191 .border_style(self.theme.border);
192
193 frame.render_widget(popup, frame.area());
194 }
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use std::sync::Arc;
201
202 use ratatui::crossterm::event::{Event, KeyCode, KeyEvent};
203
204 use crate::{
205 app::AppEvent,
206 config::Config,
207 framework::{fingerprint::Fingerprint, info::FrameworkInfo},
208 tui::{theme::ThemeVariant, Tui},
209 };
210
211 #[test]
212 fn handle_input_internal_quit_event() {
213 let fingerprint = Arc::new(Fingerprint::percentage());
214 let info = FrameworkInfo::default();
215 let config = Config::default();
216 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
217 let event = Event::Key(KeyEvent::from(KeyCode::Char('q')));
218
219 let app_event = tui.handle_input(event);
220
221 assert!(matches!(app_event, Ok(Some(AppEvent::Quit))))
222 }
223
224 #[test]
225 fn next_theme_cycles_forward() {
226 let fingerprint = Arc::new(Fingerprint::percentage());
227 let info = FrameworkInfo::default();
228 let config = Config::default();
229 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
230
231 assert_eq!(tui.config.theme, ThemeVariant::Default);
232
233 tui.next_theme();
235 assert_eq!(tui.config.theme, ThemeVariant::Framework);
236
237 tui.next_theme();
238 assert_eq!(tui.config.theme, ThemeVariant::Alucard);
239
240 tui.next_theme();
241 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
242
243 tui.next_theme();
244 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinLatte);
245
246 tui.next_theme();
247 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMacchiato);
248
249 tui.next_theme();
250 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMocha);
251
252 tui.next_theme();
253 assert_eq!(tui.config.theme, ThemeVariant::Dracula);
254
255 tui.next_theme();
256 assert_eq!(tui.config.theme, ThemeVariant::GameBoy);
257
258 tui.next_theme();
259 assert_eq!(tui.config.theme, ThemeVariant::GithubDark);
260
261 tui.next_theme();
262 assert_eq!(tui.config.theme, ThemeVariant::GithubLight);
263
264 tui.next_theme();
265 assert_eq!(tui.config.theme, ThemeVariant::GruvboxDark);
266
267 tui.next_theme();
268 assert_eq!(tui.config.theme, ThemeVariant::GruvboxLight);
269
270 tui.next_theme();
271 assert_eq!(tui.config.theme, ThemeVariant::MonochromeDark);
272
273 tui.next_theme();
274 assert_eq!(tui.config.theme, ThemeVariant::MonochromeLight);
275
276 tui.next_theme();
277 assert_eq!(tui.config.theme, ThemeVariant::MonokaiPro);
278
279 tui.next_theme();
281 assert_eq!(tui.config.theme, ThemeVariant::Default);
282 }
283
284 #[test]
285 fn previous_theme_cycles_backward() {
286 let fingerprint = Arc::new(Fingerprint::percentage());
287 let info = FrameworkInfo::default();
288 let config = Config::default();
289 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
290
291 assert_eq!(tui.config.theme, ThemeVariant::Default);
292
293 tui.previous_theme();
295 assert_eq!(tui.config.theme, ThemeVariant::MonokaiPro);
296
297 tui.previous_theme();
298 assert_eq!(tui.config.theme, ThemeVariant::MonochromeLight);
299
300 tui.previous_theme();
301 assert_eq!(tui.config.theme, ThemeVariant::MonochromeDark);
302
303 tui.previous_theme();
304 assert_eq!(tui.config.theme, ThemeVariant::GruvboxLight);
305
306 tui.previous_theme();
307 assert_eq!(tui.config.theme, ThemeVariant::GruvboxDark);
308
309 tui.previous_theme();
310 assert_eq!(tui.config.theme, ThemeVariant::GithubLight);
311
312 tui.previous_theme();
313 assert_eq!(tui.config.theme, ThemeVariant::GithubDark);
314
315 tui.previous_theme();
316 assert_eq!(tui.config.theme, ThemeVariant::GameBoy);
317
318 tui.previous_theme();
319 assert_eq!(tui.config.theme, ThemeVariant::Dracula);
320
321 tui.previous_theme();
322 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMocha);
323
324 tui.previous_theme();
325 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMacchiato);
326
327 tui.previous_theme();
328 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinLatte);
329
330 tui.previous_theme();
331 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
332
333 tui.previous_theme();
334 assert_eq!(tui.config.theme, ThemeVariant::Alucard);
335
336 tui.previous_theme();
337 assert_eq!(tui.config.theme, ThemeVariant::Framework);
338 }
339
340 #[test]
341 fn current_theme_name_returns_correct_name() {
342 let fingerprint = Arc::new(Fingerprint::percentage());
343 let info = FrameworkInfo::default();
344 let config = Config::default();
345 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
346
347 assert_eq!(tui.current_theme_name(), "Default");
348
349 tui.next_theme();
350 assert_eq!(tui.current_theme_name(), "Framework");
351
352 tui.next_theme();
353 assert_eq!(tui.current_theme_name(), "Alucard");
354
355 tui.next_theme();
356 assert_eq!(tui.current_theme_name(), "Catppuccin Frappe");
357
358 tui.next_theme();
359 assert_eq!(tui.current_theme_name(), "Catppuccin Latte");
360 }
361
362 #[test]
363 fn handle_input_n_switches_to_next_theme() {
364 let fingerprint = Arc::new(Fingerprint::percentage());
365 let info = FrameworkInfo::default();
366 let config = Config::default();
367 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
368
369 assert_eq!(tui.config.theme, ThemeVariant::Default);
370
371 let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
372 let result = tui.handle_input(event);
373
374 assert!(matches!(result, Ok(None)));
375 assert_eq!(tui.config.theme, ThemeVariant::Framework);
376 }
377
378 #[test]
379 fn handle_input_b_switches_to_previous_theme() {
380 let fingerprint = Arc::new(Fingerprint::percentage());
381 let info = FrameworkInfo::default();
382 let config = Config::default();
383 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
384
385 assert_eq!(tui.config.theme, ThemeVariant::Default);
386
387 let event = Event::Key(KeyEvent::from(KeyCode::Char('b')));
388 let result = tui.handle_input(event);
389
390 assert!(matches!(result, Ok(None)));
391 assert_eq!(tui.config.theme, ThemeVariant::MonokaiPro);
392 }
393
394 #[test]
395 fn handle_input_left_without_ctrl_does_not_switch_theme() {
396 let fingerprint = Arc::new(Fingerprint::percentage());
397 let info = FrameworkInfo::default();
398 let config = Config::default();
399 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
400
401 let initial_theme = tui.config.theme;
402 let event = Event::Key(KeyEvent::from(KeyCode::Left));
403 let _result = tui.handle_input(event);
404
405 assert_eq!(tui.config.theme, initial_theme);
407 }
408
409 #[test]
410 fn handle_input_right_without_ctrl_does_not_switch_theme() {
411 let fingerprint = Arc::new(Fingerprint::percentage());
412 let info = FrameworkInfo::default();
413 let config = Config::default();
414 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
415
416 let initial_theme = tui.config.theme;
417 let event = Event::Key(KeyEvent::from(KeyCode::Right));
418 let _result = tui.handle_input(event);
419
420 assert_eq!(tui.config.theme, initial_theme);
422 }
423
424 #[test]
425 fn theme_switching_does_not_pass_event_to_main_component() {
426 let fingerprint = Arc::new(Fingerprint::percentage());
429 let info = FrameworkInfo::default();
430 let config = Config::default();
431 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
432
433 let initial_theme = tui.config.theme;
434
435 let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
437 let result = tui.handle_input(event);
438
439 assert!(matches!(result, Ok(None)));
441 assert_ne!(tui.config.theme, initial_theme);
442
443 let new_theme = tui.config.theme;
445 let event = Event::Key(KeyEvent::from(KeyCode::Char('b')));
446 let result = tui.handle_input(event);
447
448 assert!(matches!(result, Ok(None)));
450 assert_eq!(tui.config.theme, initial_theme);
451 assert_ne!(tui.config.theme, new_theme);
452 }
453
454 #[test]
455 fn multiple_theme_switches_work_correctly() {
456 let fingerprint = Arc::new(Fingerprint::percentage());
457 let info = FrameworkInfo::default();
458 let config = Config::default();
459 let mut tui = Tui::new(fingerprint, &info, config).unwrap();
460
461 assert_eq!(tui.config.theme, ThemeVariant::Default);
463
464 for _ in 0..3 {
466 let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
467 let result = tui.handle_input(event);
468 assert!(matches!(result, Ok(None)));
469 }
470 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
472
473 let event = Event::Key(KeyEvent::from(KeyCode::Char('b')));
475 let result = tui.handle_input(event);
476 assert!(matches!(result, Ok(None)));
477 assert_eq!(tui.config.theme, ThemeVariant::Alucard);
478 }
479}