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