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