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::sync::Mutex;
12
13use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
14use mecomp_prost::{RecordId, Ulid};
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) -> Line<'static> {
80        Line::from("Select a Playlist")
81    }
82
83    fn instructions(&self) -> Line<'static> {
84        if self.input_box_visible {
85            Line::default()
86        } else {
87            Line::from("  \u{23CE} : Select | ↑/↓: Up/Down")
88        }
89    }
90
91    fn update_with_state(&mut self, state: &AppState) {
92        self.props = Props::from(state);
93    }
94
95    fn area(&self, terminal_area: Rect) -> Rect {
96        let [_, horizontal_area, _] = *Layout::default()
97            .direction(Direction::Horizontal)
98            .constraints([
99                Constraint::Percentage(50),
100                Constraint::Min(31),
101                Constraint::Percentage(19),
102            ])
103            .split(terminal_area)
104        else {
105            panic!("Failed to split horizontal area");
106        };
107
108        let [_, area, _] = *Layout::default()
109            .direction(Direction::Vertical)
110            .constraints([
111                Constraint::Max(10),
112                Constraint::Min(10),
113                Constraint::Max(10),
114            ])
115            .split(horizontal_area)
116        else {
117            panic!("Failed to split vertical area");
118        };
119        area
120    }
121
122    fn inner_handle_key_event(&mut self, key: KeyEvent) {
123        // this component has 2 distinct states:
124        // 1. the user is selecting a playlist
125        // 2. the user is creating a new playlist
126        // when the user is creating a new playlist, the input box is visible
127        // and the user can type the name of the new playlist
128        // when the user is selecting a playlist, the input box is not visible
129        // and the user can navigate the list of playlists
130        if self.input_box_visible {
131            match key.code {
132                // if the user presses Enter, we try to create a new playlist with the given name
133                // and add the items to that playlist
134                KeyCode::Enter => {
135                    let name = self.input_box.text();
136                    if !name.is_empty() {
137                        // create the playlist and add the items,
138                        self.action_tx
139                            .send(Action::Library(LibraryAction::CreatePlaylistAndAddThings(
140                                name.to_string(),
141                                self.items.clone(),
142                            )))
143                            .unwrap();
144                        // close the popup
145                        self.action_tx
146                            .send(Action::Popup(PopupAction::Close))
147                            .unwrap();
148                    }
149                    self.input_box_visible = false;
150                }
151                // defer to the input box
152                _ => self.input_box.handle_key_event(key),
153            }
154        } else {
155            match key.code {
156                // if the user presses the "n" key, we show the input box
157                KeyCode::Char('n') => {
158                    self.input_box_visible = true;
159                }
160                // arrow keys
161                KeyCode::PageUp => {
162                    self.tree_state.lock().unwrap().select_relative(|current| {
163                        current.map_or(self.props.playlists.len() - 1, |c| c.saturating_sub(10))
164                    });
165                }
166                KeyCode::Up => {
167                    self.tree_state.lock().unwrap().key_up();
168                }
169                KeyCode::PageDown => {
170                    self.tree_state
171                        .lock()
172                        .unwrap()
173                        .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
174                }
175                KeyCode::Down => {
176                    self.tree_state.lock().unwrap().key_down();
177                }
178                KeyCode::Left => {
179                    self.tree_state.lock().unwrap().key_left();
180                }
181                KeyCode::Right => {
182                    self.tree_state.lock().unwrap().key_right();
183                }
184                // Enter key adds the items to the selected playlist
185                // and closes the popup
186                KeyCode::Enter => {
187                    if self.tree_state.lock().unwrap().toggle_selected() {
188                        let things = self.tree_state.lock().unwrap().get_selected_thing();
189
190                        if let Some(thing) = things {
191                            // add the items to the selected playlist
192                            self.action_tx
193                                .send(Action::Library(LibraryAction::AddThingsToPlaylist(
194                                    thing,
195                                    self.items.clone(),
196                                )))
197                                .unwrap();
198                            // close the popup
199                            self.action_tx
200                                .send(Action::Popup(PopupAction::Close))
201                                .unwrap();
202                        }
203                    }
204                }
205                _ => {}
206            }
207        }
208    }
209
210    /// Mouse Event Handler for the inner component of the popup,
211    /// when an item in the list is clicked, it will be selected.
212    fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
213        let MouseEvent {
214            kind, column, row, ..
215        } = mouse;
216        let mouse_position = Position::new(column, row);
217
218        // adjust the area to account for the border
219        let area = area.inner(Margin::new(1, 1));
220
221        // defer to input box if it's visible
222        if self.input_box_visible {
223            let [input_box_area, content_area] = split_area(area);
224            if input_box_area.contains(mouse_position) {
225                self.input_box.handle_mouse_event(mouse, input_box_area);
226            } else if content_area.contains(mouse_position)
227                && kind == MouseEventKind::Down(MouseButton::Left)
228            {
229                self.input_box_visible = false;
230            }
231            return;
232        }
233
234        // if the mouse is outside the area, return
235        if !area.contains(mouse_position) {
236            return;
237        }
238
239        match kind {
240            MouseEventKind::Down(MouseButton::Left) => {
241                self.tree_state.lock().unwrap().mouse_click(mouse_position);
242            }
243            MouseEventKind::ScrollDown => {
244                self.tree_state.lock().unwrap().key_down();
245            }
246            MouseEventKind::ScrollUp => {
247                self.tree_state.lock().unwrap().key_up();
248            }
249            _ => {}
250        }
251    }
252}
253
254fn split_area(area: Rect) -> [Rect; 2] {
255    let [input_box_area, content_area] = *Layout::default()
256        .direction(Direction::Vertical)
257        .constraints([Constraint::Length(3), Constraint::Min(4)])
258        .split(area)
259    else {
260        panic!("Failed to split playlist selector area");
261    };
262    [input_box_area, content_area]
263}
264
265impl ComponentRender<Rect> for PlaylistSelector {
266    fn render_border(&self, frame: &mut ratatui::Frame<'_>, area: Rect) -> Rect {
267        let area = self.render_popup_border(frame, area);
268
269        let content_area = if self.input_box_visible {
270            // split content area to make room for the input box
271            let [input_box_area, content_area] = split_area(area);
272
273            // render input box
274            self.input_box.render(
275                frame,
276                RenderProps {
277                    area: input_box_area,
278                    text_color: (*TEXT_HIGHLIGHT_ALT).into(),
279                    border: Block::bordered()
280                        .title("Enter Name:")
281                        .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
282                    show_cursor: self.input_box_visible,
283                },
284            );
285
286            content_area
287        } else {
288            area
289        };
290
291        // draw additional border around content area to display additional instructions
292        let border = Block::new()
293            .borders(Borders::TOP)
294            .title_top(if self.input_box_visible {
295                " \u{23CE} : Create (cancel if empty)"
296            } else {
297                "n: new playlist"
298            })
299            .border_style(Style::default().fg(self.border_color()));
300        frame.render_widget(&border, content_area);
301        border.inner(content_area)
302    }
303
304    fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
305        // create a tree for the playlists
306        let playlists = self
307            .props
308            .playlists
309            .iter()
310            .map(create_playlist_tree_leaf)
311            .collect::<Vec<_>>();
312
313        // render the playlists
314        frame.render_stateful_widget(
315            CheckTree::new(&playlists)
316                .unwrap()
317                .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
318                // we want this to be rendered like a normal tree, not a check tree, so we don't show the checkboxes
319                .node_unchecked_symbol("▪ ")
320                .node_checked_symbol("▪ ")
321                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
322            area,
323            &mut self.tree_state.lock().unwrap(),
324        );
325    }
326}
327
328/// Popup for changing the name of a playlist.
329pub struct PlaylistEditor {
330    action_tx: UnboundedSender<Action>,
331    playlist_id: Ulid,
332    input_box: InputBox,
333}
334
335impl PlaylistEditor {
336    #[must_use]
337    pub fn new(
338        state: &AppState,
339        action_tx: UnboundedSender<Action>,
340        playlist_id: Ulid,
341        playlist_name: &str,
342    ) -> Self {
343        let mut input_box = InputBox::new(state, action_tx.clone());
344        input_box.set_text(playlist_name);
345
346        Self {
347            action_tx,
348            playlist_id,
349            input_box,
350        }
351    }
352}
353
354impl Popup for PlaylistEditor {
355    fn title(&self) -> Line<'static> {
356        Line::from("Rename Playlist")
357    }
358
359    fn instructions(&self) -> Line<'static> {
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::{item_id, setup_test_terminal},
449        ui::components::content_view::{ActiveView, views::ViewData},
450    };
451    use anyhow::Result;
452    use mecomp_core::{config::Settings, state::StateAudio};
453    use mecomp_prost::{LibraryBrief, PlaylistBrief, SearchResult};
454    use mecomp_storage::db::schemas::playlist::TABLE_NAME;
455    use pretty_assertions::assert_eq;
456    use ratatui::{
457        buffer::Buffer,
458        style::{Color, Style},
459        text::Span,
460    };
461    use rstest::{fixture, rstest};
462
463    #[fixture]
464    fn state() -> AppState {
465        AppState {
466            active_component: ActiveComponent::default(),
467            audio: StateAudio::default(),
468            search: SearchResult::default(),
469            library: LibraryBrief {
470                playlists: vec![PlaylistBrief {
471                    id: RecordId::new(TABLE_NAME, item_id()),
472                    name: "playlist 1".into(),
473                }],
474                ..Default::default()
475            },
476            active_view: ActiveView::default(),
477            additional_view_data: ViewData::default(),
478            settings: Settings::default(),
479        }
480    }
481
482    #[fixture]
483    fn border_style() -> Style {
484        Style::reset().fg(Color::Rgb(3, 169, 244))
485    }
486
487    #[fixture]
488    fn input_box_style() -> Style {
489        Style::reset().fg(Color::Rgb(239, 154, 154))
490    }
491
492    #[rstest]
493    #[case::large((100, 100), Rect::new(50, 10, 31, 80))]
494    #[case::small((31, 10), Rect::new(0, 0, 31, 10))]
495    #[case::too_small((20, 5), Rect::new(0, 0, 20, 5))]
496    fn test_playlist_selector_area(
497        #[case] terminal_size: (u16, u16),
498        #[case] expected_area: Rect,
499        state: AppState,
500    ) {
501        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
502        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
503        let items = vec![];
504        let area = PlaylistSelector::new(&state, action_tx, items).area(area);
505        assert_eq!(area, expected_area);
506    }
507
508    #[rstest]
509    fn test_playlist_selector_render(
510        state: AppState,
511        #[from(border_style)] style: Style,
512    ) -> Result<()> {
513        let (mut terminal, _) = setup_test_terminal(31, 10);
514        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
515        let items = vec![];
516        let popup = PlaylistSelector::new(&state, action_tx, items);
517        let buffer = terminal
518            .draw(|frame| popup.render_popup(frame))?
519            .buffer
520            .clone();
521        let expected = Buffer::with_lines([
522            Line::styled("┌Select a Playlist────────────┐", style),
523            Line::styled("│n: new playlist──────────────│", style),
524            Line::from(vec![
525                Span::styled("│", style),
526                Span::raw("▪ "),
527                Span::raw("playlist 1").bold(),
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::from(vec![
542                Span::styled("│", style),
543                Span::raw("                             "),
544                Span::styled("│", style),
545            ]),
546            Line::from(vec![
547                Span::styled("│", style),
548                Span::raw("                             "),
549                Span::styled("│", style),
550            ]),
551            Line::from(vec![
552                Span::styled("│", style),
553                Span::raw("                             "),
554                Span::styled("│", style),
555            ]),
556            Line::from(vec![
557                Span::styled("│", style),
558                Span::raw("                             "),
559                Span::styled("│", style),
560            ]),
561            Line::styled("└  ⏎ : Select | ↑/↓: Up/Down──┘", style),
562        ]);
563
564        assert_eq!(buffer, expected);
565
566        Ok(())
567    }
568
569    #[rstest]
570    fn test_playlist_selector_render_input_box(
571        state: AppState,
572        border_style: Style,
573        input_box_style: Style,
574    ) -> Result<()> {
575        let (mut terminal, _) = setup_test_terminal(31, 10);
576        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
577        let items = vec![];
578        let mut popup = PlaylistSelector::new(&state, action_tx, items);
579        popup.inner_handle_key_event(KeyEvent::from(KeyCode::Char('n')));
580        let buffer = terminal
581            .draw(|frame| popup.render_popup(frame))?
582            .buffer
583            .clone();
584        let expected = Buffer::with_lines([
585            Line::styled("┌Select a Playlist────────────┐", border_style),
586            Line::from(vec![
587                Span::styled("│", border_style),
588                Span::styled("┌Enter Name:────────────────┐", input_box_style),
589                Span::styled("│", border_style),
590            ]),
591            Line::from(vec![
592                Span::styled("│", border_style),
593                Span::styled("│                           │", input_box_style),
594                Span::styled("│", border_style),
595            ]),
596            Line::from(vec![
597                Span::styled("│", border_style),
598                Span::styled("└───────────────────────────┘", input_box_style),
599                Span::styled("│", border_style),
600            ]),
601            Line::styled("│ ⏎ : Create (cancel if empty)│", border_style),
602            Line::from(vec![
603                Span::styled("│", border_style),
604                Span::raw("▪ "),
605                Span::raw("playlist 1").bold(),
606                Span::raw("                 "),
607                Span::styled("│", border_style),
608            ]),
609            Line::from(vec![
610                Span::styled("│", border_style),
611                Span::raw("                             "),
612                Span::styled("│", border_style),
613            ]),
614            Line::from(vec![
615                Span::styled("│", border_style),
616                Span::raw("                             "),
617                Span::styled("│", border_style),
618            ]),
619            Line::from(vec![
620                Span::styled("│", border_style),
621                Span::raw("                             "),
622                Span::styled("│", border_style),
623            ]),
624            Line::styled("└─────────────────────────────┘", border_style),
625        ]);
626
627        assert_eq!(buffer, expected);
628
629        Ok(())
630    }
631}
632
633#[cfg(test)]
634mod editor_tests {
635    use super::*;
636    use crate::{
637        state::component::ActiveComponent,
638        test_utils::{assert_buffer_eq, item_id, setup_test_terminal},
639        ui::components::content_view::{ActiveView, views::ViewData},
640    };
641    use anyhow::Result;
642    use mecomp_core::{config::Settings, state::StateAudio};
643    use mecomp_prost::{LibraryBrief, PlaylistBrief, SearchResult};
644    use mecomp_storage::db::schemas::playlist::TABLE_NAME;
645    use pretty_assertions::assert_eq;
646    use ratatui::buffer::Buffer;
647    use rstest::{fixture, rstest};
648
649    #[fixture]
650    fn state() -> AppState {
651        AppState {
652            active_component: ActiveComponent::default(),
653            audio: StateAudio::default(),
654            search: SearchResult::default(),
655            library: LibraryBrief::default(),
656            active_view: ActiveView::default(),
657            additional_view_data: ViewData::default(),
658            settings: Settings::default(),
659        }
660    }
661
662    #[fixture]
663    fn playlist() -> PlaylistBrief {
664        PlaylistBrief {
665            id: RecordId::new(TABLE_NAME, item_id()),
666            name: "Test Playlist".into(),
667        }
668    }
669
670    #[rstest]
671    #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
672    #[case::small((20,5), Rect::new(0, 0, 20, 5))]
673    #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
674    fn test_playlist_editor_area(
675        #[case] terminal_size: (u16, u16),
676        #[case] expected_area: Rect,
677        state: AppState,
678        playlist: PlaylistBrief,
679    ) {
680        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
681        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
682        let editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
683        let area = editor.area(area);
684        assert_eq!(area, expected_area);
685    }
686
687    #[rstest]
688    fn test_playlist_editor_render(state: AppState, playlist: PlaylistBrief) -> Result<()> {
689        let (mut terminal, _) = setup_test_terminal(20, 5);
690        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
691        let editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
692        let buffer = terminal
693            .draw(|frame| editor.render_popup(frame))?
694            .buffer
695            .clone();
696
697        let expected = Buffer::with_lines([
698            "┌Rename Playlist───┐",
699            "│┌Enter Name:─────┐│",
700            "││Test Playlist   ││",
701            "│└────────────────┘│",
702            "└ ⏎ : Rename───────┘",
703        ]);
704
705        assert_buffer_eq(&buffer, &expected);
706        Ok(())
707    }
708
709    #[rstest]
710    fn test_playlist_editor_input(state: AppState, playlist: PlaylistBrief) {
711        let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
712        let mut editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
713
714        // Test typing
715        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
716        assert_eq!(editor.input_box.text(), "Test Playlista");
717
718        // Test enter with name
719        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
720        assert_eq!(editor.input_box.text(), "Test Playlista");
721        assert_eq!(
722            action_rx.blocking_recv(),
723            Some(Action::Popup(PopupAction::Close))
724        );
725        assert_eq!(
726            action_rx.blocking_recv(),
727            Some(Action::Library(LibraryAction::RenamePlaylist(
728                playlist.id.into(),
729                "Test Playlista".into()
730            )))
731        );
732
733        // Test backspace
734        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
735        assert_eq!(editor.input_box.text(), "Test Playlist");
736
737        // Test enter with empty name
738        editor.input_box.set_text("");
739        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
740        assert_eq!(editor.input_box.text(), "");
741    }
742}