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

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