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