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::Collection;
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<[Collection]>,
330    sort_mode: NameSort<Collection>,
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 pretty_assertions::assert_eq;
503    use rstest::rstest;
504    use std::time::Duration;
505
506    #[rstest]
507    #[case(NameSort::default(), NameSort::default())]
508    fn test_sort_mode_next_prev(
509        #[case] mode: NameSort<Collection>,
510        #[case] expected: NameSort<Collection>,
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<Collection>, #[case] expected: &str) {
519        assert_eq!(mode.to_string(), expected);
520    }
521
522    #[rstest]
523    fn test_sort_collectionss() {
524        let mut songs = vec![
525            Collection {
526                id: Collection::generate_id(),
527                name: "C".into(),
528                song_count: 0,
529                runtime: Duration::from_secs(0),
530            },
531            Collection {
532                id: Collection::generate_id(),
533                name: "A".into(),
534                song_count: 0,
535                runtime: Duration::from_secs(0),
536            },
537            Collection {
538                id: Collection::generate_id(),
539                name: "B".into(),
540                song_count: 0,
541                runtime: Duration::from_secs(0),
542            },
543        ];
544
545        NameSort::default().sort_items(&mut songs);
546        assert_eq!(songs[0].name, "A");
547        assert_eq!(songs[1].name, "B");
548        assert_eq!(songs[2].name, "C");
549    }
550}
551
552#[cfg(test)]
553mod item_view_tests {
554    use super::*;
555    use crate::{
556        state::action::{AudioAction, PopupAction, QueueAction},
557        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
558        ui::{components::content_view::ActiveView, widgets::popups::PopupType},
559    };
560    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
561    use pretty_assertions::assert_eq;
562    use ratatui::buffer::Buffer;
563
564    #[test]
565    fn test_new() {
566        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
567        let state = state_with_everything();
568        let view = CollectionView::new(&state, tx);
569
570        assert_eq!(view.name(), "Collection View");
571        assert_eq!(
572            view.props,
573            Some(state.additional_view_data.collection.unwrap())
574        );
575    }
576
577    #[test]
578    fn test_move_with_state() {
579        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
580        let state = AppState::default();
581        let new_state = state_with_everything();
582        let view = CollectionView::new(&state, tx).move_with_state(&new_state);
583
584        assert_eq!(
585            view.props,
586            Some(new_state.additional_view_data.collection.unwrap())
587        );
588    }
589    #[test]
590    fn test_render_no_collection() {
591        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
592        let view = CollectionView::new(&AppState::default(), tx);
593
594        let (mut terminal, area) = setup_test_terminal(22, 3);
595        let props = RenderProps {
596            area,
597            is_focused: true,
598        };
599        let buffer = terminal
600            .draw(|frame| view.render(frame, props))
601            .unwrap()
602            .buffer
603            .clone();
604        #[rustfmt::skip]
605        let expected = Buffer::with_lines([
606            "┌Collection View─────┐",
607            "│No active collection│",
608            "└────────────────────┘",
609        ]);
610
611        assert_buffer_eq(&buffer, &expected);
612    }
613
614    #[test]
615    fn test_render() {
616        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
617        let view = CollectionView::new(&state_with_everything(), tx);
618
619        let (mut terminal, area) = setup_test_terminal(60, 9);
620        let props = RenderProps {
621            area,
622            is_focused: true,
623        };
624        let buffer = terminal
625            .draw(|frame| view.render(frame, props))
626            .unwrap()
627            .buffer
628            .clone();
629        let expected = Buffer::with_lines([
630            "┌Collection View sorted by: Artist─────────────────────────┐",
631            "│                       Collection 0                       │",
632            "│              Songs: 1  Duration: 00:03:00.00             │",
633            "│                                                          │",
634            "│q: add to queue | p: add to playlist──────────────────────│",
635            "│Performing operations on entire collection────────────────│",
636            "│☐ Test Song Test Artist                                   │",
637            "│s/S: change sort──────────────────────────────────────────│",
638            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
639        ]);
640
641        assert_buffer_eq(&buffer, &expected);
642    }
643
644    #[test]
645    fn test_render_with_checked() {
646        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
647        let mut view = CollectionView::new(&state_with_everything(), tx);
648        let (mut terminal, area) = setup_test_terminal(60, 9);
649        let props = RenderProps {
650            area,
651            is_focused: true,
652        };
653        let buffer = terminal
654            .draw(|frame| view.render(frame, props))
655            .unwrap()
656            .buffer
657            .clone();
658        let expected = Buffer::with_lines([
659            "┌Collection View sorted by: Artist─────────────────────────┐",
660            "│                       Collection 0                       │",
661            "│              Songs: 1  Duration: 00:03:00.00             │",
662            "│                                                          │",
663            "│q: add to queue | p: add to playlist──────────────────────│",
664            "│Performing operations on entire collection────────────────│",
665            "│☐ Test Song Test Artist                                   │",
666            "│s/S: change sort──────────────────────────────────────────│",
667            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
668        ]);
669        assert_buffer_eq(&buffer, &expected);
670
671        // select the album
672        view.handle_key_event(KeyEvent::from(KeyCode::Down));
673        view.handle_key_event(KeyEvent::from(KeyCode::Down));
674        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
675
676        let buffer = terminal
677            .draw(|frame| view.render(frame, props))
678            .unwrap()
679            .buffer
680            .clone();
681        let expected = Buffer::with_lines([
682            "┌Collection View sorted by: Artist─────────────────────────┐",
683            "│                       Collection 0                       │",
684            "│              Songs: 1  Duration: 00:03:00.00             │",
685            "│                                                          │",
686            "│q: add to queue | p: add to playlist──────────────────────│",
687            "│Performing operations on checked items────────────────────│",
688            "│☑ Test Song Test Artist                                   │",
689            "│s/S: change sort──────────────────────────────────────────│",
690            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
691        ]);
692
693        assert_buffer_eq(&buffer, &expected);
694    }
695
696    #[test]
697    fn smoke_navigation() {
698        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
699        let mut view = CollectionView::new(&state_with_everything(), tx);
700
701        view.handle_key_event(KeyEvent::from(KeyCode::Up));
702        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
703        view.handle_key_event(KeyEvent::from(KeyCode::Down));
704        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
705        view.handle_key_event(KeyEvent::from(KeyCode::Left));
706        view.handle_key_event(KeyEvent::from(KeyCode::Right));
707    }
708
709    #[test]
710    fn test_actions() {
711        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
712        let mut view = CollectionView::new(&state_with_everything(), tx);
713
714        // need to render the view at least once to load the tree state
715        let (mut terminal, area) = setup_test_terminal(60, 9);
716        let props = RenderProps {
717            area,
718            is_focused: true,
719        };
720        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
721
722        // we test the actions when:
723        // there are no checked items
724        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
725        assert_eq!(
726            rx.blocking_recv().unwrap(),
727            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
728                ("collection", item_id()).into()
729            ])))
730        );
731        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
732        assert_eq!(
733            rx.blocking_recv().unwrap(),
734            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
735                ("collection", item_id()).into()
736            ])))
737        );
738        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
739
740        // there are checked items
741        // first we need to select an item (the album)
742        view.handle_key_event(KeyEvent::from(KeyCode::Up));
743        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
744
745        // open the selected view
746        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
747        assert_eq!(
748            rx.blocking_recv().unwrap(),
749            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
750        );
751
752        // check the artist
753        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
754
755        // add to queue
756        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
757        assert_eq!(
758            rx.blocking_recv().unwrap(),
759            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
760                ("song", item_id()).into()
761            ])))
762        );
763
764        // add to collection
765        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
766        assert_eq!(
767            rx.blocking_recv().unwrap(),
768            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
769                ("song", item_id()).into()
770            ])))
771        );
772    }
773
774    #[test]
775    #[allow(clippy::too_many_lines)]
776    fn test_mouse_event() {
777        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
778        let mut view = CollectionView::new(&state_with_everything(), tx);
779
780        // need to render the view at least once to load the tree state
781        let (mut terminal, area) = setup_test_terminal(60, 9);
782        let props = RenderProps {
783            area,
784            is_focused: true,
785        };
786        let buffer = terminal
787            .draw(|frame| view.render(frame, props))
788            .unwrap()
789            .buffer
790            .clone();
791        let expected = Buffer::with_lines([
792            "┌Collection View sorted by: Artist─────────────────────────┐",
793            "│                       Collection 0                       │",
794            "│              Songs: 1  Duration: 00:03:00.00             │",
795            "│                                                          │",
796            "│q: add to queue | p: add to playlist──────────────────────│",
797            "│Performing operations on entire collection────────────────│",
798            "│☐ Test Song Test Artist                                   │",
799            "│s/S: change sort──────────────────────────────────────────│",
800            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
801        ]);
802        assert_buffer_eq(&buffer, &expected);
803
804        // click on the song (selecting it)
805        view.handle_mouse_event(
806            MouseEvent {
807                kind: MouseEventKind::Down(MouseButton::Left),
808                column: 2,
809                row: 6,
810                modifiers: KeyModifiers::empty(),
811            },
812            area,
813        );
814        let buffer = terminal
815            .draw(|frame| view.render(frame, props))
816            .unwrap()
817            .buffer
818            .clone();
819        let expected = Buffer::with_lines([
820            "┌Collection View sorted by: Artist─────────────────────────┐",
821            "│                       Collection 0                       │",
822            "│              Songs: 1  Duration: 00:03:00.00             │",
823            "│                                                          │",
824            "│q: add to queue | p: add to playlist──────────────────────│",
825            "│Performing operations on checked items────────────────────│",
826            "│☑ Test Song Test Artist                                   │",
827            "│s/S: change sort──────────────────────────────────────────│",
828            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
829        ]);
830        assert_buffer_eq(&buffer, &expected);
831
832        // click down the song (opening it)
833        view.handle_mouse_event(
834            MouseEvent {
835                kind: MouseEventKind::Down(MouseButton::Left),
836                column: 2,
837                row: 6,
838                modifiers: KeyModifiers::empty(),
839            },
840            area,
841        );
842        assert_eq!(
843            rx.blocking_recv().unwrap(),
844            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
845        );
846        let expected = Buffer::with_lines([
847            "┌Collection View sorted by: Artist─────────────────────────┐",
848            "│                       Collection 0                       │",
849            "│              Songs: 1  Duration: 00:03:00.00             │",
850            "│                                                          │",
851            "│q: add to queue | p: add to playlist──────────────────────│",
852            "│Performing operations on entire collection────────────────│",
853            "│☐ Test Song Test Artist                                   │",
854            "│s/S: change sort──────────────────────────────────────────│",
855            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
856        ]);
857        let buffer = terminal
858            .draw(|frame| view.render(frame, props))
859            .unwrap()
860            .buffer
861            .clone();
862        assert_buffer_eq(&buffer, &expected);
863
864        // scroll down
865        view.handle_mouse_event(
866            MouseEvent {
867                kind: MouseEventKind::ScrollDown,
868                column: 2,
869                row: 6,
870                modifiers: KeyModifiers::empty(),
871            },
872            area,
873        );
874        let buffer = terminal
875            .draw(|frame| view.render(frame, props))
876            .unwrap()
877            .buffer
878            .clone();
879        assert_buffer_eq(&buffer, &expected);
880        // scroll up
881        view.handle_mouse_event(
882            MouseEvent {
883                kind: MouseEventKind::ScrollUp,
884                column: 2,
885                row: 6,
886                modifiers: KeyModifiers::empty(),
887            },
888            area,
889        );
890        let buffer = terminal
891            .draw(|frame| view.render(frame, props))
892            .unwrap()
893            .buffer
894            .clone();
895        assert_buffer_eq(&buffer, &expected);
896    }
897}
898
899#[cfg(test)]
900mod library_view_tests {
901    use super::*;
902    use crate::{
903        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
904        ui::components::content_view::ActiveView,
905    };
906    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
907    use pretty_assertions::assert_eq;
908    use ratatui::buffer::Buffer;
909
910    #[test]
911    fn test_new() {
912        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
913        let state = state_with_everything();
914        let view = LibraryCollectionsView::new(&state, tx);
915
916        assert_eq!(view.name(), "Library Collections View");
917        assert_eq!(view.props.collections, state.library.collections);
918    }
919
920    #[test]
921    fn test_move_with_state() {
922        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
923        let state = AppState::default();
924        let new_state = state_with_everything();
925        let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
926
927        assert_eq!(view.props.collections, new_state.library.collections);
928    }
929
930    #[test]
931    fn test_render() {
932        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
933        let view = LibraryCollectionsView::new(&state_with_everything(), tx);
934
935        let (mut terminal, area) = setup_test_terminal(60, 6);
936        let props = RenderProps {
937            area,
938            is_focused: true,
939        };
940        let buffer = terminal
941            .draw(|frame| view.render(frame, props))
942            .unwrap()
943            .buffer
944            .clone();
945        let expected = Buffer::with_lines([
946            "┌Library Collections sorted by: Name───────────────────────┐",
947            "│──────────────────────────────────────────────────────────│",
948            "│▪ Collection 0                                            │",
949            "│                                                          │",
950            "│                                                          │",
951            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
952        ]);
953
954        assert_buffer_eq(&buffer, &expected);
955    }
956
957    #[test]
958    fn test_sort_keys() {
959        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
960        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
961
962        assert_eq!(view.props.sort_mode, NameSort::default());
963        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
964        assert_eq!(view.props.sort_mode, NameSort::default());
965        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
966        assert_eq!(view.props.sort_mode, NameSort::default());
967    }
968
969    #[test]
970    fn smoke_navigation() {
971        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
972        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
973
974        view.handle_key_event(KeyEvent::from(KeyCode::Up));
975        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
976        view.handle_key_event(KeyEvent::from(KeyCode::Down));
977        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
978        view.handle_key_event(KeyEvent::from(KeyCode::Left));
979        view.handle_key_event(KeyEvent::from(KeyCode::Right));
980    }
981
982    #[test]
983    fn test_actions() {
984        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
985        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
986
987        // need to render the view at least once to load the tree state
988        let (mut terminal, area) = setup_test_terminal(60, 9);
989        let props = RenderProps {
990            area,
991            is_focused: true,
992        };
993        terminal.draw(|frame| view.render(frame, props)).unwrap();
994
995        // first we need to navigate to the collection
996        view.handle_key_event(KeyEvent::from(KeyCode::Down));
997
998        // open the selected view
999        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1000        assert_eq!(
1001            rx.blocking_recv().unwrap(),
1002            Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1003        );
1004    }
1005
1006    #[test]
1007    fn test_mouse_event() {
1008        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1009        let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
1010
1011        // need to render the view at least once to load the tree state
1012        let (mut terminal, area) = setup_test_terminal(60, 9);
1013        let props = RenderProps {
1014            area,
1015            is_focused: true,
1016        };
1017        let buffer = terminal
1018            .draw(|frame| view.render(frame, props))
1019            .unwrap()
1020            .buffer
1021            .clone();
1022        let expected = Buffer::with_lines([
1023            "┌Library Collections sorted by: Name───────────────────────┐",
1024            "│──────────────────────────────────────────────────────────│",
1025            "│▪ Collection 0                                            │",
1026            "│                                                          │",
1027            "│                                                          │",
1028            "│                                                          │",
1029            "│                                                          │",
1030            "│                                                          │",
1031            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1032        ]);
1033        assert_buffer_eq(&buffer, &expected);
1034
1035        // scroll down (selecting the collection)
1036        view.handle_mouse_event(
1037            MouseEvent {
1038                kind: MouseEventKind::ScrollDown,
1039                column: 2,
1040                row: 2,
1041                modifiers: KeyModifiers::empty(),
1042            },
1043            area,
1044        );
1045
1046        // click down the collection (opening it)
1047        view.handle_mouse_event(
1048            MouseEvent {
1049                kind: MouseEventKind::Down(MouseButton::Left),
1050                column: 2,
1051                row: 2,
1052                modifiers: KeyModifiers::empty(),
1053            },
1054            area,
1055        );
1056        assert_eq!(
1057            rx.blocking_recv().unwrap(),
1058            Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1059        );
1060        let buffer = terminal
1061            .draw(|frame| view.render(frame, props))
1062            .unwrap()
1063            .buffer
1064            .clone();
1065        assert_buffer_eq(&buffer, &expected);
1066
1067        // scroll up
1068        view.handle_mouse_event(
1069            MouseEvent {
1070                kind: MouseEventKind::ScrollUp,
1071                column: 2,
1072                row: 2,
1073                modifiers: KeyModifiers::empty(),
1074            },
1075            area,
1076        );
1077
1078        // click down on selected item
1079        view.handle_mouse_event(
1080            MouseEvent {
1081                kind: MouseEventKind::Down(MouseButton::Left),
1082                column: 2,
1083                row: 2,
1084                modifiers: KeyModifiers::empty(),
1085            },
1086            area,
1087        );
1088        assert_eq!(
1089            rx.blocking_recv().unwrap(),
1090            Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1091        );
1092
1093        // clicking on an empty area should clear the selection
1094        let mouse = MouseEvent {
1095            kind: MouseEventKind::Down(MouseButton::Left),
1096            column: 2,
1097            row: 3,
1098            modifiers: KeyModifiers::empty(),
1099        };
1100        view.handle_mouse_event(mouse, area);
1101        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1102        view.handle_mouse_event(mouse, area);
1103        assert_eq!(
1104            rx.try_recv(),
1105            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1106        );
1107    }
1108}