Skip to main content

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

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