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::LibraryBrief},
374 };
375 use mecomp_storage::db::schemas::song::{Song, SongBrief};
376 use pretty_assertions::assert_eq;
377 use rstest::{fixture, rstest};
378 use tokio::sync::mpsc::unbounded_channel;
379
380 #[fixture]
381 fn song() -> SongBrief {
382 SongBrief {
383 id: Song::generate_id(),
384 title: "Test Song".into(),
385 artist: "Test Artist".to_string().into(),
386 album_artist: "Test Album Artist".to_string().into(),
387 album: "Test Album".into(),
388 genre: "Test Genre".to_string().into(),
389 runtime: Duration::from_secs(180),
390 track: Some(0),
391 disc: Some(0),
392 release_year: Some(2021),
393 extension: "mp3".into(),
394 path: "test.mp3".into(),
395 }
396 }
397
398 #[rstest]
399 #[case::tab(KeyCode::Tab, Action::ActiveComponent(ComponentAction::Next))]
400 #[case::back_tab(KeyCode::BackTab, Action::ActiveComponent(ComponentAction::Previous))]
401 #[case::esc(KeyCode::Esc, Action::General(GeneralAction::Exit))]
402 fn test_actions(#[case] key_code: KeyCode, #[case] expected: Action) {
403 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
404 let mut app = App::new(&AppState::default(), tx);
405
406 app.handle_key_event(KeyEvent::from(key_code));
407
408 let action = rx.blocking_recv().unwrap();
409
410 assert_eq!(action, expected);
411 }
412
413 #[rstest]
414 #[case::sidebar(ActiveComponent::Sidebar)]
415 #[case::content_view(ActiveComponent::ContentView)]
416 #[case::queuebar(ActiveComponent::QueueBar)]
417 #[case::control_panel(ActiveComponent::ControlPanel)]
418 fn smoke_render(#[case] active_component: ActiveComponent) {
419 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
420 let app = App::new(
421 &AppState {
422 active_component,
423 ..Default::default()
424 },
425 tx,
426 );
427
428 let (mut terminal, area) = setup_test_terminal(100, 100);
429 let completed_frame = terminal.draw(|frame| app.render(frame, area));
430
431 assert!(completed_frame.is_ok());
432 }
433
434 #[rstest]
435 #[case::sidebar(ActiveComponent::Sidebar)]
436 #[case::content_view(ActiveComponent::ContentView)]
437 #[case::queuebar(ActiveComponent::QueueBar)]
438 #[case::control_panel(ActiveComponent::ControlPanel)]
439 fn test_render_with_popup(#[case] active_component: ActiveComponent) {
440 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
441 let app = App::new(
442 &AppState {
443 active_component,
444 ..Default::default()
445 },
446 tx,
447 );
448
449 let (mut terminal, area) = setup_test_terminal(100, 100);
450 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
451
452 let app = app.move_with_popup(Some(Box::new(Notification::new(
453 "Hello, World!".into(),
454 unbounded_channel().0,
455 ))));
456
457 let (mut terminal, area) = setup_test_terminal(100, 100);
458 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
459
460 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
461 }
462
463 #[rstest]
464 #[case::sidebar(ActiveComponent::Sidebar)]
465 #[case::content_view(ActiveComponent::ContentView)]
466 #[case::queuebar(ActiveComponent::QueueBar)]
467 #[case::control_panel(ActiveComponent::ControlPanel)]
468 #[tokio::test]
469 async fn test_popup_takes_over_key_events(#[case] active_component: ActiveComponent) {
470 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
471 let mut app = App::new(
472 &AppState {
473 active_component,
474 ..Default::default()
475 },
476 tx,
477 );
478
479 let (mut terminal, area) = setup_test_terminal(100, 100);
480 let pre_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
481
482 let popup = Box::new(Notification::new(
483 "Hello, World!".into(),
484 unbounded_channel().0,
485 ));
486 app = app.move_with_popup(Some(popup));
487
488 let (mut terminal, area) = setup_test_terminal(100, 100);
489 let post_popup = terminal.draw(|frame| app.render(frame, area)).unwrap();
490
491 assert!(!pre_popup.buffer.diff(post_popup.buffer).is_empty());
493
494 app.handle_key_event(KeyEvent::from(KeyCode::Esc));
496
497 let action = rx.recv().await.unwrap();
499 assert_eq!(action, Action::Popup(PopupAction::Close));
500
501 app = app.move_with_popup(None);
503
504 let (mut terminal, area) = setup_test_terminal(100, 100);
505 let post_close = terminal.draw(|frame| app.render(frame, area)).unwrap();
506
507 assert!(!post_popup.buffer.diff(post_close.buffer).is_empty());
509 assert!(pre_popup.buffer.diff(post_close.buffer).is_empty());
510 }
511
512 #[rstest]
513 fn test_move_with_search(song: SongBrief) {
514 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
515 let state = AppState::default();
516 let mut app = App::new(&state, tx);
517
518 let state = AppState {
519 search: SearchResult {
520 songs: vec![song].into_boxed_slice(),
521 ..Default::default()
522 },
523 ..state
524 };
525 app = app.move_with_search(&state);
526
527 assert_eq!(
528 app.content_view.search_view.props.search_results,
529 state.search,
530 );
531 }
532
533 #[rstest]
534 fn test_move_with_audio(song: SongBrief) {
535 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
536 let state = AppState::default();
537 let mut app = App::new(&state, tx);
538
539 let state = AppState {
540 audio: StateAudio {
541 queue: vec![song.clone()].into_boxed_slice(),
542 queue_position: Some(0),
543 current_song: Some(song.clone()),
544 repeat_mode: RepeatMode::One,
545 runtime: Some(StateRuntime {
546 seek_position: Duration::from_secs(0),
547 seek_percent: Percent::new(0.0),
548 duration: song.runtime,
549 }),
550 status: Status::Stopped,
551 muted: false,
552 volume: 1.0,
553 },
554 ..state
555 };
556 app = app.move_with_audio(&state);
557
558 let components::queuebar::Props {
559 queue,
560 current_position,
561 repeat_mode,
562 } = app.queuebar.props;
563 assert_eq!(queue, state.audio.queue);
564 assert_eq!(current_position, state.audio.queue_position);
565 assert_eq!(repeat_mode, state.audio.repeat_mode);
566
567 let components::control_panel::Props {
568 is_playing,
569 muted,
570 volume,
571 song_runtime,
572 song_title,
573 song_artist,
574 } = app.control_panel.props;
575
576 assert_eq!(is_playing, !state.audio.paused());
577 assert_eq!(muted, state.audio.muted);
578 assert!(
579 f32::EPSILON > (volume - state.audio.volume).abs(),
580 "{} != {}",
581 volume,
582 state.audio.volume
583 );
584 assert_eq!(song_runtime, state.audio.runtime);
585 assert_eq!(
586 song_title,
587 state
588 .audio
589 .current_song
590 .as_ref()
591 .map(|song| song.title.to_string())
592 );
593 assert_eq!(
594 song_artist,
595 state.audio.current_song.as_ref().map(|song| {
596 song.artist
597 .iter()
598 .map(ToString::to_string)
599 .collect::<Vec<String>>()
600 .join(", ")
601 })
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_boxed_slice(),
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_owned().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}