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