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_storage::db::schemas::{RecordId, playlist::PlaylistBrief};
15use ratatui::{
16    Frame,
17    layout::{Constraint, Direction, Layout, Margin, Position, Rect},
18    style::{Style, Stylize},
19    text::Line,
20    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
21};
22use tokio::sync::mpsc::UnboundedSender;
23
24use crate::{
25    state::action::{Action, LibraryAction, PopupAction},
26    ui::{
27        AppState,
28        colors::{BORDER_FOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT},
29        components::{
30            Component, ComponentRender,
31            content_view::views::{checktree_utils::create_playlist_tree_leaf, playlist::Props},
32        },
33        widgets::{
34            input_box::{InputBox, RenderProps},
35            tree::{CheckTree, state::CheckTreeState},
36        },
37    },
38};
39
40use super::Popup;
41
42/// A popup that prompts the user to select a playlist, or create a new one.
43///
44/// The popup will consist of a list of playlists to select from,
45/// and if the user wants to create a new playlist, they can press the "n" key,
46/// which will make an input box appear for the user to type the name of the new playlist.
47#[allow(clippy::module_name_repetitions)]
48#[derive(Debug)]
49pub struct PlaylistSelector {
50    /// Action Sender
51    action_tx: UnboundedSender<Action>,
52    /// Mapped Props from state
53    props: Props,
54    /// tree state
55    tree_state: Mutex<CheckTreeState<String>>,
56    /// Playlist Name Input Box
57    input_box: InputBox,
58    /// Is the input box visible
59    input_box_visible: bool,
60    /// The items to add to the playlist
61    items: Vec<RecordId>,
62}
63
64impl PlaylistSelector {
65    #[must_use]
66    pub fn new(state: &AppState, action_tx: UnboundedSender<Action>, items: Vec<RecordId>) -> Self {
67        Self {
68            input_box: InputBox::new(state, action_tx.clone()),
69            input_box_visible: false,
70            action_tx,
71            props: Props::from(state),
72            tree_state: Mutex::new(CheckTreeState::default()),
73            items,
74        }
75    }
76}
77
78impl Popup for PlaylistSelector {
79    fn title(&self) -> 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: RecordId,
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: PlaylistBrief,
341    ) -> Self {
342        let mut input_box = InputBox::new(state, action_tx.clone());
343        input_box.set_text(&playlist.name);
344
345        Self {
346            input_box,
347            action_tx,
348            playlist_id: playlist.id.into(),
349        }
350    }
351}
352
353impl Popup for PlaylistEditor {
354    fn title(&self) -> Line<'static> {
355        Line::from("Rename Playlist")
356    }
357
358    fn instructions(&self) -> Line<'static> {
359        Line::from(" \u{23CE} : Rename")
360    }
361
362    /// Should be located in the upper middle of the screen
363    fn area(&self, terminal_area: Rect) -> Rect {
364        let height = 5;
365        let width = u16::try_from(
366            self.input_box
367                .text()
368                .len()
369                .max(self.instructions().width())
370                .max(self.title().width())
371                + 5,
372        )
373        .unwrap_or(terminal_area.width)
374        .min(terminal_area.width);
375
376        let x = (terminal_area.width - width) / 2;
377        let y = (terminal_area.height - height) / 2;
378
379        Rect::new(x, y, width, height)
380    }
381
382    fn update_with_state(&mut self, _: &AppState) {}
383
384    fn inner_handle_key_event(&mut self, key: KeyEvent) {
385        match key.code {
386            KeyCode::Enter => {
387                let name = self.input_box.text();
388                if name.is_empty() {
389                    return;
390                }
391
392                self.action_tx
393                    .send(Action::Popup(PopupAction::Close))
394                    .unwrap();
395                self.action_tx
396                    .send(Action::Library(LibraryAction::RenamePlaylist(
397                        self.playlist_id.clone(),
398                        name.to_string(),
399                    )))
400                    .unwrap();
401            }
402            _ => self.input_box.handle_key_event(key),
403        }
404    }
405
406    fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
407        let MouseEvent {
408            column, row, kind, ..
409        } = mouse;
410        let mouse_position = Position::new(column, row);
411
412        if area.contains(mouse_position) {
413            self.input_box.handle_mouse_event(mouse, area);
414        } else if kind == MouseEventKind::Down(MouseButton::Left) {
415            self.action_tx
416                .send(Action::Popup(PopupAction::Close))
417                .unwrap();
418        }
419    }
420}
421
422impl ComponentRender<Rect> for PlaylistEditor {
423    fn render_border(&self, frame: &mut Frame, area: Rect) -> Rect {
424        self.render_popup_border(frame, area)
425    }
426
427    fn render_content(&self, frame: &mut Frame, area: Rect) {
428        self.input_box.render(
429            frame,
430            RenderProps {
431                area,
432                text_color: (*TEXT_HIGHLIGHT_ALT).into(),
433                border: Block::bordered()
434                    .title("Enter Name:")
435                    .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
436                show_cursor: true,
437            },
438        );
439    }
440}
441
442#[cfg(test)]
443mod selector_tests {
444    use super::*;
445    use crate::{
446        state::component::ActiveComponent,
447        test_utils::setup_test_terminal,
448        ui::components::content_view::{ActiveView, views::ViewData},
449    };
450    use anyhow::Result;
451    use mecomp_core::{
452        config::Settings,
453        rpc::SearchResult,
454        state::{StateAudio, library::LibraryBrief},
455    };
456    use mecomp_storage::db::schemas::playlist::Playlist;
457    use pretty_assertions::assert_eq;
458    use ratatui::{
459        buffer::Buffer,
460        style::{Color, Style},
461        text::Span,
462    };
463    use rstest::{fixture, rstest};
464
465    #[fixture]
466    fn state() -> AppState {
467        AppState {
468            active_component: ActiveComponent::default(),
469            audio: StateAudio::default(),
470            search: SearchResult::default(),
471            library: LibraryBrief {
472                playlists: vec![PlaylistBrief {
473                    id: Playlist::generate_id(),
474                    name: "playlist 1".into(),
475                }]
476                .into_boxed_slice(),
477                ..Default::default()
478            },
479            active_view: ActiveView::default(),
480            additional_view_data: ViewData::default(),
481            settings: Settings::default(),
482        }
483    }
484
485    #[fixture]
486    fn border_style() -> Style {
487        Style::reset().fg(Color::Rgb(3, 169, 244))
488    }
489
490    #[fixture]
491    fn input_box_style() -> Style {
492        Style::reset().fg(Color::Rgb(239, 154, 154))
493    }
494
495    #[rstest]
496    #[case::large((100, 100), Rect::new(50, 10, 31, 80))]
497    #[case::small((31, 10), Rect::new(0, 0, 31, 10))]
498    #[case::too_small((20, 5), Rect::new(0, 0, 20, 5))]
499    fn test_playlist_selector_area(
500        #[case] terminal_size: (u16, u16),
501        #[case] expected_area: Rect,
502        state: AppState,
503    ) {
504        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
505        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
506        let items = vec![];
507        let area = PlaylistSelector::new(&state, action_tx, items).area(area);
508        assert_eq!(area, expected_area);
509    }
510
511    #[rstest]
512    fn test_playlist_selector_render(
513        state: AppState,
514        #[from(border_style)] style: Style,
515    ) -> Result<()> {
516        let (mut terminal, _) = setup_test_terminal(31, 10);
517        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
518        let items = vec![];
519        let popup = PlaylistSelector::new(&state, action_tx, items);
520        let buffer = terminal
521            .draw(|frame| popup.render_popup(frame))?
522            .buffer
523            .clone();
524        let expected = Buffer::with_lines([
525            Line::styled("┌Select a Playlist────────────┐", style),
526            Line::styled("│n: new playlist──────────────│", style),
527            Line::from(vec![
528                Span::styled("│", style),
529                Span::raw("▪ "),
530                Span::raw("playlist 1").bold(),
531                Span::raw("                 "),
532                Span::styled("│", style),
533            ]),
534            Line::from(vec![
535                Span::styled("│", style),
536                Span::raw("                             "),
537                Span::styled("│", style),
538            ]),
539            Line::from(vec![
540                Span::styled("│", style),
541                Span::raw("                             "),
542                Span::styled("│", style),
543            ]),
544            Line::from(vec![
545                Span::styled("│", style),
546                Span::raw("                             "),
547                Span::styled("│", style),
548            ]),
549            Line::from(vec![
550                Span::styled("│", style),
551                Span::raw("                             "),
552                Span::styled("│", style),
553            ]),
554            Line::from(vec![
555                Span::styled("│", style),
556                Span::raw("                             "),
557                Span::styled("│", style),
558            ]),
559            Line::from(vec![
560                Span::styled("│", style),
561                Span::raw("                             "),
562                Span::styled("│", style),
563            ]),
564            Line::styled("└  ⏎ : Select | ↑/↓: Up/Down──┘", style),
565        ]);
566
567        assert_eq!(buffer, expected);
568
569        Ok(())
570    }
571
572    #[rstest]
573    fn test_playlist_selector_render_input_box(
574        state: AppState,
575        border_style: Style,
576        input_box_style: Style,
577    ) -> Result<()> {
578        let (mut terminal, _) = setup_test_terminal(31, 10);
579        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
580        let items = vec![];
581        let mut popup = PlaylistSelector::new(&state, action_tx, items);
582        popup.inner_handle_key_event(KeyEvent::from(KeyCode::Char('n')));
583        let buffer = terminal
584            .draw(|frame| popup.render_popup(frame))?
585            .buffer
586            .clone();
587        let expected = Buffer::with_lines([
588            Line::styled("┌Select a Playlist────────────┐", border_style),
589            Line::from(vec![
590                Span::styled("│", border_style),
591                Span::styled("┌Enter Name:────────────────┐", input_box_style),
592                Span::styled("│", border_style),
593            ]),
594            Line::from(vec![
595                Span::styled("│", border_style),
596                Span::styled("│                           │", input_box_style),
597                Span::styled("│", border_style),
598            ]),
599            Line::from(vec![
600                Span::styled("│", border_style),
601                Span::styled("└───────────────────────────┘", input_box_style),
602                Span::styled("│", border_style),
603            ]),
604            Line::styled("│ ⏎ : Create (cancel if empty)│", border_style),
605            Line::from(vec![
606                Span::styled("│", border_style),
607                Span::raw("▪ "),
608                Span::raw("playlist 1").bold(),
609                Span::raw("                 "),
610                Span::styled("│", border_style),
611            ]),
612            Line::from(vec![
613                Span::styled("│", border_style),
614                Span::raw("                             "),
615                Span::styled("│", border_style),
616            ]),
617            Line::from(vec![
618                Span::styled("│", border_style),
619                Span::raw("                             "),
620                Span::styled("│", border_style),
621            ]),
622            Line::from(vec![
623                Span::styled("│", border_style),
624                Span::raw("                             "),
625                Span::styled("│", border_style),
626            ]),
627            Line::styled("└─────────────────────────────┘", border_style),
628        ]);
629
630        assert_eq!(buffer, expected);
631
632        Ok(())
633    }
634}
635
636#[cfg(test)]
637mod editor_tests {
638    use super::*;
639    use crate::{
640        state::component::ActiveComponent,
641        test_utils::{assert_buffer_eq, setup_test_terminal},
642        ui::components::content_view::{ActiveView, views::ViewData},
643    };
644    use anyhow::Result;
645    use mecomp_core::{
646        config::Settings,
647        rpc::SearchResult,
648        state::{StateAudio, library::LibraryBrief},
649    };
650    use mecomp_storage::db::schemas::playlist::Playlist;
651    use pretty_assertions::assert_eq;
652    use ratatui::buffer::Buffer;
653    use rstest::{fixture, rstest};
654
655    #[fixture]
656    fn state() -> AppState {
657        AppState {
658            active_component: ActiveComponent::default(),
659            audio: StateAudio::default(),
660            search: SearchResult::default(),
661            library: LibraryBrief::default(),
662            active_view: ActiveView::default(),
663            additional_view_data: ViewData::default(),
664            settings: Settings::default(),
665        }
666    }
667
668    #[fixture]
669    fn playlist() -> PlaylistBrief {
670        PlaylistBrief {
671            id: Playlist::generate_id(),
672            name: "Test Playlist".into(),
673        }
674    }
675
676    #[rstest]
677    #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
678    #[case::small((20,5), Rect::new(0, 0, 20, 5))]
679    #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
680    fn test_playlist_editor_area(
681        #[case] terminal_size: (u16, u16),
682        #[case] expected_area: Rect,
683        state: AppState,
684        playlist: PlaylistBrief,
685    ) {
686        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
687        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
688        let editor = PlaylistEditor::new(&state, action_tx, playlist);
689        let area = editor.area(area);
690        assert_eq!(area, expected_area);
691    }
692
693    #[rstest]
694    fn test_playlist_editor_render(state: AppState, playlist: PlaylistBrief) -> Result<()> {
695        let (mut terminal, _) = setup_test_terminal(20, 5);
696        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
697        let editor = PlaylistEditor::new(&state, action_tx, playlist);
698        let buffer = terminal
699            .draw(|frame| editor.render_popup(frame))?
700            .buffer
701            .clone();
702
703        let expected = Buffer::with_lines([
704            "┌Rename Playlist───┐",
705            "│┌Enter Name:─────┐│",
706            "││Test Playlist   ││",
707            "│└────────────────┘│",
708            "└ ⏎ : Rename───────┘",
709        ]);
710
711        assert_buffer_eq(&buffer, &expected);
712        Ok(())
713    }
714
715    #[rstest]
716    fn test_playlist_editor_input(state: AppState, playlist: PlaylistBrief) {
717        let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
718        let mut editor = PlaylistEditor::new(&state, action_tx, playlist.clone());
719
720        // Test typing
721        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
722        assert_eq!(editor.input_box.text(), "Test Playlista");
723
724        // Test enter with name
725        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
726        assert_eq!(editor.input_box.text(), "Test Playlista");
727        assert_eq!(
728            action_rx.blocking_recv(),
729            Some(Action::Popup(PopupAction::Close))
730        );
731        assert_eq!(
732            action_rx.blocking_recv(),
733            Some(Action::Library(LibraryAction::RenamePlaylist(
734                playlist.id.into(),
735                "Test Playlista".into()
736            )))
737        );
738
739        // Test backspace
740        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
741        assert_eq!(editor.input_box.text(), "Test Playlist");
742
743        // Test enter with empty name
744        editor.input_box.set_text("");
745        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
746        assert_eq!(editor.input_box.text(), "");
747    }
748}