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