1use std::{ops::Not, 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) -> 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(
339 state: &AppState,
340 action_tx: UnboundedSender<Action>,
341 playlist: PlaylistBrief,
342 ) -> Self {
343 let mut input_box = InputBox::new(state, action_tx.clone());
344 input_box.set_text(&playlist.name);
345
346 Self {
347 input_box,
348 action_tx,
349 playlist_id: playlist.id.into(),
350 }
351 }
352}
353
354impl Popup for PlaylistEditor {
355 fn title(&self) -> Line {
356 Line::from("Rename Playlist")
357 }
358
359 fn instructions(&self) -> Line {
360 Line::from(" \u{23CE} : Rename")
361 }
362
363 fn area(&self, terminal_area: Rect) -> Rect {
365 let height = 5;
366 let width = u16::try_from(
367 self.input_box
368 .text()
369 .len()
370 .max(self.instructions().width())
371 .max(self.title().width())
372 + 5,
373 )
374 .unwrap_or(terminal_area.width)
375 .min(terminal_area.width);
376
377 let x = (terminal_area.width - width) / 2;
378 let y = (terminal_area.height - height) / 2;
379
380 Rect::new(x, y, width, height)
381 }
382
383 fn update_with_state(&mut self, _: &AppState) {}
384
385 fn inner_handle_key_event(&mut self, key: KeyEvent) {
386 match key.code {
387 KeyCode::Enter => {
388 let name = self.input_box.text();
389 if name.is_empty() {
390 return;
391 }
392
393 self.action_tx
394 .send(Action::Popup(PopupAction::Close))
395 .unwrap();
396 self.action_tx
397 .send(Action::Library(LibraryAction::RenamePlaylist(
398 self.playlist_id.clone(),
399 name.to_string(),
400 )))
401 .unwrap();
402 }
403 _ => self.input_box.handle_key_event(key),
404 }
405 }
406
407 fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
408 let MouseEvent {
409 column, row, kind, ..
410 } = mouse;
411 let mouse_position = Position::new(column, row);
412
413 if area.contains(mouse_position) {
414 self.input_box.handle_mouse_event(mouse, area);
415 } else if kind == MouseEventKind::Down(MouseButton::Left) {
416 self.action_tx
417 .send(Action::Popup(PopupAction::Close))
418 .unwrap();
419 }
420 }
421}
422
423impl ComponentRender<Rect> for PlaylistEditor {
424 fn render_border(&self, frame: &mut Frame, area: Rect) -> Rect {
425 self.render_popup_border(frame, area)
426 }
427
428 fn render_content(&self, frame: &mut Frame, area: Rect) {
429 self.input_box.render(
430 frame,
431 RenderProps {
432 area,
433 text_color: (*TEXT_HIGHLIGHT_ALT).into(),
434 border: Block::bordered()
435 .title("Enter Name:")
436 .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
437 show_cursor: true,
438 },
439 );
440 }
441}
442
443#[cfg(test)]
444mod selector_tests {
445 use super::*;
446 use crate::{
447 state::component::ActiveComponent,
448 test_utils::setup_test_terminal,
449 ui::components::content_view::{ActiveView, views::ViewData},
450 };
451 use anyhow::Result;
452 use mecomp_core::{
453 config::Settings,
454 rpc::SearchResult,
455 state::{StateAudio, library::LibraryBrief},
456 };
457 use mecomp_storage::db::schemas::playlist::Playlist;
458 use pretty_assertions::assert_eq;
459 use ratatui::{
460 buffer::Buffer,
461 style::{Color, Style},
462 text::Span,
463 };
464 use rstest::{fixture, rstest};
465
466 #[fixture]
467 fn state() -> AppState {
468 AppState {
469 active_component: ActiveComponent::default(),
470 audio: StateAudio::default(),
471 search: SearchResult::default(),
472 library: LibraryBrief {
473 playlists: vec![PlaylistBrief {
474 id: Playlist::generate_id(),
475 name: "playlist 1".into(),
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 super::*;
640 use crate::{
641 state::component::ActiveComponent,
642 test_utils::{assert_buffer_eq, setup_test_terminal},
643 ui::components::content_view::{ActiveView, views::ViewData},
644 };
645 use anyhow::Result;
646 use mecomp_core::{
647 config::Settings,
648 rpc::SearchResult,
649 state::{StateAudio, library::LibraryBrief},
650 };
651 use mecomp_storage::db::schemas::playlist::Playlist;
652 use pretty_assertions::assert_eq;
653 use ratatui::buffer::Buffer;
654 use rstest::{fixture, rstest};
655
656 #[fixture]
657 fn state() -> AppState {
658 AppState {
659 active_component: ActiveComponent::default(),
660 audio: StateAudio::default(),
661 search: SearchResult::default(),
662 library: LibraryBrief::default(),
663 active_view: ActiveView::default(),
664 additional_view_data: ViewData::default(),
665 settings: Settings::default(),
666 }
667 }
668
669 #[fixture]
670 fn playlist() -> PlaylistBrief {
671 PlaylistBrief {
672 id: Playlist::generate_id(),
673 name: "Test Playlist".into(),
674 }
675 }
676
677 #[rstest]
678 #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
679 #[case::small((20,5), Rect::new(0, 0, 20, 5))]
680 #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
681 fn test_playlist_editor_area(
682 #[case] terminal_size: (u16, u16),
683 #[case] expected_area: Rect,
684 state: AppState,
685 playlist: PlaylistBrief,
686 ) {
687 let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
688 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
689 let editor = PlaylistEditor::new(&state, action_tx, playlist);
690 let area = editor.area(area);
691 assert_eq!(area, expected_area);
692 }
693
694 #[rstest]
695 fn test_playlist_editor_render(state: AppState, playlist: PlaylistBrief) -> Result<()> {
696 let (mut terminal, _) = setup_test_terminal(20, 5);
697 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
698 let editor = PlaylistEditor::new(&state, action_tx, playlist);
699 let buffer = terminal
700 .draw(|frame| editor.render_popup(frame))?
701 .buffer
702 .clone();
703
704 let expected = Buffer::with_lines([
705 "┌Rename Playlist───┐",
706 "│┌Enter Name:─────┐│",
707 "││Test Playlist ││",
708 "│└────────────────┘│",
709 "└ ⏎ : Rename───────┘",
710 ]);
711
712 assert_buffer_eq(&buffer, &expected);
713 Ok(())
714 }
715
716 #[rstest]
717 fn test_playlist_editor_input(state: AppState, playlist: PlaylistBrief) {
718 let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
719 let mut editor = PlaylistEditor::new(&state, action_tx, playlist.clone());
720
721 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
723 assert_eq!(editor.input_box.text(), "Test Playlista");
724
725 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
727 assert_eq!(editor.input_box.text(), "Test Playlista");
728 assert_eq!(
729 action_rx.blocking_recv(),
730 Some(Action::Popup(PopupAction::Close))
731 );
732 assert_eq!(
733 action_rx.blocking_recv(),
734 Some(Action::Library(LibraryAction::RenamePlaylist(
735 playlist.id.into(),
736 "Test Playlista".into()
737 )))
738 );
739
740 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
742 assert_eq!(editor.input_box.text(), "Test Playlist");
743
744 editor.input_box.set_text("");
746 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
747 assert_eq!(editor.input_box.text(), "");
748 }
749}