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                    current.map_or(
91                        self.props
92                            .as_ref()
93                            .map_or(0, |p| p.songs.len().saturating_sub(1)),
94                        |c| c.saturating_sub(10),
95                    )
96                });
97            }
98            KeyCode::Up => {
99                self.tree_state.lock().unwrap().key_up();
100            }
101            KeyCode::PageDown => {
102                self.tree_state
103                    .lock()
104                    .unwrap()
105                    .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
106            }
107            KeyCode::Down => {
108                self.tree_state.lock().unwrap().key_down();
109            }
110            KeyCode::Left => {
111                self.tree_state.lock().unwrap().key_left();
112            }
113            KeyCode::Right => {
114                self.tree_state.lock().unwrap().key_right();
115            }
116            KeyCode::Char(' ') => {
117                self.tree_state.lock().unwrap().key_space();
118            }
119            // Change sort mode
120            KeyCode::Char('s') => {
121                self.sort_mode = self.sort_mode.next();
122                if let Some(props) = &mut self.props {
123                    self.sort_mode.sort_items(&mut props.songs);
124                    self.tree_state.lock().unwrap().scroll_selected_into_view();
125                }
126            }
127            KeyCode::Char('S') => {
128                self.sort_mode = self.sort_mode.prev();
129                if let Some(props) = &mut self.props {
130                    self.sort_mode.sort_items(&mut props.songs);
131                    self.tree_state.lock().unwrap().scroll_selected_into_view();
132                }
133            }
134            // Enter key opens selected view
135            KeyCode::Enter => {
136                if self.tree_state.lock().unwrap().toggle_selected() {
137                    let things = self.tree_state.lock().unwrap().get_selected_thing();
138
139                    if let Some(thing) = things {
140                        self.action_tx
141                            .send(Action::ActiveView(ViewAction::Set(thing.into())))
142                            .unwrap();
143                    }
144                }
145            }
146            // if there are checked items, add them to the queue, otherwise send the whole collection to the queue
147            KeyCode::Char('q') => {
148                let checked_things = self.tree_state.lock().unwrap().get_checked_things();
149                if let Some(action) = construct_add_to_queue_action(
150                    checked_things,
151                    self.props.as_ref().map(|p| &p.id),
152                ) {
153                    self.action_tx.send(action).unwrap();
154                }
155            }
156            // if there are checked items, add them to the playlist, otherwise send the whole collection to the playlist
157            KeyCode::Char('p') => {
158                let checked_things = self.tree_state.lock().unwrap().get_checked_things();
159                if let Some(action) = construct_add_to_playlist_action(
160                    checked_things,
161                    self.props.as_ref().map(|p| &p.id),
162                ) {
163                    self.action_tx.send(action).unwrap();
164                }
165            }
166            _ => {}
167        }
168    }
169
170    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
171        // adjust the area to account for the border
172        let area = area.inner(Margin::new(1, 1));
173        let [_, content_area] = split_area(area);
174        let content_area = content_area.inner(Margin::new(0, 1));
175
176        let result = self
177            .tree_state
178            .lock()
179            .unwrap()
180            .handle_mouse_event(mouse, content_area);
181        if let Some(action) = result {
182            self.action_tx.send(action).unwrap();
183        }
184    }
185}
186
187fn split_area(area: Rect) -> [Rect; 2] {
188    let [info_area, content_area] = *Layout::default()
189        .direction(Direction::Vertical)
190        .constraints([Constraint::Length(3), Constraint::Min(4)])
191        .split(area)
192    else {
193        panic!("Failed to split collection view area")
194    };
195
196    [info_area, content_area]
197}
198
199impl ComponentRender<RenderProps> for CollectionView {
200    fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
201        let border_style = Style::default().fg(border_color(props.is_focused).into());
202
203        let area = if let Some(state) = &self.props {
204            let border = Block::bordered()
205                .title_top(Line::from(vec![
206                    Span::styled("Collection View".to_string(), Style::default().bold()),
207                    Span::raw(" sorted by: "),
208                    Span::styled(self.sort_mode.to_string(), Style::default().italic()),
209                ]))
210                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
211                .border_style(border_style);
212            frame.render_widget(&border, props.area);
213            let content_area = border.inner(props.area);
214
215            // split content area to make room for the collection info
216            let [info_area, content_area] = split_area(content_area);
217
218            // render the collection info
219            frame.render_widget(
220                Paragraph::new(vec![
221                    Line::from(Span::styled(
222                        state.collection.name.to_string(),
223                        Style::default().bold(),
224                    )),
225                    Line::from(vec![
226                        Span::raw("Songs: "),
227                        Span::styled(
228                            state.collection.song_count.to_string(),
229                            Style::default().italic(),
230                        ),
231                        Span::raw("  Duration: "),
232                        Span::styled(
233                            format_duration(&state.collection.runtime),
234                            Style::default().italic(),
235                        ),
236                    ]),
237                ])
238                .alignment(Alignment::Center),
239                info_area,
240            );
241
242            // draw an additional border around the content area to display additionally instructions
243            let border = Block::new()
244                .borders(Borders::TOP | Borders::BOTTOM)
245                .title_top("q: add to queue | p: add to playlist")
246                .title_bottom("s/S: change sort")
247                .border_style(border_style);
248            frame.render_widget(&border, content_area);
249            let content_area = border.inner(content_area);
250
251            // draw an additional border around the content area to indicate whether operations will be performed on the entire item, or just the checked items
252            let border = Block::default()
253                .borders(Borders::TOP)
254                .title_top(Line::from(vec![
255                    Span::raw("Performing operations on "),
256                    Span::raw(
257                        if self
258                            .tree_state
259                            .lock()
260                            .unwrap()
261                            .get_checked_things()
262                            .is_empty()
263                        {
264                            "entire collection"
265                        } else {
266                            "checked items"
267                        },
268                    )
269                    .fg(*TEXT_HIGHLIGHT),
270                ]))
271                .italic()
272                .border_style(border_style);
273            frame.render_widget(&border, content_area);
274            border.inner(content_area)
275        } else {
276            let border = Block::bordered()
277                .title_top("Collection View")
278                .border_style(border_style);
279            frame.render_widget(&border, props.area);
280            border.inner(props.area)
281        };
282
283        RenderProps { area, ..props }
284    }
285
286    fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
287        if let Some(state) = &self.props {
288            // create list to hold collection songs
289            let items = state
290                .songs
291                .iter()
292                .map(create_song_tree_leaf)
293                .collect::<Vec<_>>();
294
295            // render the collections songs
296            frame.render_stateful_widget(
297                CheckTree::new(&items)
298                    .unwrap()
299                    .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
300                    .experimental_scrollbar(Some(Scrollbar::new(
301                        ScrollbarOrientation::VerticalRight,
302                    ))),
303                props.area,
304                &mut self.tree_state.lock().unwrap(),
305            );
306        } else {
307            let text = "No active collection";
308
309            frame.render_widget(
310                Line::from(text)
311                    .style(Style::default().fg((*TEXT_NORMAL).into()))
312                    .alignment(Alignment::Center),
313                props.area,
314            );
315        }
316    }
317}
318
319pub struct LibraryCollectionsView {
320    /// Action Sender
321    pub action_tx: UnboundedSender<Action>,
322    /// Mapped Props from state
323    props: Props,
324    /// tree state
325    tree_state: Mutex<CheckTreeState<String>>,
326}
327
328struct Props {
329    collections: Box<[CollectionBrief]>,
330    sort_mode: NameSort<CollectionBrief>,
331}
332
333impl Component for LibraryCollectionsView {
334    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
335    where
336        Self: Sized,
337    {
338        let sort_mode = NameSort::default();
339        let mut collections = state.library.collections.clone();
340        sort_mode.sort_items(&mut collections);
341        Self {
342            action_tx,
343            props: Props {
344                collections,
345                sort_mode,
346            },
347            tree_state: Mutex::new(CheckTreeState::default()),
348        }
349    }
350
351    fn move_with_state(self, state: &AppState) -> Self
352    where
353        Self: Sized,
354    {
355        let mut collections = state.library.collections.clone();
356        self.props.sort_mode.sort_items(&mut collections);
357        let tree_state = (state.active_view == ActiveView::Collections)
358            .then_some(self.tree_state)
359            .unwrap_or_default();
360
361        Self {
362            props: Props {
363                collections,
364                ..self.props
365            },
366            tree_state,
367            ..self
368        }
369    }
370
371    fn name(&self) -> &'static str {
372        "Library Collections View"
373    }
374
375    fn handle_key_event(&mut self, key: KeyEvent) {
376        match key.code {
377            // arrow keys
378            KeyCode::PageUp => {
379                self.tree_state.lock().unwrap().select_relative(|current| {
380                    current.map_or(self.props.collections.len().saturating_sub(1), |c| {
381                        c.saturating_sub(10)
382                    })
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);
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 (opening it)
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        assert_eq!(
837            rx.blocking_recv().unwrap(),
838            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
839        );
840        let expected = Buffer::with_lines([
841            "┌Collection View sorted by: Artist─────────────────────────┐",
842            "│                       Collection 0                       │",
843            "│              Songs: 1  Duration: 00:03:00.00             │",
844            "│                                                          │",
845            "│q: add to queue | p: add to playlist──────────────────────│",
846            "│Performing operations on entire collection────────────────│",
847            "│☐ Test Song Test Artist                                   │",
848            "│s/S: change sort──────────────────────────────────────────│",
849            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
850        ]);
851        let buffer = terminal
852            .draw(|frame| view.render(frame, props))
853            .unwrap()
854            .buffer
855            .clone();
856        assert_buffer_eq(&buffer, &expected);
857
858        // scroll down
859        view.handle_mouse_event(
860            MouseEvent {
861                kind: MouseEventKind::ScrollDown,
862                column: 2,
863                row: 6,
864                modifiers: KeyModifiers::empty(),
865            },
866            area,
867        );
868        let buffer = terminal
869            .draw(|frame| view.render(frame, props))
870            .unwrap()
871            .buffer
872            .clone();
873        assert_buffer_eq(&buffer, &expected);
874        // scroll up
875        view.handle_mouse_event(
876            MouseEvent {
877                kind: MouseEventKind::ScrollUp,
878                column: 2,
879                row: 6,
880                modifiers: KeyModifiers::empty(),
881            },
882            area,
883        );
884        let buffer = terminal
885            .draw(|frame| view.render(frame, props))
886            .unwrap()
887            .buffer
888            .clone();
889        assert_buffer_eq(&buffer, &expected);
890    }
891}
892
893#[cfg(test)]
894mod library_view_tests {
895    use super::*;
896    use crate::{
897        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
898        ui::components::content_view::ActiveView,
899    };
900    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
901    use pretty_assertions::assert_eq;
902    use ratatui::buffer::Buffer;
903
904    #[test]
905    fn test_new() {
906        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
907        let state = state_with_everything();
908        let view = LibraryCollectionsView::new(&state, tx);
909
910        assert_eq!(view.name(), "Library Collections View");
911        assert_eq!(view.props.collections, state.library.collections);
912    }
913
914    #[test]
915    fn test_move_with_state() {
916        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
917        let state = AppState::default();
918        let new_state = state_with_everything();
919        let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
920
921        assert_eq!(view.props.collections, new_state.library.collections);
922    }
923
924    #[test]
925    fn test_render() {
926        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
927        let view = LibraryCollectionsView::new(&state_with_everything(), tx);
928
929        let (mut terminal, area) = setup_test_terminal(60, 6);
930        let props = RenderProps {
931            area,
932            is_focused: true,
933        };
934        let buffer = terminal
935            .draw(|frame| view.render(frame, props))
936            .unwrap()
937            .buffer
938            .clone();
939        let expected = Buffer::with_lines([
940            "┌Library Collections sorted by: Name───────────────────────┐",
941            "│──────────────────────────────────────────────────────────│",
942            "│▪ Collection 0                                            │",
943            "│                                                          │",
944            "│                                                          │",
945            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
946        ]);
947
948        assert_buffer_eq(&buffer, &expected);
949    }
950
951    #[test]
952    fn test_sort_keys() {
953        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
954        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
955
956        assert_eq!(view.props.sort_mode, NameSort::default());
957        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
958        assert_eq!(view.props.sort_mode, NameSort::default());
959        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
960        assert_eq!(view.props.sort_mode, NameSort::default());
961    }
962
963    #[test]
964    fn smoke_navigation() {
965        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
966        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
967
968        view.handle_key_event(KeyEvent::from(KeyCode::Up));
969        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
970        view.handle_key_event(KeyEvent::from(KeyCode::Down));
971        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
972        view.handle_key_event(KeyEvent::from(KeyCode::Left));
973        view.handle_key_event(KeyEvent::from(KeyCode::Right));
974    }
975
976    #[test]
977    fn test_actions() {
978        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
979        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
980
981        // need to render the view at least once to load the tree state
982        let (mut terminal, area) = setup_test_terminal(60, 9);
983        let props = RenderProps {
984            area,
985            is_focused: true,
986        };
987        terminal.draw(|frame| view.render(frame, props)).unwrap();
988
989        // first we need to navigate to the collection
990        view.handle_key_event(KeyEvent::from(KeyCode::Down));
991
992        // open the selected view
993        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
994        assert_eq!(
995            rx.blocking_recv().unwrap(),
996            Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
997        );
998    }
999
1000    #[test]
1001    fn test_mouse_event() {
1002        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1003        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
1004
1005        // need to render the view at least once to load the tree state
1006        let (mut terminal, area) = setup_test_terminal(60, 9);
1007        let props = RenderProps {
1008            area,
1009            is_focused: true,
1010        };
1011        let buffer = terminal
1012            .draw(|frame| view.render(frame, props))
1013            .unwrap()
1014            .buffer
1015            .clone();
1016        let expected = Buffer::with_lines([
1017            "┌Library Collections sorted by: Name───────────────────────┐",
1018            "│──────────────────────────────────────────────────────────│",
1019            "│▪ Collection 0                                            │",
1020            "│                                                          │",
1021            "│                                                          │",
1022            "│                                                          │",
1023            "│                                                          │",
1024            "│                                                          │",
1025            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1026        ]);
1027        assert_buffer_eq(&buffer, &expected);
1028
1029        // scroll down (selecting the collection)
1030        view.handle_mouse_event(
1031            MouseEvent {
1032                kind: MouseEventKind::ScrollDown,
1033                column: 2,
1034                row: 2,
1035                modifiers: KeyModifiers::empty(),
1036            },
1037            area,
1038        );
1039
1040        // click down the collection (opening it)
1041        view.handle_mouse_event(
1042            MouseEvent {
1043                kind: MouseEventKind::Down(MouseButton::Left),
1044                column: 2,
1045                row: 2,
1046                modifiers: KeyModifiers::empty(),
1047            },
1048            area,
1049        );
1050        assert_eq!(
1051            rx.blocking_recv().unwrap(),
1052            Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1053        );
1054        let buffer = terminal
1055            .draw(|frame| view.render(frame, props))
1056            .unwrap()
1057            .buffer
1058            .clone();
1059        assert_buffer_eq(&buffer, &expected);
1060
1061        // scroll up
1062        view.handle_mouse_event(
1063            MouseEvent {
1064                kind: MouseEventKind::ScrollUp,
1065                column: 2,
1066                row: 2,
1067                modifiers: KeyModifiers::empty(),
1068            },
1069            area,
1070        );
1071
1072        // click down on selected item
1073        view.handle_mouse_event(
1074            MouseEvent {
1075                kind: MouseEventKind::Down(MouseButton::Left),
1076                column: 2,
1077                row: 2,
1078                modifiers: KeyModifiers::empty(),
1079            },
1080            area,
1081        );
1082        assert_eq!(
1083            rx.blocking_recv().unwrap(),
1084            Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1085        );
1086
1087        // clicking on an empty area should clear the selection
1088        let mouse = MouseEvent {
1089            kind: MouseEventKind::Down(MouseButton::Left),
1090            column: 2,
1091            row: 3,
1092            modifiers: KeyModifiers::empty(),
1093        };
1094        view.handle_mouse_event(mouse, area);
1095        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1096        view.handle_mouse_event(mouse, area);
1097        assert_eq!(
1098            rx.try_recv(),
1099            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1100        );
1101    }
1102}