1use 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#[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<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 if self.input_box_visible {
132 match key.code {
133 KeyCode::Enter => {
136 let name = self.input_box.text();
137 if !name.is_empty() {
138 self.action_tx
140 .send(Action::Library(LibraryAction::CreatePlaylistAndAddThings(
141 name.to_string(),
142 self.items.clone(),
143 )))
144 .unwrap();
145 self.action_tx
147 .send(Action::Popup(PopupAction::Close))
148 .unwrap();
149 }
150 self.input_box_visible = false;
151 }
152 _ => self.input_box.handle_key_event(key),
154 }
155 } else {
156 match key.code {
157 KeyCode::Char('n') => {
159 self.input_box_visible = true;
160 }
161 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 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 self.action_tx
194 .send(Action::Library(LibraryAction::AddThingsToPlaylist(
195 thing,
196 self.items.clone(),
197 )))
198 .unwrap();
199 self.action_tx
201 .send(Action::Popup(PopupAction::Close))
202 .unwrap();
203 }
204 }
205 }
206 _ => {}
207 }
208 }
209 }
210
211 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 let area = area.inner(Margin::new(1, 1));
221
222 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 !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 let [input_box_area, content_area] = split_area(area);
273
274 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 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 let playlists = self
308 .props
309 .playlists
310 .iter()
311 .map(create_playlist_tree_leaf)
312 .collect::<Vec<_>>();
313
314 frame.render_stateful_widget(
316 CheckTree::new(&playlists)
317 .unwrap()
318 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
319 .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
329pub 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 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 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
727 assert_eq!(editor.input_box.text(), "Test Playlista");
728
729 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 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
746 assert_eq!(editor.input_box.text(), "Test Playlist");
747
748 editor.input_box.set_text("");
750 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
751 assert_eq!(editor.input_box.text(), "");
752 }
753}