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