1use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
7use ratatui::{
8 Frame,
9 layout::{Constraint, Direction, Layout, Margin, Position, Rect},
10 style::Style,
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 .areas(area);
255
256 let [sidebar, content_view, queuebar] = Layout::default()
257 .direction(Direction::Horizontal)
258 .constraints(
259 [
260 Constraint::Length(19),
261 Constraint::Fill(4),
262 Constraint::Min(25),
263 ]
264 .as_ref(),
265 )
266 .areas(main_views);
267
268 Areas {
269 control_panel,
270 sidebar,
271 content_view,
272 queuebar,
273 }
274}
275
276impl ComponentRender<Rect> for App {
277 fn render_border(&self, frame: &mut Frame<'_>, area: Rect) -> Rect {
278 let block = Block::bordered()
279 .title_top(Span::styled(
280 "MECOMP",
281 Style::default().bold().fg((*APP_BORDER_TEXT).into()),
282 ))
283 .title_bottom(Span::styled(
284 "Tab/Shift+Tab to switch focus | Esc to quit | F5 to refresh",
285 Style::default().fg((*APP_BORDER_TEXT).into()),
286 ))
287 .border_style(Style::default().fg((*APP_BORDER).into()))
288 .style(Style::default().fg((*TEXT_NORMAL).into()));
289 let app_area = block.inner(area);
290 debug_assert_eq!(area.inner(Margin::new(1, 1)), app_area);
291
292 frame.render_widget(block, area);
293 app_area
294 }
295
296 fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
297 let Areas {
298 control_panel,
299 sidebar,
300 content_view,
301 queuebar,
302 } = split_area(area);
303
304 let (control_panel_focused, sidebar_focused, content_view_focused, queuebar_focused) =
306 match self.active_component {
307 ActiveComponent::ControlPanel => (true, false, false, false),
308 ActiveComponent::Sidebar => (false, true, false, false),
309 ActiveComponent::ContentView => (false, false, true, false),
310 ActiveComponent::QueueBar => (false, false, false, true),
311 };
312
313 self.control_panel.render(
315 frame,
316 RenderProps {
317 area: control_panel,
318 is_focused: control_panel_focused,
319 },
320 );
321
322 self.sidebar.render(
324 frame,
325 RenderProps {
326 area: sidebar,
327 is_focused: sidebar_focused,
328 },
329 );
330
331 self.content_view.render(
333 frame,
334 RenderProps {
335 area: content_view,
336 is_focused: content_view_focused,
337 },
338 );
339
340 self.queuebar.render(
342 frame,
343 RenderProps {
344 area: queuebar,
345 is_focused: queuebar_focused,
346 },
347 );
348
349 if let Some(popup) = &self.popup {
351 popup.render_popup(frame);
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use std::time::Duration;
359
360 use super::*;
361 use crate::{
362 state::action::PopupAction,
363 test_utils::setup_test_terminal,
364 ui::{
365 components::{self, content_view::ActiveView},
366 widgets::popups::notification::Notification,
367 },
368 };
369 use crossterm::event::KeyModifiers;
370 use mecomp_core::state::{Percent, RepeatMode, StateAudio, StateRuntime, Status};
371 use mecomp_prost::{LibraryBrief, SearchResult};
372 use mecomp_storage::db::schemas::song::{Song, SongBrief};
373 use pretty_assertions::assert_eq;
374 use rstest::{fixture, rstest};
375 use tokio::sync::mpsc::unbounded_channel;
376
377 #[fixture]
378 fn song() -> SongBrief {
379 SongBrief {
380 id: Song::generate_id(),
381 title: "Test Song".into(),
382 artist: "Test Artist".to_string().into(),
383 album_artist: "Test Album Artist".to_string().into(),
384 album: "Test Album".into(),
385 genre: "Test Genre".to_string().into(),
386 runtime: Duration::from_secs(180),
387 track: Some(0),
388 disc: Some(0),
389 release_year: Some(2021),
390 path: "test.mp3".into(),
391 }
392 }
393
394 #[rstest]
395 #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
396 #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
397 #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
398 fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
399 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
400 let mut app = App::new(&AppState::default(), tx);
401
402 app.handle_key_event(KeyEvent::from(key_code));
403
404 let action = rx.blocking_recv().unwrap();
405
406 assert_eq!(action, expected);
407 }
408
409 #[rstest]
410 #[case::sidebar(ActiveComponent::Sidebar)]
411 #[case::content_view(ActiveComponent::ContentView)]
412 #[case::queuebar(ActiveComponent::QueueBar)]
413 #[case::control_panel(ActiveComponent::ControlPanel)]
414 fn smoke_render(#[case] active_component: ActiveComponent) {
415 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
416 let app = App::new(
417 &AppState {
418 active_component,
419 ..Default::default()
420 },
421 tx,
422 );
423
424 let (mut terminal, area) = setup_test_terminal(100, 100);
425 let completed_frame = terminal.draw(|frame| app.render(frame, area));
426
427 assert!(completed_frame.is_ok());
428 }
429
430 #[rstest]
431 #[case::sidebar(ActiveComponent::Sidebar)]
432 #[case::content_view(ActiveComponent::ContentView)]
433 #[case::queuebar(ActiveComponent::QueueBar)]
434 #[case::control_panel(ActiveComponent::ControlPanel)]
435 fn test_render_with_popup(#[case] active_component: ActiveComponent) {
436 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
437 let app = App::new(
438 &AppState {
439 active_component,
440 ..Default::default()
441 },
442 tx,
443 );
444
445 let (mut terminal, area) = setup_test_terminal(100, 100);
446 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
447
448 let app = app.move_with_popup(Some(Box::new(Notification::new(
449 "Hello, World!".into(),
450 unbounded_channel().0,
451 ))));
452
453 let (mut terminal, area) = setup_test_terminal(100, 100);
454 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
455
456 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
457 }
458
459 #[rstest]
460 #[case::sidebar(ActiveComponent::Sidebar)]
461 #[case::content_view(ActiveComponent::ContentView)]
462 #[case::queuebar(ActiveComponent::QueueBar)]
463 #[case::control_panel(ActiveComponent::ControlPanel)]
464 #[tokio::test]
465 async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
466 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
467 let mut app = App::new(
468 &AppState {
469 active_component,
470 ..Default::default()
471 },
472 tx,
473 );
474
475 let (mut terminal, area) = setup_test_terminal(100, 100);
476 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
477
478 let popup = Box::new(Notification::new(
479 "Hello, World!".into(),
480 unbounded_channel().0,
481 ));
482 app = app.move_with_popup(Some(popup));
483
484 let (mut terminal, area) = setup_test_terminal(100, 100);
485 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
486
487 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
489
490 app.handle_key_event(KeyEvent::from(KeyCode::Esc));
492
493 let action = rx.recv().await.unwrap();
495 assert_eq!(action, Action::Popup(PopupAction::Close));
496
497 app = app.move_with_popup(None);
499
500 let (mut terminal, area) = setup_test_terminal(100, 100);
501 let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
502
503 assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
505 assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
506 }
507
508 #[rstest]
509 fn test_move_with_search(song: SongBrief) {
510 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
511 let state = AppState::default();
512 let mut app = App::new(&state, tx);
513
514 let state = AppState {
515 search: SearchResult {
516 songs: vec![song.into()],
517 ..Default::default()
518 },
519 ..state
520 };
521 app = app.move_with_search(&state);
522
523 assert_eq!(
524 app.content_view.search_view.props.search_results,
525 state.search,
526 );
527 }
528
529 #[rstest]
530 fn test_move_with_audio(song: SongBrief) {
531 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
532 let state = AppState::default();
533 let mut app = App::new(&state, tx);
534
535 let state = AppState {
536 audio: StateAudio {
537 queue: vec![song.clone()].into_boxed_slice(),
538 queue_position: Some(0),
539 current_song: Some(song.clone()),
540 repeat_mode: RepeatMode::One,
541 runtime: Some(StateRuntime {
542 seek_position: Duration::from_secs(0),
543 seek_percent: Percent::new(0.0),
544 duration: song.runtime,
545 }),
546 status: Status::Stopped,
547 muted: false,
548 volume: 1.0,
549 },
550 ..state
551 };
552 app = app.move_with_audio(&state);
553
554 let components::queuebar::Props {
555 queue,
556 current_position,
557 repeat_mode,
558 } = app.queuebar.props;
559 assert_eq!(queue, state.audio.queue);
560 assert_eq!(current_position, state.audio.queue_position);
561 assert_eq!(repeat_mode, state.audio.repeat_mode);
562
563 let components::control_panel::Props {
564 is_playing,
565 muted,
566 volume,
567 song_runtime,
568 song_title,
569 song_artist,
570 } = app.control_panel.props;
571
572 assert_eq!(is_playing, !state.audio.paused());
573 assert_eq!(muted, state.audio.muted);
574 assert!(
575 f32::EPSILON > (volume - state.audio.volume).abs(),
576 "{} != {}",
577 volume,
578 state.audio.volume
579 );
580 assert_eq!(song_runtime, state.audio.runtime);
581 assert_eq!(
582 song_title,
583 state
584 .audio
585 .current_song
586 .as_ref()
587 .map(|song| song.title.to_string())
588 );
589 assert_eq!(
590 song_artist,
591 state
592 .audio
593 .current_song
594 .as_ref()
595 .map(|song| { song.artist.as_slice().join(", ") })
596 );
597 }
598
599 #[rstest]
600 fn test_move_with_library(song: SongBrief) {
601 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
602 let state = AppState {
603 active_component: ActiveComponent::ContentView,
604 active_view: ActiveView::Songs,
605 ..Default::default()
606 };
607 let mut app = App::new(&state, tx);
608
609 let state = AppState {
610 library: LibraryBrief {
611 songs: vec![song.into()],
612 ..Default::default()
613 },
614 ..state
615 };
616 app = app.move_with_library(&state);
617
618 assert_eq!(app.content_view.songs_view.props.songs, state.library.songs);
619 }
620
621 #[rstest]
622 fn test_move_with_view(song: SongBrief) {
623 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
624 let state = AppState {
625 active_component: ActiveComponent::ContentView,
626 active_view: ActiveView::Songs,
627 ..Default::default()
628 };
629 let mut app = App::new(&state, tx);
630
631 let state = AppState {
632 active_view: ActiveView::Song(song.id.key().to_string().into()),
633 ..state
634 };
635 app = app.move_with_view(&state);
636
637 assert_eq!(app.content_view.props.active_view, state.active_view);
638 }
639
640 #[test]
641 fn test_move_with_component() {
642 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
643 let app = App::new(&AppState::default(), tx);
644
645 assert_eq!(app.active_component, ActiveComponent::Sidebar);
646
647 let state = AppState {
648 active_component: ActiveComponent::QueueBar,
649 ..Default::default()
650 };
651 let app = app.move_with_component(&state);
652
653 assert_eq!(app.active_component, ActiveComponent::QueueBar);
654 }
655
656 #[rstest]
657 fn test_move_with_popup() {
658 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
659 let app = App::new(&AppState::default(), tx);
660
661 assert!(app.popup.is_none());
662
663 let popup = Box::new(Notification::new(
664 "Hello, World!".into(),
665 unbounded_channel().0,
666 ));
667 let app = app.move_with_popup(Some(popup));
668
669 assert!(app.popup.is_some());
670 }
671
672 #[rstest]
673 #[case::sidebar(ActiveComponent::Sidebar)]
674 #[case::content_view(ActiveComponent::ContentView)]
675 #[case::queuebar(ActiveComponent::QueueBar)]
676 #[case::control_panel(ActiveComponent::ControlPanel)]
677 fn test_get_active_view_component(#[case] active_component: ActiveComponent) {
678 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
679 let state = AppState {
680 active_component,
681 ..Default::default()
682 };
683 let app = App::new(&state, tx.clone());
684
685 let component = app.get_active_view_component();
686
687 match active_component {
688 ActiveComponent::Sidebar => assert_eq!(component.name(), "Sidebar"),
689 ActiveComponent::ContentView => assert_eq!(component.name(), "None"), ActiveComponent::QueueBar => assert_eq!(component.name(), "Queue"),
691 ActiveComponent::ControlPanel => assert_eq!(component.name(), "ControlPanel"),
692 }
693
694 assert_eq!(
696 component.name(),
697 App::new(&state, tx,).get_active_view_component_mut().name()
698 );
699 }
700
701 #[test]
702 fn test_click_to_focus() {
703 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
704 let mut app = App::new(&AppState::default(), tx);
705
706 let (mut terminal, area) = setup_test_terminal(100, 100);
707 let _frame = terminal.draw(|frame| app.render(frame, area)).unwrap();
708
709 let mouse = crossterm::event::MouseEvent {
710 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
711 column: 2,
712 row: 2,
713 modifiers: KeyModifiers::empty(),
714 };
715 app.handle_mouse_event(mouse, area);
716
717 let action = rx.blocking_recv().unwrap();
718 assert_eq!(
719 action,
720 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::Sidebar))
721 );
722
723 let mouse = crossterm::event::MouseEvent {
724 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
725 column: 50,
726 row: 10,
727 modifiers: KeyModifiers::empty(),
728 };
729 app.handle_mouse_event(mouse, area);
730
731 let action = rx.blocking_recv().unwrap();
732 assert_eq!(
733 action,
734 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ContentView))
735 );
736
737 let mouse = crossterm::event::MouseEvent {
738 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
739 column: 90,
740 row: 10,
741 modifiers: KeyModifiers::empty(),
742 };
743 app.handle_mouse_event(mouse, area);
744
745 let action = rx.blocking_recv().unwrap();
746 assert_eq!(
747 action,
748 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::QueueBar))
749 );
750
751 let mouse = crossterm::event::MouseEvent {
752 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
753 column: 60,
754 row: 98,
755 modifiers: KeyModifiers::empty(),
756 };
757 app.handle_mouse_event(mouse, area);
758
759 let action = rx.blocking_recv().unwrap();
760 assert_eq!(
761 action,
762 Action::ActiveComponent(ComponentAction::Set(ActiveComponent::ControlPanel))
763 );
764 }
765}