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