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