Skip to main content

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

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