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