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

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