mecomp_tui/ui/components/content_view/views/
song.rs

1//! Views for both a single song, and the library of songs.
2
3use std::{ops::Not, sync::Mutex};
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use mecomp_storage::db::schemas::song::Song;
7use ratatui::{
8    layout::{Margin, Rect},
9    style::{Style, Stylize},
10    text::{Line, Span},
11    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use crate::{
16    state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
17    ui::{
18        colors::{border_color, TEXT_HIGHLIGHT},
19        components::{content_view::ActiveView, Component, ComponentRender, RenderProps},
20        widgets::{
21            popups::PopupType,
22            tree::{state::CheckTreeState, CheckTree},
23        },
24        AppState,
25    },
26};
27
28use super::{
29    checktree_utils::create_song_tree_leaf, generic::ItemView, sort_mode::SongSort,
30    traits::SortMode, SongViewProps,
31};
32
33#[allow(clippy::module_name_repetitions)]
34pub type SongView = ItemView<SongViewProps>;
35
36pub struct LibrarySongsView {
37    /// Action Sender
38    pub action_tx: UnboundedSender<Action>,
39    /// Mapped Props from state
40    pub(crate) props: Props,
41    /// tree state
42    tree_state: Mutex<CheckTreeState<String>>,
43}
44
45pub(crate) struct Props {
46    pub(crate) songs: Box<[Song]>,
47    pub(crate) sort_mode: SongSort,
48}
49
50impl Component for LibrarySongsView {
51    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
52    where
53        Self: Sized,
54    {
55        let sort_mode = SongSort::default();
56        let mut songs = state.library.songs.clone();
57        sort_mode.sort_items(&mut songs);
58        Self {
59            action_tx,
60            props: Props { songs, sort_mode },
61            tree_state: Mutex::new(CheckTreeState::default()),
62        }
63    }
64
65    fn move_with_state(self, state: &AppState) -> Self
66    where
67        Self: Sized,
68    {
69        let mut songs = state.library.songs.clone();
70        self.props.sort_mode.sort_items(&mut songs);
71        let tree_state = (state.active_view == ActiveView::Songs)
72            .then_some(self.tree_state)
73            .unwrap_or_default();
74
75        Self {
76            props: Props {
77                songs,
78                ..self.props
79            },
80            tree_state,
81            ..self
82        }
83    }
84
85    fn name(&self) -> &'static str {
86        "Library Songs View"
87    }
88
89    fn handle_key_event(&mut self, key: KeyEvent) {
90        match key.code {
91            // arrow keys
92            KeyCode::PageUp => {
93                self.tree_state.lock().unwrap().select_relative(|current| {
94                    current.map_or(self.props.songs.len() - 1, |c| c.saturating_sub(10))
95                });
96            }
97            KeyCode::Up => {
98                self.tree_state.lock().unwrap().key_up();
99            }
100            KeyCode::PageDown => {
101                self.tree_state
102                    .lock()
103                    .unwrap()
104                    .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
105            }
106            KeyCode::Down => {
107                self.tree_state.lock().unwrap().key_down();
108            }
109            KeyCode::Left => {
110                self.tree_state.lock().unwrap().key_left();
111            }
112            KeyCode::Right => {
113                self.tree_state.lock().unwrap().key_right();
114            }
115            KeyCode::Char(' ') => {
116                self.tree_state.lock().unwrap().key_space();
117            }
118            // Enter key opens selected view
119            KeyCode::Enter => {
120                if self.tree_state.lock().unwrap().toggle_selected() {
121                    let things = self.tree_state.lock().unwrap().get_selected_thing();
122
123                    if let Some(thing) = things {
124                        self.action_tx
125                            .send(Action::ActiveView(ViewAction::Set(thing.into())))
126                            .unwrap();
127                    }
128                }
129            }
130            // when there are checked items, "q" will send the checked items to the queue
131            KeyCode::Char('q') => {
132                let things = self.tree_state.lock().unwrap().get_checked_things();
133                if !things.is_empty() {
134                    self.action_tx
135                        .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
136                        .unwrap();
137                }
138            }
139            // when there are checked items, "r" will start a radio with the checked items
140            KeyCode::Char('r') => {
141                let things = self.tree_state.lock().unwrap().get_checked_things();
142                if !things.is_empty() {
143                    self.action_tx
144                        .send(Action::ActiveView(ViewAction::Set(ActiveView::Radio(
145                            things,
146                        ))))
147                        .unwrap();
148                }
149            }
150            // when there are checked items, "p" will send the checked items to the playlist
151            KeyCode::Char('p') => {
152                let things = self.tree_state.lock().unwrap().get_checked_things();
153                if !things.is_empty() {
154                    self.action_tx
155                        .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
156                            things,
157                        ))))
158                        .unwrap();
159                }
160            }
161            // Change sort mode
162            KeyCode::Char('s') => {
163                self.props.sort_mode = self.props.sort_mode.next();
164                self.props.sort_mode.sort_items(&mut self.props.songs);
165            }
166            KeyCode::Char('S') => {
167                self.props.sort_mode = self.props.sort_mode.prev();
168                self.props.sort_mode.sort_items(&mut self.props.songs);
169            }
170            _ => {}
171        }
172    }
173
174    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
175        // adjust the area to account for the border
176        let area = area.inner(Margin::new(1, 2));
177
178        let result = self
179            .tree_state
180            .lock()
181            .unwrap()
182            .handle_mouse_event(mouse, area);
183        if let Some(action) = result {
184            self.action_tx.send(action).unwrap();
185        }
186    }
187}
188
189impl ComponentRender<RenderProps> for LibrarySongsView {
190    fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
191        let border_style = Style::default().fg(border_color(props.is_focused).into());
192
193        // draw primary border
194        let border = Block::bordered()
195            .title_top(Line::from(vec![
196                Span::styled("Library Songs".to_string(), Style::default().bold()),
197                Span::raw(" sorted by: "),
198                Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
199            ]))
200            .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
201            .border_style(border_style);
202        let content_area = border.inner(props.area);
203        frame.render_widget(border, props.area);
204
205        // draw an additional border around the content area to display additional instructions
206        let border = Block::new()
207            .borders(Borders::TOP | Borders::BOTTOM)
208            .title_top(
209                self.tree_state
210                    .lock()
211                    .unwrap()
212                    .get_checked_things()
213                    .is_empty()
214                    .not()
215                    .then_some("q: add to queue | r: start radio | p: add to playlist ")
216                    .unwrap_or_default(),
217            )
218            .title_bottom("s/S: change sort")
219            .border_style(border_style);
220        frame.render_widget(&border, content_area);
221        let content_area = border.inner(content_area);
222
223        RenderProps {
224            area: content_area,
225            is_focused: props.is_focused,
226        }
227    }
228
229    fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
230        // create a tree to hold the songs
231        let items = self
232            .props
233            .songs
234            .iter()
235            .map(create_song_tree_leaf)
236            .collect::<Vec<_>>();
237
238        // render the tree
239        frame.render_stateful_widget(
240            CheckTree::new(&items)
241                .unwrap()
242                .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
243                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
244            props.area,
245            &mut self.tree_state.lock().unwrap(),
246        );
247    }
248}
249
250#[cfg(test)]
251mod sort_mode_tests {
252    use super::*;
253    use one_or_many::OneOrMany;
254    use pretty_assertions::assert_eq;
255    use rstest::rstest;
256    use std::time::Duration;
257
258    #[rstest]
259    #[case(SongSort::Title, SongSort::Artist)]
260    #[case(SongSort::Artist, SongSort::Album)]
261    #[case(SongSort::Album, SongSort::AlbumArtist)]
262    #[case(SongSort::AlbumArtist, SongSort::Genre)]
263    #[case(SongSort::Genre, SongSort::Title)]
264    fn test_sort_mode_next_prev(#[case] mode: SongSort, #[case] expected: SongSort) {
265        assert_eq!(mode.next(), expected);
266        assert_eq!(mode.next().prev(), mode);
267    }
268
269    #[rstest]
270    #[case(SongSort::Title, "Title")]
271    #[case(SongSort::Artist, "Artist")]
272    #[case(SongSort::Album, "Album")]
273    #[case(SongSort::AlbumArtist, "Album Artist")]
274    #[case(SongSort::Genre, "Genre")]
275    fn test_sort_mode_display(#[case] mode: SongSort, #[case] expected: &str) {
276        assert_eq!(mode.to_string(), expected);
277    }
278
279    #[rstest]
280    fn test_sort_songs() {
281        let mut songs = vec![
282            Song {
283                id: Song::generate_id(),
284                title: "C".into(),
285                artist: OneOrMany::One("B".into()),
286                album: "A".into(),
287                album_artist: OneOrMany::One("C".into()),
288                genre: OneOrMany::One("B".into()),
289                runtime: Duration::from_secs(180),
290                track: Some(1),
291                disc: Some(1),
292                release_year: Some(2021),
293                extension: "mp3".into(),
294                path: "test.mp3".into(),
295            },
296            Song {
297                id: Song::generate_id(),
298                title: "B".into(),
299                artist: OneOrMany::One("A".into()),
300                album: "C".into(),
301                album_artist: OneOrMany::One("B".into()),
302                genre: OneOrMany::One("A".into()),
303                runtime: Duration::from_secs(180),
304                track: Some(1),
305                disc: Some(1),
306                release_year: Some(2021),
307                extension: "mp3".into(),
308                path: "test.mp3".into(),
309            },
310            Song {
311                id: Song::generate_id(),
312                title: "A".into(),
313                artist: OneOrMany::One("C".into()),
314                album: "B".into(),
315                album_artist: OneOrMany::One("A".into()),
316                genre: OneOrMany::One("C".into()),
317                runtime: Duration::from_secs(180),
318                track: Some(1),
319                disc: Some(1),
320                release_year: Some(2021),
321                extension: "mp3".into(),
322                path: "test.mp3".into(),
323            },
324        ];
325
326        SongSort::Title.sort_items(&mut songs);
327        assert_eq!(songs[0].title, "A");
328        assert_eq!(songs[1].title, "B");
329        assert_eq!(songs[2].title, "C");
330
331        SongSort::Artist.sort_items(&mut songs);
332        assert_eq!(songs[0].artist, OneOrMany::One("A".into()));
333        assert_eq!(songs[1].artist, OneOrMany::One("B".into()));
334        assert_eq!(songs[2].artist, OneOrMany::One("C".into()));
335
336        SongSort::Album.sort_items(&mut songs);
337        assert_eq!(songs[0].album, "A");
338        assert_eq!(songs[1].album, "B");
339        assert_eq!(songs[2].album, "C");
340
341        SongSort::AlbumArtist.sort_items(&mut songs);
342        assert_eq!(songs[0].album_artist, OneOrMany::One("A".into()));
343        assert_eq!(songs[1].album_artist, OneOrMany::One("B".into()));
344        assert_eq!(songs[2].album_artist, OneOrMany::One("C".into()));
345
346        SongSort::Genre.sort_items(&mut songs);
347        assert_eq!(songs[0].genre, OneOrMany::One("A".into()));
348        assert_eq!(songs[1].genre, OneOrMany::One("B".into()));
349        assert_eq!(songs[2].genre, OneOrMany::One("C".into()));
350    }
351}
352
353#[cfg(test)]
354mod item_view_tests {
355    use super::*;
356    use crate::test_utils::{
357        assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
358    };
359    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
360    use pretty_assertions::assert_eq;
361    use ratatui::buffer::Buffer;
362
363    #[test]
364    fn test_new() {
365        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
366        let state = state_with_everything();
367        let view = SongView::new(&state, tx);
368
369        assert_eq!(view.name(), "Song View");
370        assert_eq!(view.props, Some(state.additional_view_data.song.unwrap()));
371    }
372
373    #[test]
374    fn test_move_with_state() {
375        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
376        let state = AppState::default();
377        let new_state = state_with_everything();
378        let view = SongView::new(&state, tx).move_with_state(&new_state);
379
380        assert_eq!(
381            view.props,
382            Some(new_state.additional_view_data.song.unwrap())
383        );
384    }
385
386    #[test]
387    fn test_render_no_song() {
388        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
389        let view = SongView::new(&AppState::default(), tx);
390
391        let (mut terminal, area) = setup_test_terminal(16, 3);
392        let props = RenderProps {
393            area,
394            is_focused: true,
395        };
396        let buffer = terminal
397            .draw(|frame| view.render(frame, props))
398            .unwrap()
399            .buffer
400            .clone();
401        #[rustfmt::skip]
402        let expected = Buffer::with_lines([
403            "┌Song View─────┐",
404            "│No active song│",
405            "└──────────────┘",
406        ]);
407
408        assert_buffer_eq(&buffer, &expected);
409    }
410
411    #[test]
412    #[allow(clippy::too_many_lines)]
413    fn test_render_no_playlist_no_collection() {
414        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
415        let mut state = state_with_everything();
416        state.additional_view_data.song.as_mut().unwrap().playlists = [].into();
417        state
418            .additional_view_data
419            .song
420            .as_mut()
421            .unwrap()
422            .collections = [].into();
423        let mut view = SongView::new(&state, tx);
424
425        let (mut terminal, area) = setup_test_terminal(60, 12);
426        let props = RenderProps {
427            area,
428            is_focused: true,
429        };
430        let buffer = terminal
431            .draw(|frame| view.render(frame, props))
432            .unwrap()
433            .buffer
434            .clone();
435        let expected = Buffer::with_lines([
436            "┌Song View─────────────────────────────────────────────────┐",
437            "│                   Test Song Test Artist                  │",
438            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
439            "│                                                          │",
440            "│q: add to queue | r: start radio | p: add to playlist─────│",
441            "│Performing operations on the song─────────────────────────│",
442            "│▶ Artists (1):                                            │",
443            "│☐ Album: Test Album Test Artist                           │",
444            "│▶ Playlists (0):                                          │",
445            "│▶ Collections (0):                                        │",
446            "│                                                          │",
447            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
448        ]);
449
450        assert_buffer_eq(&buffer, &expected);
451        assert!(view.tree_state.lock().unwrap().selected().is_empty());
452
453        view.handle_key_event(KeyEvent::from(KeyCode::Down));
454        assert_eq!(view.tree_state.lock().unwrap().selected(), &["Artists"]);
455        view.handle_key_event(KeyEvent::from(KeyCode::Down));
456        assert_eq!(
457            view.tree_state.lock().unwrap().selected(),
458            &[state.library.albums[0].id.to_string()]
459        );
460        view.handle_key_event(KeyEvent::from(KeyCode::Down));
461        assert_eq!(view.tree_state.lock().unwrap().selected(), &["Playlists"]);
462        view.handle_key_event(KeyEvent::from(KeyCode::Right));
463
464        let buffer = terminal
465            .draw(|frame| view.render(frame, props))
466            .unwrap()
467            .buffer
468            .clone();
469        let expected = Buffer::with_lines([
470            "┌Song View─────────────────────────────────────────────────┐",
471            "│                   Test Song Test Artist                  │",
472            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
473            "│                                                          │",
474            "│q: add to queue | r: start radio | p: add to playlist─────│",
475            "│Performing operations on the song─────────────────────────│",
476            "│▶ Artists (1):                                            │",
477            "│☐ Album: Test Album Test Artist                           │",
478            "│▼ Playlists (0):                                          │",
479            "│                                                          │",
480            "│▶ Collections (0):                                        │",
481            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
482        ]);
483        assert_buffer_eq(&buffer, &expected);
484
485        view.handle_key_event(KeyEvent::from(KeyCode::Left));
486        let buffer = terminal
487            .draw(|frame| view.render(frame, props))
488            .unwrap()
489            .buffer
490            .clone();
491        let expected = Buffer::with_lines([
492            "┌Song View─────────────────────────────────────────────────┐",
493            "│                   Test Song Test Artist                  │",
494            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
495            "│                                                          │",
496            "│q: add to queue | r: start radio | p: add to playlist─────│",
497            "│Performing operations on the song─────────────────────────│",
498            "│▶ Artists (1):                                            │",
499            "│☐ Album: Test Album Test Artist                           │",
500            "│▶ Playlists (0):                                          │",
501            "│▶ Collections (0):                                        │",
502            "│                                                          │",
503            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
504        ]);
505        assert_buffer_eq(&buffer, &expected);
506
507        view.handle_key_event(KeyEvent::from(KeyCode::Down));
508        assert_eq!(view.tree_state.lock().unwrap().selected(), &["Collections"]);
509        view.handle_key_event(KeyEvent::from(KeyCode::Right));
510
511        let buffer = terminal
512            .draw(|frame| view.render(frame, props))
513            .unwrap()
514            .buffer
515            .clone();
516        let expected = Buffer::with_lines([
517            "┌Song View─────────────────────────────────────────────────┐",
518            "│                   Test Song Test Artist                  │",
519            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
520            "│                                                          │",
521            "│q: add to queue | r: start radio | p: add to playlist─────│",
522            "│Performing operations on the song─────────────────────────│",
523            "│▶ Artists (1):                                            │",
524            "│☐ Album: Test Album Test Artist                           │",
525            "│▶ Playlists (0):                                          │",
526            "│▼ Collections (0):                                        │",
527            "│                                                          │",
528            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
529        ]);
530        assert_buffer_eq(&buffer, &expected);
531    }
532
533    #[test]
534    fn test_render() {
535        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
536        let view = SongView::new(&state_with_everything(), tx);
537
538        let (mut terminal, area) = setup_test_terminal(60, 12);
539        let props = RenderProps {
540            area,
541            is_focused: true,
542        };
543        let buffer = terminal
544            .draw(|frame| view.render(frame, props))
545            .unwrap()
546            .buffer
547            .clone();
548        let expected = Buffer::with_lines([
549            "┌Song View─────────────────────────────────────────────────┐",
550            "│                   Test Song Test Artist                  │",
551            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
552            "│                                                          │",
553            "│q: add to queue | r: start radio | p: add to playlist─────│",
554            "│Performing operations on the song─────────────────────────│",
555            "│▶ Artists (1):                                            │",
556            "│☐ Album: Test Album Test Artist                           │",
557            "│▶ Playlists (1):                                          │",
558            "│▶ Collections (1):                                        │",
559            "│                                                          │",
560            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
561        ]);
562
563        assert_buffer_eq(&buffer, &expected);
564    }
565
566    #[test]
567    fn test_render_with_checked() {
568        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
569        let mut view = SongView::new(&state_with_everything(), tx);
570        let (mut terminal, area) = setup_test_terminal(60, 9);
571        let props = RenderProps {
572            area,
573            is_focused: true,
574        };
575        let buffer = terminal
576            .draw(|frame| view.render(frame, props))
577            .unwrap()
578            .buffer
579            .clone();
580        let expected = Buffer::with_lines([
581            "┌Song View─────────────────────────────────────────────────┐",
582            "│                   Test Song Test Artist                  │",
583            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
584            "│                                                          │",
585            "│q: add to queue | r: start radio | p: add to playlist─────│",
586            "│Performing operations on the song─────────────────────────│",
587            "│▶ Artists (1):                                            │",
588            "│☐ Album: Test Album Test Artist                           │",
589            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
590        ]);
591        assert_buffer_eq(&buffer, &expected);
592
593        // select the album
594        view.handle_key_event(KeyEvent::from(KeyCode::Down));
595        view.handle_key_event(KeyEvent::from(KeyCode::Down));
596        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
597
598        let buffer = terminal
599            .draw(|frame| view.render(frame, props))
600            .unwrap()
601            .buffer
602            .clone();
603        let expected = Buffer::with_lines([
604            "┌Song View─────────────────────────────────────────────────┐",
605            "│                   Test Song Test Artist                  │",
606            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
607            "│                                                          │",
608            "│q: add to queue | r: start radio | p: add to playlist─────│",
609            "│Performing operations on checked items────────────────────│",
610            "│▶ Artists (1):                                            │",
611            "│☑ Album: Test Album Test Artist                           │",
612            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
613        ]);
614
615        assert_buffer_eq(&buffer, &expected);
616    }
617
618    #[test]
619    fn smoke_navigation() {
620        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
621        let mut view = SongView::new(&state_with_everything(), tx);
622
623        view.handle_key_event(KeyEvent::from(KeyCode::Up));
624        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
625        view.handle_key_event(KeyEvent::from(KeyCode::Down));
626        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
627        view.handle_key_event(KeyEvent::from(KeyCode::Left));
628        view.handle_key_event(KeyEvent::from(KeyCode::Right));
629    }
630
631    #[test]
632    fn test_actions() {
633        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
634        let mut view = SongView::new(&state_with_everything(), tx);
635
636        // need to render the view at least once to load the tree state
637        let (mut terminal, area) = setup_test_terminal(60, 9);
638        let props = RenderProps {
639            area,
640            is_focused: true,
641        };
642        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
643
644        // we test the actions when:
645        // there are no checked items
646        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
647        assert_eq!(
648            rx.blocking_recv().unwrap(),
649            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
650                "song",
651                item_id()
652            )
653                .into()])))
654        );
655        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
656        assert_eq!(
657            rx.blocking_recv().unwrap(),
658            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
659                "song",
660                item_id()
661            )
662                .into()],)))
663        );
664        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
665        assert_eq!(
666            rx.blocking_recv().unwrap(),
667            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
668                "song",
669                item_id()
670            )
671                .into()])))
672        );
673
674        // there are checked items
675        // first we need to select an item
676        view.handle_key_event(KeyEvent::from(KeyCode::Down));
677        view.handle_key_event(KeyEvent::from(KeyCode::Down));
678        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
679
680        // open the selected view
681        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
682        assert_eq!(
683            rx.blocking_recv().unwrap(),
684            Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
685        );
686
687        // check the item
688        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
689
690        // add to queue
691        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
692        assert_eq!(
693            rx.blocking_recv().unwrap(),
694            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
695                "album",
696                item_id()
697            )
698                .into()])))
699        );
700
701        // start radio
702        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
703        assert_eq!(
704            rx.blocking_recv().unwrap(),
705            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
706                "album",
707                item_id()
708            )
709                .into()],)))
710        );
711
712        // add to playlist
713        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
714        assert_eq!(
715            rx.blocking_recv().unwrap(),
716            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
717                "album",
718                item_id()
719            )
720                .into()])))
721        );
722    }
723
724    #[test]
725    #[allow(clippy::too_many_lines)]
726    fn test_mouse_event() {
727        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
728        let mut view = SongView::new(&state_with_everything(), tx);
729
730        // need to render the view at least once to load the tree state
731        let (mut terminal, area) = setup_test_terminal(60, 9);
732        let props = RenderProps {
733            area,
734            is_focused: true,
735        };
736        let buffer = terminal
737            .draw(|frame| view.render(frame, props))
738            .unwrap()
739            .buffer
740            .clone();
741        let expected = Buffer::with_lines([
742            "┌Song View─────────────────────────────────────────────────┐",
743            "│                   Test Song Test Artist                  │",
744            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
745            "│                                                          │",
746            "│q: add to queue | r: start radio | p: add to playlist─────│",
747            "│Performing operations on the song─────────────────────────│",
748            "│▶ Artists (1):                                            │",
749            "│☐ Album: Test Album Test Artist                           │",
750            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
751        ]);
752        assert_buffer_eq(&buffer, &expected);
753
754        // click on the dropdown
755        view.handle_mouse_event(
756            MouseEvent {
757                kind: MouseEventKind::Down(MouseButton::Left),
758                column: 2,
759                row: 6,
760                modifiers: KeyModifiers::empty(),
761            },
762            area,
763        );
764        let buffer = terminal
765            .draw(|frame| view.render(frame, props))
766            .unwrap()
767            .buffer
768            .clone();
769        let expected = Buffer::with_lines([
770            "┌Song View─────────────────────────────────────────────────┐",
771            "│                   Test Song Test Artist                  │",
772            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
773            "│                                                          │",
774            "│q: add to queue | r: start radio | p: add to playlist─────│",
775            "│Performing operations on the song─────────────────────────│",
776            "│▼ Artists (1):                                            │",
777            "│  ☐ Test Artist                                           │",
778            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
779        ]);
780        assert_buffer_eq(&buffer, &expected);
781
782        // scroll down
783        view.handle_mouse_event(
784            MouseEvent {
785                kind: MouseEventKind::ScrollDown,
786                column: 2,
787                row: 6,
788                modifiers: KeyModifiers::empty(),
789            },
790            area,
791        );
792        let buffer = terminal
793            .draw(|frame| view.render(frame, props))
794            .unwrap()
795            .buffer
796            .clone();
797        assert_buffer_eq(&buffer, &expected);
798
799        // click down the checkbox item (which is already selected thanks to the scroll)
800        view.handle_mouse_event(
801            MouseEvent {
802                kind: MouseEventKind::Down(MouseButton::Left),
803                column: 2,
804                row: 7,
805                modifiers: KeyModifiers::empty(),
806            },
807            area,
808        );
809        assert_eq!(
810            rx.blocking_recv().unwrap(),
811            Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
812        );
813        let buffer = terminal
814            .draw(|frame| view.render(frame, props))
815            .unwrap()
816            .buffer
817            .clone();
818        let expected = Buffer::with_lines([
819            "┌Song View─────────────────────────────────────────────────┐",
820            "│                   Test Song Test Artist                  │",
821            "│  Track/Disc: 0/0  Duration: 3:00.0  Genre(s): Test Genre │",
822            "│                                                          │",
823            "│q: add to queue | r: start radio | p: add to playlist─────│",
824            "│Performing operations on checked items────────────────────│",
825            "│▼ Artists (1):                                            │",
826            "│  ☑ Test Artist                                           │",
827            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
828        ]);
829        assert_buffer_eq(&buffer, &expected);
830
831        // scroll up
832        view.handle_mouse_event(
833            MouseEvent {
834                kind: MouseEventKind::ScrollUp,
835                column: 2,
836                row: 7,
837                modifiers: KeyModifiers::empty(),
838            },
839            area,
840        );
841        let buffer = terminal
842            .draw(|frame| view.render(frame, props))
843            .unwrap()
844            .buffer
845            .clone();
846        assert_buffer_eq(&buffer, &expected);
847    }
848}
849
850#[cfg(test)]
851mod library_view_tests {
852    use super::*;
853    use crate::test_utils::{
854        assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
855    };
856
857    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
858    use pretty_assertions::assert_eq;
859    use ratatui::buffer::Buffer;
860
861    #[test]
862    fn test_new() {
863        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
864        let state = state_with_everything();
865        let view = LibrarySongsView::new(&state, tx);
866
867        assert_eq!(view.name(), "Library Songs View");
868        assert_eq!(view.props.songs, state.library.songs);
869    }
870
871    #[test]
872    fn test_move_with_state() {
873        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
874        let state = AppState::default();
875        let new_state = state_with_everything();
876        let view = LibrarySongsView::new(&state, tx).move_with_state(&new_state);
877
878        assert_eq!(view.props.songs, new_state.library.songs);
879    }
880
881    #[test]
882    fn test_render() {
883        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
884        let view = LibrarySongsView::new(&state_with_everything(), tx);
885
886        let (mut terminal, area) = setup_test_terminal(60, 6);
887        let props = RenderProps {
888            area,
889            is_focused: true,
890        };
891        let buffer = terminal
892            .draw(|frame| view.render(frame, props))
893            .unwrap()
894            .buffer
895            .clone();
896        let expected = Buffer::with_lines([
897            "┌Library Songs sorted by: Artist───────────────────────────┐",
898            "│──────────────────────────────────────────────────────────│",
899            "│☐ Test Song Test Artist                                   │",
900            "│                                                          │",
901            "│s/S: change sort──────────────────────────────────────────│",
902            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
903        ]);
904
905        assert_buffer_eq(&buffer, &expected);
906    }
907
908    #[test]
909    fn test_render_with_checked() {
910        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
911        let mut view = LibrarySongsView::new(&state_with_everything(), tx);
912        let (mut terminal, area) = setup_test_terminal(60, 6);
913        let props = RenderProps {
914            area,
915            is_focused: true,
916        };
917        let buffer = terminal
918            .draw(|frame| view.render(frame, props))
919            .unwrap()
920            .buffer
921            .clone();
922        let expected = Buffer::with_lines([
923            "┌Library Songs sorted by: Artist───────────────────────────┐",
924            "│──────────────────────────────────────────────────────────│",
925            "│☐ Test Song Test Artist                                   │",
926            "│                                                          │",
927            "│s/S: change sort──────────────────────────────────────────│",
928            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
929        ]);
930        assert_buffer_eq(&buffer, &expected);
931
932        // check the first song
933        view.handle_key_event(KeyEvent::from(KeyCode::Down));
934        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
935
936        let buffer = terminal
937            .draw(|frame| view.render(frame, props))
938            .unwrap()
939            .buffer
940            .clone();
941        let expected = Buffer::with_lines([
942            "┌Library Songs sorted by: Artist───────────────────────────┐",
943            "│q: add to queue | r: start radio | p: add to playlist ────│",
944            "│☑ Test Song Test Artist                                   │",
945            "│                                                          │",
946            "│s/S: change sort──────────────────────────────────────────│",
947            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
948        ]);
949
950        assert_buffer_eq(&buffer, &expected);
951    }
952
953    #[test]
954    fn test_sort_keys() {
955        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
956        let mut view = LibrarySongsView::new(&state_with_everything(), tx);
957
958        assert_eq!(view.props.sort_mode, SongSort::Artist);
959        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
960        assert_eq!(view.props.sort_mode, SongSort::Album);
961        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
962        assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
963        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
964        assert_eq!(view.props.sort_mode, SongSort::Genre);
965        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
966        assert_eq!(view.props.sort_mode, SongSort::Title);
967        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
968        assert_eq!(view.props.sort_mode, SongSort::Artist);
969        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
970        assert_eq!(view.props.sort_mode, SongSort::Title);
971        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
972        assert_eq!(view.props.sort_mode, SongSort::Genre);
973        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
974        assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
975        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
976        assert_eq!(view.props.sort_mode, SongSort::Album);
977        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
978        assert_eq!(view.props.sort_mode, SongSort::Artist);
979    }
980
981    #[test]
982    fn smoke_navigation() {
983        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
984        let mut view = LibrarySongsView::new(&state_with_everything(), tx);
985
986        view.handle_key_event(KeyEvent::from(KeyCode::Up));
987        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
988        view.handle_key_event(KeyEvent::from(KeyCode::Down));
989        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
990        view.handle_key_event(KeyEvent::from(KeyCode::Left));
991        view.handle_key_event(KeyEvent::from(KeyCode::Right));
992    }
993
994    #[test]
995    fn test_actions() {
996        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
997        let mut view = LibrarySongsView::new(&state_with_everything(), tx);
998
999        // need to render the view at least once to load the tree state
1000        let (mut terminal, area) = setup_test_terminal(60, 9);
1001        let props = RenderProps {
1002            area,
1003            is_focused: true,
1004        };
1005        terminal.draw(|frame| view.render(frame, props)).unwrap();
1006
1007        // first we need to navigate to the song
1008        view.handle_key_event(KeyEvent::from(KeyCode::Down));
1009
1010        // now, we test the actions that require checked items when:
1011        // there are no checked items (order is different so that if an action is performed, the assertion later will fail)
1012        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1013        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1014        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1015        // open
1016        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1017        let action = rx.blocking_recv().unwrap();
1018        assert_eq!(
1019            action,
1020            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1021        );
1022
1023        // there are checked items
1024        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
1025
1026        // add to queue
1027        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1028        let action = rx.blocking_recv().unwrap();
1029        assert_eq!(
1030            action,
1031            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
1032                "song",
1033                item_id()
1034            )
1035                .into()])))
1036        );
1037
1038        // start radio
1039        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1040        let action = rx.blocking_recv().unwrap();
1041        assert_eq!(
1042            action,
1043            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
1044                "song",
1045                item_id()
1046            )
1047                .into()],)))
1048        );
1049
1050        // add to playlist
1051        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1052        let action = rx.blocking_recv().unwrap();
1053        assert_eq!(
1054            action,
1055            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
1056                "song",
1057                item_id()
1058            )
1059                .into()])))
1060        );
1061    }
1062
1063    #[test]
1064    fn test_mouse() {
1065        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1066        let mut view = LibrarySongsView::new(&state_with_everything(), tx);
1067
1068        // need to render the view at least once to load the tree state
1069        let (mut terminal, area) = setup_test_terminal(60, 6);
1070        let props = RenderProps {
1071            area,
1072            is_focused: true,
1073        };
1074        let buffer = terminal
1075            .draw(|frame| view.render(frame, props))
1076            .unwrap()
1077            .buffer
1078            .clone();
1079        let expected = Buffer::with_lines([
1080            "┌Library Songs sorted by: Artist───────────────────────────┐",
1081            "│──────────────────────────────────────────────────────────│",
1082            "│☐ Test Song Test Artist                                   │",
1083            "│                                                          │",
1084            "│s/S: change sort──────────────────────────────────────────│",
1085            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1086        ]);
1087        assert_buffer_eq(&buffer, &expected);
1088
1089        // click on the album
1090        view.handle_mouse_event(
1091            MouseEvent {
1092                kind: MouseEventKind::Down(MouseButton::Left),
1093                column: 2,
1094                row: 2,
1095                modifiers: KeyModifiers::empty(),
1096            },
1097            area,
1098        );
1099        let buffer = terminal
1100            .draw(|frame| view.render(frame, props))
1101            .unwrap()
1102            .buffer
1103            .clone();
1104        let expected = Buffer::with_lines([
1105            "┌Library Songs sorted by: Artist───────────────────────────┐",
1106            "│q: add to queue | r: start radio | p: add to playlist ────│",
1107            "│☑ Test Song Test Artist                                   │",
1108            "│                                                          │",
1109            "│s/S: change sort──────────────────────────────────────────│",
1110            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1111        ]);
1112        assert_buffer_eq(&buffer, &expected);
1113
1114        // scroll down
1115        view.handle_mouse_event(
1116            MouseEvent {
1117                kind: MouseEventKind::ScrollDown,
1118                column: 2,
1119                row: 2,
1120                modifiers: KeyModifiers::empty(),
1121            },
1122            area,
1123        );
1124        let buffer = terminal
1125            .draw(|frame| view.render(frame, props))
1126            .unwrap()
1127            .buffer
1128            .clone();
1129        assert_buffer_eq(&buffer, &expected);
1130
1131        // scroll up
1132        view.handle_mouse_event(
1133            MouseEvent {
1134                kind: MouseEventKind::ScrollUp,
1135                column: 2,
1136                row: 2,
1137                modifiers: KeyModifiers::empty(),
1138            },
1139            area,
1140        );
1141        let buffer = terminal
1142            .draw(|frame| view.render(frame, props))
1143            .unwrap()
1144            .buffer
1145            .clone();
1146        assert_buffer_eq(&buffer, &expected);
1147
1148        // click down on selected item
1149        view.handle_mouse_event(
1150            MouseEvent {
1151                kind: MouseEventKind::Down(MouseButton::Left),
1152                column: 2,
1153                row: 2,
1154                modifiers: KeyModifiers::empty(),
1155            },
1156            area,
1157        );
1158        assert_eq!(
1159            rx.blocking_recv().unwrap(),
1160            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1161        );
1162
1163        // clicking on an empty area should clear the selection
1164        let mouse = MouseEvent {
1165            kind: MouseEventKind::Down(MouseButton::Left),
1166            column: 2,
1167            row: 3,
1168            modifiers: KeyModifiers::empty(),
1169        };
1170        view.handle_mouse_event(mouse, area);
1171        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1172        view.handle_mouse_event(mouse, area);
1173        assert_eq!(
1174            rx.try_recv(),
1175            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1176        );
1177    }
1178}