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

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