1use std::sync::Mutex;
12
13use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
14use mecomp_prost::{RecordId, Ulid};
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: Ulid,
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_id: Ulid,
341 playlist_name: &str,
342 ) -> Self {
343 let mut input_box = InputBox::new(state, action_tx.clone());
344 input_box.set_text(playlist_name);
345
346 Self {
347 action_tx,
348 playlist_id,
349 input_box,
350 }
351 }
352}
353
354impl Popup for PlaylistEditor {
355 fn title(&self) -> Line<'static> {
356 Line::from("Rename Playlist")
357 }
358
359 fn instructions(&self) -> Line<'static> {
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::{item_id, setup_test_terminal},
449 ui::components::content_view::{ActiveView, views::ViewData},
450 };
451 use anyhow::Result;
452 use mecomp_core::{config::Settings, state::StateAudio};
453 use mecomp_prost::{LibraryBrief, PlaylistBrief, SearchResult};
454 use mecomp_storage::db::schemas::playlist::TABLE_NAME;
455 use pretty_assertions::assert_eq;
456 use ratatui::{
457 buffer::Buffer,
458 style::{Color, Style},
459 text::Span,
460 };
461 use rstest::{fixture, rstest};
462
463 #[fixture]
464 fn state() -> AppState {
465 AppState {
466 active_component: ActiveComponent::default(),
467 audio: StateAudio::default(),
468 search: SearchResult::default(),
469 library: LibraryBrief {
470 playlists: vec![PlaylistBrief {
471 id: RecordId::new(TABLE_NAME, item_id()),
472 name: "playlist 1".into(),
473 }],
474 ..Default::default()
475 },
476 active_view: ActiveView::default(),
477 additional_view_data: ViewData::default(),
478 settings: Settings::default(),
479 }
480 }
481
482 #[fixture]
483 fn border_style() -> Style {
484 Style::reset().fg(Color::Rgb(3, 169, 244))
485 }
486
487 #[fixture]
488 fn input_box_style() -> Style {
489 Style::reset().fg(Color::Rgb(239, 154, 154))
490 }
491
492 #[rstest]
493 #[case::large((100, 100), Rect::new(50, 10, 31, 80))]
494 #[case::small((31, 10), Rect::new(0, 0, 31, 10))]
495 #[case::too_small((20, 5), Rect::new(0, 0, 20, 5))]
496 fn test_playlist_selector_area(
497 #[case] terminal_size: (u16, u16),
498 #[case] expected_area: Rect,
499 state: AppState,
500 ) {
501 let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
502 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
503 let items = vec![];
504 let area = PlaylistSelector::new(&state, action_tx, items).area(area);
505 assert_eq!(area, expected_area);
506 }
507
508 #[rstest]
509 fn test_playlist_selector_render(
510 state: AppState,
511 #[from(border_style)] style: Style,
512 ) -> Result<()> {
513 let (mut terminal, _) = setup_test_terminal(31, 10);
514 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
515 let items = vec![];
516 let popup = PlaylistSelector::new(&state, action_tx, items);
517 let buffer = terminal
518 .draw(|frame| popup.render_popup(frame))?
519 .buffer
520 .clone();
521 let expected = Buffer::with_lines([
522 Line::styled("┌Select a Playlist────────────┐", style),
523 Line::styled("│n: new playlist──────────────│", style),
524 Line::from(vec![
525 Span::styled("│", style),
526 Span::raw("▪ "),
527 Span::raw("playlist 1").bold(),
528 Span::raw(" "),
529 Span::styled("│", style),
530 ]),
531 Line::from(vec![
532 Span::styled("│", style),
533 Span::raw(" "),
534 Span::styled("│", style),
535 ]),
536 Line::from(vec![
537 Span::styled("│", style),
538 Span::raw(" "),
539 Span::styled("│", style),
540 ]),
541 Line::from(vec![
542 Span::styled("│", style),
543 Span::raw(" "),
544 Span::styled("│", style),
545 ]),
546 Line::from(vec![
547 Span::styled("│", style),
548 Span::raw(" "),
549 Span::styled("│", style),
550 ]),
551 Line::from(vec![
552 Span::styled("│", style),
553 Span::raw(" "),
554 Span::styled("│", style),
555 ]),
556 Line::from(vec![
557 Span::styled("│", style),
558 Span::raw(" "),
559 Span::styled("│", style),
560 ]),
561 Line::styled("└ ⏎ : Select | ↑/↓: Up/Down──┘", style),
562 ]);
563
564 assert_eq!(buffer, expected);
565
566 Ok(())
567 }
568
569 #[rstest]
570 fn test_playlist_selector_render_input_box(
571 state: AppState,
572 border_style: Style,
573 input_box_style: Style,
574 ) -> Result<()> {
575 let (mut terminal, _) = setup_test_terminal(31, 10);
576 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
577 let items = vec![];
578 let mut popup = PlaylistSelector::new(&state, action_tx, items);
579 popup.inner_handle_key_event(KeyEvent::from(KeyCode::Char('n')));
580 let buffer = terminal
581 .draw(|frame| popup.render_popup(frame))?
582 .buffer
583 .clone();
584 let expected = Buffer::with_lines([
585 Line::styled("┌Select a Playlist────────────┐", border_style),
586 Line::from(vec![
587 Span::styled("│", border_style),
588 Span::styled("┌Enter Name:────────────────┐", input_box_style),
589 Span::styled("│", border_style),
590 ]),
591 Line::from(vec![
592 Span::styled("│", border_style),
593 Span::styled("│ │", input_box_style),
594 Span::styled("│", border_style),
595 ]),
596 Line::from(vec![
597 Span::styled("│", border_style),
598 Span::styled("└───────────────────────────┘", input_box_style),
599 Span::styled("│", border_style),
600 ]),
601 Line::styled("│ ⏎ : Create (cancel if empty)│", border_style),
602 Line::from(vec![
603 Span::styled("│", border_style),
604 Span::raw("▪ "),
605 Span::raw("playlist 1").bold(),
606 Span::raw(" "),
607 Span::styled("│", border_style),
608 ]),
609 Line::from(vec![
610 Span::styled("│", border_style),
611 Span::raw(" "),
612 Span::styled("│", border_style),
613 ]),
614 Line::from(vec![
615 Span::styled("│", border_style),
616 Span::raw(" "),
617 Span::styled("│", border_style),
618 ]),
619 Line::from(vec![
620 Span::styled("│", border_style),
621 Span::raw(" "),
622 Span::styled("│", border_style),
623 ]),
624 Line::styled("└─────────────────────────────┘", border_style),
625 ]);
626
627 assert_eq!(buffer, expected);
628
629 Ok(())
630 }
631}
632
633#[cfg(test)]
634mod editor_tests {
635 use super::*;
636 use crate::{
637 state::component::ActiveComponent,
638 test_utils::{assert_buffer_eq, item_id, setup_test_terminal},
639 ui::components::content_view::{ActiveView, views::ViewData},
640 };
641 use anyhow::Result;
642 use mecomp_core::{config::Settings, state::StateAudio};
643 use mecomp_prost::{LibraryBrief, PlaylistBrief, SearchResult};
644 use mecomp_storage::db::schemas::playlist::TABLE_NAME;
645 use pretty_assertions::assert_eq;
646 use ratatui::buffer::Buffer;
647 use rstest::{fixture, rstest};
648
649 #[fixture]
650 fn state() -> AppState {
651 AppState {
652 active_component: ActiveComponent::default(),
653 audio: StateAudio::default(),
654 search: SearchResult::default(),
655 library: LibraryBrief::default(),
656 active_view: ActiveView::default(),
657 additional_view_data: ViewData::default(),
658 settings: Settings::default(),
659 }
660 }
661
662 #[fixture]
663 fn playlist() -> PlaylistBrief {
664 PlaylistBrief {
665 id: RecordId::new(TABLE_NAME, item_id()),
666 name: "Test Playlist".into(),
667 }
668 }
669
670 #[rstest]
671 #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
672 #[case::small((20,5), Rect::new(0, 0, 20, 5))]
673 #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
674 fn test_playlist_editor_area(
675 #[case] terminal_size: (u16, u16),
676 #[case] expected_area: Rect,
677 state: AppState,
678 playlist: PlaylistBrief,
679 ) {
680 let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
681 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
682 let editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
683 let area = editor.area(area);
684 assert_eq!(area, expected_area);
685 }
686
687 #[rstest]
688 fn test_playlist_editor_render(state: AppState, playlist: PlaylistBrief) -> Result<()> {
689 let (mut terminal, _) = setup_test_terminal(20, 5);
690 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
691 let editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
692 let buffer = terminal
693 .draw(|frame| editor.render_popup(frame))?
694 .buffer
695 .clone();
696
697 let expected = Buffer::with_lines([
698 "┌Rename Playlist───┐",
699 "│┌Enter Name:─────┐│",
700 "││Test Playlist ││",
701 "│└────────────────┘│",
702 "└ ⏎ : Rename───────┘",
703 ]);
704
705 assert_buffer_eq(&buffer, &expected);
706 Ok(())
707 }
708
709 #[rstest]
710 fn test_playlist_editor_input(state: AppState, playlist: PlaylistBrief) {
711 let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
712 let mut editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
713
714 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
716 assert_eq!(editor.input_box.text(), "Test Playlista");
717
718 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
720 assert_eq!(editor.input_box.text(), "Test Playlista");
721 assert_eq!(
722 action_rx.blocking_recv(),
723 Some(Action::Popup(PopupAction::Close))
724 );
725 assert_eq!(
726 action_rx.blocking_recv(),
727 Some(Action::Library(LibraryAction::RenamePlaylist(
728 playlist.id.into(),
729 "Test Playlista".into()
730 )))
731 );
732
733 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
735 assert_eq!(editor.input_box.text(), "Test Playlist");
736
737 editor.input_box.set_text("");
739 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
740 assert_eq!(editor.input_box.text(), "");
741 }
742}