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

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