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