Skip to main content

mecomp_tui/ui/components/content_view/views/
playlist.rs

1//! Views for both a single playlist, and the library of playlists.
2use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
3use mecomp_prost::{PlaylistBrief, SongBrief};
4use ratatui::{
5    layout::{Constraint, Direction, Layout, Margin, Offset, Position, Rect},
6    style::Style,
7    text::{Line, Span},
8    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
9};
10use tokio::sync::mpsc::UnboundedSender;
11
12use crate::{
13    state::action::{Action, LibraryAction, ViewAction},
14    ui::{
15        AppState,
16        colors::{BORDER_FOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, border_color},
17        components::{
18            Component, ComponentRender, RenderProps,
19            content_view::{ActiveView, views::generic::SortableItemView},
20        },
21        widgets::{
22            input_box::{InputBox, InputBoxState},
23            tree::{CheckTree, state::CheckTreeState},
24        },
25    },
26};
27
28use super::{
29    PlaylistViewProps,
30    checktree_utils::create_playlist_tree_leaf,
31    sort_mode::{NameSort, SongSort},
32    traits::SortMode,
33};
34
35#[allow(clippy::module_name_repetitions)]
36pub type PlaylistView = SortableItemView<PlaylistViewProps, SongSort, SongBrief>;
37
38pub struct LibraryPlaylistsView {
39    /// Action Sender
40    pub action_tx: UnboundedSender<Action>,
41    /// Mapped Props from state
42    props: Props,
43    /// tree state
44    tree_state: CheckTreeState<String>,
45    /// Playlist Name Input Box
46    input_box: InputBoxState,
47    /// Is the input box visible
48    input_box_visible: bool,
49}
50
51#[derive(Debug)]
52pub struct Props {
53    pub playlists: Vec<PlaylistBrief>,
54    sort_mode: NameSort<PlaylistBrief>,
55}
56
57impl From<&AppState> for Props {
58    fn from(state: &AppState) -> Self {
59        let mut playlists = state.library.playlists.clone();
60        let sort_mode = NameSort::default();
61        sort_mode.sort_items(&mut playlists);
62        Self {
63            playlists,
64            sort_mode,
65        }
66    }
67}
68
69impl Component for LibraryPlaylistsView {
70    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
71    where
72        Self: Sized,
73    {
74        Self {
75            input_box: InputBoxState::new(),
76            input_box_visible: false,
77            action_tx,
78            props: Props::from(state),
79            tree_state: CheckTreeState::default(),
80        }
81    }
82
83    fn move_with_state(self, state: &AppState) -> Self
84    where
85        Self: Sized,
86    {
87        let tree_state = if state.active_view == ActiveView::Playlists {
88            self.tree_state
89        } else {
90            CheckTreeState::default()
91        };
92
93        Self {
94            props: Props::from(state),
95            tree_state,
96            ..self
97        }
98    }
99
100    fn name(&self) -> &'static str {
101        "Library Playlists View"
102    }
103
104    fn handle_key_event(&mut self, key: KeyEvent) {
105        // this page has 2 distinct "modes",
106        // one for navigating the tree when the input box is not visible
107        // one for interacting with the input box when it is visible
108        if self.input_box_visible {
109            match key.code {
110                // if the user presses Enter, we try to create a new playlist with the given name
111                KeyCode::Enter => {
112                    let name = self.input_box.text().to_string();
113                    if !name.is_empty() {
114                        self.action_tx
115                            .send(Action::Library(LibraryAction::CreatePlaylist(name)))
116                            .unwrap();
117                    }
118                    self.input_box.clear();
119                    self.input_box_visible = false;
120                }
121                // defer to the input box
122                _ => {
123                    self.input_box.handle_key_event(key);
124                }
125            }
126        } else {
127            match key.code {
128                // arrow keys
129                KeyCode::PageUp => {
130                    self.tree_state.select_relative(|current| {
131                        current.map_or(self.props.playlists.len() - 1, |c| c.saturating_sub(10))
132                    });
133                }
134                KeyCode::Up => {
135                    self.tree_state.key_up();
136                }
137                KeyCode::PageDown => {
138                    self.tree_state
139                        .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
140                }
141                KeyCode::Down => {
142                    self.tree_state.key_down();
143                }
144                KeyCode::Left => {
145                    self.tree_state.key_left();
146                }
147                KeyCode::Right => {
148                    self.tree_state.key_right();
149                }
150                // Enter key opens selected view
151                KeyCode::Enter => {
152                    if self.tree_state.toggle_selected() {
153                        let things = self.tree_state.get_selected_thing();
154
155                        if let Some(thing) = things {
156                            self.action_tx
157                                .send(Action::ActiveView(ViewAction::Set(thing.into())))
158                                .unwrap();
159                        }
160                    }
161                }
162                // Change sort mode
163                KeyCode::Char('s') => {
164                    self.props.sort_mode = self.props.sort_mode.next();
165                    self.props.sort_mode.sort_items(&mut self.props.playlists);
166                    self.tree_state.scroll_selected_into_view();
167                }
168                KeyCode::Char('S') => {
169                    self.props.sort_mode = self.props.sort_mode.prev();
170                    self.props.sort_mode.sort_items(&mut self.props.playlists);
171                    self.tree_state.scroll_selected_into_view();
172                }
173                // "n" key to create a new playlist
174                KeyCode::Char('n') => {
175                    self.input_box_visible = true;
176                }
177                // "d" key to delete the selected playlist
178                KeyCode::Char('d') => {
179                    let things = self.tree_state.get_selected_thing();
180
181                    if let Some(thing) = things {
182                        self.action_tx
183                            .send(Action::Library(LibraryAction::RemovePlaylist(thing.ulid())))
184                            .unwrap();
185                    }
186                }
187                _ => {}
188            }
189        }
190    }
191
192    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
193        let MouseEvent {
194            kind, column, row, ..
195        } = mouse;
196        let mouse_position = Position::new(column, row);
197
198        // adjust the area to account for the border
199        let area = area.inner(Margin::new(1, 1));
200
201        if self.input_box_visible {
202            let [input_box_area, content_area] = lib_split_area(area);
203            let content_area = Rect {
204                y: content_area.y + 1,
205                height: content_area.height - 1,
206                ..content_area
207            };
208            if input_box_area.contains(mouse_position) {
209                self.input_box.handle_mouse_event(mouse, input_box_area);
210            } else if content_area.contains(mouse_position)
211                && kind == MouseEventKind::Down(MouseButton::Left)
212            {
213                self.input_box_visible = false;
214            }
215        } else {
216            let area = Rect {
217                y: area.y + 1,
218                height: area.height - 1,
219                ..area
220            };
221
222            let result = self.tree_state.handle_mouse_event(mouse, area, true);
223            if let Some(action) = result {
224                self.action_tx.send(action).unwrap();
225            }
226        }
227    }
228}
229
230fn lib_split_area(area: Rect) -> [Rect; 2] {
231    let [input_box_area, content_area] = *Layout::default()
232        .direction(Direction::Vertical)
233        .constraints([Constraint::Length(3), Constraint::Min(1)])
234        .split(area)
235    else {
236        panic!("Failed to split library playlists view area");
237    };
238    [input_box_area, content_area]
239}
240
241impl ComponentRender<RenderProps> for LibraryPlaylistsView {
242    fn render_border(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
243        let border_style = Style::default().fg(border_color(props.is_focused).into());
244
245        // render primary border
246        let border_title_bottom = if self.input_box_visible {
247            ""
248        } else {
249            " \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort"
250        };
251        let border = Block::bordered()
252            .title_top(Line::from(vec![
253                Span::styled("Library Playlists".to_string(), Style::default().bold()),
254                Span::raw(" sorted by: "),
255                Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
256            ]))
257            .title_bottom(border_title_bottom)
258            .border_style(border_style);
259        let content_area = border.inner(props.area);
260        frame.render_widget(border, props.area);
261
262        // render input box (if visible)
263        let content_area = if self.input_box_visible {
264            // split content area to make room for the input box
265            let [input_box_area, content_area] = lib_split_area(content_area);
266
267            // render the input box
268            let input_box = InputBox::new()
269                .text_color((*TEXT_HIGHLIGHT_ALT).into())
270                .border(
271                    Block::bordered()
272                        .title("Enter Name:")
273                        .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
274                );
275            frame.render_stateful_widget(input_box, input_box_area, &mut self.input_box);
276            if self.input_box_visible {
277                let position = input_box_area + self.input_box.cursor_offset() + Offset::new(1, 1);
278                frame.set_cursor_position(position);
279            }
280
281            content_area
282        } else {
283            content_area
284        };
285
286        // draw additional border around content area to display additional instructions
287        let border = Block::new()
288            .borders(Borders::TOP)
289            .title_top(if self.input_box_visible {
290                " \u{23CE} : Create (cancel if empty)"
291            } else {
292                "n: new playlist | d: delete playlist"
293            })
294            .border_style(border_style);
295        let area = border.inner(content_area);
296        frame.render_widget(border, content_area);
297
298        RenderProps { area, ..props }
299    }
300
301    fn render_content(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
302        // create a tree for the playlists
303        let items = self
304            .props
305            .playlists
306            .iter()
307            .map(create_playlist_tree_leaf)
308            .collect::<Vec<_>>();
309
310        // render the playlists
311        frame.render_stateful_widget(
312            CheckTree::new(&items)
313                .unwrap()
314                .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
315                // we want this to be rendered like a normal tree, not a check tree, so we don't show the checkboxes
316                .node_unchecked_symbol("▪ ")
317                .node_checked_symbol("▪ ")
318                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
319            props.area,
320            &mut self.tree_state,
321        );
322    }
323}
324
325#[cfg(test)]
326mod sort_mode_tests {
327    use super::*;
328    use mecomp_prost::RecordId;
329    use pretty_assertions::assert_eq;
330    use rstest::rstest;
331
332    #[rstest]
333    #[case(NameSort::default(), NameSort::default())]
334    fn test_sort_mode_next_prev(
335        #[case] mode: NameSort<PlaylistBrief>,
336        #[case] expected: NameSort<PlaylistBrief>,
337    ) {
338        assert_eq!(mode.next(), expected);
339        assert_eq!(mode.next().prev(), mode);
340    }
341
342    #[rstest]
343    #[case(NameSort::default(), "Name")]
344    fn test_sort_mode_display(#[case] mode: NameSort<PlaylistBrief>, #[case] expected: &str) {
345        assert_eq!(mode.to_string(), expected);
346    }
347
348    #[rstest]
349    fn test_sort_items() {
350        let mut songs = vec![
351            PlaylistBrief {
352                id: RecordId::new("playlist", "playlist1"),
353                name: "C".into(),
354            },
355            PlaylistBrief {
356                id: RecordId::new("playlist", "playlist2"),
357                name: "A".into(),
358            },
359            PlaylistBrief {
360                id: RecordId::new("playlist", "playlist3"),
361                name: "B".into(),
362            },
363        ];
364
365        NameSort::default().sort_items(&mut songs);
366        assert_eq!(songs[0].name, "A");
367        assert_eq!(songs[1].name, "B");
368        assert_eq!(songs[2].name, "C");
369    }
370}
371
372#[cfg(test)]
373mod item_view_tests {
374    use super::*;
375    use crate::{
376        state::action::{AudioAction, PopupAction, QueueAction},
377        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
378        ui::{components::content_view::ActiveView, widgets::popups::PopupType},
379    };
380    use crossterm::event::KeyModifiers;
381    use pretty_assertions::assert_eq;
382    use ratatui::buffer::Buffer;
383
384    #[test]
385    fn test_new() {
386        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
387        let state = state_with_everything();
388        let view = PlaylistView::new(&state, tx).item_view;
389
390        assert_eq!(view.name(), "Playlist View");
391        assert_eq!(
392            view.props,
393            Some(state.additional_view_data.playlist.unwrap())
394        );
395    }
396
397    #[test]
398    fn test_move_with_state() {
399        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
400        let state = AppState::default();
401        let new_state = state_with_everything();
402        let view = PlaylistView::new(&state, tx)
403            .move_with_state(&new_state)
404            .item_view;
405
406        assert_eq!(
407            view.props,
408            Some(new_state.additional_view_data.playlist.unwrap())
409        );
410    }
411    #[test]
412    fn test_render_no_playlist() {
413        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
414        let mut view = PlaylistView::new(&AppState::default(), tx);
415
416        let (mut terminal, area) = setup_test_terminal(20, 3);
417        let props = RenderProps {
418            area,
419            is_focused: true,
420        };
421        let buffer = terminal
422            .draw(|frame| view.render(frame, props))
423            .unwrap()
424            .buffer
425            .clone();
426        #[rustfmt::skip]
427        let expected = Buffer::with_lines([
428            "┌Playlist View─────┐",
429            "│No active playlist│",
430            "└──────────────────┘",
431        ]);
432
433        assert_buffer_eq(&buffer, &expected);
434    }
435
436    #[test]
437    fn test_render() {
438        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
439        let mut view = PlaylistView::new(&state_with_everything(), tx);
440
441        let (mut terminal, area) = setup_test_terminal(60, 9);
442        let props = RenderProps {
443            area,
444            is_focused: true,
445        };
446        let buffer = terminal
447            .draw(|frame| view.render(frame, props))
448            .unwrap()
449            .buffer
450            .clone();
451        let expected = Buffer::with_lines([
452            "┌Playlist View sorted by: Artist───────────────────────────┐",
453            "│                       Test Playlist                      │",
454            "│              Songs: 1  Duration: 00:03:00.00             │",
455            "│                                                          │",
456            "│q: add to queue | r: start radio | p: add to playlist─────│",
457            "│Performing operations on entire playlist──────────────────│",
458            "│☐ Test Song Test Artist                                   │",
459            "│s/S: sort | d: remove selected | e: edit──────────────────│",
460            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
461        ]);
462
463        assert_buffer_eq(&buffer, &expected);
464    }
465
466    #[test]
467    fn test_render_with_checked() {
468        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
469        let mut view = PlaylistView::new(&state_with_everything(), tx);
470        let (mut terminal, area) = setup_test_terminal(60, 9);
471        let props = RenderProps {
472            area,
473            is_focused: true,
474        };
475        let buffer = terminal
476            .draw(|frame| view.render(frame, props))
477            .unwrap()
478            .buffer
479            .clone();
480        let expected = Buffer::with_lines([
481            "┌Playlist View sorted by: Artist───────────────────────────┐",
482            "│                       Test Playlist                      │",
483            "│              Songs: 1  Duration: 00:03:00.00             │",
484            "│                                                          │",
485            "│q: add to queue | r: start radio | p: add to playlist─────│",
486            "│Performing operations on entire playlist──────────────────│",
487            "│☐ Test Song Test Artist                                   │",
488            "│s/S: sort | d: remove selected | e: edit──────────────────│",
489            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
490        ]);
491        assert_buffer_eq(&buffer, &expected);
492
493        // select the song
494        view.handle_key_event(KeyEvent::from(KeyCode::Down));
495        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
496
497        let buffer = terminal
498            .draw(|frame| view.render(frame, props))
499            .unwrap()
500            .buffer
501            .clone();
502        let expected = Buffer::with_lines([
503            "┌Playlist View sorted by: Artist───────────────────────────┐",
504            "│                       Test Playlist                      │",
505            "│              Songs: 1  Duration: 00:03:00.00             │",
506            "│                                                          │",
507            "│q: add to queue | r: start radio | p: add to playlist─────│",
508            "│Performing operations on checked items────────────────────│",
509            "│☑ Test Song Test Artist                                   │",
510            "│s/S: sort | d: remove selected | e: edit──────────────────│",
511            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
512        ]);
513
514        assert_buffer_eq(&buffer, &expected);
515    }
516
517    #[test]
518    fn smoke_navigation_and_sort() {
519        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
520        let mut view = PlaylistView::new(&state_with_everything(), tx);
521
522        view.handle_key_event(KeyEvent::from(KeyCode::Up));
523        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
524        view.handle_key_event(KeyEvent::from(KeyCode::Down));
525        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
526        view.handle_key_event(KeyEvent::from(KeyCode::Left));
527        view.handle_key_event(KeyEvent::from(KeyCode::Right));
528        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
529        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
530    }
531
532    #[test]
533    fn test_actions() {
534        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
535        let mut view = PlaylistView::new(&state_with_everything(), tx);
536
537        // need to render the view at least once to load the tree state
538        let (mut terminal, area) = setup_test_terminal(60, 9);
539        let props = RenderProps {
540            area,
541            is_focused: true,
542        };
543        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
544
545        // we test the actions when:
546        // there are no checked items
547        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
548        assert_eq!(
549            rx.blocking_recv().unwrap(),
550            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
551                ("playlist", item_id()).into()
552            ])))
553        );
554        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
555        assert_eq!(
556            rx.blocking_recv().unwrap(),
557            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
558                ("playlist", item_id()).into()
559            ],)))
560        );
561        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
562        assert_eq!(
563            rx.blocking_recv().unwrap(),
564            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
565                ("playlist", item_id()).into()
566            ])))
567        );
568        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
569
570        // there are checked items
571        // first we need to select an item
572        view.handle_key_event(KeyEvent::from(KeyCode::Up));
573        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
574
575        // open the selected view
576        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
577        assert_eq!(
578            rx.blocking_recv().unwrap(),
579            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
580        );
581
582        // check the artist
583        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
584
585        // add to queue
586        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
587        assert_eq!(
588            rx.blocking_recv().unwrap(),
589            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
590                ("song", item_id()).into()
591            ])))
592        );
593
594        // start radio
595        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
596        assert_eq!(
597            rx.blocking_recv().unwrap(),
598            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
599                ("song", item_id()).into()
600            ],)))
601        );
602
603        // add to playlist
604        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
605        assert_eq!(
606            rx.blocking_recv().unwrap(),
607            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
608                ("song", item_id()).into()
609            ])))
610        );
611
612        // remove from playlist
613        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
614        assert_eq!(
615            rx.blocking_recv().unwrap(),
616            Action::Library(LibraryAction::RemoveSongsFromPlaylist(
617                item_id(),
618                vec![("song", item_id()).into()]
619            ))
620        );
621    }
622
623    #[test]
624    #[allow(clippy::too_many_lines)]
625    fn test_mouse_event() {
626        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
627        let mut view = PlaylistView::new(&state_with_everything(), tx);
628
629        // need to render the view at least once to load the tree state
630        let (mut terminal, area) = setup_test_terminal(60, 9);
631        let props = RenderProps {
632            area,
633            is_focused: true,
634        };
635        let buffer = terminal
636            .draw(|frame| view.render(frame, props))
637            .unwrap()
638            .buffer
639            .clone();
640        let expected = Buffer::with_lines([
641            "┌Playlist View sorted by: Artist───────────────────────────┐",
642            "│                       Test Playlist                      │",
643            "│              Songs: 1  Duration: 00:03:00.00             │",
644            "│                                                          │",
645            "│q: add to queue | r: start radio | p: add to playlist─────│",
646            "│Performing operations on entire playlist──────────────────│",
647            "│☐ Test Song Test Artist                                   │",
648            "│s/S: sort | d: remove selected | e: edit──────────────────│",
649            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
650        ]);
651        assert_buffer_eq(&buffer, &expected);
652
653        // click on the song (selecting it)
654        view.handle_mouse_event(
655            MouseEvent {
656                kind: MouseEventKind::Down(MouseButton::Left),
657                column: 2,
658                row: 6,
659                modifiers: KeyModifiers::empty(),
660            },
661            area,
662        );
663        let buffer = terminal
664            .draw(|frame| view.render(frame, props))
665            .unwrap()
666            .buffer
667            .clone();
668        let expected = Buffer::with_lines([
669            "┌Playlist View sorted by: Artist───────────────────────────┐",
670            "│                       Test Playlist                      │",
671            "│              Songs: 1  Duration: 00:03:00.00             │",
672            "│                                                          │",
673            "│q: add to queue | r: start radio | p: add to playlist─────│",
674            "│Performing operations on checked items────────────────────│",
675            "│☑ Test Song Test Artist                                   │",
676            "│s/S: sort | d: remove selected | e: edit──────────────────│",
677            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
678        ]);
679        assert_buffer_eq(&buffer, &expected);
680
681        // click down the song (unselecting it)
682        view.handle_mouse_event(
683            MouseEvent {
684                kind: MouseEventKind::Down(MouseButton::Left),
685                column: 2,
686                row: 6,
687                modifiers: KeyModifiers::empty(),
688            },
689            area,
690        );
691        let expected = Buffer::with_lines([
692            "┌Playlist View sorted by: Artist───────────────────────────┐",
693            "│                       Test Playlist                      │",
694            "│              Songs: 1  Duration: 00:03:00.00             │",
695            "│                                                          │",
696            "│q: add to queue | r: start radio | p: add to playlist─────│",
697            "│Performing operations on entire playlist──────────────────│",
698            "│☐ Test Song Test Artist                                   │",
699            "│s/S: sort | d: remove selected | e: edit──────────────────│",
700            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
701        ]);
702        let buffer = terminal
703            .draw(|frame| view.render(frame, props))
704            .unwrap()
705            .buffer
706            .clone();
707        assert_buffer_eq(&buffer, &expected);
708        // ctrl-click on the song (opening it)
709        for _ in 0..2 {
710            view.handle_mouse_event(
711                MouseEvent {
712                    kind: MouseEventKind::Down(MouseButton::Left),
713                    column: 2,
714                    row: 6,
715                    modifiers: KeyModifiers::CONTROL,
716                },
717                area,
718            );
719            assert_eq!(
720                rx.blocking_recv().unwrap(),
721                Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
722            );
723        }
724
725        // scroll down
726        view.handle_mouse_event(
727            MouseEvent {
728                kind: MouseEventKind::ScrollDown,
729                column: 2,
730                row: 6,
731                modifiers: KeyModifiers::empty(),
732            },
733            area,
734        );
735        let buffer = terminal
736            .draw(|frame| view.render(frame, props))
737            .unwrap()
738            .buffer
739            .clone();
740        assert_buffer_eq(&buffer, &expected);
741        // scroll up
742        view.handle_mouse_event(
743            MouseEvent {
744                kind: MouseEventKind::ScrollUp,
745                column: 2,
746                row: 7,
747                modifiers: KeyModifiers::empty(),
748            },
749            area,
750        );
751        let buffer = terminal
752            .draw(|frame| view.render(frame, props))
753            .unwrap()
754            .buffer
755            .clone();
756        assert_buffer_eq(&buffer, &expected);
757    }
758}
759
760#[cfg(test)]
761mod library_view_tests {
762    use super::*;
763    use crate::{
764        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
765        ui::components::content_view::ActiveView,
766    };
767    use crossterm::event::KeyModifiers;
768    use pretty_assertions::assert_eq;
769    use ratatui::buffer::Buffer;
770
771    #[test]
772    fn test_new() {
773        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
774        let state = state_with_everything();
775        let view = LibraryPlaylistsView::new(&state, tx);
776
777        assert_eq!(view.name(), "Library Playlists View");
778        assert_eq!(view.props.playlists, state.library.playlists);
779    }
780
781    #[test]
782    fn test_move_with_state() {
783        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
784        let state = AppState::default();
785        let new_state = state_with_everything();
786        let view = LibraryPlaylistsView::new(&state, tx).move_with_state(&new_state);
787
788        assert_eq!(view.props.playlists, new_state.library.playlists);
789    }
790
791    #[test]
792    fn test_render() {
793        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
794        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
795
796        let (mut terminal, area) = setup_test_terminal(60, 6);
797        let props = RenderProps {
798            area,
799            is_focused: true,
800        };
801        let buffer = terminal
802            .draw(|frame| view.render(frame, props))
803            .unwrap()
804            .buffer
805            .clone();
806        let expected = Buffer::with_lines([
807            "┌Library Playlists sorted by: Name─────────────────────────┐",
808            "│n: new playlist | d: delete playlist──────────────────────│",
809            "│▪ Test Playlist                                           │",
810            "│                                                          │",
811            "│                                                          │",
812            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
813        ]);
814
815        assert_buffer_eq(&buffer, &expected);
816    }
817
818    #[test]
819    fn test_render_input_box() {
820        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
821        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
822
823        // open the input box
824        view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
825
826        let (mut terminal, area) = setup_test_terminal(60, 7);
827        let props = RenderProps {
828            area,
829            is_focused: true,
830        };
831        let buffer = terminal
832            .draw(|frame| view.render(frame, props))
833            .unwrap()
834            .buffer
835            .clone();
836        let expected = Buffer::with_lines([
837            "┌Library Playlists sorted by: Name─────────────────────────┐",
838            "│┌Enter Name:─────────────────────────────────────────────┐│",
839            "││                                                        ││",
840            "│└────────────────────────────────────────────────────────┘│",
841            "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
842            "│▪ Test Playlist                                           │",
843            "└──────────────────────────────────────────────────────────┘",
844        ]);
845
846        assert_buffer_eq(&buffer, &expected);
847    }
848
849    #[test]
850    fn test_sort_keys() {
851        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
852        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
853
854        assert_eq!(view.props.sort_mode, NameSort::default());
855        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
856        assert_eq!(view.props.sort_mode, NameSort::default());
857        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
858        assert_eq!(view.props.sort_mode, NameSort::default());
859    }
860
861    #[test]
862    fn smoke_navigation() {
863        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
864        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
865
866        view.handle_key_event(KeyEvent::from(KeyCode::Up));
867        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
868        view.handle_key_event(KeyEvent::from(KeyCode::Down));
869        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
870        view.handle_key_event(KeyEvent::from(KeyCode::Left));
871        view.handle_key_event(KeyEvent::from(KeyCode::Right));
872    }
873
874    #[test]
875    fn test_actions() {
876        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
877        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
878
879        // need to render the view at least once to load the tree state
880        let (mut terminal, area) = setup_test_terminal(60, 9);
881        let props = RenderProps {
882            area,
883            is_focused: true,
884        };
885        terminal.draw(|frame| view.render(frame, props)).unwrap();
886
887        // first we need to navigate to the playlist
888        view.handle_key_event(KeyEvent::from(KeyCode::Down));
889
890        // open the selected view
891        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
892        assert_eq!(
893            rx.blocking_recv().unwrap(),
894            Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
895        );
896
897        // new playlist
898        view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
899        assert_eq!(view.input_box_visible, true);
900        view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
901        view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
902        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
903        assert_eq!(view.input_box_visible, false);
904        assert_eq!(
905            rx.blocking_recv().unwrap(),
906            Action::Library(LibraryAction::CreatePlaylist("ab".to_string()))
907        );
908
909        // delete playlist
910        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
911        assert_eq!(
912            rx.blocking_recv().unwrap(),
913            Action::Library(LibraryAction::RemovePlaylist(item_id()))
914        );
915    }
916
917    #[test]
918    fn test_mouse_event() {
919        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
920        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
921
922        // need to render the view at least once to load the tree state
923        let (mut terminal, area) = setup_test_terminal(60, 9);
924        let props = RenderProps {
925            area,
926            is_focused: true,
927        };
928        let buffer = terminal
929            .draw(|frame| view.render(frame, props))
930            .unwrap()
931            .buffer
932            .clone();
933        let expected = Buffer::with_lines([
934            "┌Library Playlists sorted by: Name─────────────────────────┐",
935            "│n: new playlist | d: delete playlist──────────────────────│",
936            "│▪ Test Playlist                                           │",
937            "│                                                          │",
938            "│                                                          │",
939            "│                                                          │",
940            "│                                                          │",
941            "│                                                          │",
942            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
943        ]);
944        assert_buffer_eq(&buffer, &expected);
945
946        // click on the playlist when it's not selected
947        view.handle_mouse_event(
948            MouseEvent {
949                kind: MouseEventKind::Down(MouseButton::Left),
950                column: 2,
951                row: 2,
952                modifiers: KeyModifiers::empty(),
953            },
954            area,
955        );
956        assert_eq!(
957            rx.blocking_recv().unwrap(),
958            Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
959        );
960        let buffer = terminal
961            .draw(|frame| view.render(frame, props))
962            .unwrap()
963            .buffer
964            .clone();
965        assert_buffer_eq(&buffer, &expected);
966
967        // scroll down (selecting the collection)
968        view.handle_mouse_event(
969            MouseEvent {
970                kind: MouseEventKind::ScrollDown,
971                column: 2,
972                row: 2,
973                modifiers: KeyModifiers::empty(),
974            },
975            area,
976        );
977
978        // click down the playlist when it is selected
979        view.handle_mouse_event(
980            MouseEvent {
981                kind: MouseEventKind::Down(MouseButton::Left),
982                column: 2,
983                row: 2,
984                modifiers: KeyModifiers::empty(),
985            },
986            area,
987        );
988        assert_eq!(
989            rx.blocking_recv().unwrap(),
990            Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
991        );
992        let buffer = terminal
993            .draw(|frame| view.render(frame, props))
994            .unwrap()
995            .buffer
996            .clone();
997        assert_buffer_eq(&buffer, &expected);
998
999        // scroll up
1000        view.handle_mouse_event(
1001            MouseEvent {
1002                kind: MouseEventKind::ScrollUp,
1003                column: 2,
1004                row: 3,
1005                modifiers: KeyModifiers::empty(),
1006            },
1007            area,
1008        );
1009
1010        // click down on selected item
1011        view.handle_mouse_event(
1012            MouseEvent {
1013                kind: MouseEventKind::Down(MouseButton::Left),
1014                column: 2,
1015                row: 2,
1016                modifiers: KeyModifiers::empty(),
1017            },
1018            area,
1019        );
1020        assert_eq!(
1021            rx.blocking_recv().unwrap(),
1022            Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1023        );
1024
1025        // clicking on an empty area should clear the selection
1026        let mouse = MouseEvent {
1027            kind: MouseEventKind::Down(MouseButton::Left),
1028            column: 2,
1029            row: 3,
1030            modifiers: KeyModifiers::empty(),
1031        };
1032        view.handle_mouse_event(mouse, area);
1033        assert_eq!(view.tree_state.get_selected_thing(), None);
1034        view.handle_mouse_event(mouse, area);
1035        assert_eq!(
1036            rx.try_recv(),
1037            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1038        );
1039    }
1040}