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