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        terminal.draw(|frame| {
134            let block = Block::default().style(
135                Style::default()
136                    .bg(self.theme.background)
137                    .fg(self.theme.text),
138            );
139            frame.render_widget(block, frame.area());
140
141            let area = frame.area();
142            let [area] = Layout::vertical([Constraint::Max(49)])
143                .flex(Flex::Center)
144                .areas(area);
145            let [area] = Layout::horizontal([Constraint::Max(140)])
146                .flex(Flex::Center)
147                .areas(area);
148
149            let [title_area, main_area, footer_area] =
150                Layout::vertical([Constraint::Max(3), Constraint::Max(44), Constraint::Max(3)])
151                    .flex(Flex::Center)
152                    .areas(area);
153
154            // Title
155            self.title
156                .set_theme_name(self.current_theme_name().to_string());
157            self.title.render(frame, title_area, &self.theme, info);
158
159            // Main
160            self.main.render(frame, main_area, &self.theme, info);
161
162            // Footer
163            self.footer.render(frame, footer_area, &self.theme, info);
164
165            // Error popup if error is set
166            self.render_error_popup(frame);
167        })?;
168
169        Ok(())
170    }
171
172    pub fn set_error(&mut self, message: String) {
173        self.error_message = Some(message);
174    }
175
176    fn render_error_popup(&self, frame: &mut Frame) {
177        if let Some(message) = &self.error_message {
178            let mut text = Text::default();
179            let message = format!(" {} ", message);
180
181            text.push_line("");
182            text.push_line(message.as_str());
183            text.push_line("");
184
185            let popup = Popup::new(text)
186                .title(" Error ")
187                .style(
188                    Style::default()
189                        .bg(self.theme.background)
190                        .fg(self.theme.indication_warning),
191                )
192                .border_style(Style::default().fg(self.theme.border));
193
194            frame.render_widget(&popup, frame.area());
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use std::sync::Arc;
202
203    use ratatui::crossterm::event::{Event, KeyCode, KeyEvent};
204
205    use crate::{
206        app::AppEvent,
207        config::Config,
208        framework::{fingerprint::Fingerprint, info::FrameworkInfo},
209        tui::{theme::ThemeVariant, Tui},
210    };
211
212    #[test]
213    fn handle_input_internal_quit_event() {
214        let fingerprint = Arc::new(Fingerprint::percentage());
215        let info = FrameworkInfo::default();
216        let config = Config::default();
217        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
218        let event = Event::Key(KeyEvent::from(KeyCode::Char('q')));
219
220        let app_event = tui.handle_input(event);
221
222        assert!(matches!(app_event, Ok(Some(AppEvent::Quit))))
223    }
224
225    #[test]
226    fn next_theme_cycles_forward() {
227        let fingerprint = Arc::new(Fingerprint::percentage());
228        let info = FrameworkInfo::default();
229        let config = Config::default();
230        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
231
232        assert_eq!(tui.config.theme, ThemeVariant::Default);
233
234        // Cycle to next theme
235        tui.next_theme();
236        assert_eq!(tui.config.theme, ThemeVariant::Framework);
237
238        tui.next_theme();
239        assert_eq!(tui.config.theme, ThemeVariant::Alucard);
240
241        tui.next_theme();
242        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
243
244        tui.next_theme();
245        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinLatte);
246
247        tui.next_theme();
248        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMacchiato);
249
250        tui.next_theme();
251        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMocha);
252
253        tui.next_theme();
254        assert_eq!(tui.config.theme, ThemeVariant::Dracula);
255
256        tui.next_theme();
257        assert_eq!(tui.config.theme, ThemeVariant::GameBoy);
258
259        tui.next_theme();
260        assert_eq!(tui.config.theme, ThemeVariant::GithubDark);
261
262        tui.next_theme();
263        assert_eq!(tui.config.theme, ThemeVariant::GithubLight);
264
265        tui.next_theme();
266        assert_eq!(tui.config.theme, ThemeVariant::GruvboxDark);
267
268        tui.next_theme();
269        assert_eq!(tui.config.theme, ThemeVariant::GruvboxLight);
270
271        tui.next_theme();
272        assert_eq!(tui.config.theme, ThemeVariant::MonochromeDark);
273
274        tui.next_theme();
275        assert_eq!(tui.config.theme, ThemeVariant::MonochromeLight);
276
277        tui.next_theme();
278        assert_eq!(tui.config.theme, ThemeVariant::MonokaiPro);
279
280        // Should wrap back to Default
281        tui.next_theme();
282        assert_eq!(tui.config.theme, ThemeVariant::Default);
283    }
284
285    #[test]
286    fn previous_theme_cycles_backward() {
287        let fingerprint = Arc::new(Fingerprint::percentage());
288        let info = FrameworkInfo::default();
289        let config = Config::default();
290        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
291
292        assert_eq!(tui.config.theme, ThemeVariant::Default);
293
294        // Cycle to previous theme (should wrap to MonokaiPro)
295        tui.previous_theme();
296        assert_eq!(tui.config.theme, ThemeVariant::MonokaiPro);
297
298        tui.previous_theme();
299        assert_eq!(tui.config.theme, ThemeVariant::MonochromeLight);
300
301        tui.previous_theme();
302        assert_eq!(tui.config.theme, ThemeVariant::MonochromeDark);
303
304        tui.previous_theme();
305        assert_eq!(tui.config.theme, ThemeVariant::GruvboxLight);
306
307        tui.previous_theme();
308        assert_eq!(tui.config.theme, ThemeVariant::GruvboxDark);
309
310        tui.previous_theme();
311        assert_eq!(tui.config.theme, ThemeVariant::GithubLight);
312
313        tui.previous_theme();
314        assert_eq!(tui.config.theme, ThemeVariant::GithubDark);
315
316        tui.previous_theme();
317        assert_eq!(tui.config.theme, ThemeVariant::GameBoy);
318
319        tui.previous_theme();
320        assert_eq!(tui.config.theme, ThemeVariant::Dracula);
321
322        tui.previous_theme();
323        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMocha);
324
325        tui.previous_theme();
326        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinMacchiato);
327
328        tui.previous_theme();
329        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinLatte);
330
331        tui.previous_theme();
332        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
333
334        tui.previous_theme();
335        assert_eq!(tui.config.theme, ThemeVariant::Alucard);
336
337        tui.previous_theme();
338        assert_eq!(tui.config.theme, ThemeVariant::Framework);
339    }
340
341    #[test]
342    fn current_theme_name_returns_correct_name() {
343        let fingerprint = Arc::new(Fingerprint::percentage());
344        let info = FrameworkInfo::default();
345        let config = Config::default();
346        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
347
348        assert_eq!(tui.current_theme_name(), "Default");
349
350        tui.next_theme();
351        assert_eq!(tui.current_theme_name(), "Framework");
352
353        tui.next_theme();
354        assert_eq!(tui.current_theme_name(), "Alucard");
355
356        tui.next_theme();
357        assert_eq!(tui.current_theme_name(), "Catppuccin Frappe");
358
359        tui.next_theme();
360        assert_eq!(tui.current_theme_name(), "Catppuccin Latte");
361    }
362
363    #[test]
364    fn handle_input_n_switches_to_next_theme() {
365        let fingerprint = Arc::new(Fingerprint::percentage());
366        let info = FrameworkInfo::default();
367        let config = Config::default();
368        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
369
370        assert_eq!(tui.config.theme, ThemeVariant::Default);
371
372        let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
373        let result = tui.handle_input(event);
374
375        assert!(matches!(result, Ok(None)));
376        assert_eq!(tui.config.theme, ThemeVariant::Framework);
377    }
378
379    #[test]
380    fn handle_input_b_switches_to_previous_theme() {
381        let fingerprint = Arc::new(Fingerprint::percentage());
382        let info = FrameworkInfo::default();
383        let config = Config::default();
384        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
385
386        assert_eq!(tui.config.theme, ThemeVariant::Default);
387
388        let event = Event::Key(KeyEvent::from(KeyCode::Char('b')));
389        let result = tui.handle_input(event);
390
391        assert!(matches!(result, Ok(None)));
392        assert_eq!(tui.config.theme, ThemeVariant::MonokaiPro);
393    }
394
395    #[test]
396    fn handle_input_left_without_ctrl_does_not_switch_theme() {
397        let fingerprint = Arc::new(Fingerprint::percentage());
398        let info = FrameworkInfo::default();
399        let config = Config::default();
400        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
401
402        let initial_theme = tui.config.theme;
403        let event = Event::Key(KeyEvent::from(KeyCode::Left));
404        let _result = tui.handle_input(event);
405
406        // Theme should remain unchanged
407        assert_eq!(tui.config.theme, initial_theme);
408    }
409
410    #[test]
411    fn handle_input_right_without_ctrl_does_not_switch_theme() {
412        let fingerprint = Arc::new(Fingerprint::percentage());
413        let info = FrameworkInfo::default();
414        let config = Config::default();
415        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
416
417        let initial_theme = tui.config.theme;
418        let event = Event::Key(KeyEvent::from(KeyCode::Right));
419        let _result = tui.handle_input(event);
420
421        // Theme should remain unchanged
422        assert_eq!(tui.config.theme, initial_theme);
423    }
424
425    #[test]
426    fn theme_switching_does_not_pass_event_to_main_component() {
427        // This test ensures that 'b' and 'n' events are consumed by theme switching
428        // and not passed down to child components
429        let fingerprint = Arc::new(Fingerprint::percentage());
430        let info = FrameworkInfo::default();
431        let config = Config::default();
432        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
433
434        let initial_theme = tui.config.theme;
435
436        // Send 'n' event
437        let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
438        let result = tui.handle_input(event);
439
440        // Should return Ok(None) and change theme
441        assert!(matches!(result, Ok(None)));
442        assert_ne!(tui.config.theme, initial_theme);
443
444        // Try switching back with 'b'
445        let new_theme = tui.config.theme;
446        let event = Event::Key(KeyEvent::from(KeyCode::Char('b')));
447        let result = tui.handle_input(event);
448
449        // Should return Ok(None) and change theme back
450        assert!(matches!(result, Ok(None)));
451        assert_eq!(tui.config.theme, initial_theme);
452        assert_ne!(tui.config.theme, new_theme);
453    }
454
455    #[test]
456    fn multiple_theme_switches_work_correctly() {
457        let fingerprint = Arc::new(Fingerprint::percentage());
458        let info = FrameworkInfo::default();
459        let config = Config::default();
460        let mut tui = Tui::new(fingerprint, &info, config).unwrap();
461
462        // Start at Default
463        assert_eq!(tui.config.theme, ThemeVariant::Default);
464
465        // Switch forward 3 times with 'n'
466        for _ in 0..3 {
467            let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
468            let result = tui.handle_input(event);
469            assert!(matches!(result, Ok(None)));
470        }
471        // After 3 next: Default -> Framework -> Alucard -> CatppuccinFrappe
472        assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
473
474        // Switch backward once with 'b'
475        let event = Event::Key(KeyEvent::from(KeyCode::Char('b')));
476        let result = tui.handle_input(event);
477        assert!(matches!(result, Ok(None)));
478        assert_eq!(tui.config.theme, ThemeVariant::Alucard);
479    }
480}