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