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