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::Playlist};
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(state: &AppState, action_tx: UnboundedSender<Action>, playlist: Playlist) -> Self {
339        let mut input_box = InputBox::new(state, action_tx.clone());
340        input_box.set_text(&playlist.name);
341
342        Self {
343            input_box,
344            action_tx,
345            playlist_id: playlist.id.into(),
346        }
347    }
348}
349
350impl Popup for PlaylistEditor {
351    fn title(&self) -> Line {
352        Line::from("Rename Playlist")
353    }
354
355    fn instructions(&self) -> Line {
356        Line::from(" \u{23CE} : Rename")
357    }
358
359    /// Should be located in the upper middle of the screen
360    fn area(&self, terminal_area: Rect) -> Rect {
361        let height = 5;
362        let width = u16::try_from(
363            self.input_box
364                .text()
365                .len()
366                .max(self.instructions().width())
367                .max(self.title().width())
368                + 5,
369        )
370        .unwrap_or(terminal_area.width)
371        .min(terminal_area.width);
372
373        let x = (terminal_area.width - width) / 2;
374        let y = (terminal_area.height - height) / 2;
375
376        Rect::new(x, y, width, height)
377    }
378
379    fn update_with_state(&mut self, _: &AppState) {}
380
381    fn inner_handle_key_event(&mut self, key: KeyEvent) {
382        match key.code {
383            KeyCode::Enter => {
384                let name = self.input_box.text();
385                if name.is_empty() {
386                    return;
387                }
388
389                self.action_tx
390                    .send(Action::Popup(PopupAction::Close))
391                    .unwrap();
392                self.action_tx
393                    .send(Action::Library(LibraryAction::RenamePlaylist(
394                        self.playlist_id.clone(),
395                        name.to_string(),
396                    )))
397                    .unwrap();
398            }
399            _ => self.input_box.handle_key_event(key),
400        }
401    }
402
403    fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
404        let MouseEvent {
405            column, row, kind, ..
406        } = mouse;
407        let mouse_position = Position::new(column, row);
408
409        if area.contains(mouse_position) {
410            self.input_box.handle_mouse_event(mouse, area);
411        } else if kind == MouseEventKind::Down(MouseButton::Left) {
412            self.action_tx
413                .send(Action::Popup(PopupAction::Close))
414                .unwrap();
415        }
416    }
417}
418
419impl ComponentRender<Rect> for PlaylistEditor {
420    fn render_border(&self, frame: &mut Frame, area: Rect) -> Rect {
421        self.render_popup_border(frame, area)
422    }
423
424    fn render_content(&self, frame: &mut Frame, area: Rect) {
425        self.input_box.render(
426            frame,
427            RenderProps {
428                area,
429                text_color: TEXT_HIGHLIGHT_ALT.into(),
430                border: Block::bordered()
431                    .title("Enter Name:")
432                    .border_style(Style::default().fg(BORDER_FOCUSED.into())),
433                show_cursor: true,
434            },
435        );
436    }
437}
438
439#[cfg(test)]
440mod selector_tests {
441    use std::time::Duration;
442
443    use super::*;
444    use crate::{
445        state::component::ActiveComponent,
446        test_utils::setup_test_terminal,
447        ui::components::content_view::{ActiveView, views::ViewData},
448    };
449    use anyhow::Result;
450    use mecomp_core::{
451        config::Settings,
452        rpc::SearchResult,
453        state::{StateAudio, library::LibraryFull},
454    };
455    use mecomp_storage::db::schemas::playlist::Playlist;
456    use pretty_assertions::assert_eq;
457    use ratatui::{
458        buffer::Buffer,
459        style::{Color, Style},
460        text::Span,
461    };
462    use rstest::{fixture, rstest};
463
464    #[fixture]
465    fn state() -> AppState {
466        AppState {
467            active_component: ActiveComponent::default(),
468            audio: StateAudio::default(),
469            search: SearchResult::default(),
470            library: LibraryFull {
471                playlists: vec![Playlist {
472                    id: Playlist::generate_id(),
473                    name: "playlist 1".into(),
474                    runtime: Duration::default(),
475                    song_count: 0,
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 std::time::Duration;
640
641    use super::*;
642    use crate::{
643        state::component::ActiveComponent,
644        test_utils::{assert_buffer_eq, setup_test_terminal},
645        ui::components::content_view::{ActiveView, views::ViewData},
646    };
647    use anyhow::Result;
648    use mecomp_core::{
649        config::Settings,
650        rpc::SearchResult,
651        state::{StateAudio, library::LibraryFull},
652    };
653    use mecomp_storage::db::schemas::playlist::Playlist;
654    use pretty_assertions::assert_eq;
655    use ratatui::buffer::Buffer;
656    use rstest::{fixture, rstest};
657
658    #[fixture]
659    fn state() -> AppState {
660        AppState {
661            active_component: ActiveComponent::default(),
662            audio: StateAudio::default(),
663            search: SearchResult::default(),
664            library: LibraryFull::default(),
665            active_view: ActiveView::default(),
666            additional_view_data: ViewData::default(),
667            settings: Settings::default(),
668        }
669    }
670
671    #[fixture]
672    fn playlist() -> Playlist {
673        Playlist {
674            id: Playlist::generate_id(),
675            name: "Test Playlist".into(),
676            runtime: Duration::default(),
677            song_count: 0,
678        }
679    }
680
681    #[rstest]
682    #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
683    #[case::small((20,5), Rect::new(0, 0, 20, 5))]
684    #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
685    fn test_playlist_editor_area(
686        #[case] terminal_size: (u16, u16),
687        #[case] expected_area: Rect,
688        state: AppState,
689        playlist: Playlist,
690    ) {
691        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
692        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
693        let editor = PlaylistEditor::new(&state, action_tx, playlist);
694        let area = editor.area(area);
695        assert_eq!(area, expected_area);
696    }
697
698    #[rstest]
699    fn test_playlist_editor_render(state: AppState, playlist: Playlist) -> Result<()> {
700        let (mut terminal, _) = setup_test_terminal(20, 5);
701        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
702        let editor = PlaylistEditor::new(&state, action_tx, playlist);
703        let buffer = terminal
704            .draw(|frame| editor.render_popup(frame))?
705            .buffer
706            .clone();
707
708        let expected = Buffer::with_lines([
709            "┌Rename Playlist───┐",
710            "│┌Enter Name:─────┐│",
711            "││Test Playlist   ││",
712            "│└────────────────┘│",
713            "└ ⏎ : Rename───────┘",
714        ]);
715
716        assert_buffer_eq(&buffer, &expected);
717        Ok(())
718    }
719
720    #[rstest]
721    fn test_playlist_editor_input(state: AppState, playlist: Playlist) {
722        let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
723        let mut editor = PlaylistEditor::new(&state, action_tx, playlist.clone());
724
725        // Test typing
726        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
727        assert_eq!(editor.input_box.text(), "Test Playlista");
728
729        // Test enter with name
730        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
731        assert_eq!(editor.input_box.text(), "Test Playlista");
732        assert_eq!(
733            action_rx.blocking_recv(),
734            Some(Action::Popup(PopupAction::Close))
735        );
736        assert_eq!(
737            action_rx.blocking_recv(),
738            Some(Action::Library(LibraryAction::RenamePlaylist(
739                playlist.id.into(),
740                "Test Playlista".into()
741            )))
742        );
743
744        // Test backspace
745        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
746        assert_eq!(editor.input_box.text(), "Test Playlist");
747
748        // Test enter with empty name
749        editor.input_box.set_text("");
750        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
751        assert_eq!(editor.input_box.text(), "");
752    }
753}