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::Playlist;
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(),
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<[Playlist]>,
373    sort_mode: NameSort<Playlist>,
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 pretty_assertions::assert_eq;
655    use rstest::rstest;
656    use std::time::Duration;
657
658    #[rstest]
659    #[case(NameSort::default(), NameSort::default())]
660    fn test_sort_mode_next_prev(
661        #[case] mode: NameSort<Playlist>,
662        #[case] expected: NameSort<Playlist>,
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<Playlist>, #[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            Playlist {
678                id: Playlist::generate_id(),
679                name: "C".into(),
680                song_count: 0,
681                runtime: Duration::from_secs(0),
682            },
683            Playlist {
684                id: Playlist::generate_id(),
685                name: "A".into(),
686                song_count: 0,
687                runtime: Duration::from_secs(0),
688            },
689            Playlist {
690                id: Playlist::generate_id(),
691                name: "B".into(),
692                song_count: 0,
693                runtime: Duration::from_secs(0),
694            },
695        ];
696
697        NameSort::default().sort_items(&mut songs);
698        assert_eq!(songs[0].name, "A");
699        assert_eq!(songs[1].name, "B");
700        assert_eq!(songs[2].name, "C");
701    }
702}
703
704#[cfg(test)]
705mod item_view_tests {
706    use super::*;
707    use crate::{
708        state::action::{AudioAction, PopupAction, QueueAction},
709        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
710        ui::{components::content_view::ActiveView, widgets::popups::PopupType},
711    };
712    use crossterm::event::KeyModifiers;
713    use pretty_assertions::assert_eq;
714    use ratatui::buffer::Buffer;
715
716    #[test]
717    fn test_new() {
718        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
719        let state = state_with_everything();
720        let view = PlaylistView::new(&state, tx);
721
722        assert_eq!(view.name(), "Playlist View");
723        assert_eq!(
724            view.props,
725            Some(state.additional_view_data.playlist.unwrap())
726        );
727    }
728
729    #[test]
730    fn test_move_with_state() {
731        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
732        let state = AppState::default();
733        let new_state = state_with_everything();
734        let view = PlaylistView::new(&state, tx).move_with_state(&new_state);
735
736        assert_eq!(
737            view.props,
738            Some(new_state.additional_view_data.playlist.unwrap())
739        );
740    }
741    #[test]
742    fn test_render_no_playlist() {
743        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
744        let view = PlaylistView::new(&AppState::default(), tx);
745
746        let (mut terminal, area) = setup_test_terminal(20, 3);
747        let props = RenderProps {
748            area,
749            is_focused: true,
750        };
751        let buffer = terminal
752            .draw(|frame| view.render(frame, props))
753            .unwrap()
754            .buffer
755            .clone();
756        #[rustfmt::skip]
757        let expected = Buffer::with_lines([
758            "┌Playlist View─────┐",
759            "│No active playlist│",
760            "└──────────────────┘",
761        ]);
762
763        assert_buffer_eq(&buffer, &expected);
764    }
765
766    #[test]
767    fn test_render() {
768        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
769        let view = PlaylistView::new(&state_with_everything(), tx);
770
771        let (mut terminal, area) = setup_test_terminal(60, 9);
772        let props = RenderProps {
773            area,
774            is_focused: true,
775        };
776        let buffer = terminal
777            .draw(|frame| view.render(frame, props))
778            .unwrap()
779            .buffer
780            .clone();
781        let expected = Buffer::with_lines([
782            "┌Playlist View sorted by: Artist───────────────────────────┐",
783            "│                       Test Playlist                      │",
784            "│              Songs: 1  Duration: 00:03:00.00             │",
785            "│                                                          │",
786            "│q: add to queue | r: start radio | p: add to playlist─────│",
787            "│Performing operations on entire playlist──────────────────│",
788            "│☐ Test Song Test Artist                                   │",
789            "│s/S: sort | d: remove selected | e: edit──────────────────│",
790            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
791        ]);
792
793        assert_buffer_eq(&buffer, &expected);
794    }
795
796    #[test]
797    fn test_render_with_checked() {
798        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
799        let mut view = PlaylistView::new(&state_with_everything(), tx);
800        let (mut terminal, area) = setup_test_terminal(60, 9);
801        let props = RenderProps {
802            area,
803            is_focused: true,
804        };
805        let buffer = terminal
806            .draw(|frame| view.render(frame, props))
807            .unwrap()
808            .buffer
809            .clone();
810        let expected = Buffer::with_lines([
811            "┌Playlist View sorted by: Artist───────────────────────────┐",
812            "│                       Test Playlist                      │",
813            "│              Songs: 1  Duration: 00:03:00.00             │",
814            "│                                                          │",
815            "│q: add to queue | r: start radio | p: add to playlist─────│",
816            "│Performing operations on entire playlist──────────────────│",
817            "│☐ Test Song Test Artist                                   │",
818            "│s/S: sort | d: remove selected | e: edit──────────────────│",
819            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
820        ]);
821        assert_buffer_eq(&buffer, &expected);
822
823        // select the song
824        view.handle_key_event(KeyEvent::from(KeyCode::Down));
825        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
826
827        let buffer = terminal
828            .draw(|frame| view.render(frame, props))
829            .unwrap()
830            .buffer
831            .clone();
832        let expected = Buffer::with_lines([
833            "┌Playlist View sorted by: Artist───────────────────────────┐",
834            "│                       Test Playlist                      │",
835            "│              Songs: 1  Duration: 00:03:00.00             │",
836            "│                                                          │",
837            "│q: add to queue | r: start radio | p: add to playlist─────│",
838            "│Performing operations on checked items────────────────────│",
839            "│☑ Test Song Test Artist                                   │",
840            "│s/S: sort | d: remove selected | e: edit──────────────────│",
841            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
842        ]);
843
844        assert_buffer_eq(&buffer, &expected);
845    }
846
847    #[test]
848    fn smoke_navigation_and_sort() {
849        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
850        let mut view = PlaylistView::new(&state_with_everything(), tx);
851
852        view.handle_key_event(KeyEvent::from(KeyCode::Up));
853        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
854        view.handle_key_event(KeyEvent::from(KeyCode::Down));
855        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
856        view.handle_key_event(KeyEvent::from(KeyCode::Left));
857        view.handle_key_event(KeyEvent::from(KeyCode::Right));
858        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
859        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
860    }
861
862    #[test]
863    fn test_actions() {
864        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
865        let mut view = PlaylistView::new(&state_with_everything(), tx);
866
867        // need to render the view at least once to load the tree state
868        let (mut terminal, area) = setup_test_terminal(60, 9);
869        let props = RenderProps {
870            area,
871            is_focused: true,
872        };
873        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
874
875        // we test the actions when:
876        // there are no checked items
877        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
878        assert_eq!(
879            rx.blocking_recv().unwrap(),
880            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
881                ("playlist", item_id()).into()
882            ])))
883        );
884        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
885        assert_eq!(
886            rx.blocking_recv().unwrap(),
887            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
888                ("playlist", item_id()).into()
889            ],)))
890        );
891        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
892        assert_eq!(
893            rx.blocking_recv().unwrap(),
894            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
895                ("playlist", item_id()).into()
896            ])))
897        );
898        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
899
900        // there are checked items
901        // first we need to select an item
902        view.handle_key_event(KeyEvent::from(KeyCode::Up));
903        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
904
905        // open the selected view
906        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
907        assert_eq!(
908            rx.blocking_recv().unwrap(),
909            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
910        );
911
912        // check the artist
913        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
914
915        // add to queue
916        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
917        assert_eq!(
918            rx.blocking_recv().unwrap(),
919            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
920                ("song", item_id()).into()
921            ])))
922        );
923
924        // start radio
925        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
926        assert_eq!(
927            rx.blocking_recv().unwrap(),
928            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
929                ("song", item_id()).into()
930            ],)))
931        );
932
933        // add to playlist
934        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
935        assert_eq!(
936            rx.blocking_recv().unwrap(),
937            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
938                ("song", item_id()).into()
939            ])))
940        );
941
942        // remove from playlist
943        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
944        assert_eq!(
945            rx.blocking_recv().unwrap(),
946            Action::Library(LibraryAction::RemoveSongsFromPlaylist(
947                ("playlist", item_id()).into(),
948                vec![("song", item_id()).into()]
949            ))
950        );
951    }
952
953    #[test]
954    #[allow(clippy::too_many_lines)]
955    fn test_mouse_event() {
956        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
957        let mut view = PlaylistView::new(&state_with_everything(), tx);
958
959        // need to render the view at least once to load the tree state
960        let (mut terminal, area) = setup_test_terminal(60, 9);
961        let props = RenderProps {
962            area,
963            is_focused: true,
964        };
965        let buffer = terminal
966            .draw(|frame| view.render(frame, props))
967            .unwrap()
968            .buffer
969            .clone();
970        let expected = Buffer::with_lines([
971            "┌Playlist View sorted by: Artist───────────────────────────┐",
972            "│                       Test Playlist                      │",
973            "│              Songs: 1  Duration: 00:03:00.00             │",
974            "│                                                          │",
975            "│q: add to queue | r: start radio | p: add to playlist─────│",
976            "│Performing operations on entire playlist──────────────────│",
977            "│☐ Test Song Test Artist                                   │",
978            "│s/S: sort | d: remove selected | e: edit──────────────────│",
979            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
980        ]);
981        assert_buffer_eq(&buffer, &expected);
982
983        // click on the song (selecting it)
984        view.handle_mouse_event(
985            MouseEvent {
986                kind: MouseEventKind::Down(MouseButton::Left),
987                column: 2,
988                row: 6,
989                modifiers: KeyModifiers::empty(),
990            },
991            area,
992        );
993        let buffer = terminal
994            .draw(|frame| view.render(frame, props))
995            .unwrap()
996            .buffer
997            .clone();
998        let expected = Buffer::with_lines([
999            "┌Playlist View sorted by: Artist───────────────────────────┐",
1000            "│                       Test Playlist                      │",
1001            "│              Songs: 1  Duration: 00:03:00.00             │",
1002            "│                                                          │",
1003            "│q: add to queue | r: start radio | p: add to playlist─────│",
1004            "│Performing operations on checked items────────────────────│",
1005            "│☑ Test Song Test Artist                                   │",
1006            "│s/S: sort | d: remove selected | e: edit──────────────────│",
1007            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1008        ]);
1009        assert_buffer_eq(&buffer, &expected);
1010
1011        // click down the song (opening it)
1012        view.handle_mouse_event(
1013            MouseEvent {
1014                kind: MouseEventKind::Down(MouseButton::Left),
1015                column: 2,
1016                row: 6,
1017                modifiers: KeyModifiers::empty(),
1018            },
1019            area,
1020        );
1021        assert_eq!(
1022            rx.blocking_recv().unwrap(),
1023            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1024        );
1025        let expected = Buffer::with_lines([
1026            "┌Playlist View sorted by: Artist───────────────────────────┐",
1027            "│                       Test Playlist                      │",
1028            "│              Songs: 1  Duration: 00:03:00.00             │",
1029            "│                                                          │",
1030            "│q: add to queue | r: start radio | p: add to playlist─────│",
1031            "│Performing operations on entire playlist──────────────────│",
1032            "│☐ Test Song Test Artist                                   │",
1033            "│s/S: sort | d: remove selected | e: edit──────────────────│",
1034            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1035        ]);
1036        let buffer = terminal
1037            .draw(|frame| view.render(frame, props))
1038            .unwrap()
1039            .buffer
1040            .clone();
1041        assert_buffer_eq(&buffer, &expected);
1042
1043        // scroll down
1044        view.handle_mouse_event(
1045            MouseEvent {
1046                kind: MouseEventKind::ScrollDown,
1047                column: 2,
1048                row: 6,
1049                modifiers: KeyModifiers::empty(),
1050            },
1051            area,
1052        );
1053        let buffer = terminal
1054            .draw(|frame| view.render(frame, props))
1055            .unwrap()
1056            .buffer
1057            .clone();
1058        assert_buffer_eq(&buffer, &expected);
1059        // scroll up
1060        view.handle_mouse_event(
1061            MouseEvent {
1062                kind: MouseEventKind::ScrollUp,
1063                column: 2,
1064                row: 7,
1065                modifiers: KeyModifiers::empty(),
1066            },
1067            area,
1068        );
1069        let buffer = terminal
1070            .draw(|frame| view.render(frame, props))
1071            .unwrap()
1072            .buffer
1073            .clone();
1074        assert_buffer_eq(&buffer, &expected);
1075    }
1076}
1077
1078#[cfg(test)]
1079mod library_view_tests {
1080    use super::*;
1081    use crate::{
1082        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
1083        ui::components::content_view::ActiveView,
1084    };
1085    use crossterm::event::KeyModifiers;
1086    use pretty_assertions::assert_eq;
1087    use ratatui::buffer::Buffer;
1088
1089    #[test]
1090    fn test_new() {
1091        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1092        let state = state_with_everything();
1093        let view = LibraryPlaylistsView::new(&state, tx);
1094
1095        assert_eq!(view.name(), "Library Playlists View");
1096        assert_eq!(view.props.playlists, state.library.playlists);
1097    }
1098
1099    #[test]
1100    fn test_move_with_state() {
1101        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1102        let state = AppState::default();
1103        let new_state = state_with_everything();
1104        let view = LibraryPlaylistsView::new(&state, tx).move_with_state(&new_state);
1105
1106        assert_eq!(view.props.playlists, new_state.library.playlists);
1107    }
1108
1109    #[test]
1110    fn test_render() {
1111        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1112        let view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1113
1114        let (mut terminal, area) = setup_test_terminal(60, 6);
1115        let props = RenderProps {
1116            area,
1117            is_focused: true,
1118        };
1119        let buffer = terminal
1120            .draw(|frame| view.render(frame, props))
1121            .unwrap()
1122            .buffer
1123            .clone();
1124        let expected = Buffer::with_lines([
1125            "┌Library Playlists sorted by: Name─────────────────────────┐",
1126            "│n: new playlist | d: delete playlist──────────────────────│",
1127            "│▪ Test Playlist                                           │",
1128            "│                                                          │",
1129            "│                                                          │",
1130            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1131        ]);
1132
1133        assert_buffer_eq(&buffer, &expected);
1134    }
1135
1136    #[test]
1137    fn test_render_input_box() {
1138        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1139        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1140
1141        // open the input box
1142        view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1143
1144        let (mut terminal, area) = setup_test_terminal(60, 7);
1145        let props = RenderProps {
1146            area,
1147            is_focused: true,
1148        };
1149        let buffer = terminal
1150            .draw(|frame| view.render(frame, props))
1151            .unwrap()
1152            .buffer
1153            .clone();
1154        let expected = Buffer::with_lines([
1155            "┌Library Playlists sorted by: Name─────────────────────────┐",
1156            "│┌Enter Name:─────────────────────────────────────────────┐│",
1157            "││                                                        ││",
1158            "│└────────────────────────────────────────────────────────┘│",
1159            "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1160            "│▪ Test Playlist                                           │",
1161            "└──────────────────────────────────────────────────────────┘",
1162        ]);
1163
1164        assert_buffer_eq(&buffer, &expected);
1165    }
1166
1167    #[test]
1168    fn test_sort_keys() {
1169        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1170        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1171
1172        assert_eq!(view.props.sort_mode, NameSort::default());
1173        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
1174        assert_eq!(view.props.sort_mode, NameSort::default());
1175        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
1176        assert_eq!(view.props.sort_mode, NameSort::default());
1177    }
1178
1179    #[test]
1180    fn smoke_navigation() {
1181        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1182        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1183
1184        view.handle_key_event(KeyEvent::from(KeyCode::Up));
1185        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
1186        view.handle_key_event(KeyEvent::from(KeyCode::Down));
1187        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
1188        view.handle_key_event(KeyEvent::from(KeyCode::Left));
1189        view.handle_key_event(KeyEvent::from(KeyCode::Right));
1190    }
1191
1192    #[test]
1193    fn test_actions() {
1194        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1195        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1196
1197        // need to render the view at least once to load the tree state
1198        let (mut terminal, area) = setup_test_terminal(60, 9);
1199        let props = RenderProps {
1200            area,
1201            is_focused: true,
1202        };
1203        terminal.draw(|frame| view.render(frame, props)).unwrap();
1204
1205        // first we need to navigate to the playlist
1206        view.handle_key_event(KeyEvent::from(KeyCode::Down));
1207
1208        // open the selected view
1209        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1210        assert_eq!(
1211            rx.blocking_recv().unwrap(),
1212            Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1213        );
1214
1215        // new playlist
1216        view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1217        assert_eq!(view.input_box_visible, true);
1218        view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
1219        view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
1220        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1221        assert_eq!(view.input_box_visible, false);
1222        assert_eq!(
1223            rx.blocking_recv().unwrap(),
1224            Action::Library(LibraryAction::CreatePlaylist("ab".to_string()))
1225        );
1226
1227        // delete playlist
1228        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
1229        assert_eq!(
1230            rx.blocking_recv().unwrap(),
1231            Action::Library(LibraryAction::RemovePlaylist(
1232                ("playlist", item_id()).into()
1233            ))
1234        );
1235    }
1236
1237    #[test]
1238    fn test_mouse_event() {
1239        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1240        let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1241
1242        // need to render the view at least once to load the tree state
1243        let (mut terminal, area) = setup_test_terminal(60, 9);
1244        let props = RenderProps {
1245            area,
1246            is_focused: true,
1247        };
1248        let buffer = terminal
1249            .draw(|frame| view.render(frame, props))
1250            .unwrap()
1251            .buffer
1252            .clone();
1253        let expected = Buffer::with_lines([
1254            "┌Library Playlists sorted by: Name─────────────────────────┐",
1255            "│n: new playlist | d: delete playlist──────────────────────│",
1256            "│▪ Test Playlist                                           │",
1257            "│                                                          │",
1258            "│                                                          │",
1259            "│                                                          │",
1260            "│                                                          │",
1261            "│                                                          │",
1262            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1263        ]);
1264        assert_buffer_eq(&buffer, &expected);
1265
1266        // scroll down (selecting the collection)
1267        view.handle_mouse_event(
1268            MouseEvent {
1269                kind: MouseEventKind::ScrollDown,
1270                column: 2,
1271                row: 2,
1272                modifiers: KeyModifiers::empty(),
1273            },
1274            area,
1275        );
1276
1277        // click down the collection (opening it)
1278        view.handle_mouse_event(
1279            MouseEvent {
1280                kind: MouseEventKind::Down(MouseButton::Left),
1281                column: 2,
1282                row: 2,
1283                modifiers: KeyModifiers::empty(),
1284            },
1285            area,
1286        );
1287        assert_eq!(
1288            rx.blocking_recv().unwrap(),
1289            Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1290        );
1291        let buffer = terminal
1292            .draw(|frame| view.render(frame, props))
1293            .unwrap()
1294            .buffer
1295            .clone();
1296        assert_buffer_eq(&buffer, &expected);
1297
1298        // scroll up
1299        view.handle_mouse_event(
1300            MouseEvent {
1301                kind: MouseEventKind::ScrollUp,
1302                column: 2,
1303                row: 3,
1304                modifiers: KeyModifiers::empty(),
1305            },
1306            area,
1307        );
1308
1309        // click down on selected item
1310        view.handle_mouse_event(
1311            MouseEvent {
1312                kind: MouseEventKind::Down(MouseButton::Left),
1313                column: 2,
1314                row: 2,
1315                modifiers: KeyModifiers::empty(),
1316            },
1317            area,
1318        );
1319        assert_eq!(
1320            rx.blocking_recv().unwrap(),
1321            Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1322        );
1323
1324        // clicking on an empty area should clear the selection
1325        let mouse = MouseEvent {
1326            kind: MouseEventKind::Down(MouseButton::Left),
1327            column: 2,
1328            row: 3,
1329            modifiers: KeyModifiers::empty(),
1330        };
1331        view.handle_mouse_event(mouse, area);
1332        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1333        view.handle_mouse_event(mouse, area);
1334        assert_eq!(
1335            rx.try_recv(),
1336            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1337        );
1338    }
1339}