1use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
7use ratatui::{
8 Frame,
9 layout::{Constraint, Direction, Layout, Margin, Position, Rect},
10 style::{Style, Stylize},
11 text::Span,
12 widgets::Block,
13};
14use tokio::sync::mpsc::UnboundedSender;
15
16use crate::state::{
17 action::{Action, ComponentAction, GeneralAction, LibraryAction},
18 component::ActiveComponent,
19};
20
21use super::{
22 AppState,
23 colors::{APP_BORDER, APP_BORDER_TEXT, TEXT_NORMAL},
24 components::{
25 Component, ComponentRender, RenderProps, content_view::ContentView,
26 control_panel::ControlPanel, queuebar::QueueBar, sidebar::Sidebar,
27 },
28 widgets::popups::Popup,
29};
30
31#[must_use]
32pub struct App {
33 pub action_tx: UnboundedSender<Action>,
35 active_component: ActiveComponent,
37 sidebar: Sidebar,
39 queuebar: QueueBar,
40 control_panel: ControlPanel,
41 content_view: ContentView,
42 popup: Option<Box<dyn Popup>>,
44}
45
46impl App {
47 fn get_active_view_component(&self) -> &dyn Component {
48 match self.active_component {
49 ActiveComponent::Sidebar => &self.sidebar,
50 ActiveComponent::QueueBar => &self.queuebar,
51 ActiveComponent::ControlPanel => &self.control_panel,
52 ActiveComponent::ContentView => &self.content_view,
53 }
54 }
55
56 fn get_active_view_component_mut(&mut self) -> &mut dyn Component {
57 match self.active_component {
58 ActiveComponent::Sidebar => &mut self.sidebar,
59 ActiveComponent::QueueBar => &mut self.queuebar,
60 ActiveComponent::ControlPanel => &mut self.control_panel,
61 ActiveComponent::ContentView => &mut self.content_view,
62 }
63 }
64
65 pub fn move_with_search(self, state: &AppState) -> Self {
69 let new = self.content_view.search_view.move_with_state(state);
70 Self {
71 content_view: ContentView {
72 search_view: new,
73 ..self.content_view
74 },
75 ..self
76 }
77 }
78
79 pub fn move_with_audio(self, state: &AppState) -> Self {
83 Self {
84 queuebar: self.queuebar.move_with_state(state),
85 control_panel: self.control_panel.move_with_state(state),
86 ..self
87 }
88 }
89
90 pub fn move_with_library(self, state: &AppState) -> Self {
94 let content_view = self.content_view.move_with_state(state);
95 Self {
96 content_view,
97 ..self
98 }
99 }
100
101 pub fn move_with_view(self, state: &AppState) -> Self {
105 let content_view = self.content_view.move_with_state(state);
106 Self {
107 content_view,
108 ..self
109 }
110 }
111
112 pub fn move_with_component(self, state: &AppState) -> Self {
116 Self {
117 active_component: state.active_component,
118 ..self
119 }
120 }
121
122 pub fn move_with_popup(self, popup: Option<Box<dyn Popup>>) -> Self {
126 Self { popup, ..self }
127 }
128}
129
130impl Component for App {
131 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
132 where
133 Self: Sized,
134 {
135 Self {
136 action_tx: action_tx.clone(),
137 active_component: state.active_component,
138 sidebar: Sidebar::new(state, action_tx.clone()),
140 queuebar: QueueBar::new(state, action_tx.clone()),
141 control_panel: ControlPanel::new(state, action_tx.clone()),
142 content_view: ContentView::new(state, action_tx),
143 popup: None,
145 }
146 .move_with_state(state)
147 }
148
149 fn move_with_state(self, state: &AppState) -> Self
150 where
151 Self: Sized,
152 {
153 Self {
154 sidebar: self.sidebar.move_with_state(state),
155 queuebar: self.queuebar.move_with_state(state),
156 control_panel: self.control_panel.move_with_state(state),
157 content_view: self.content_view.move_with_state(state),
158 popup: self.popup.map(|popup| {
159 let mut popup = popup;
160 popup.update_with_state(state);
161 popup
162 }),
163 ..self
164 }
165 }
166
167 fn name(&self) -> &str {
169 self.get_active_view_component().name()
170 }
171
172 fn handle_key_event(&mut self, key: KeyEvent) {
173 if key.kind != KeyEventKind::Press {
174 return;
175 }
176
177 if let Some(popup) = self.popup.as_mut() {
179 popup.handle_key_event(key, self.action_tx.clone());
180 return;
181 }
182
183 match key.code {
186 KeyCode::Esc => {
188 self.action_tx
189 .send(Action::General(GeneralAction::Exit))
190 .unwrap();
191 }
192 KeyCode::Tab => self
194 .action_tx
195 .send(Action::ActiveComponent(ComponentAction::Next))
196 .unwrap(),
197 KeyCode::BackTab => self
198 .action_tx
199 .send(Action::ActiveComponent(ComponentAction::Previous))
200 .unwrap(),
201 KeyCode::F(5) => self
203 .action_tx
204 .send(Action::Library(LibraryAction::Update))
205 .unwrap(),
206 _ => self.get_active_view_component_mut().handle_key_event(key),
208 }
209 }
210
211 fn handle_mouse_event(&mut self, mouse: crossterm::event::MouseEvent, area: Rect) {
212 if let Some(popup) = self.popup.as_mut() {
214 popup.handle_mouse_event(mouse, popup.area(area), self.action_tx.clone());
215 return;
216 }
217
218 let area = area.inner(Margin::new(1, 1));
220
221 let mouse_position = Position::new(mouse.column, mouse.row);
223 let Areas {
224 control_panel,
225 sidebar,
226 content_view,
227 queuebar,
228 } = split_area(area);
229
230 if control_panel.contains(mouse_position) {
231 self.control_panel.handle_mouse_event(mouse, control_panel);
232 } else if sidebar.contains(mouse_position) {
233 self.sidebar.handle_mouse_event(mouse, sidebar);
234 } else if content_view.contains(mouse_position) {
235 self.content_view.handle_mouse_event(mouse, content_view);
236 } else if queuebar.contains(mouse_position) {
237 self.queuebar.handle_mouse_event(mouse, queuebar);
238 }
239 }
240}
241
242#[derive(Debug)]
243struct Areas {
244 pub control_panel: Rect,
245 pub sidebar: Rect,
246 pub content_view: Rect,
247 pub queuebar: Rect,
248}
249
250fn split_area(area: Rect) -> Areas {
251 let [main_views, control_panel] = *Layout::default()
252 .direction(Direction::Vertical)
253 .constraints([Constraint::Min(10), Constraint::Length(4)].as_ref())
254 .split(area)
255 else {
256 panic!("Failed to split frame into areas")
257 };
258
259 let [sidebar, content_view, queuebar] = *Layout::default()
260 .direction(Direction::Horizontal)
261 .constraints(
262 [
263 Constraint::Length(19),
264 Constraint::Fill(4),
265 Constraint::Min(25),
266 ]
267 .as_ref(),
268 )
269 .split(main_views)
270 else {
271 panic!("Failed to split main views area")
272 };
273
274 Areas {
275 control_panel,
276 sidebar,
277 content_view,
278 queuebar,
279 }
280}
281
282impl ComponentRender<Rect> for App {
283 fn render_border(&self, frame: &mut Frame<'_>, area: Rect) -> Rect {
284 let block = Block::bordered()
285 .title_top(Span::styled(
286 "MECOMP",
287 Style::default().bold().fg((*APP_BORDER_TEXT).into()),
288 ))
289 .title_bottom(Span::styled(
290 "Tab/Shift+Tab to switch focus | Esc to quit | F5 to refresh",
291 Style::default().fg((*APP_BORDER_TEXT).into()),
292 ))
293 .border_style(Style::default().fg((*APP_BORDER).into()))
294 .style(Style::default().fg((*TEXT_NORMAL).into()));
295 let app_area = block.inner(area);
296 debug_assert_eq!(area.inner(Margin::new(1, 1)), app_area);
297
298 frame.render_widget(block, area);
299 app_area
300 }
301
302 fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
303 let Areas {
304 control_panel,
305 sidebar,
306 content_view,
307 queuebar,
308 } = split_area(area);
309
310 let (control_panel_focused, sidebar_focused, content_view_focused, queuebar_focused) =
312 match self.active_component {
313 ActiveComponent::ControlPanel => (true, false, false, false),
314 ActiveComponent::Sidebar => (false, true, false, false),
315 ActiveComponent::ContentView => (false, false, true, false),
316 ActiveComponent::QueueBar => (false, false, false, true),
317 };
318
319 self.control_panel.render(
321 frame,
322 RenderProps {
323 area: control_panel,
324 is_focused: control_panel_focused,
325 },
326 );
327
328 self.sidebar.render(
330 frame,
331 RenderProps {
332 area: sidebar,
333 is_focused: sidebar_focused,
334 },
335 );
336
337 self.content_view.render(
339 frame,
340 RenderProps {
341 area: content_view,
342 is_focused: content_view_focused,
343 },
344 );
345
346 self.queuebar.render(
348 frame,
349 RenderProps {
350 area: queuebar,
351 is_focused: queuebar_focused,
352 },
353 );
354
355 if let Some(popup) = &self.popup {
357 popup.render_popup(frame);
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use std::time::Duration;
365
366 use super::*;
367 use crate::{
368 state::action::PopupAction,
369 test_utils::setup_test_terminal,
370 ui::{
371 components::{self, content_view::ActiveView},
372 widgets::popups::notification::Notification,
373 },
374 };
375 use crossterm::event::KeyModifiers;
376 use mecomp_core::state::{Percent, RepeatMode, StateAudio, StateRuntime, Status};
377 use mecomp_prost::{LibraryBrief, SearchResult};
378 use mecomp_storage::db::schemas::song::{Song, SongBrief};
379 use pretty_assertions::assert_eq;
380 use rstest::{fixture, rstest};
381 use tokio::sync::mpsc::unbounded_channel;
382
383 #[fixture]
384 fn song() -> SongBrief {
385 SongBrief {
386 id: Song::generate_id(),
387 title: "Test Song".into(),
388 artist: "Test Artist".to_string().into(),
389 album_artist: "Test Album Artist".to_string().into(),
390 album: "Test Album".into(),
391 genre: "Test Genre".to_string().into(),
392 runtime: Duration::from_secs(180),
393 track: Some(0),
394 disc: Some(0),
395 release_year: Some(2021),
396 path: "test.mp3".into(),
397 }
398 }
399
400 #[rstest]
401 #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
402 #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
403 #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
404 fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
405 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
406 let mut app = App::new(&AppState::default(), tx);
407
408 app.handle_key_event(KeyEvent::from(key_code));
409
410 let action = rx.blocking_recv().unwrap();
411
412 assert_eq!(action, expected);
413 }
414
415 #[rstest]
416 #[case::sidebar(ActiveComponent::Sidebar)]
417 #[case::content_view(ActiveComponent::ContentView)]
418 #[case::queuebar(ActiveComponent::QueueBar)]
419 #[case::control_panel(ActiveComponent::ControlPanel)]
420 fn smoke_render(#[case] active_component: ActiveComponent) {
421 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
422 let app = App::new(
423 &AppState {
424 active_component,
425 ..Default::default()
426 },
427 tx,
428 );
429
430 let (mut terminal, area) = setup_test_terminal(100, 100);
431 let completed_frame = terminal.draw(|frame| app.render(frame, area));
432
433 assert!(completed_frame.is_ok());
434 }
435
436 #[rstest]
437 #[case::sidebar(ActiveComponent::Sidebar)]
438 #[case::content_view(ActiveComponent::ContentView)]
439 #[case::queuebar(ActiveComponent::QueueBar)]
440 #[case::control_panel(ActiveComponent::ControlPanel)]
441 fn test_render_with_popup(#[case] active_component: ActiveComponent) {
442 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
443 let app = App::new(
444 &AppState {
445 active_component,
446 ..Default::default()
447 },
448 tx,
449 );
450
451 let (mut terminal, area) = setup_test_terminal(100, 100);
452 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
453
454 let app = app.move_with_popup(Some(Box::new(Notification::new(
455 "Hello, World!".into(),
456 unbounded_channel().0,
457 ))));
458
459 let (mut terminal, area) = setup_test_terminal(100, 100);
460 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
461
462 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
463 }
464
465 #[rstest]
466 #[case::sidebar(ActiveComponent::Sidebar)]
467 #[case::content_view(ActiveComponent::ContentView)]
468 #[case::queuebar(ActiveComponent::QueueBar)]
469 #[case::control_panel(ActiveComponent::ControlPanel)]
470 #[tokio::test]
471 async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
472 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
473 let mut app = App::new(
474 &AppState {
475 active_component,
476 ..Default::default()
477 },
478 tx,
479 );
480
481 let (mut terminal, area) = setup_test_terminal(100, 100);
482 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
483
484 let popup = Box::new(Notification::new(
485 "Hello, World!".into(),
486 unbounded_channel().0,
487 ));
488 app = app.move_with_popup(Some(popup));
489
490 let (mut terminal, area) = setup_test_terminal(100, 100);
491 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
492
493 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
495
496 app.handle_key_event(KeyEvent::from(KeyCode::Esc));
498
499 let action = rx.recv().await.unwrap();
501 assert_eq!(action, Action::Popup(PopupAction::Close));
502
503 app = app.move_with_popup(None);
505
506 let (mut terminal, area) = setup_test_terminal(100, 100);
507 let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
508
509 assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
511 assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
512 }
513
514 #[rstest]
515 fn test_move_with_search(song: SongBrief) {
516 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
517 let state = AppState::default();
518 let mut app = App::new(&state, tx);
519
520 let state = AppState {
521 search: SearchResult {
522 songs: vec![song.into()],
523 ..Default::default()
524 },
525 ..state
526 };
527 app = app.move_with_search(&state);
528
529 assert_eq!(
530 app.content_view.search_view.props.search_results,
531 state.search,
532 );
533 }
534
535 #[rstest]
536 fn test_move_with_audio(song: SongBrief) {
537 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
538 let state = AppState::default();
539 let mut app = App::new(&state, tx);
540
541 let state = AppState {
542 audio: StateAudio {
543 queue: vec![song.clone()].into_boxed_slice(),
544 queue_position: Some(0),
545 current_song: Some(song.clone()),
546 repeat_mode: RepeatMode::One,
547 runtime: Some(StateRuntime {
548 seek_position: Duration::from_secs(0),
549 seek_percent: Percent::new(0.0),
550 duration: song.runtime,
551 }),
552 status: Status::Stopped,
553 muted: false,
554 volume: 1.0,
555 },
556 ..state
557 };
558 app = app.move_with_audio(&state);
559
560 let components::queuebar::Props {
561 queue,
562 current_position,
563 repeat_mode,
564 } = app.queuebar.props;
565 assert_eq!(queue, state.audio.queue);
566 assert_eq!(current_position, state.audio.queue_position);
567 assert_eq!(repeat_mode, state.audio.repeat_mode);
568
569 let components::control_panel::Props {
570 is_playing,
571 muted,
572 volume,
573 song_runtime,
574 song_title,
575 song_artist,
576 } = app.control_panel.props;
577
578 assert_eq!(is_playing, !state.audio.paused());
579 assert_eq!(muted, state.audio.muted);
580 assert!(
581 f32::EPSILON > (volume - state.audio.volume).abs(),
582 "{} != {}",
583 volume,
584 state.audio.volume
585 );
586 assert_eq!(song_runtime, state.audio.runtime);
587 assert_eq!(
588 song_title,
589 state
590 .audio
591 .current_song
592 .as_ref()
593 .map(|song| song.title.to_string())
594 );
595 assert_eq!(
596 song_artist,
597 state
598 .audio
599 .current_song
600 .as_ref()
601 .map(|song| { song.artist.as_slice().join(", ") })
602 );
603 }
604
605 #[rstest]
606 fn test_move_with_library(song: SongBrief) {
607 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
608 let state = AppState {
609 active_component: ActiveComponent::ContentView,
610 active_view: ActiveView::Songs,
611 ..Default::default()
612 };
613 let mut app = App::new(&state, tx);
614
615 let state = AppState {
616 library: LibraryBrief {
617 songs: vec![song.into()],
618 ..Default::default()
619 },
620 ..state
621 };
622 app = app.move_with_library(&state);
623
624 assert_eq!(app.content_view.songs_view.props.songs, state.library.songs);
625 }
626
627 #[rstest]
628 fn test_move_with_view(song: SongBrief) {
629 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
630 let state = AppState {
631 active_component: ActiveComponent::ContentView,
632 active_view: ActiveView::Songs,
633 ..Default::default()
634 };
635 let mut app = App::new(&state, tx);
636
637 let state = AppState {
638 active_view: ActiveView::Song(song.id.key().to_string().into()),
639 ..state
640 };
641 app = app.move_with_view(&state);
642
643 assert_eq!(app.content_view.props.active_view, state.active_view);
644 }
645
646 #[test]
647 fn test_move_with_component() {
648 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
649 let app = App::new(&AppState::default(), tx);
650
651 assert_eq!(app.active_component, ActiveComponent::Sidebar);
652
653 let state = AppState {
654 active_component: ActiveComponent::QueueBar,
655 ..Default::default()
656 };
657 let app = app.move_with_component(&state);
658
659 assert_eq!(app.active_component, ActiveComponent::QueueBar);
660 }
661
662 #[rstest]
663 fn test_move_with_popup() {
664 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
665 let app = App::new(&AppState::default(), tx);
666
667 assert!(app.popup.is_none());
668
669 let popup = Box::new(Notification::new(
670 "Hello, World!".into(),
671 unbounded_channel().0,
672 ));
673 let app = app.move_with_popup(Some(popup));
674
675 assert!(app.popup.is_some());
676 }
677
678 #[rstest]
679 #[case::sidebar(ActiveComponent::Sidebar)]
680 #[case::content_view(ActiveComponent::ContentView)]
681 #[case::queuebar(ActiveComponent::QueueBar)]
682 #[case::control_panel(ActiveComponent::ControlPanel)]
683 fn test_get_active_view_component(#[case] active_component: ActiveComponent) {
684 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
685 let state = AppState {
686 active_component,
687 ..Default::default()
688 };
689 let app = App::new(&state, tx.clone());
690
691 let component = app.get_active_view_component();
692
693 match active_component {
694 ActiveComponent::Sidebar => assert_eq!(component.name(), "Sidebar"),
695 ActiveComponent::ContentView => assert_eq!(component.name(), "None"), ActiveComponent::QueueBar => assert_eq!(component.name(), "Queue"),
697 ActiveComponent::ControlPanel => assert_eq!(component.name(), "ControlPanel"),
698 }
699
700 assert_eq!(
702 component.name(),
703 App::new(&state, tx,).get_active_view_component_mut().name()
704 );
705 }
706
707 #[test]
708 fn test_click_to_focus() {
709 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
710 let mut app = App::new(&AppState::default(), tx);
711
712 let (mut terminal, area) = setup_test_terminal(100, 100);
713 let _frame = terminal.draw(|frame| app.render(frame, area)).unwrap();
714
715 let mouse = crossterm::event::MouseEvent {
716 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
717 column: 2,
718 row: 2,
719 modifiers: KeyModifiers::empty(),
720 };
721 app.handle_mouse_event(mouse, area);
722
723 let action = rx.blocking_recv().unwrap();
724 assert_eq!(
725 action,
726 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::Sidebar))
727 );
728
729 let mouse = crossterm::event::MouseEvent {
730 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
731 column: 50,
732 row: 10,
733 modifiers: KeyModifiers::empty(),
734 };
735 app.handle_mouse_event(mouse, area);
736
737 let action = rx.blocking_recv().unwrap();
738 assert_eq!(
739 action,
740 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ContentView))
741 );
742
743 let mouse = crossterm::event::MouseEvent {
744 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
745 column: 90,
746 row: 10,
747 modifiers: KeyModifiers::empty(),
748 };
749 app.handle_mouse_event(mouse, area);
750
751 let action = rx.blocking_recv().unwrap();
752 assert_eq!(
753 action,
754 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::QueueBar))
755 );
756
757 let mouse = crossterm::event::MouseEvent {
758 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
759 column: 60,
760 row: 98,
761 modifiers: KeyModifiers::empty(),
762 };
763 app.handle_mouse_event(mouse, area);
764
765 let action = rx.blocking_recv().unwrap();
766 assert_eq!(
767 action,
768 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ControlPanel))
769 );
770 }
771}