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