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,
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 layout = Layout::default()
97 .direction(Direction::Horizontal)
98 .constraints([
99 Constraint::Percentage(50),
100 Constraint::Min(31),
101 Constraint::Percentage(19),
102 ]);
103 let [_, horizontal_area, _] = terminal_area.layout(&layout);
104
105 let layout = Layout::default()
106 .direction(Direction::Vertical)
107 .constraints([
108 Constraint::Max(10),
109 Constraint::Min(10),
110 Constraint::Max(10),
111 ]);
112 let [_, area, _] = horizontal_area.layout(&layout);
113 area
114 }
115
116 fn inner_handle_key_event(&mut self, key: KeyEvent) {
117 if self.input_box_visible {
125 match key.code {
126 KeyCode::Enter => {
129 let name = self.input_box.text();
130 if !name.is_empty() {
131 self.action_tx
133 .send(Action::Library(LibraryAction::CreatePlaylistAndAddThings(
134 name.to_string(),
135 self.items.clone(),
136 )))
137 .unwrap();
138 self.action_tx
140 .send(Action::Popup(PopupAction::Close))
141 .unwrap();
142 }
143 self.input_box_visible = false;
144 }
145 _ => self.input_box.handle_key_event(key),
147 }
148 } else {
149 match key.code {
150 KeyCode::Char('n') => {
152 self.input_box_visible = true;
153 }
154 KeyCode::PageUp => {
156 self.tree_state.lock().unwrap().select_relative(|current| {
157 current.map_or(self.props.playlists.len() - 1, |c| c.saturating_sub(10))
158 });
159 }
160 KeyCode::Up => {
161 self.tree_state.lock().unwrap().key_up();
162 }
163 KeyCode::PageDown => {
164 self.tree_state
165 .lock()
166 .unwrap()
167 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
168 }
169 KeyCode::Down => {
170 self.tree_state.lock().unwrap().key_down();
171 }
172 KeyCode::Left => {
173 self.tree_state.lock().unwrap().key_left();
174 }
175 KeyCode::Right => {
176 self.tree_state.lock().unwrap().key_right();
177 }
178 KeyCode::Enter => {
181 if self.tree_state.lock().unwrap().toggle_selected() {
182 let things = self.tree_state.lock().unwrap().get_selected_thing();
183
184 if let Some(thing) = things {
185 self.action_tx
187 .send(Action::Library(LibraryAction::AddThingsToPlaylist(
188 thing,
189 self.items.clone(),
190 )))
191 .unwrap();
192 self.action_tx
194 .send(Action::Popup(PopupAction::Close))
195 .unwrap();
196 }
197 }
198 }
199 _ => {}
200 }
201 }
202 }
203
204 fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
207 let MouseEvent {
208 kind, column, row, ..
209 } = mouse;
210 let mouse_position = Position::new(column, row);
211
212 let area = area.inner(Margin::new(1, 1));
214
215 if self.input_box_visible {
217 let [input_box_area, content_area] = split_area(area);
218 if input_box_area.contains(mouse_position) {
219 self.input_box.handle_mouse_event(mouse, input_box_area);
220 } else if content_area.contains(mouse_position)
221 && kind == MouseEventKind::Down(MouseButton::Left)
222 {
223 self.input_box_visible = false;
224 }
225 return;
226 }
227
228 if !area.contains(mouse_position) {
230 return;
231 }
232
233 match kind {
234 MouseEventKind::Down(MouseButton::Left) => {
235 self.tree_state.lock().unwrap().mouse_click(mouse_position);
236 }
237 MouseEventKind::ScrollDown => {
238 self.tree_state.lock().unwrap().key_down();
239 }
240 MouseEventKind::ScrollUp => {
241 self.tree_state.lock().unwrap().key_up();
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 .areas(area);
253 [input_box_area, content_area]
254}
255
256impl ComponentRender<Rect> for PlaylistSelector {
257 fn render_border(&self, frame: &mut ratatui::Frame<'_>, area: Rect) -> Rect {
258 let area = self.render_popup_border(frame, area);
259
260 let content_area = if self.input_box_visible {
261 let [input_box_area, content_area] = split_area(area);
263
264 self.input_box.render(
266 frame,
267 RenderProps {
268 area: input_box_area,
269 text_color: (*TEXT_HIGHLIGHT_ALT).into(),
270 border: Block::bordered()
271 .title("Enter Name:")
272 .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
273 show_cursor: self.input_box_visible,
274 },
275 );
276
277 content_area
278 } else {
279 area
280 };
281
282 let border = Block::new()
284 .borders(Borders::TOP)
285 .title_top(if self.input_box_visible {
286 " \u{23CE} : Create (cancel if empty)"
287 } else {
288 "n: new playlist"
289 })
290 .border_style(Style::default().fg(self.border_color()));
291 frame.render_widget(&border, content_area);
292 border.inner(content_area)
293 }
294
295 fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
296 let playlists = self
298 .props
299 .playlists
300 .iter()
301 .map(create_playlist_tree_leaf)
302 .collect::<Vec<_>>();
303
304 frame.render_stateful_widget(
306 CheckTree::new(&playlists)
307 .unwrap()
308 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
309 .node_unchecked_symbol("▪ ")
311 .node_checked_symbol("▪ ")
312 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
313 area,
314 &mut self.tree_state.lock().unwrap(),
315 );
316 }
317}
318
319pub struct PlaylistEditor {
321 action_tx: UnboundedSender<Action>,
322 playlist_id: Ulid,
323 input_box: InputBox,
324}
325
326impl PlaylistEditor {
327 #[must_use]
328 pub fn new(
329 state: &AppState,
330 action_tx: UnboundedSender<Action>,
331 playlist_id: Ulid,
332 playlist_name: &str,
333 ) -> Self {
334 let mut input_box = InputBox::new(state, action_tx.clone());
335 input_box.set_text(playlist_name);
336
337 Self {
338 action_tx,
339 playlist_id,
340 input_box,
341 }
342 }
343}
344
345impl Popup for PlaylistEditor {
346 fn title(&self) -> Line<'static> {
347 Line::from("Rename Playlist")
348 }
349
350 fn instructions(&self) -> Line<'static> {
351 Line::from(" \u{23CE} : Rename")
352 }
353
354 fn area(&self, terminal_area: Rect) -> Rect {
356 let height = 5;
357 let width = u16::try_from(
358 self.input_box
359 .text()
360 .len()
361 .max(self.instructions().width())
362 .max(self.title().width())
363 + 5,
364 )
365 .unwrap_or(terminal_area.width)
366 .min(terminal_area.width);
367
368 let x = (terminal_area.width - width) / 2;
369 let y = (terminal_area.height - height) / 2;
370
371 Rect::new(x, y, width, height)
372 }
373
374 fn update_with_state(&mut self, _: &AppState) {}
375
376 fn inner_handle_key_event(&mut self, key: KeyEvent) {
377 match key.code {
378 KeyCode::Enter => {
379 let name = self.input_box.text();
380 if name.is_empty() {
381 return;
382 }
383
384 self.action_tx
385 .send(Action::Popup(PopupAction::Close))
386 .unwrap();
387 self.action_tx
388 .send(Action::Library(LibraryAction::RenamePlaylist(
389 self.playlist_id.clone(),
390 name.to_string(),
391 )))
392 .unwrap();
393 }
394 _ => self.input_box.handle_key_event(key),
395 }
396 }
397
398 fn inner_handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
399 let MouseEvent {
400 column, row, kind, ..
401 } = mouse;
402 let mouse_position = Position::new(column, row);
403
404 if area.contains(mouse_position) {
405 self.input_box.handle_mouse_event(mouse, area);
406 } else if kind == MouseEventKind::Down(MouseButton::Left) {
407 self.action_tx
408 .send(Action::Popup(PopupAction::Close))
409 .unwrap();
410 }
411 }
412}
413
414impl ComponentRender<Rect> for PlaylistEditor {
415 fn render_border(&self, frame: &mut Frame<'_>, area: Rect) -> Rect {
416 self.render_popup_border(frame, area)
417 }
418
419 fn render_content(&self, frame: &mut Frame<'_>, area: Rect) {
420 self.input_box.render(
421 frame,
422 RenderProps {
423 area,
424 text_color: (*TEXT_HIGHLIGHT_ALT).into(),
425 border: Block::bordered()
426 .title("Enter Name:")
427 .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
428 show_cursor: true,
429 },
430 );
431 }
432}
433
434#[cfg(test)]
435mod selector_tests {
436 use super::*;
437 use crate::{
438 state::component::ActiveComponent,
439 test_utils::{item_id, setup_test_terminal},
440 ui::components::content_view::{ActiveView, views::ViewData},
441 };
442 use anyhow::Result;
443 use mecomp_core::{config::Settings, state::StateAudio};
444 use mecomp_prost::{LibraryBrief, PlaylistBrief, SearchResult};
445 use mecomp_storage::db::schemas::playlist::TABLE_NAME;
446 use pretty_assertions::assert_eq;
447 use ratatui::{
448 buffer::Buffer,
449 style::{Color, Style, Stylize},
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: LibraryBrief {
461 playlists: vec![PlaylistBrief {
462 id: RecordId::new(TABLE_NAME, item_id()),
463 name: "playlist 1".into(),
464 }],
465 ..Default::default()
466 },
467 active_view: ActiveView::default(),
468 additional_view_data: ViewData::default(),
469 settings: Settings::default(),
470 }
471 }
472
473 #[fixture]
474 fn border_style() -> Style {
475 Style::reset().fg(Color::Rgb(3, 169, 244))
476 }
477
478 #[fixture]
479 fn input_box_style() -> Style {
480 Style::reset().fg(Color::Rgb(239, 154, 154))
481 }
482
483 #[rstest]
484 #[case::large((100, 100), Rect::new(50, 10, 31, 80))]
485 #[case::small((31, 10), Rect::new(0, 0, 31, 10))]
486 #[case::too_small((20, 5), Rect::new(0, 0, 20, 5))]
487 fn test_playlist_selector_area(
488 #[case] terminal_size: (u16, u16),
489 #[case] expected_area: Rect,
490 state: AppState,
491 ) {
492 let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
493 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
494 let items = vec![];
495 let area = PlaylistSelector::new(&state, action_tx, items).area(area);
496 assert_eq!(area, expected_area);
497 }
498
499 #[rstest]
500 fn test_playlist_selector_render(
501 state: AppState,
502 #[from(border_style)] style: Style,
503 ) -> Result<()> {
504 let (mut terminal, _) = setup_test_terminal(31, 10);
505 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
506 let items = vec![];
507 let popup = PlaylistSelector::new(&state, action_tx, items);
508 let buffer = terminal
509 .draw(|frame| popup.render_popup(frame))?
510 .buffer
511 .clone();
512 let expected = Buffer::with_lines([
513 Line::styled("┌Select a Playlist────────────┐", style),
514 Line::styled("│n: new playlist──────────────│", style),
515 Line::from(vec![
516 Span::styled("│", style),
517 Span::raw("▪ "),
518 Span::raw("playlist 1").bold(),
519 Span::raw(" "),
520 Span::styled("│", style),
521 ]),
522 Line::from(vec![
523 Span::styled("│", style),
524 Span::raw(" "),
525 Span::styled("│", style),
526 ]),
527 Line::from(vec![
528 Span::styled("│", style),
529 Span::raw(" "),
530 Span::styled("│", style),
531 ]),
532 Line::from(vec![
533 Span::styled("│", style),
534 Span::raw(" "),
535 Span::styled("│", style),
536 ]),
537 Line::from(vec![
538 Span::styled("│", style),
539 Span::raw(" "),
540 Span::styled("│", style),
541 ]),
542 Line::from(vec![
543 Span::styled("│", style),
544 Span::raw(" "),
545 Span::styled("│", style),
546 ]),
547 Line::from(vec![
548 Span::styled("│", style),
549 Span::raw(" "),
550 Span::styled("│", style),
551 ]),
552 Line::styled("└ ⏎ : Select | ↑/↓: Up/Down──┘", style),
553 ]);
554
555 assert_eq!(buffer, expected);
556
557 Ok(())
558 }
559
560 #[rstest]
561 fn test_playlist_selector_render_input_box(
562 state: AppState,
563 border_style: Style,
564 input_box_style: Style,
565 ) -> Result<()> {
566 let (mut terminal, _) = setup_test_terminal(31, 10);
567 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
568 let items = vec![];
569 let mut popup = PlaylistSelector::new(&state, action_tx, items);
570 popup.inner_handle_key_event(KeyEvent::from(KeyCode::Char('n')));
571 let buffer = terminal
572 .draw(|frame| popup.render_popup(frame))?
573 .buffer
574 .clone();
575 let expected = Buffer::with_lines([
576 Line::styled("┌Select a Playlist────────────┐", border_style),
577 Line::from(vec![
578 Span::styled("│", border_style),
579 Span::styled("┌Enter Name:────────────────┐", input_box_style),
580 Span::styled("│", border_style),
581 ]),
582 Line::from(vec![
583 Span::styled("│", border_style),
584 Span::styled("│ │", input_box_style),
585 Span::styled("│", border_style),
586 ]),
587 Line::from(vec![
588 Span::styled("│", border_style),
589 Span::styled("└───────────────────────────┘", input_box_style),
590 Span::styled("│", border_style),
591 ]),
592 Line::styled("│ ⏎ : Create (cancel if empty)│", border_style),
593 Line::from(vec![
594 Span::styled("│", border_style),
595 Span::raw("▪ "),
596 Span::raw("playlist 1").bold(),
597 Span::raw(" "),
598 Span::styled("│", border_style),
599 ]),
600 Line::from(vec![
601 Span::styled("│", border_style),
602 Span::raw(" "),
603 Span::styled("│", border_style),
604 ]),
605 Line::from(vec![
606 Span::styled("│", border_style),
607 Span::raw(" "),
608 Span::styled("│", border_style),
609 ]),
610 Line::from(vec![
611 Span::styled("│", border_style),
612 Span::raw(" "),
613 Span::styled("│", border_style),
614 ]),
615 Line::styled("└─────────────────────────────┘", border_style),
616 ]);
617
618 assert_eq!(buffer, expected);
619
620 Ok(())
621 }
622}
623
624#[cfg(test)]
625mod editor_tests {
626 use super::*;
627 use crate::{
628 state::component::ActiveComponent,
629 test_utils::{assert_buffer_eq, item_id, setup_test_terminal},
630 ui::components::content_view::{ActiveView, views::ViewData},
631 };
632 use anyhow::Result;
633 use mecomp_core::{config::Settings, state::StateAudio};
634 use mecomp_prost::{LibraryBrief, PlaylistBrief, SearchResult};
635 use mecomp_storage::db::schemas::playlist::TABLE_NAME;
636 use pretty_assertions::assert_eq;
637 use ratatui::buffer::Buffer;
638 use rstest::{fixture, rstest};
639
640 #[fixture]
641 fn state() -> AppState {
642 AppState {
643 active_component: ActiveComponent::default(),
644 audio: StateAudio::default(),
645 search: SearchResult::default(),
646 library: LibraryBrief::default(),
647 active_view: ActiveView::default(),
648 additional_view_data: ViewData::default(),
649 settings: Settings::default(),
650 }
651 }
652
653 #[fixture]
654 fn playlist() -> PlaylistBrief {
655 PlaylistBrief {
656 id: RecordId::new(TABLE_NAME, item_id()),
657 name: "Test Playlist".into(),
658 }
659 }
660
661 #[rstest]
662 #[case::large((100, 100), Rect::new(40, 47, 20, 5))]
663 #[case::small((20,5), Rect::new(0, 0, 20, 5))]
664 #[case::too_small((10, 5), Rect::new(0, 0, 10, 5))]
665 fn test_playlist_editor_area(
666 #[case] terminal_size: (u16, u16),
667 #[case] expected_area: Rect,
668 state: AppState,
669 playlist: PlaylistBrief,
670 ) {
671 let (_, area) = setup_test_terminal(terminal_size.0, terminal_size.1);
672 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
673 let editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
674 let area = editor.area(area);
675 assert_eq!(area, expected_area);
676 }
677
678 #[rstest]
679 fn test_playlist_editor_render(state: AppState, playlist: PlaylistBrief) -> Result<()> {
680 let (mut terminal, _) = setup_test_terminal(20, 5);
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 buffer = terminal
684 .draw(|frame| editor.render_popup(frame))?
685 .buffer
686 .clone();
687
688 let expected = Buffer::with_lines([
689 "┌Rename Playlist───┐",
690 "│┌Enter Name:─────┐│",
691 "││Test Playlist ││",
692 "│└────────────────┘│",
693 "└ ⏎ : Rename───────┘",
694 ]);
695
696 assert_buffer_eq(&buffer, &expected);
697 Ok(())
698 }
699
700 #[rstest]
701 fn test_playlist_editor_input(state: AppState, playlist: PlaylistBrief) {
702 let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
703 let mut editor = PlaylistEditor::new(&state, action_tx, playlist.id.ulid(), &playlist.name);
704
705 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Char('a')));
707 assert_eq!(editor.input_box.text(), "Test Playlista");
708
709 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
711 assert_eq!(editor.input_box.text(), "Test Playlista");
712 assert_eq!(
713 action_rx.blocking_recv(),
714 Some(Action::Popup(PopupAction::Close))
715 );
716 assert_eq!(
717 action_rx.blocking_recv(),
718 Some(Action::Library(LibraryAction::RenamePlaylist(
719 playlist.id.into(),
720 "Test Playlista".into()
721 )))
722 );
723
724 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Backspace));
726 assert_eq!(editor.input_box.text(), "Test Playlist");
727
728 editor.input_box.set_text("");
730 editor.inner_handle_key_event(KeyEvent::from(KeyCode::Enter));
731 assert_eq!(editor.input_box.text(), "");
732 }
733}