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::{
377 rpc::SearchResult,
378 state::{Percent, RepeatMode, StateAudio, StateRuntime, Status, library::LibraryBrief},
379 };
380 use mecomp_storage::db::schemas::song::{Song, SongBrief};
381 use pretty_assertions::assert_eq;
382 use rstest::{fixture, rstest};
383 use tokio::sync::mpsc::unbounded_channel;
384
385 #[fixture]
386 fn song() -> SongBrief {
387 SongBrief {
388 id: Song::generate_id(),
389 title: "Test Song".into(),
390 artist: "Test Artist".to_string().into(),
391 album_artist: "Test Album Artist".to_string().into(),
392 album: "Test Album".into(),
393 genre: "Test Genre".to_string().into(),
394 runtime: Duration::from_secs(180),
395 track: Some(0),
396 disc: Some(0),
397 release_year: Some(2021),
398 extension: "mp3".into(),
399 path: "test.mp3".into(),
400 }
401 }
402
403 #[rstest]
404 #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
405 #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
406 #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
407 fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
408 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
409 let mut app = App::new(&AppState::default(), tx);
410
411 app.handle_key_event(KeyEvent::from(key_code));
412
413 let action = rx.blocking_recv().unwrap();
414
415 assert_eq!(action, expected);
416 }
417
418 #[rstest]
419 #[case::sidebar(ActiveComponent::Sidebar)]
420 #[case::content_view(ActiveComponent::ContentView)]
421 #[case::queuebar(ActiveComponent::QueueBar)]
422 #[case::control_panel(ActiveComponent::ControlPanel)]
423 fn smoke_render(#[case] active_component: ActiveComponent) {
424 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
425 let app = App::new(
426 &AppState {
427 active_component,
428 ..Default::default()
429 },
430 tx,
431 );
432
433 let (mut terminal, area) = setup_test_terminal(100, 100);
434 let completed_frame = terminal.draw(|frame| app.render(frame, area));
435
436 assert!(completed_frame.is_ok());
437 }
438
439 #[rstest]
440 #[case::sidebar(ActiveComponent::Sidebar)]
441 #[case::content_view(ActiveComponent::ContentView)]
442 #[case::queuebar(ActiveComponent::QueueBar)]
443 #[case::control_panel(ActiveComponent::ControlPanel)]
444 fn test_render_with_popup(#[case] active_component: ActiveComponent) {
445 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
446 let app = App::new(
447 &AppState {
448 active_component,
449 ..Default::default()
450 },
451 tx,
452 );
453
454 let (mut terminal, area) = setup_test_terminal(100, 100);
455 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
456
457 let app = app.move_with_popup(Some(Box::new(Notification::new(
458 "Hello, World!".into(),
459 unbounded_channel().0,
460 ))));
461
462 let (mut terminal, area) = setup_test_terminal(100, 100);
463 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
464
465 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
466 }
467
468 #[rstest]
469 #[case::sidebar(ActiveComponent::Sidebar)]
470 #[case::content_view(ActiveComponent::ContentView)]
471 #[case::queuebar(ActiveComponent::QueueBar)]
472 #[case::control_panel(ActiveComponent::ControlPanel)]
473 #[tokio::test]
474 async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
475 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
476 let mut app = App::new(
477 &AppState {
478 active_component,
479 ..Default::default()
480 },
481 tx,
482 );
483
484 let (mut terminal, area) = setup_test_terminal(100, 100);
485 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
486
487 let popup = Box::new(Notification::new(
488 "Hello, World!".into(),
489 unbounded_channel().0,
490 ));
491 app = app.move_with_popup(Some(popup));
492
493 let (mut terminal, area) = setup_test_terminal(100, 100);
494 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
495
496 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
498
499 app.handle_key_event(KeyEvent::from(KeyCode::Esc));
501
502 let action = rx.recv().await.unwrap();
504 assert_eq!(action, Action::Popup(PopupAction::Close));
505
506 app = app.move_with_popup(None);
508
509 let (mut terminal, area) = setup_test_terminal(100, 100);
510 let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
511
512 assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
514 assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
515 }
516
517 #[rstest]
518 fn test_move_with_search(song: SongBrief) {
519 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
520 let state = AppState::default();
521 let mut app = App::new(&state, tx);
522
523 let state = AppState {
524 search: SearchResult {
525 songs: vec![song].into_boxed_slice(),
526 ..Default::default()
527 },
528 ..state
529 };
530 app = app.move_with_search(&state);
531
532 assert_eq!(
533 app.content_view.search_view.props.search_results,
534 state.search,
535 );
536 }
537
538 #[rstest]
539 fn test_move_with_audio(song: SongBrief) {
540 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
541 let state = AppState::default();
542 let mut app = App::new(&state, tx);
543
544 let state = AppState {
545 audio: StateAudio {
546 queue: vec![song.clone()].into_boxed_slice(),
547 queue_position: Some(0),
548 current_song: Some(song.clone()),
549 repeat_mode: RepeatMode::One,
550 runtime: Some(StateRuntime {
551 seek_position: Duration::from_secs(0),
552 seek_percent: Percent::new(0.0),
553 duration: song.runtime,
554 }),
555 status: Status::Stopped,
556 muted: false,
557 volume: 1.0,
558 },
559 ..state
560 };
561 app = app.move_with_audio(&state);
562
563 let components::queuebar::Props {
564 queue,
565 current_position,
566 repeat_mode,
567 } = app.queuebar.props;
568 assert_eq!(queue, state.audio.queue);
569 assert_eq!(current_position, state.audio.queue_position);
570 assert_eq!(repeat_mode, state.audio.repeat_mode);
571
572 let components::control_panel::Props {
573 is_playing,
574 muted,
575 volume,
576 song_runtime,
577 song_title,
578 song_artist,
579 } = app.control_panel.props;
580
581 assert_eq!(is_playing, !state.audio.paused());
582 assert_eq!(muted, state.audio.muted);
583 assert!(
584 f32::EPSILON > (volume - state.audio.volume).abs(),
585 "{} != {}",
586 volume,
587 state.audio.volume
588 );
589 assert_eq!(song_runtime, state.audio.runtime);
590 assert_eq!(
591 song_title,
592 state
593 .audio
594 .current_song
595 .as_ref()
596 .map(|song| song.title.to_string())
597 );
598 assert_eq!(
599 song_artist,
600 state.audio.current_song.as_ref().map(|song| {
601 song.artist
602 .iter()
603 .map(ToString::to_string)
604 .collect::<Vec<String>>()
605 .join(", ")
606 })
607 );
608 }
609
610 #[rstest]
611 fn test_move_with_library(song: SongBrief) {
612 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
613 let state = AppState {
614 active_component: ActiveComponent::ContentView,
615 active_view: ActiveView::Songs,
616 ..Default::default()
617 };
618 let mut app = App::new(&state, tx);
619
620 let state = AppState {
621 library: LibraryBrief {
622 songs: vec![song].into_boxed_slice(),
623 ..Default::default()
624 },
625 ..state
626 };
627 app = app.move_with_library(&state);
628
629 assert_eq!(app.content_view.songs_view.props.songs, state.library.songs);
630 }
631
632 #[rstest]
633 fn test_move_with_view(song: SongBrief) {
634 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
635 let state = AppState {
636 active_component: ActiveComponent::ContentView,
637 active_view: ActiveView::Songs,
638 ..Default::default()
639 };
640 let mut app = App::new(&state, tx);
641
642 let state = AppState {
643 active_view: ActiveView::Song(song.id.key().to_owned().into()),
644 ..state
645 };
646 app = app.move_with_view(&state);
647
648 assert_eq!(app.content_view.props.active_view, state.active_view);
649 }
650
651 #[test]
652 fn test_move_with_component() {
653 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
654 let app = App::new(&AppState::default(), tx);
655
656 assert_eq!(app.active_component, ActiveComponent::Sidebar);
657
658 let state = AppState {
659 active_component: ActiveComponent::QueueBar,
660 ..Default::default()
661 };
662 let app = app.move_with_component(&state);
663
664 assert_eq!(app.active_component, ActiveComponent::QueueBar);
665 }
666
667 #[rstest]
668 fn test_move_with_popup() {
669 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
670 let app = App::new(&AppState::default(), tx);
671
672 assert!(app.popup.is_none());
673
674 let popup = Box::new(Notification::new(
675 "Hello, World!".into(),
676 unbounded_channel().0,
677 ));
678 let app = app.move_with_popup(Some(popup));
679
680 assert!(app.popup.is_some());
681 }
682
683 #[rstest]
684 #[case::sidebar(ActiveComponent::Sidebar)]
685 #[case::content_view(ActiveComponent::ContentView)]
686 #[case::queuebar(ActiveComponent::QueueBar)]
687 #[case::control_panel(ActiveComponent::ControlPanel)]
688 fn test_get_active_view_component(#[case] active_component: ActiveComponent) {
689 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
690 let state = AppState {
691 active_component,
692 ..Default::default()
693 };
694 let app = App::new(&state, tx.clone());
695
696 let component = app.get_active_view_component();
697
698 match active_component {
699 ActiveComponent::Sidebar => assert_eq!(component.name(), "Sidebar"),
700 ActiveComponent::ContentView => assert_eq!(component.name(), "None"), ActiveComponent::QueueBar => assert_eq!(component.name(), "Queue"),
702 ActiveComponent::ControlPanel => assert_eq!(component.name(), "ControlPanel"),
703 }
704
705 assert_eq!(
707 component.name(),
708 App::new(&state, tx,).get_active_view_component_mut().name()
709 );
710 }
711
712 #[test]
713 fn test_click_to_focus() {
714 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
715 let mut app = App::new(&AppState::default(), tx);
716
717 let (mut terminal, area) = setup_test_terminal(100, 100);
718 let _frame = terminal.draw(|frame| app.render(frame, area)).unwrap();
719
720 let mouse = crossterm::event::MouseEvent {
721 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
722 column: 2,
723 row: 2,
724 modifiers: KeyModifiers::empty(),
725 };
726 app.handle_mouse_event(mouse, area);
727
728 let action = rx.blocking_recv().unwrap();
729 assert_eq!(
730 action,
731 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::Sidebar))
732 );
733
734 let mouse = crossterm::event::MouseEvent {
735 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
736 column: 50,
737 row: 10,
738 modifiers: KeyModifiers::empty(),
739 };
740 app.handle_mouse_event(mouse, area);
741
742 let action = rx.blocking_recv().unwrap();
743 assert_eq!(
744 action,
745 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ContentView))
746 );
747
748 let mouse = crossterm::event::MouseEvent {
749 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
750 column: 90,
751 row: 10,
752 modifiers: KeyModifiers::empty(),
753 };
754 app.handle_mouse_event(mouse, area);
755
756 let action = rx.blocking_recv().unwrap();
757 assert_eq!(
758 action,
759 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::QueueBar))
760 );
761
762 let mouse = crossterm::event::MouseEvent {
763 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
764 column: 60,
765 row: 98,
766 modifiers: KeyModifiers::empty(),
767 };
768 app.handle_mouse_event(mouse, area);
769
770 let action = rx.blocking_recv().unwrap();
771 assert_eq!(
772 action,
773 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ControlPanel))
774 );
775 }
776}