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

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