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