1use 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#[allow(clippy::module_name_repetitions)]
48#[derive(Debug)]
49pub struct PlaylistSelector {
50 action_tx: UnboundedSender<Action>,
52 props: Props,
54 tree_state: Mutex<CheckTreeState<String>>,
56 input_box: InputBox,
58 input_box_visible: bool,
60 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 if self.input_box_visible {
131 match key.code {
132 KeyCode::Enter => {
135 let name = self.input_box.text();
136 if !name.is_empty() {
137 self.action_tx
139 .send(Action::Library(LibraryAction::CreatePlaylistAndAddThings(
140 name.to_string(),
141 self.items.clone(),
142 )))
143 .unwrap();
144 self.action_tx
146 .send(Action::Popup(PopupAction::Close))
147 .unwrap();
148 }
149 self.input_box_visible = false;
150 }
151 _ => self.input_box.handle_key_event(key),
153 }
154 } else {
155 match key.code {
156 KeyCode::Char('n') => {
158 self.input_box_visible = true;
159 }
160 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 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 self.action_tx
193 .send(Action::Library(LibraryAction::AddThingsToPlaylist(
194 thing,
195 self.items.clone(),
196 )))
197 .unwrap();
198 self.action_tx
200 .send(Action::Popup(PopupAction::Close))
201 .unwrap();
202 }
203 }
204 }
205 _ => {}
206 }
207 }
208 }
209
210 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 let area = area.inner(Margin::new(1, 1));
220
221 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 let [input_box_area, content_area] = split_area(area);
266
267 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 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 let playlists = self
301 .props
302 .playlists
303 .iter()
304 .map(create_playlist_tree_leaf)
305 .collect::<Vec<_>>();
306
307 frame.render_stateful_widget(
309 CheckTree::new(&playlists)
310 .unwrap()
311 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
312 .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
322pub 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 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 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
714 assert_eq!(editor.input_box.text(), "Test Playlista");
715
716 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 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
733 assert_eq!(editor.input_box.text(), "Test Playlist");
734
735 editor.input_box.set_text("");
737 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
738 assert_eq!(editor.input_box.text(), "");
739 }
740}