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::{playlist::Playlist, Thing};
15use ratatui::{
16    layout::{Constraint, Direction, Layout, Margin, Position, Rect},
17    style::{Style, Stylize},
18    text::Line,
19    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
20    Frame,
21};
22use tokio::sync::mpsc::UnboundedSender;
23
24use crate::{
25    state::action::{Action, LibraryAction, PopupAction},
26    ui::{
27        colors::{BORDER_FOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT},
28        components::{
29            content_view::views::{checktree_utils::create_playlist_tree_leaf, playlist::Props},
30            Component, ComponentRender,
31        },
32        widgets::{
33            input_box::{InputBox, RenderProps},
34            tree::{state::CheckTreeState, CheckTree},
35        },
36        AppState,
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<Thing>,
62}
63
64impl PlaylistSelector {
65    #[must_use]
66    pub fn new(state: &AppState, action_tx: UnboundedSender<Action>, items: Vec<Thing>) -> 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(if self.input_box_visible {
85            ""
86        } else {
87            "  \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        } else {
232            match kind {
233                MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
234                    self.tree_state.lock().unwrap().mouse_click(mouse_position);
235                }
236                MouseEventKind::ScrollDown if area.contains(mouse_position) => {
237                    self.tree_state.lock().unwrap().key_down();
238                }
239                MouseEventKind::ScrollUp if area.contains(mouse_position) => {
240                    self.tree_state.lock().unwrap().key_up();
241                }
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        .split(area)
253    else {
254        panic!("Failed to split playlist selector area");
255    };
256    [input_box_area, content_area]
257}
258
259impl ComponentRender<Rect> for PlaylistSelector {
260    fn render_border(&self, frame: &mut ratatui::Frame, area: Rect) -> Rect {
261        let area = self.render_popup_border(frame, area);
262
263        let content_area = if self.input_box_visible {
264            // split content area to make room for the input box
265            let [input_box_area, content_area] = split_area(area);
266
267            // render input box
268            self.input_box.render(
269                frame,
270                RenderProps {
271                    area: input_box_area,
272                    text_color: TEXT_HIGHLIGHT_ALT.into(),
273                    border: Block::bordered()
274                        .title("Enter Name:")
275                        .border_style(Style::default().fg(BORDER_FOCUSED.into())),
276                    show_cursor: self.input_box_visible,
277                },
278            );
279
280            content_area
281        } else {
282            area
283        };
284
285        // draw additional border around content area to display additional instructions
286        let border = Block::new()
287            .borders(Borders::TOP)
288            .title_top(if self.input_box_visible {
289                " \u{23CE} : Create (cancel if empty)"
290            } else {
291                "n: new playlist"
292            })
293            .border_style(Style::default().fg(self.border_color()));
294        frame.render_widget(&border, content_area);
295        border.inner(content_area)
296    }
297
298    fn render_content(&self, frame: &mut Frame, area: Rect) {
299        // create a tree for the playlists
300        let playlists = self
301            .props
302            .playlists
303            .iter()
304            .map(create_playlist_tree_leaf)
305            .collect::<Vec<_>>();
306
307        // render the playlists
308        frame.render_stateful_widget(
309            CheckTree::new(&playlists)
310                .unwrap()
311                .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
312                // we want this to be rendered like a normal tree, not a check tree, so we don't show the checkboxes
313                .node_unchecked_symbol("▪ ")
314                .node_checked_symbol("▪ ")
315                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
316            area,
317            &mut self.tree_state.lock().unwrap(),
318        );
319    }
320}
321
322/// Popup for changing the name of a playlist.
323pub struct PlaylistEditor {
324    action_tx: UnboundedSender<Action>,
325    playlist_id: Thing,
326    input_box: InputBox,
327}
328
329impl PlaylistEditor {
330    #[must_use]
331    pub fn new(state: &AppState, action_tx: UnboundedSender<Action>, playlist: Playlist) -> Self {
332        let mut input_box = InputBox::new(state, action_tx.clone());
333        input_box.set_text(&playlist.name);
334
335        Self {
336            input_box,
337            action_tx,
338            playlist_id: playlist.id.into(),
339        }
340    }
341}
342
343impl Popup for PlaylistEditor {
344    fn title(&self) -> Line {
345        Line::from("Rename Playlist")
346    }
347
348    fn instructions(&self) -> Line {
349        Line::from(" \u{23CE} : Rename")
350    }
351
352    /// Should be located in the upper middle of the screen
353    fn area(&self, terminal_area: Rect) -> Rect {
354        let height = 5;
355        let width = u16::try_from(
356            self.input_box
357                .text()
358                .len()
359                .max(self.instructions().width())
360                .max(self.title().width())
361                + 5,
362        )
363        .unwrap_or(terminal_area.width)
364        .min(terminal_area.width);
365
366        let x = (terminal_area.width - width) / 2;
367        let y = (terminal_area.height - height) / 2;
368
369        Rect::new(x, y, width, height)
370    }
371
372    fn update_with_state(&mut self, _: &AppState) {}
373
374    fn inner_handle_key_event(&mut self, key: KeyEvent) {
375        match key.code {
376            KeyCode::Enter => {
377                let name = self.input_box.text();
378                if !name.is_empty() {
379                    self.action_tx
380                        .send(Action::Popup(PopupAction::Close))
381                        .unwrap();
382                    self.action_tx
383                        .send(Action::Library(LibraryAction::RenamePlaylist(
384                            self.playlist_id.clone(),
385                            name.to_string(),
386                        )))
387                        .unwrap();
388                }
389            }
390            _ => self.input_box.handle_key_event(key),
391        }
392    }
393
394    fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
395        let MouseEvent {
396            column, row, kind, ..
397        } = mouse;
398        let mouse_position = Position::new(column, row);
399
400        if area.contains(mouse_position) {
401            self.input_box.handle_mouse_event(mouse, area);
402        } else if kind == MouseEventKind::Down(MouseButton::Left) {
403            self.action_tx
404                .send(Action::Popup(PopupAction::Close))
405                .unwrap();
406        }
407    }
408}
409
410impl ComponentRender<Rect> for PlaylistEditor {
411    fn render_border(&self, frame: &mut Frame, area: Rect) -> Rect {
412        self.render_popup_border(frame, area)
413    }
414
415    fn render_content(&self, frame: &mut Frame, area: Rect) {
416        self.input_box.render(
417            frame,
418            RenderProps {
419                area,
420                text_color: TEXT_HIGHLIGHT_ALT.into(),
421                border: Block::bordered()
422                    .title("Enter Name:")
423                    .border_style(Style::default().fg(BORDER_FOCUSED.into())),
424                show_cursor: true,
425            },
426        );
427    }
428}
429
430#[cfg(test)]
431mod selector_tests {
432    use std::time::Duration;
433
434    use super::*;
435    use crate::{
436        state::component::ActiveComponent,
437        test_utils::setup_test_terminal,
438        ui::components::content_view::{views::ViewData, ActiveView},
439    };
440    use anyhow::Result;
441    use mecomp_core::{
442        rpc::SearchResult,
443        state::{library::LibraryFull, StateAudio},
444    };
445    use mecomp_storage::db::schemas::playlist::Playlist;
446    use pretty_assertions::assert_eq;
447    use ratatui::{
448        buffer::Buffer,
449        style::{Color, Style},
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: LibraryFull {
461                playlists: vec![Playlist {
462                    id: Playlist::generate_id(),
463                    name: "playlist 1".into(),
464                    runtime: Duration::default(),
465                    song_count: 0,
466                }]
467                .into_boxed_slice(),
468                ..Default::default()
469            },
470            active_view: ActiveView::default(),
471            additional_view_data: ViewData::default(),
472        }
473    }
474
475    #[fixture]
476    fn border_style() -> Style {
477        Style::reset().fg(Color::Rgb(3, 169, 244))
478    }
479
480    #[fixture]
481    fn input_box_style() -> Style {
482        Style::reset().fg(Color::Rgb(239, 154, 154))
483    }
484
485    #[rstest]
486    #[case::large((100, 100), Rect::new(50, 10, 31, 80))]
487    #[case::small((31, 10), Rect::new(0, 0, 31, 10))]
488    #[case::too_small((20, 5), Rect::new(0, 0, 20, 5))]
489    fn test_playlist_selector_area(
490        #[case] terminal_size: (u16, u16),
491        #[case] expected_area: Rect,
492        state: AppState,
493    ) {
494        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
495        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
496        let items = vec![];
497        let area = PlaylistSelector::new(&state, action_tx, items).area(area);
498        assert_eq!(area, expected_area);
499    }
500
501    #[rstest]
502    fn test_playlist_selector_render(
503        state: AppState,
504        #[from(border_style)] style: Style,
505    ) -> Result<()> {
506        let (mut terminal, _) = setup_test_terminal(31, 10);
507        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
508        let items = vec![];
509        let popup = PlaylistSelector::new(&state, action_tx, items);
510        let buffer = terminal
511            .draw(|frame| popup.render_popup(frame))?
512            .buffer
513            .clone();
514        let expected = Buffer::with_lines([
515            Line::styled("┌Select a Playlist────────────┐", style),
516            Line::styled("│n: new playlist──────────────│", style),
517            Line::from(vec![
518                Span::styled("│", style),
519                Span::raw("▪ "),
520                Span::raw("playlist 1").bold(),
521                Span::raw("                 "),
522                Span::styled("│", style),
523            ]),
524            Line::from(vec![
525                Span::styled("│", style),
526                Span::raw("                             "),
527                Span::styled("│", style),
528            ]),
529            Line::from(vec![
530                Span::styled("│", style),
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::styled("└  ⏎ : Select | ↑/↓: Up/Down──┘", style),
555        ]);
556
557        assert_eq!(buffer, expected);
558
559        Ok(())
560    }
561
562    #[rstest]
563    fn test_playlist_selector_render_input_box(
564        state: AppState,
565        border_style: Style,
566        input_box_style: Style,
567    ) -> Result<()> {
568        let (mut terminal, _) = setup_test_terminal(31, 10);
569        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
570        let items = vec![];
571        let mut popup = PlaylistSelector::new(&state, action_tx, items);
572        popup.inner_handle_key_event(KeyEvent::from(KeyCode::Char('n')));
573        let buffer = terminal
574            .draw(|frame| popup.render_popup(frame))?
575            .buffer
576            .clone();
577        let expected = Buffer::with_lines([
578            Line::styled("┌Select a Playlist────────────┐", border_style),
579            Line::from(vec![
580                Span::styled("│", border_style),
581                Span::styled("┌Enter Name:────────────────┐", input_box_style),
582                Span::styled("│", border_style),
583            ]),
584            Line::from(vec![
585                Span::styled("│", border_style),
586                Span::styled("│                           │", input_box_style),
587                Span::styled("│", border_style),
588            ]),
589            Line::from(vec![
590                Span::styled("│", border_style),
591                Span::styled("└───────────────────────────┘", input_box_style),
592                Span::styled("│", border_style),
593            ]),
594            Line::styled("│ ⏎ : Create (cancel if empty)│", border_style),
595            Line::from(vec![
596                Span::styled("│", border_style),
597                Span::raw("▪ "),
598                Span::raw("playlist 1").bold(),
599                Span::raw("                 "),
600                Span::styled("│", border_style),
601            ]),
602            Line::from(vec![
603                Span::styled("│", border_style),
604                Span::raw("                             "),
605                Span::styled("│", border_style),
606            ]),
607            Line::from(vec![
608                Span::styled("│", border_style),
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::styled("└─────────────────────────────┘", border_style),
618        ]);
619
620        assert_eq!(buffer, expected);
621
622        Ok(())
623    }
624}
625
626#[cfg(test)]
627mod editor_tests {
628    use std::time::Duration;
629
630    use super::*;
631    use crate::{
632        state::component::ActiveComponent,
633        test_utils::{assert_buffer_eq, setup_test_terminal},
634        ui::components::content_view::{views::ViewData, ActiveView},
635    };
636    use anyhow::Result;
637    use mecomp_core::{
638        rpc::SearchResult,
639        state::{library::LibraryFull, StateAudio},
640    };
641    use mecomp_storage::db::schemas::playlist::Playlist;
642    use pretty_assertions::assert_eq;
643    use ratatui::buffer::Buffer;
644    use rstest::{fixture, rstest};
645
646    #[fixture]
647    fn state() -> AppState {
648        AppState {
649            active_component: ActiveComponent::default(),
650            audio: StateAudio::default(),
651            search: SearchResult::default(),
652            library: LibraryFull::default(),
653            active_view: ActiveView::default(),
654            additional_view_data: ViewData::default(),
655        }
656    }
657
658    #[fixture]
659    fn playlist() -> Playlist {
660        Playlist {
661            id: Playlist::generate_id(),
662            name: "Test Playlist".into(),
663            runtime: Duration::default(),
664            song_count: 0,
665        }
666    }
667
668    #[rstest]
669    #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
670    #[case::small((20,5), Rect::new(0, 0, 20, 5))]
671    #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
672    fn test_playlist_editor_area(
673        #[case] terminal_size: (u16, u16),
674        #[case] expected_area: Rect,
675        state: AppState,
676        playlist: Playlist,
677    ) {
678        let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
679        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
680        let editor = PlaylistEditor::new(&state, action_tx, playlist);
681        let area = editor.area(area);
682        assert_eq!(area, expected_area);
683    }
684
685    #[rstest]
686    fn test_playlist_editor_render(state: AppState, playlist: Playlist) -> Result<()> {
687        let (mut terminal, _) = setup_test_terminal(20, 5);
688        let action_tx = tokio::sync::mpsc::unbounded_channel().0;
689        let editor = PlaylistEditor::new(&state, action_tx, playlist);
690        let buffer = terminal
691            .draw(|frame| editor.render_popup(frame))?
692            .buffer
693            .clone();
694
695        let expected = Buffer::with_lines([
696            "┌Rename Playlist───┐",
697            "│┌Enter Name:─────┐│",
698            "││Test Playlist   ││",
699            "│└────────────────┘│",
700            "└ ⏎ : Rename───────┘",
701        ]);
702
703        assert_buffer_eq(&buffer, &expected);
704        Ok(())
705    }
706
707    #[rstest]
708    fn test_playlist_editor_input(state: AppState, playlist: Playlist) {
709        let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
710        let mut editor = PlaylistEditor::new(&state, action_tx, playlist.clone());
711
712        // Test typing
713        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
714        assert_eq!(editor.input_box.text(), "Test Playlista");
715
716        // Test enter with name
717        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
718        assert_eq!(editor.input_box.text(), "Test Playlista");
719        assert_eq!(
720            action_rx.blocking_recv(),
721            Some(Action::Popup(PopupAction::Close))
722        );
723        assert_eq!(
724            action_rx.blocking_recv(),
725            Some(Action::Library(LibraryAction::RenamePlaylist(
726                playlist.id.clone().into(),
727                "Test Playlista".into()
728            )))
729        );
730
731        // Test backspace
732        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
733        assert_eq!(editor.input_box.text(), "Test Playlist");
734
735        // Test enter with empty name
736        editor.input_box.set_text("");
737        editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
738        assert_eq!(editor.input_box.text(), "");
739    }
740}