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