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