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