Skip to main content

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