Skip to main content

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