1use 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#[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) -> 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 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 return;
232 }
233
234 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 let [input_box_area, content_area] = split_area(area);
272
273 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 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 let playlists = self
307 .props
308 .playlists
309 .iter()
310 .map(create_playlist_tree_leaf)
311 .collect::<Vec<_>>();
312
313 frame.render_stateful_widget(
315 CheckTree::new(&playlists)
316 .unwrap()
317 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
318 .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
328pub 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 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 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
722 assert_eq!(editor.input_box.text(), "Test Playlista");
723
724 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 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
741 assert_eq!(editor.input_box.text(), "Test Playlist");
742
743 editor.input_box.set_text("");
745 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
746 assert_eq!(editor.input_box.text(), "");
747 }
748}