mecomp_tui/ui/widgets/popups/
playlist.rs

1//! A popup that prompts the user to select a playlist, or create a new one.
2//!
3//! The popup will consist of an input box for the playlist name, a list of playlists to select from, and a button to create a new playlist.
4//!
5//! The user can navigate the list of playlists using the arrow keys, and select a playlist by pressing the enter key.
6//!
7//! The user can create a new playlist by typing a name in the input box and pressing the enter key.
8//!
9//! The user can cancel the popup by pressing the escape key.
10
11use std::{ops::Not, sync::Mutex};
12
13use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
14use mecomp_storage::db::schemas::{RecordId, playlist::PlaylistBrief};
15use ratatui::{
16    Frame,
17    layout::{Constraint, Direction, Layout, Margin, Position, Rect},
18    style::{Style, Stylize},
19    text::Line,
20    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
21};
22use tokio::sync::mpsc::UnboundedSender;
23
24use crate::{
25    state::action::{Action, LibraryAction, PopupAction},
26    ui::{
27        AppState,
28        colors::{BORDER_FOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT},
29        components::{
30            Component, ComponentRender,
31            content_view::views::{checktree_utils::create_playlist_tree_leaf, playlist::Props},
32        },
33        widgets::{
34            input_box::{InputBox, RenderProps},
35            tree::{CheckTree, state::CheckTreeState},
36        },
37    },
38};
39
40use super::Popup;
41
42/// A popup that prompts the user to select a playlist, or create a new one.
43///
44/// The popup will consist of a list of playlists to select from,
45/// and if the user wants to create a new playlist, they can press the "n" key,
46/// which will make an input box appear for the user to type the name of the new playlist.
47#[allow(clippy::module_name_repetitions)]
48#[derive(Debug)]
49pub struct PlaylistSelector {
50    /// Action Sender
51    action_tx: UnboundedSender<Action>,
52    /// Mapped Props from state
53    props: Props,
54    /// tree state
55    tree_state: Mutex<CheckTreeState<String>>,
56    /// Playlist Name Input Box
57    input_box: InputBox,
58    /// Is the input box visible
59    input_box_visible: bool,
60    /// The items to add to the playlist
61    items: Vec<RecordId>,
62}
63
64impl PlaylistSelector {
65    #[must_use]
66    pub fn new(state: &AppState, action_tx: UnboundedSender<Action>, items: Vec<RecordId>) -> Self {
67        Self {
68            input_box: InputBox::new(state, action_tx.clone()),
69            input_box_visible: false,
70            action_tx,
71            props: Props::from(state),
72            tree_state: Mutex::new(CheckTreeState::default()),
73            items,
74        }
75    }
76}
77
78impl Popup for PlaylistSelector {
79    fn title(&self) -> ratatui::prelude::Line {
80        Line::from("Select a Playlist")
81    }
82
83    fn instructions(&self) -> ratatui::prelude::Line {
84        Line::from(
85            self.input_box_visible
86                .not()
87                .then_some("  \u{23CE} : Select | ↑/↓: Up/Down")
88                .unwrap_or_default(),
89        )
90    }
91
92    fn update_with_state(&mut self, state: &AppState) {
93        self.props = Props::from(state);
94    }
95
96    fn area(&self, terminal_area: Rect) -> Rect {
97        let [_, horizontal_area, _] = *Layout::default()
98            .direction(Direction::Horizontal)
99            .constraints([
100                Constraint::Percentage(50),
101                Constraint::Min(31),
102                Constraint::Percentage(19),
103            ])
104            .split(terminal_area)
105        else {
106            panic!("Failed to split horizontal area");
107        };
108
109        let [_, area, _] = *Layout::default()
110            .direction(Direction::Vertical)
111            .constraints([
112                Constraint::Max(10),
113                Constraint::Min(10),
114                Constraint::Max(10),
115            ])
116            .split(horizontal_area)
117        else {
118            panic!("Failed to split vertical area");
119        };
120        area
121    }
122
123    fn inner_handle_key_event(&mut self, key: KeyEvent) {
124        // this component has 2 distinct states:
125        // 1. the user is selecting a playlist
126        // 2. the user is creating a new playlist
127        // when the user is creating a new playlist, the input box is visible
128        // and the user can type the name of the new playlist
129        // when the user is selecting a playlist, the input box is not visible
130        // and the user can navigate the list of playlists
131        if self.input_box_visible {
132            match key.code {
133                // if the user presses Enter, we try to create a new playlist with the given name
134                // and add the items to that playlist
135                KeyCode::Enter => {
136                    let name = self.input_box.text();
137                    if !name.is_empty() {
138                        // create the playlist and add the items,
139                        self.action_tx
140                            .send(Action::Library(LibraryAction::CreatePlaylistAndAddThings(
141                                name.to_string(),
142                                self.items.clone(),
143                            )))
144                            .unwrap();
145                        // close the popup
146                        self.action_tx
147                            .send(Action::Popup(PopupAction::Close))
148                            .unwrap();
149                    }
150                    self.input_box_visible = false;
151                }
152                // defer to the input box
153                _ => self.input_box.handle_key_event(key),
154            }
155        } else {
156            match key.code {
157                // if the user presses the "n" key, we show the input box
158                KeyCode::Char('n') => {
159                    self.input_box_visible = true;
160                }
161                // arrow keys
162                KeyCode::PageUp => {
163                    self.tree_state.lock().unwrap().select_relative(|current| {
164                        current.map_or(self.props.playlists.len() - 1, |c| c.saturating_sub(10))
165                    });
166                }
167                KeyCode::Up => {
168                    self.tree_state.lock().unwrap().key_up();
169                }
170                KeyCode::PageDown => {
171                    self.tree_state
172                        .lock()
173                        .unwrap()
174                        .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
175                }
176                KeyCode::Down => {
177                    self.tree_state.lock().unwrap().key_down();
178                }
179                KeyCode::Left => {
180                    self.tree_state.lock().unwrap().key_left();
181                }
182                KeyCode::Right => {
183                    self.tree_state.lock().unwrap().key_right();
184                }
185                // Enter key adds the items to the selected playlist
186                // and closes the popup
187                KeyCode::Enter => {
188                    if self.tree_state.lock().unwrap().toggle_selected() {
189                        let things = self.tree_state.lock().unwrap().get_selected_thing();
190
191                        if let Some(thing) = things {
192                            // add the items to the selected playlist
193                            self.action_tx
194                                .send(Action::Library(LibraryAction::AddThingsToPlaylist(
195                                    thing,
196                                    self.items.clone(),
197                                )))
198                                .unwrap();
199                            // close the popup
200                            self.action_tx
201                                .send(Action::Popup(PopupAction::Close))
202                                .unwrap();
203                        }
204                    }
205                }
206                _ => {}
207            }
208        }
209    }
210
211    /// Mouse Event Handler for the inner component of the popup,
212    /// when an item in the list is clicked, it will be selected.
213    fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
214        let MouseEvent {
215            kind, column, row, ..
216        } = mouse;
217        let mouse_position = Position::new(column, row);
218
219        // adjust the area to account for the border
220        let area = area.inner(Margin::new(1, 1));
221
222        // defer to input box if it's visible
223        if self.input_box_visible {
224            let [input_box_area, content_area] = split_area(area);
225            if input_box_area.contains(mouse_position) {
226                self.input_box.handle_mouse_event(mouse, input_box_area);
227            } else if content_area.contains(mouse_position)
228                && kind == MouseEventKind::Down(MouseButton::Left)
229            {
230                self.input_box_visible = false;
231            }
232            return;
233        }
234
235        // if the mouse is outside the area, return
236        if !area.contains(mouse_position) {
237            return;
238        }
239
240        match kind {
241            MouseEventKind::Down(MouseButton::Left) => {
242                self.tree_state.lock().unwrap().mouse_click(mouse_position);
243            }
244            MouseEventKind::ScrollDown => {
245                self.tree_state.lock().unwrap().key_down();
246            }
247            MouseEventKind::ScrollUp => {
248                self.tree_state.lock().unwrap().key_up();
249            }
250            _ => {}
251        }
252    }
253}
254
255fn split_area(area: Rect) -> [Rect; 2] {
256    let [input_box_area, content_area] = *Layout::default()
257        .direction(Direction::Vertical)
258        .constraints([Constraint::Length(3), Constraint::Min(4)])
259        .split(area)
260    else {
261        panic!("Failed to split playlist selector area");
262    };
263    [input_box_area, content_area]
264}
265
266impl ComponentRender<Rect> for PlaylistSelector {
267    fn render_border(&self, frame: &mut ratatui::Frame, area: Rect) -> Rect {
268        let area = self.render_popup_border(frame, area);
269
270        let content_area = if self.input_box_visible {
271            // split content area to make room for the input box
272            let [input_box_area, content_area] = split_area(area);
273
274            // render input box
275            self.input_box.render(
276                frame,
277                RenderProps {
278                    area: input_box_area,
279                    text_color: (*TEXT_HIGHLIGHT_ALT).into(),
280                    border: Block::bordered()
281                        .title("Enter Name:")
282                        .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
283                    show_cursor: self.input_box_visible,
284                },
285            );
286
287            content_area
288        } else {
289            area
290        };
291
292        // draw additional border around content area to display additional instructions
293        let border = Block::new()
294            .borders(Borders::TOP)
295            .title_top(if self.input_box_visible {
296                " \u{23CE} : Create (cancel if empty)"
297            } else {
298                "n: new playlist"
299            })
300            .border_style(Style::default().fg(self.border_color()));
301        frame.render_widget(&border, content_area);
302        border.inner(content_area)
303    }
304
305    fn render_content(&self, frame: &mut Frame, area: Rect) {
306        // create a tree for the playlists
307        let playlists = self
308            .props
309            .playlists
310            .iter()
311            .map(create_playlist_tree_leaf)
312            .collect::<Vec<_>>();
313
314        // render the playlists
315        frame.render_stateful_widget(
316            CheckTree::new(&playlists)
317                .unwrap()
318                .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
319                // we want this to be rendered like a normal tree, not a check tree, so we don't show the checkboxes
320                .node_unchecked_symbol("▪ ")
321                .node_checked_symbol("▪ ")
322                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
323            area,
324            &mut self.tree_state.lock().unwrap(),
325        );
326    }
327}
328
329/// Popup for changing the name of a playlist.
330pub struct PlaylistEditor {
331    action_tx: UnboundedSender<Action>,
332    playlist_id: RecordId,
333    input_box: InputBox,
334}
335
336impl PlaylistEditor {
337    #[must_use]
338    pub fn new(
339        state: &AppState,
340        action_tx: UnboundedSender<Action>,
341        playlist: PlaylistBrief,
342    ) -> Self {
343        let mut input_box = InputBox::new(state, action_tx.clone());
344        input_box.set_text(&playlist.name);
345
346        Self {
347            input_box,
348            action_tx,
349            playlist_id: playlist.id.into(),
350        }
351    }
352}
353
354impl Popup for PlaylistEditor {
355    fn title(&self) -> Line {
356        Line::from("Rename Playlist")
357    }
358
359    fn instructions(&self) -> Line {
360        Line::from(" \u{23CE} : Rename")
361    }
362
363    /// Should be located in the upper middle of the screen
364    fn area(&self, terminal_area: Rect) -> Rect {
365        let height = 5;
366        let width = u16::try_from(
367            self.input_box
368                .text()
369                .len()
370                .max(self.instructions().width())
371                .max(self.title().width())
372                + 5,
373        )
374        .unwrap_or(terminal_area.width)
375        .min(terminal_area.width);
376
377        let x = (terminal_area.width - width) / 2;
378        let y = (terminal_area.height - height) / 2;
379
380        Rect::new(x, y, width, height)
381    }
382
383    fn update_with_state(&mut self, _: &AppState) {}
384
385    fn inner_handle_key_event(&mut self, key: KeyEvent) {
386        match key.code {
387            KeyCode::Enter => {
388                let name = self.input_box.text();
389                if name.is_empty() {
390                    return;
391                }
392
393                self.action_tx
394                    .send(Action::Popup(PopupAction::Close))
395                    .unwrap();
396                self.action_tx
397                    .send(Action::Library(LibraryAction::RenamePlaylist(
398                        self.playlist_id.clone(),
399                        name.to_string(),
400                    )))
401                    .unwrap();
402            }
403            _ => self.input_box.handle_key_event(key),
404        }
405    }
406
407    fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
408        let MouseEvent {
409            column, row, kind, ..
410        } = mouse;
411        let mouse_position = Position::new(column, row);
412
413        if area.contains(mouse_position) {
414            self.input_box.handle_mouse_event(mouse, area);
415        } else if kind == MouseEventKind::Down(MouseButton::Left) {
416            self.action_tx
417                .send(Action::Popup(PopupAction::Close))
418                .unwrap();
419        }
420    }
421}
422
423impl ComponentRender<Rect> for PlaylistEditor {
424    fn render_border(&self, frame: &mut Frame, area: Rect) -> Rect {
425        self.render_popup_border(frame, area)
426    }
427
428    fn render_content(&self, frame: &mut Frame, area: Rect) {
429        self.input_box.render(
430            frame,
431            RenderProps {
432                area,
433                text_color: (*TEXT_HIGHLIGHT_ALT).into(),
434                border: Block::bordered()
435                    .title("Enter Name:")
436                    .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
437                show_cursor: true,
438            },
439        );
440    }
441}
442
443#[cfg(test)]
444mod selector_tests {
445    use super::*;
446    use crate::{
447        state::component::ActiveComponent,
448        test_utils::setup_test_terminal,
449        ui::components::content_view::{ActiveView, views::ViewData},
450    };
451    use anyhow::Result;
452    use mecomp_core::{
453        config::Settings,
454        rpc::SearchResult,
455        state::{StateAudio, library::LibraryBrief},
456    };
457    use mecomp_storage::db::schemas::playlist::Playlist;
458    use pretty_assertions::assert_eq;
459    use ratatui::{
460        buffer::Buffer,
461        style::{Color, Style},
462        text::Span,
463    };
464    use rstest::{fixture, rstest};
465
466    #[fixture]
467    fn state() -> AppState {
468        AppState {
469            active_component: ActiveComponent::default(),
470            audio: StateAudio::default(),
471            search: SearchResult::default(),
472            library: LibraryBrief {
473                playlists: vec![PlaylistBrief {
474                    id: Playlist::generate_id(),
475                    name: "playlist 1".into(),
476                }]
477                .into_boxed_slice(),
478                ..Default::default()
479            },
480            active_view: ActiveView::default(),
481            additional_view_data: ViewData::default(),
482            settings: Settings::default(),
483        }
484    }
485
486    #[fixture]
487    fn border_style() -> Style {
488        Style::reset().fg(Color::Rgb(3, 169, 244))
489    }
490
491    #[fixture]
492    fn input_box_style() -> Style {
493        Style::reset().fg(Color::Rgb(239, 154, 154))
494    }
495
496    #[rstest]
497    #[case::large((100, 100), Rect::new(50, 10, 31, 80))]
498    #[case::small((31, 10), Rect::new(0, 0, 31, 10))]
499    #[case::too_small((20, 5), Rect::new(0, 0, 20, 5))]
500    fn test_playlist_selector_area(
501        #[case] terminal_size: (u16, u16),
502        #[case] expected_area: Rect,
503        state: AppState,
504    ) {
505        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
506        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
507        let items = vec![];
508        let area = PlaylistSelector::new(&state, action_tx, items).area(area);
509        assert_eq!(area, expected_area);
510    }
511
512    #[rstest]
513    fn test_playlist_selector_render(
514        state: AppState,
515        #[from(border_style)] style: Style,
516    ) -> Result<()> {
517        let (mut terminal, _) = setup_test_terminal(31, 10);
518        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
519        let items = vec![];
520        let popup = PlaylistSelector::new(&state, action_tx, items);
521        let buffer = terminal
522            .draw(|frame| popup.render_popup(frame))?
523            .buffer
524            .clone();
525        let expected = Buffer::with_lines([
526            Line::styled("┌Select a Playlist────────────┐", style),
527            Line::styled("│n: new playlist──────────────│", style),
528            Line::from(vec![
529                Span::styled("│", style),
530                Span::raw("▪ "),
531                Span::raw("playlist 1").bold(),
532                Span::raw("                 "),
533                Span::styled("│", style),
534            ]),
535            Line::from(vec![
536                Span::styled("│", style),
537                Span::raw("                             "),
538                Span::styled("│", style),
539            ]),
540            Line::from(vec![
541                Span::styled("│", style),
542                Span::raw("                             "),
543                Span::styled("│", style),
544            ]),
545            Line::from(vec![
546                Span::styled("│", style),
547                Span::raw("                             "),
548                Span::styled("│", style),
549            ]),
550            Line::from(vec![
551                Span::styled("│", style),
552                Span::raw("                             "),
553                Span::styled("│", style),
554            ]),
555            Line::from(vec![
556                Span::styled("│", style),
557                Span::raw("                             "),
558                Span::styled("│", style),
559            ]),
560            Line::from(vec![
561                Span::styled("│", style),
562                Span::raw("                             "),
563                Span::styled("│", style),
564            ]),
565            Line::styled("└  ⏎ : Select | ↑/↓: Up/Down──┘", style),
566        ]);
567
568        assert_eq!(buffer, expected);
569
570        Ok(())
571    }
572
573    #[rstest]
574    fn test_playlist_selector_render_input_box(
575        state: AppState,
576        border_style: Style,
577        input_box_style: Style,
578    ) -> Result<()> {
579        let (mut terminal, _) = setup_test_terminal(31, 10);
580        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
581        let items = vec![];
582        let mut popup = PlaylistSelector::new(&state, action_tx, items);
583        popup.inner_handle_key_event(KeyEvent::from(KeyCode::Char('n')));
584        let buffer = terminal
585            .draw(|frame| popup.render_popup(frame))?
586            .buffer
587            .clone();
588        let expected = Buffer::with_lines([
589            Line::styled("┌Select a Playlist────────────┐", border_style),
590            Line::from(vec![
591                Span::styled("│", border_style),
592                Span::styled("┌Enter Name:────────────────┐", input_box_style),
593                Span::styled("│", border_style),
594            ]),
595            Line::from(vec![
596                Span::styled("│", border_style),
597                Span::styled("│                           │", input_box_style),
598                Span::styled("│", border_style),
599            ]),
600            Line::from(vec![
601                Span::styled("│", border_style),
602                Span::styled("└───────────────────────────┘", input_box_style),
603                Span::styled("│", border_style),
604            ]),
605            Line::styled("│ ⏎ : Create (cancel if empty)│", border_style),
606            Line::from(vec![
607                Span::styled("│", border_style),
608                Span::raw("▪ "),
609                Span::raw("playlist 1").bold(),
610                Span::raw("                 "),
611                Span::styled("│", border_style),
612            ]),
613            Line::from(vec![
614                Span::styled("│", border_style),
615                Span::raw("                             "),
616                Span::styled("│", border_style),
617            ]),
618            Line::from(vec![
619                Span::styled("│", border_style),
620                Span::raw("                             "),
621                Span::styled("│", border_style),
622            ]),
623            Line::from(vec![
624                Span::styled("│", border_style),
625                Span::raw("                             "),
626                Span::styled("│", border_style),
627            ]),
628            Line::styled("└─────────────────────────────┘", border_style),
629        ]);
630
631        assert_eq!(buffer, expected);
632
633        Ok(())
634    }
635}
636
637#[cfg(test)]
638mod editor_tests {
639    use super::*;
640    use crate::{
641        state::component::ActiveComponent,
642        test_utils::{assert_buffer_eq, setup_test_terminal},
643        ui::components::content_view::{ActiveView, views::ViewData},
644    };
645    use anyhow::Result;
646    use mecomp_core::{
647        config::Settings,
648        rpc::SearchResult,
649        state::{StateAudio, library::LibraryBrief},
650    };
651    use mecomp_storage::db::schemas::playlist::Playlist;
652    use pretty_assertions::assert_eq;
653    use ratatui::buffer::Buffer;
654    use rstest::{fixture, rstest};
655
656    #[fixture]
657    fn state() -> AppState {
658        AppState {
659            active_component: ActiveComponent::default(),
660            audio: StateAudio::default(),
661            search: SearchResult::default(),
662            library: LibraryBrief::default(),
663            active_view: ActiveView::default(),
664            additional_view_data: ViewData::default(),
665            settings: Settings::default(),
666        }
667    }
668
669    #[fixture]
670    fn playlist() -> PlaylistBrief {
671        PlaylistBrief {
672            id: Playlist::generate_id(),
673            name: "Test Playlist".into(),
674        }
675    }
676
677    #[rstest]
678    #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
679    #[case::small((20,5), Rect::new(0, 0, 20, 5))]
680    #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
681    fn test_playlist_editor_area(
682        #[case] terminal_size: (u16, u16),
683        #[case] expected_area: Rect,
684        state: AppState,
685        playlist: PlaylistBrief,
686    ) {
687        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
688        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
689        let editor = PlaylistEditor::new(&state, action_tx, playlist);
690        let area = editor.area(area);
691        assert_eq!(area, expected_area);
692    }
693
694    #[rstest]
695    fn test_playlist_editor_render(state: AppState, playlist: PlaylistBrief) -> Result<()> {
696        let (mut terminal, _) = setup_test_terminal(20, 5);
697        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
698        let editor = PlaylistEditor::new(&state, action_tx, playlist);
699        let buffer = terminal
700            .draw(|frame| editor.render_popup(frame))?
701            .buffer
702            .clone();
703
704        let expected = Buffer::with_lines([
705            "┌Rename Playlist───┐",
706            "│┌Enter Name:─────┐│",
707            "││Test Playlist   ││",
708            "│└────────────────┘│",
709            "└ ⏎ : Rename───────┘",
710        ]);
711
712        assert_buffer_eq(&buffer, &expected);
713        Ok(())
714    }
715
716    #[rstest]
717    fn test_playlist_editor_input(state: AppState, playlist: PlaylistBrief) {
718        let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
719        let mut editor = PlaylistEditor::new(&state, action_tx, playlist.clone());
720
721        // Test typing
722        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
723        assert_eq!(editor.input_box.text(), "Test Playlista");
724
725        // Test enter with name
726        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
727        assert_eq!(editor.input_box.text(), "Test Playlista");
728        assert_eq!(
729            action_rx.blocking_recv(),
730            Some(Action::Popup(PopupAction::Close))
731        );
732        assert_eq!(
733            action_rx.blocking_recv(),
734            Some(Action::Library(LibraryAction::RenamePlaylist(
735                playlist.id.into(),
736                "Test Playlista".into()
737            )))
738        );
739
740        // Test backspace
741        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
742        assert_eq!(editor.input_box.text(), "Test Playlist");
743
744        // Test enter with empty name
745        editor.input_box.set_text("");
746        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
747        assert_eq!(editor.input_box.text(), "");
748    }
749}