1use std::{ops::Not, sync::Mutex};
4
5use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
6use mecomp_core::format_duration;
7use mecomp_storage::db::schemas::playlist::Playlist;
8use ratatui::{
9 layout::{Alignment, Constraint, Direction, Layout, Margin, Position, Rect},
10 style::{Style, Stylize},
11 text::{Line, Span},
12 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation},
13};
14use tokio::sync::mpsc::UnboundedSender;
15
16use crate::{
17 state::action::{Action, LibraryAction, PopupAction, ViewAction},
18 ui::{
19 AppState,
20 colors::{BORDER_FOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL, border_color},
21 components::{Component, ComponentRender, RenderProps, content_view::ActiveView},
22 widgets::{
23 input_box::{self, InputBox},
24 popups::PopupType,
25 tree::{CheckTree, state::CheckTreeState},
26 },
27 },
28};
29
30use super::{
31 PlaylistViewProps,
32 checktree_utils::{
33 construct_add_to_playlist_action, construct_add_to_queue_action,
34 construct_start_radio_action, create_playlist_tree_leaf, create_song_tree_leaf,
35 },
36 sort_mode::{NameSort, SongSort},
37 traits::SortMode,
38};
39
40#[allow(clippy::module_name_repetitions)]
41pub struct PlaylistView {
42 pub action_tx: UnboundedSender<Action>,
44 pub props: Option<PlaylistViewProps>,
46 tree_state: Mutex<CheckTreeState<String>>,
48 sort_mode: SongSort,
50}
51
52impl Component for PlaylistView {
53 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
54 where
55 Self: Sized,
56 {
57 Self {
58 action_tx,
59 props: state.additional_view_data.playlist.clone(),
60 tree_state: Mutex::new(CheckTreeState::default()),
61 sort_mode: SongSort::default(),
62 }
63 }
64
65 fn move_with_state(self, state: &AppState) -> Self
66 where
67 Self: Sized,
68 {
69 if let Some(props) = &state.additional_view_data.playlist {
70 let mut props = props.clone();
71 self.sort_mode.sort_items(&mut props.songs);
72
73 Self {
74 props: Some(props),
75 tree_state: Mutex::new(CheckTreeState::default()),
76 ..self
77 }
78 } else {
79 self
80 }
81 }
82
83 fn name(&self) -> &'static str {
84 "Playlist View"
85 }
86
87 #[allow(clippy::too_many_lines)]
88 fn handle_key_event(&mut self, key: KeyEvent) {
89 match key.code {
90 KeyCode::PageUp => {
92 self.tree_state.lock().unwrap().select_relative(|current| {
93 current.map_or(
94 self.props
95 .as_ref()
96 .map_or(0, |p| p.songs.len().saturating_sub(1)),
97 |c| c.saturating_sub(10),
98 )
99 });
100 }
101 KeyCode::Up => {
102 self.tree_state.lock().unwrap().key_up();
103 }
104 KeyCode::PageDown => {
105 self.tree_state
106 .lock()
107 .unwrap()
108 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
109 }
110 KeyCode::Down => {
111 self.tree_state.lock().unwrap().key_down();
112 }
113 KeyCode::Left => {
114 self.tree_state.lock().unwrap().key_left();
115 }
116 KeyCode::Right => {
117 self.tree_state.lock().unwrap().key_right();
118 }
119 KeyCode::Char(' ') => {
120 self.tree_state.lock().unwrap().key_space();
121 }
122 KeyCode::Char('s') => {
124 self.sort_mode = self.sort_mode.next();
125 if let Some(props) = &mut self.props {
126 self.sort_mode.sort_items(&mut props.songs);
127 self.tree_state.lock().unwrap().scroll_selected_into_view();
128 }
129 }
130 KeyCode::Char('S') => {
131 self.sort_mode = self.sort_mode.prev();
132 if let Some(props) = &mut self.props {
133 self.sort_mode.sort_items(&mut props.songs);
134 self.tree_state.lock().unwrap().scroll_selected_into_view();
135 }
136 }
137 KeyCode::Enter => {
139 if self.tree_state.lock().unwrap().toggle_selected() {
140 let selected_things = self.tree_state.lock().unwrap().get_selected_thing();
141 if let Some(thing) = selected_things {
142 self.action_tx
143 .send(Action::ActiveView(ViewAction::Set(thing.into())))
144 .unwrap();
145 }
146 }
147 }
148 KeyCode::Char('q') => {
150 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
151 if let Some(action) = construct_add_to_queue_action(
152 checked_things,
153 self.props.as_ref().map(|p| &p.id),
154 ) {
155 self.action_tx.send(action).unwrap();
156 }
157 }
158 KeyCode::Char('r') => {
160 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
161 if let Some(action) =
162 construct_start_radio_action(checked_things, self.props.as_ref().map(|p| &p.id))
163 {
164 self.action_tx.send(action).unwrap();
165 }
166 }
167 KeyCode::Char('p') => {
169 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
170 if let Some(action) = construct_add_to_playlist_action(
171 checked_things,
172 self.props.as_ref().map(|p| &p.id),
173 ) {
174 self.action_tx.send(action).unwrap();
175 }
176 }
177 KeyCode::Char('d') => {
179 let things = self.tree_state.lock().unwrap().get_checked_things();
180 if let Some(action) = self.props.as_ref().and_then(|props| {
181 let id = props.id.clone();
182 if things.is_empty() {
183 self.tree_state
184 .lock()
185 .unwrap()
186 .get_selected_thing()
187 .map(|thing| LibraryAction::RemoveSongsFromPlaylist(id, vec![thing]))
188 } else {
189 Some(LibraryAction::RemoveSongsFromPlaylist(id, things))
190 }
191 }) {
192 self.action_tx.send(Action::Library(action)).unwrap();
193 }
194 }
195 KeyCode::Char('e') => {
197 if let Some(props) = &self.props {
198 self.action_tx
199 .send(Action::Popup(PopupAction::Open(PopupType::PlaylistEditor(
200 props.playlist.clone(),
201 ))))
202 .unwrap();
203 }
204 }
205 _ => {}
206 }
207 }
208
209 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
210 let area = area.inner(Margin::new(1, 1));
212 let [_, content_area] = split_area(area);
213 let content_area = content_area.inner(Margin::new(0, 1));
214
215 let result = self
216 .tree_state
217 .lock()
218 .unwrap()
219 .handle_mouse_event(mouse, content_area);
220 if let Some(action) = result {
221 self.action_tx.send(action).unwrap();
222 }
223 }
224}
225
226fn split_area(area: Rect) -> [Rect; 2] {
227 let [info_area, content_area] = *Layout::default()
228 .direction(Direction::Vertical)
229 .constraints([Constraint::Length(3), Constraint::Min(4)])
230 .split(area)
231 else {
232 panic!("Failed to split playlist view area")
233 };
234
235 [info_area, content_area]
236}
237
238impl ComponentRender<RenderProps> for PlaylistView {
239 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
240 let border_style = Style::default().fg(border_color(props.is_focused).into());
241
242 let area = if let Some(state) = &self.props {
243 let border = Block::bordered()
244 .title_top(Line::from(vec![
245 Span::styled("Playlist View".to_string(), Style::default().bold()),
246 Span::raw(" sorted by: "),
247 Span::styled(self.sort_mode.to_string(), Style::default().italic()),
248 ]))
249 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
250 .border_style(border_style);
251 frame.render_widget(&border, props.area);
252 let content_area = border.inner(props.area);
253
254 let [info_area, content_area] = split_area(content_area);
256
257 frame.render_widget(
259 Paragraph::new(vec![
260 Line::from(Span::styled(
261 state.playlist.name.to_string(),
262 Style::default().bold(),
263 )),
264 Line::from(vec![
265 Span::raw("Songs: "),
266 Span::styled(
267 state.playlist.song_count.to_string(),
268 Style::default().italic(),
269 ),
270 Span::raw(" Duration: "),
271 Span::styled(
272 format_duration(&state.playlist.runtime),
273 Style::default().italic(),
274 ),
275 ]),
276 ])
277 .alignment(Alignment::Center),
278 info_area,
279 );
280
281 let border = Block::default()
283 .borders(Borders::TOP | Borders::BOTTOM)
284 .title_top("q: add to queue | r: start radio | p: add to playlist")
285 .title_bottom("s/S: sort | d: remove selected | e: edit")
286 .border_style(border_style);
287 frame.render_widget(&border, content_area);
288 let content_area = border.inner(content_area);
289
290 let border = Block::default()
292 .borders(Borders::TOP)
293 .title_top(Line::from(vec![
294 Span::raw("Performing operations on "),
295 Span::raw(
296 if self
297 .tree_state
298 .lock()
299 .unwrap()
300 .get_checked_things()
301 .is_empty()
302 {
303 "entire playlist"
304 } else {
305 "checked items"
306 },
307 )
308 .fg(TEXT_HIGHLIGHT),
309 ]))
310 .italic()
311 .border_style(border_style);
312 frame.render_widget(&border, content_area);
313 border.inner(content_area)
314 } else {
315 let border = Block::bordered()
316 .title_top("Playlist View")
317 .border_style(border_style);
318 frame.render_widget(&border, props.area);
319 border.inner(props.area)
320 };
321
322 RenderProps { area, ..props }
323 }
324
325 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
326 let Some(state) = &self.props else {
327 let text = "No active playlist";
328
329 frame.render_widget(
330 Line::from(text)
331 .style(Style::default().fg(TEXT_NORMAL.into()))
332 .alignment(Alignment::Center),
333 props.area,
334 );
335 return;
336 };
337
338 let items = state
340 .songs
341 .iter()
342 .map(create_song_tree_leaf)
343 .collect::<Vec<_>>();
344
345 frame.render_stateful_widget(
347 CheckTree::new(&items)
348 .unwrap()
349 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
350 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
351 props.area,
352 &mut self.tree_state.lock().unwrap(),
353 );
354 }
355}
356
357pub struct LibraryPlaylistsView {
358 pub action_tx: UnboundedSender<Action>,
360 props: Props,
362 tree_state: Mutex<CheckTreeState<String>>,
364 input_box: InputBox,
366 input_box_visible: bool,
368}
369
370#[derive(Debug)]
371pub struct Props {
372 pub playlists: Box<[Playlist]>,
373 sort_mode: NameSort<Playlist>,
374}
375
376impl From<&AppState> for Props {
377 fn from(state: &AppState) -> Self {
378 let mut playlists = state.library.playlists.clone();
379 let sort_mode = NameSort::default();
380 sort_mode.sort_items(&mut playlists);
381 Self {
382 playlists,
383 sort_mode,
384 }
385 }
386}
387
388impl Component for LibraryPlaylistsView {
389 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
390 where
391 Self: Sized,
392 {
393 Self {
394 input_box: InputBox::new(state, action_tx.clone()),
395 input_box_visible: false,
396 action_tx,
397 props: Props::from(state),
398 tree_state: Mutex::new(CheckTreeState::default()),
399 }
400 }
401
402 fn move_with_state(self, state: &AppState) -> Self
403 where
404 Self: Sized,
405 {
406 let tree_state = if state.active_view == ActiveView::Playlists {
407 self.tree_state
408 } else {
409 Mutex::new(CheckTreeState::default())
410 };
411
412 Self {
413 props: Props::from(state),
414 tree_state,
415 ..self
416 }
417 }
418
419 fn name(&self) -> &'static str {
420 "Library Playlists View"
421 }
422
423 fn handle_key_event(&mut self, key: KeyEvent) {
424 if self.input_box_visible {
428 match key.code {
429 KeyCode::Enter => {
431 let name = self.input_box.text();
432 if !name.is_empty() {
433 self.action_tx
434 .send(Action::Library(LibraryAction::CreatePlaylist(
435 name.to_string(),
436 )))
437 .unwrap();
438 }
439 self.input_box.reset();
440 self.input_box_visible = false;
441 }
442 _ => {
444 self.input_box.handle_key_event(key);
445 }
446 }
447 } else {
448 match key.code {
449 KeyCode::PageUp => {
451 self.tree_state.lock().unwrap().select_relative(|current| {
452 current.map_or(self.props.playlists.len() - 1, |c| c.saturating_sub(10))
453 });
454 }
455 KeyCode::Up => {
456 self.tree_state.lock().unwrap().key_up();
457 }
458 KeyCode::PageDown => {
459 self.tree_state
460 .lock()
461 .unwrap()
462 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
463 }
464 KeyCode::Down => {
465 self.tree_state.lock().unwrap().key_down();
466 }
467 KeyCode::Left => {
468 self.tree_state.lock().unwrap().key_left();
469 }
470 KeyCode::Right => {
471 self.tree_state.lock().unwrap().key_right();
472 }
473 KeyCode::Enter => {
475 if self.tree_state.lock().unwrap().toggle_selected() {
476 let things = self.tree_state.lock().unwrap().get_selected_thing();
477
478 if let Some(thing) = things {
479 self.action_tx
480 .send(Action::ActiveView(ViewAction::Set(thing.into())))
481 .unwrap();
482 }
483 }
484 }
485 KeyCode::Char('s') => {
487 self.props.sort_mode = self.props.sort_mode.next();
488 self.props.sort_mode.sort_items(&mut self.props.playlists);
489 self.tree_state.lock().unwrap().scroll_selected_into_view();
490 }
491 KeyCode::Char('S') => {
492 self.props.sort_mode = self.props.sort_mode.prev();
493 self.props.sort_mode.sort_items(&mut self.props.playlists);
494 self.tree_state.lock().unwrap().scroll_selected_into_view();
495 }
496 KeyCode::Char('n') => {
498 self.input_box_visible = true;
499 }
500 KeyCode::Char('d') => {
502 let things = self.tree_state.lock().unwrap().get_selected_thing();
503
504 if let Some(thing) = things {
505 self.action_tx
506 .send(Action::Library(LibraryAction::RemovePlaylist(thing)))
507 .unwrap();
508 }
509 }
510 _ => {}
511 }
512 }
513 }
514
515 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
516 let MouseEvent {
517 kind, column, row, ..
518 } = mouse;
519 let mouse_position = Position::new(column, row);
520
521 let area = area.inner(Margin::new(1, 1));
523
524 if self.input_box_visible {
525 let [input_box_area, content_area] = lib_split_area(area);
526 let content_area = Rect {
527 y: content_area.y + 1,
528 height: content_area.height - 1,
529 ..content_area
530 };
531 if input_box_area.contains(mouse_position) {
532 self.input_box.handle_mouse_event(mouse, input_box_area);
533 } else if content_area.contains(mouse_position)
534 && kind == MouseEventKind::Down(MouseButton::Left)
535 {
536 self.input_box_visible = false;
537 }
538 } else {
539 let area = Rect {
540 y: area.y + 1,
541 height: area.height - 1,
542 ..area
543 };
544
545 let result = self
546 .tree_state
547 .lock()
548 .unwrap()
549 .handle_mouse_event(mouse, area);
550 if let Some(action) = result {
551 self.action_tx.send(action).unwrap();
552 }
553 }
554 }
555}
556
557fn lib_split_area(area: Rect) -> [Rect; 2] {
558 let [input_box_area, content_area] = *Layout::default()
559 .direction(Direction::Vertical)
560 .constraints([Constraint::Length(3), Constraint::Min(1)])
561 .split(area)
562 else {
563 panic!("Failed to split library playlists view area");
564 };
565 [input_box_area, content_area]
566}
567
568impl ComponentRender<RenderProps> for LibraryPlaylistsView {
569 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
570 let border_style = Style::default().fg(border_color(props.is_focused).into());
571
572 let border = Block::bordered()
574 .title_top(Line::from(vec![
575 Span::styled("Library Playlists".to_string(), Style::default().bold()),
576 Span::raw(" sorted by: "),
577 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
578 ]))
579 .title_bottom(
580 self.input_box_visible
581 .not()
582 .then_some(" \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort")
583 .unwrap_or_default(),
584 )
585 .border_style(border_style);
586 let content_area = border.inner(props.area);
587 frame.render_widget(border, props.area);
588
589 let content_area = if self.input_box_visible {
591 let [input_box_area, content_area] = lib_split_area(content_area);
593
594 self.input_box.render(
596 frame,
597 input_box::RenderProps {
598 area: input_box_area,
599 text_color: TEXT_HIGHLIGHT_ALT.into(),
600 border: Block::bordered()
601 .title("Enter Name:")
602 .border_style(Style::default().fg(BORDER_FOCUSED.into())),
603 show_cursor: self.input_box_visible,
604 },
605 );
606
607 content_area
608 } else {
609 content_area
610 };
611
612 let border = Block::new()
614 .borders(Borders::TOP)
615 .title_top(if self.input_box_visible {
616 " \u{23CE} : Create (cancel if empty)"
617 } else {
618 "n: new playlist | d: delete playlist"
619 })
620 .border_style(border_style);
621 let area = border.inner(content_area);
622 frame.render_widget(border, content_area);
623
624 RenderProps { area, ..props }
625 }
626
627 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
628 let items = self
630 .props
631 .playlists
632 .iter()
633 .map(create_playlist_tree_leaf)
634 .collect::<Vec<_>>();
635
636 frame.render_stateful_widget(
638 CheckTree::new(&items)
639 .unwrap()
640 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
641 .node_unchecked_symbol("▪ ")
643 .node_checked_symbol("▪ ")
644 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
645 props.area,
646 &mut self.tree_state.lock().unwrap(),
647 );
648 }
649}
650
651#[cfg(test)]
652mod sort_mode_tests {
653 use super::*;
654 use pretty_assertions::assert_eq;
655 use rstest::rstest;
656 use std::time::Duration;
657
658 #[rstest]
659 #[case(NameSort::default(), NameSort::default())]
660 fn test_sort_mode_next_prev(
661 #[case] mode: NameSort<Playlist>,
662 #[case] expected: NameSort<Playlist>,
663 ) {
664 assert_eq!(mode.next(), expected);
665 assert_eq!(mode.next().prev(), mode);
666 }
667
668 #[rstest]
669 #[case(NameSort::default(), "Name")]
670 fn test_sort_mode_display(#[case] mode: NameSort<Playlist>, #[case] expected: &str) {
671 assert_eq!(mode.to_string(), expected);
672 }
673
674 #[rstest]
675 fn test_sort_items() {
676 let mut songs = vec![
677 Playlist {
678 id: Playlist::generate_id(),
679 name: "C".into(),
680 song_count: 0,
681 runtime: Duration::from_secs(0),
682 },
683 Playlist {
684 id: Playlist::generate_id(),
685 name: "A".into(),
686 song_count: 0,
687 runtime: Duration::from_secs(0),
688 },
689 Playlist {
690 id: Playlist::generate_id(),
691 name: "B".into(),
692 song_count: 0,
693 runtime: Duration::from_secs(0),
694 },
695 ];
696
697 NameSort::default().sort_items(&mut songs);
698 assert_eq!(songs[0].name, "A");
699 assert_eq!(songs[1].name, "B");
700 assert_eq!(songs[2].name, "C");
701 }
702}
703
704#[cfg(test)]
705mod item_view_tests {
706 use super::*;
707 use crate::{
708 state::action::{AudioAction, PopupAction, QueueAction},
709 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
710 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
711 };
712 use crossterm::event::KeyModifiers;
713 use pretty_assertions::assert_eq;
714 use ratatui::buffer::Buffer;
715
716 #[test]
717 fn test_new() {
718 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
719 let state = state_with_everything();
720 let view = PlaylistView::new(&state, tx);
721
722 assert_eq!(view.name(), "Playlist View");
723 assert_eq!(
724 view.props,
725 Some(state.additional_view_data.playlist.unwrap())
726 );
727 }
728
729 #[test]
730 fn test_move_with_state() {
731 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
732 let state = AppState::default();
733 let new_state = state_with_everything();
734 let view = PlaylistView::new(&state, tx).move_with_state(&new_state);
735
736 assert_eq!(
737 view.props,
738 Some(new_state.additional_view_data.playlist.unwrap())
739 );
740 }
741 #[test]
742 fn test_render_no_playlist() {
743 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
744 let view = PlaylistView::new(&AppState::default(), tx);
745
746 let (mut terminal, area) = setup_test_terminal(20, 3);
747 let props = RenderProps {
748 area,
749 is_focused: true,
750 };
751 let buffer = terminal
752 .draw(|frame| view.render(frame, props))
753 .unwrap()
754 .buffer
755 .clone();
756 #[rustfmt::skip]
757 let expected = Buffer::with_lines([
758 "┌Playlist View─────┐",
759 "│No active playlist│",
760 "└──────────────────┘",
761 ]);
762
763 assert_buffer_eq(&buffer, &expected);
764 }
765
766 #[test]
767 fn test_render() {
768 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
769 let view = PlaylistView::new(&state_with_everything(), tx);
770
771 let (mut terminal, area) = setup_test_terminal(60, 9);
772 let props = RenderProps {
773 area,
774 is_focused: true,
775 };
776 let buffer = terminal
777 .draw(|frame| view.render(frame, props))
778 .unwrap()
779 .buffer
780 .clone();
781 let expected = Buffer::with_lines([
782 "┌Playlist View sorted by: Artist───────────────────────────┐",
783 "│ Test Playlist │",
784 "│ Songs: 1 Duration: 00:03:00.00 │",
785 "│ │",
786 "│q: add to queue | r: start radio | p: add to playlist─────│",
787 "│Performing operations on entire playlist──────────────────│",
788 "│☐ Test Song Test Artist │",
789 "│s/S: sort | d: remove selected | e: edit──────────────────│",
790 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
791 ]);
792
793 assert_buffer_eq(&buffer, &expected);
794 }
795
796 #[test]
797 fn test_render_with_checked() {
798 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
799 let mut view = PlaylistView::new(&state_with_everything(), tx);
800 let (mut terminal, area) = setup_test_terminal(60, 9);
801 let props = RenderProps {
802 area,
803 is_focused: true,
804 };
805 let buffer = terminal
806 .draw(|frame| view.render(frame, props))
807 .unwrap()
808 .buffer
809 .clone();
810 let expected = Buffer::with_lines([
811 "┌Playlist View sorted by: Artist───────────────────────────┐",
812 "│ Test Playlist │",
813 "│ Songs: 1 Duration: 00:03:00.00 │",
814 "│ │",
815 "│q: add to queue | r: start radio | p: add to playlist─────│",
816 "│Performing operations on entire playlist──────────────────│",
817 "│☐ Test Song Test Artist │",
818 "│s/S: sort | d: remove selected | e: edit──────────────────│",
819 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
820 ]);
821 assert_buffer_eq(&buffer, &expected);
822
823 view.handle_key_event(KeyEvent::from(KeyCode::Down));
825 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
826
827 let buffer = terminal
828 .draw(|frame| view.render(frame, props))
829 .unwrap()
830 .buffer
831 .clone();
832 let expected = Buffer::with_lines([
833 "┌Playlist View sorted by: Artist───────────────────────────┐",
834 "│ Test Playlist │",
835 "│ Songs: 1 Duration: 00:03:00.00 │",
836 "│ │",
837 "│q: add to queue | r: start radio | p: add to playlist─────│",
838 "│Performing operations on checked items────────────────────│",
839 "│☑ Test Song Test Artist │",
840 "│s/S: sort | d: remove selected | e: edit──────────────────│",
841 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
842 ]);
843
844 assert_buffer_eq(&buffer, &expected);
845 }
846
847 #[test]
848 fn smoke_navigation_and_sort() {
849 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
850 let mut view = PlaylistView::new(&state_with_everything(), tx);
851
852 view.handle_key_event(KeyEvent::from(KeyCode::Up));
853 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
854 view.handle_key_event(KeyEvent::from(KeyCode::Down));
855 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
856 view.handle_key_event(KeyEvent::from(KeyCode::Left));
857 view.handle_key_event(KeyEvent::from(KeyCode::Right));
858 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
859 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
860 }
861
862 #[test]
863 fn test_actions() {
864 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
865 let mut view = PlaylistView::new(&state_with_everything(), tx);
866
867 let (mut terminal, area) = setup_test_terminal(60, 9);
869 let props = RenderProps {
870 area,
871 is_focused: true,
872 };
873 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
874
875 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
878 assert_eq!(
879 rx.blocking_recv().unwrap(),
880 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
881 ("playlist", item_id()).into()
882 ])))
883 );
884 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
885 assert_eq!(
886 rx.blocking_recv().unwrap(),
887 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
888 ("playlist", item_id()).into()
889 ],)))
890 );
891 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
892 assert_eq!(
893 rx.blocking_recv().unwrap(),
894 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
895 ("playlist", item_id()).into()
896 ])))
897 );
898 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
899
900 view.handle_key_event(KeyEvent::from(KeyCode::Up));
903 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
904
905 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
907 assert_eq!(
908 rx.blocking_recv().unwrap(),
909 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
910 );
911
912 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
914
915 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
917 assert_eq!(
918 rx.blocking_recv().unwrap(),
919 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
920 ("song", item_id()).into()
921 ])))
922 );
923
924 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
926 assert_eq!(
927 rx.blocking_recv().unwrap(),
928 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
929 ("song", item_id()).into()
930 ],)))
931 );
932
933 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
935 assert_eq!(
936 rx.blocking_recv().unwrap(),
937 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
938 ("song", item_id()).into()
939 ])))
940 );
941
942 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
944 assert_eq!(
945 rx.blocking_recv().unwrap(),
946 Action::Library(LibraryAction::RemoveSongsFromPlaylist(
947 ("playlist", item_id()).into(),
948 vec![("song", item_id()).into()]
949 ))
950 );
951 }
952
953 #[test]
954 #[allow(clippy::too_many_lines)]
955 fn test_mouse_event() {
956 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
957 let mut view = PlaylistView::new(&state_with_everything(), tx);
958
959 let (mut terminal, area) = setup_test_terminal(60, 9);
961 let props = RenderProps {
962 area,
963 is_focused: true,
964 };
965 let buffer = terminal
966 .draw(|frame| view.render(frame, props))
967 .unwrap()
968 .buffer
969 .clone();
970 let expected = Buffer::with_lines([
971 "┌Playlist View sorted by: Artist───────────────────────────┐",
972 "│ Test Playlist │",
973 "│ Songs: 1 Duration: 00:03:00.00 │",
974 "│ │",
975 "│q: add to queue | r: start radio | p: add to playlist─────│",
976 "│Performing operations on entire playlist──────────────────│",
977 "│☐ Test Song Test Artist │",
978 "│s/S: sort | d: remove selected | e: edit──────────────────│",
979 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
980 ]);
981 assert_buffer_eq(&buffer, &expected);
982
983 view.handle_mouse_event(
985 MouseEvent {
986 kind: MouseEventKind::Down(MouseButton::Left),
987 column: 2,
988 row: 6,
989 modifiers: KeyModifiers::empty(),
990 },
991 area,
992 );
993 let buffer = terminal
994 .draw(|frame| view.render(frame, props))
995 .unwrap()
996 .buffer
997 .clone();
998 let expected = Buffer::with_lines([
999 "┌Playlist View sorted by: Artist───────────────────────────┐",
1000 "│ Test Playlist │",
1001 "│ Songs: 1 Duration: 00:03:00.00 │",
1002 "│ │",
1003 "│q: add to queue | r: start radio | p: add to playlist─────│",
1004 "│Performing operations on checked items────────────────────│",
1005 "│☑ Test Song Test Artist │",
1006 "│s/S: sort | d: remove selected | e: edit──────────────────│",
1007 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1008 ]);
1009 assert_buffer_eq(&buffer, &expected);
1010
1011 view.handle_mouse_event(
1013 MouseEvent {
1014 kind: MouseEventKind::Down(MouseButton::Left),
1015 column: 2,
1016 row: 6,
1017 modifiers: KeyModifiers::empty(),
1018 },
1019 area,
1020 );
1021 assert_eq!(
1022 rx.blocking_recv().unwrap(),
1023 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1024 );
1025 let expected = Buffer::with_lines([
1026 "┌Playlist View sorted by: Artist───────────────────────────┐",
1027 "│ Test Playlist │",
1028 "│ Songs: 1 Duration: 00:03:00.00 │",
1029 "│ │",
1030 "│q: add to queue | r: start radio | p: add to playlist─────│",
1031 "│Performing operations on entire playlist──────────────────│",
1032 "│☐ Test Song Test Artist │",
1033 "│s/S: sort | d: remove selected | e: edit──────────────────│",
1034 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1035 ]);
1036 let buffer = terminal
1037 .draw(|frame| view.render(frame, props))
1038 .unwrap()
1039 .buffer
1040 .clone();
1041 assert_buffer_eq(&buffer, &expected);
1042
1043 view.handle_mouse_event(
1045 MouseEvent {
1046 kind: MouseEventKind::ScrollDown,
1047 column: 2,
1048 row: 6,
1049 modifiers: KeyModifiers::empty(),
1050 },
1051 area,
1052 );
1053 let buffer = terminal
1054 .draw(|frame| view.render(frame, props))
1055 .unwrap()
1056 .buffer
1057 .clone();
1058 assert_buffer_eq(&buffer, &expected);
1059 view.handle_mouse_event(
1061 MouseEvent {
1062 kind: MouseEventKind::ScrollUp,
1063 column: 2,
1064 row: 7,
1065 modifiers: KeyModifiers::empty(),
1066 },
1067 area,
1068 );
1069 let buffer = terminal
1070 .draw(|frame| view.render(frame, props))
1071 .unwrap()
1072 .buffer
1073 .clone();
1074 assert_buffer_eq(&buffer, &expected);
1075 }
1076}
1077
1078#[cfg(test)]
1079mod library_view_tests {
1080 use super::*;
1081 use crate::{
1082 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
1083 ui::components::content_view::ActiveView,
1084 };
1085 use crossterm::event::KeyModifiers;
1086 use pretty_assertions::assert_eq;
1087 use ratatui::buffer::Buffer;
1088
1089 #[test]
1090 fn test_new() {
1091 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1092 let state = state_with_everything();
1093 let view = LibraryPlaylistsView::new(&state, tx);
1094
1095 assert_eq!(view.name(), "Library Playlists View");
1096 assert_eq!(view.props.playlists, state.library.playlists);
1097 }
1098
1099 #[test]
1100 fn test_move_with_state() {
1101 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1102 let state = AppState::default();
1103 let new_state = state_with_everything();
1104 let view = LibraryPlaylistsView::new(&state, tx).move_with_state(&new_state);
1105
1106 assert_eq!(view.props.playlists, new_state.library.playlists);
1107 }
1108
1109 #[test]
1110 fn test_render() {
1111 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1112 let view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1113
1114 let (mut terminal, area) = setup_test_terminal(60, 6);
1115 let props = RenderProps {
1116 area,
1117 is_focused: true,
1118 };
1119 let buffer = terminal
1120 .draw(|frame| view.render(frame, props))
1121 .unwrap()
1122 .buffer
1123 .clone();
1124 let expected = Buffer::with_lines([
1125 "┌Library Playlists sorted by: Name─────────────────────────┐",
1126 "│n: new playlist | d: delete playlist──────────────────────│",
1127 "│▪ Test Playlist │",
1128 "│ │",
1129 "│ │",
1130 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1131 ]);
1132
1133 assert_buffer_eq(&buffer, &expected);
1134 }
1135
1136 #[test]
1137 fn test_render_input_box() {
1138 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1139 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1140
1141 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1143
1144 let (mut terminal, area) = setup_test_terminal(60, 7);
1145 let props = RenderProps {
1146 area,
1147 is_focused: true,
1148 };
1149 let buffer = terminal
1150 .draw(|frame| view.render(frame, props))
1151 .unwrap()
1152 .buffer
1153 .clone();
1154 let expected = Buffer::with_lines([
1155 "┌Library Playlists sorted by: Name─────────────────────────┐",
1156 "│┌Enter Name:─────────────────────────────────────────────┐│",
1157 "││ ││",
1158 "│└────────────────────────────────────────────────────────┘│",
1159 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1160 "│▪ Test Playlist │",
1161 "└──────────────────────────────────────────────────────────┘",
1162 ]);
1163
1164 assert_buffer_eq(&buffer, &expected);
1165 }
1166
1167 #[test]
1168 fn test_sort_keys() {
1169 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1170 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1171
1172 assert_eq!(view.props.sort_mode, NameSort::default());
1173 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
1174 assert_eq!(view.props.sort_mode, NameSort::default());
1175 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
1176 assert_eq!(view.props.sort_mode, NameSort::default());
1177 }
1178
1179 #[test]
1180 fn smoke_navigation() {
1181 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1182 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1183
1184 view.handle_key_event(KeyEvent::from(KeyCode::Up));
1185 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
1186 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1187 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
1188 view.handle_key_event(KeyEvent::from(KeyCode::Left));
1189 view.handle_key_event(KeyEvent::from(KeyCode::Right));
1190 }
1191
1192 #[test]
1193 fn test_actions() {
1194 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1195 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1196
1197 let (mut terminal, area) = setup_test_terminal(60, 9);
1199 let props = RenderProps {
1200 area,
1201 is_focused: true,
1202 };
1203 terminal.draw(|frame| view.render(frame, props)).unwrap();
1204
1205 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1207
1208 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1210 assert_eq!(
1211 rx.blocking_recv().unwrap(),
1212 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1213 );
1214
1215 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1217 assert_eq!(view.input_box_visible, true);
1218 view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
1219 view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
1220 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1221 assert_eq!(view.input_box_visible, false);
1222 assert_eq!(
1223 rx.blocking_recv().unwrap(),
1224 Action::Library(LibraryAction::CreatePlaylist("ab".to_string()))
1225 );
1226
1227 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
1229 assert_eq!(
1230 rx.blocking_recv().unwrap(),
1231 Action::Library(LibraryAction::RemovePlaylist(
1232 ("playlist", item_id()).into()
1233 ))
1234 );
1235 }
1236
1237 #[test]
1238 fn test_mouse_event() {
1239 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1240 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1241
1242 let (mut terminal, area) = setup_test_terminal(60, 9);
1244 let props = RenderProps {
1245 area,
1246 is_focused: true,
1247 };
1248 let buffer = terminal
1249 .draw(|frame| view.render(frame, props))
1250 .unwrap()
1251 .buffer
1252 .clone();
1253 let expected = Buffer::with_lines([
1254 "┌Library Playlists sorted by: Name─────────────────────────┐",
1255 "│n: new playlist | d: delete playlist──────────────────────│",
1256 "│▪ Test Playlist │",
1257 "│ │",
1258 "│ │",
1259 "│ │",
1260 "│ │",
1261 "│ │",
1262 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1263 ]);
1264 assert_buffer_eq(&buffer, &expected);
1265
1266 view.handle_mouse_event(
1268 MouseEvent {
1269 kind: MouseEventKind::ScrollDown,
1270 column: 2,
1271 row: 2,
1272 modifiers: KeyModifiers::empty(),
1273 },
1274 area,
1275 );
1276
1277 view.handle_mouse_event(
1279 MouseEvent {
1280 kind: MouseEventKind::Down(MouseButton::Left),
1281 column: 2,
1282 row: 2,
1283 modifiers: KeyModifiers::empty(),
1284 },
1285 area,
1286 );
1287 assert_eq!(
1288 rx.blocking_recv().unwrap(),
1289 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1290 );
1291 let buffer = terminal
1292 .draw(|frame| view.render(frame, props))
1293 .unwrap()
1294 .buffer
1295 .clone();
1296 assert_buffer_eq(&buffer, &expected);
1297
1298 view.handle_mouse_event(
1300 MouseEvent {
1301 kind: MouseEventKind::ScrollUp,
1302 column: 2,
1303 row: 3,
1304 modifiers: KeyModifiers::empty(),
1305 },
1306 area,
1307 );
1308
1309 view.handle_mouse_event(
1311 MouseEvent {
1312 kind: MouseEventKind::Down(MouseButton::Left),
1313 column: 2,
1314 row: 2,
1315 modifiers: KeyModifiers::empty(),
1316 },
1317 area,
1318 );
1319 assert_eq!(
1320 rx.blocking_recv().unwrap(),
1321 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1322 );
1323
1324 let mouse = MouseEvent {
1326 kind: MouseEventKind::Down(MouseButton::Left),
1327 column: 2,
1328 row: 3,
1329 modifiers: KeyModifiers::empty(),
1330 };
1331 view.handle_mouse_event(mouse, area);
1332 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1333 view.handle_mouse_event(mouse, area);
1334 assert_eq!(
1335 rx.try_recv(),
1336 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1337 );
1338 }
1339}