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 self.title
156 .set_theme_name(self.current_theme_name().to_string());
157 self.title.render(frame, title_area, &self.theme, info);
158
159 self.main.render(frame, main_area, &self.theme, info);
161
162 self.footer.render(frame, footer_area, &self.theme, info);
164
165 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 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 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 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 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 assert_eq!(tui.config.theme, initial_theme);
423 }
424
425 #[test]
426 fn theme_switching_does_not_pass_event_to_main_component() {
427 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 let event = Event::Key(KeyEvent::from(KeyCode::Char('n')));
438 let result = tui.handle_input(event);
439
440 assert!(matches!(result, Ok(None)));
442 assert_ne!(tui.config.theme, initial_theme);
443
444 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 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 assert_eq!(tui.config.theme, ThemeVariant::Default);
464
465 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 assert_eq!(tui.config.theme, ThemeVariant::CatppuccinFrappe);
473
474 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}