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