Skip to main content

framework_tool_tui/
tui.rs

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            // Title
158            self.title
159                .set_theme_name(self.current_theme_name().to_string());
160            self.title.render(frame, title_area, &self.theme, info);
161
162            // Main
163            self.main.render(frame, main_area, &self.theme, info);
164
165            // Footer
166            self.footer.render(frame, footer_area, &self.theme, info);
167
168            // Error popup if error is set
169            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        // Cycle to next theme
234        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        // Should wrap back to Default
280        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        // Cycle to previous theme (should wrap to MonokaiPro)
294        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        // Theme should remain unchanged
406        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        // Theme should remain unchanged
421        assert_eq!(tui.config.theme, initial_theme);
422    }
423
424    #[test]
425    fn theme_switching_does_not_pass_event_to_main_component() {
426        // This test ensures that 'b' and 'n' events are consumed by theme switching
427        // and not passed down to child components
428        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        // Send 'n' event
436        let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
437        let result = tui.handle_input(event);
438
439        // Should return Ok(None) and change theme
440        assert!(matches!(result, Ok(None)));
441        assert_ne!(tui.config.theme, initial_theme);
442
443        // Try switching back with 'b'
444        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        // Should return Ok(None) and change theme back
449        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        // Start at Default
462        assert_eq!(tui.config.theme, ThemeVariant::Default);
463
464        // Switch forward 3 times with 'n'
465        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        // After 3 next: Default -> Framework -> Alucard -> CatppuccinFrappe
471        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
472
473        // Switch backward once with 'b'
474        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}