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

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