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