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::PlaylistBrief;
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().into(),
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<[PlaylistBrief]>,
373 sort_mode: NameSort<PlaylistBrief>,
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 mecomp_storage::db::schemas::playlist::Playlist;
655 use pretty_assertions::assert_eq;
656 use rstest::rstest;
657
658 #[rstest]
659 #[case(NameSort::default(), NameSort::default())]
660 fn test_sort_mode_next_prev(
661 #[case] mode: NameSort<PlaylistBrief>,
662 #[case] expected: NameSort<PlaylistBrief>,
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<PlaylistBrief>, #[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 PlaylistBrief {
678 id: Playlist::generate_id(),
679 name: "C".into(),
680 },
681 PlaylistBrief {
682 id: Playlist::generate_id(),
683 name: "A".into(),
684 },
685 PlaylistBrief {
686 id: Playlist::generate_id(),
687 name: "B".into(),
688 },
689 ];
690
691 NameSort::default().sort_items(&mut songs);
692 assert_eq!(songs[0].name, "A");
693 assert_eq!(songs[1].name, "B");
694 assert_eq!(songs[2].name, "C");
695 }
696}
697
698#[cfg(test)]
699mod item_view_tests {
700 use super::*;
701 use crate::{
702 state::action::{AudioAction, PopupAction, QueueAction},
703 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
704 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
705 };
706 use crossterm::event::KeyModifiers;
707 use pretty_assertions::assert_eq;
708 use ratatui::buffer::Buffer;
709
710 #[test]
711 fn test_new() {
712 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
713 let state = state_with_everything();
714 let view = PlaylistView::new(&state, tx);
715
716 assert_eq!(view.name(), "Playlist View");
717 assert_eq!(
718 view.props,
719 Some(state.additional_view_data.playlist.unwrap())
720 );
721 }
722
723 #[test]
724 fn test_move_with_state() {
725 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
726 let state = AppState::default();
727 let new_state = state_with_everything();
728 let view = PlaylistView::new(&state, tx).move_with_state(&new_state);
729
730 assert_eq!(
731 view.props,
732 Some(new_state.additional_view_data.playlist.unwrap())
733 );
734 }
735 #[test]
736 fn test_render_no_playlist() {
737 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
738 let view = PlaylistView::new(&AppState::default(), tx);
739
740 let (mut terminal, area) = setup_test_terminal(20, 3);
741 let props = RenderProps {
742 area,
743 is_focused: true,
744 };
745 let buffer = terminal
746 .draw(|frame| view.render(frame, props))
747 .unwrap()
748 .buffer
749 .clone();
750 #[rustfmt::skip]
751 let expected = Buffer::with_lines([
752 "┌Playlist View─────┐",
753 "│No active playlist│",
754 "└──────────────────┘",
755 ]);
756
757 assert_buffer_eq(&buffer, &expected);
758 }
759
760 #[test]
761 fn test_render() {
762 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
763 let view = PlaylistView::new(&state_with_everything(), tx);
764
765 let (mut terminal, area) = setup_test_terminal(60, 9);
766 let props = RenderProps {
767 area,
768 is_focused: true,
769 };
770 let buffer = terminal
771 .draw(|frame| view.render(frame, props))
772 .unwrap()
773 .buffer
774 .clone();
775 let expected = Buffer::with_lines([
776 "┌Playlist View sorted by: Artist───────────────────────────┐",
777 "│ Test Playlist │",
778 "│ Songs: 1 Duration: 00:03:00.00 │",
779 "│ │",
780 "│q: add to queue | r: start radio | p: add to playlist─────│",
781 "│Performing operations on entire playlist──────────────────│",
782 "│☐ Test Song Test Artist │",
783 "│s/S: sort | d: remove selected | e: edit──────────────────│",
784 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
785 ]);
786
787 assert_buffer_eq(&buffer, &expected);
788 }
789
790 #[test]
791 fn test_render_with_checked() {
792 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
793 let mut view = PlaylistView::new(&state_with_everything(), tx);
794 let (mut terminal, area) = setup_test_terminal(60, 9);
795 let props = RenderProps {
796 area,
797 is_focused: true,
798 };
799 let buffer = terminal
800 .draw(|frame| view.render(frame, props))
801 .unwrap()
802 .buffer
803 .clone();
804 let expected = Buffer::with_lines([
805 "┌Playlist View sorted by: Artist───────────────────────────┐",
806 "│ Test Playlist │",
807 "│ Songs: 1 Duration: 00:03:00.00 │",
808 "│ │",
809 "│q: add to queue | r: start radio | p: add to playlist─────│",
810 "│Performing operations on entire playlist──────────────────│",
811 "│☐ Test Song Test Artist │",
812 "│s/S: sort | d: remove selected | e: edit──────────────────│",
813 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
814 ]);
815 assert_buffer_eq(&buffer, &expected);
816
817 view.handle_key_event(KeyEvent::from(KeyCode::Down));
819 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
820
821 let buffer = terminal
822 .draw(|frame| view.render(frame, props))
823 .unwrap()
824 .buffer
825 .clone();
826 let expected = Buffer::with_lines([
827 "┌Playlist View sorted by: Artist───────────────────────────┐",
828 "│ Test Playlist │",
829 "│ Songs: 1 Duration: 00:03:00.00 │",
830 "│ │",
831 "│q: add to queue | r: start radio | p: add to playlist─────│",
832 "│Performing operations on checked items────────────────────│",
833 "│☑ Test Song Test Artist │",
834 "│s/S: sort | d: remove selected | e: edit──────────────────│",
835 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
836 ]);
837
838 assert_buffer_eq(&buffer, &expected);
839 }
840
841 #[test]
842 fn smoke_navigation_and_sort() {
843 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
844 let mut view = PlaylistView::new(&state_with_everything(), tx);
845
846 view.handle_key_event(KeyEvent::from(KeyCode::Up));
847 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
848 view.handle_key_event(KeyEvent::from(KeyCode::Down));
849 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
850 view.handle_key_event(KeyEvent::from(KeyCode::Left));
851 view.handle_key_event(KeyEvent::from(KeyCode::Right));
852 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
853 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
854 }
855
856 #[test]
857 fn test_actions() {
858 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
859 let mut view = PlaylistView::new(&state_with_everything(), tx);
860
861 let (mut terminal, area) = setup_test_terminal(60, 9);
863 let props = RenderProps {
864 area,
865 is_focused: true,
866 };
867 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
868
869 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
872 assert_eq!(
873 rx.blocking_recv().unwrap(),
874 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
875 ("playlist", item_id()).into()
876 ])))
877 );
878 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
879 assert_eq!(
880 rx.blocking_recv().unwrap(),
881 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
882 ("playlist", item_id()).into()
883 ],)))
884 );
885 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
886 assert_eq!(
887 rx.blocking_recv().unwrap(),
888 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
889 ("playlist", item_id()).into()
890 ])))
891 );
892 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
893
894 view.handle_key_event(KeyEvent::from(KeyCode::Up));
897 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
898
899 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
901 assert_eq!(
902 rx.blocking_recv().unwrap(),
903 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
904 );
905
906 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
908
909 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
911 assert_eq!(
912 rx.blocking_recv().unwrap(),
913 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
914 ("song", item_id()).into()
915 ])))
916 );
917
918 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
920 assert_eq!(
921 rx.blocking_recv().unwrap(),
922 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
923 ("song", item_id()).into()
924 ],)))
925 );
926
927 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
929 assert_eq!(
930 rx.blocking_recv().unwrap(),
931 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
932 ("song", item_id()).into()
933 ])))
934 );
935
936 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
938 assert_eq!(
939 rx.blocking_recv().unwrap(),
940 Action::Library(LibraryAction::RemoveSongsFromPlaylist(
941 ("playlist", item_id()).into(),
942 vec![("song", item_id()).into()]
943 ))
944 );
945 }
946
947 #[test]
948 #[allow(clippy::too_many_lines)]
949 fn test_mouse_event() {
950 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
951 let mut view = PlaylistView::new(&state_with_everything(), tx);
952
953 let (mut terminal, area) = setup_test_terminal(60, 9);
955 let props = RenderProps {
956 area,
957 is_focused: true,
958 };
959 let buffer = terminal
960 .draw(|frame| view.render(frame, props))
961 .unwrap()
962 .buffer
963 .clone();
964 let expected = Buffer::with_lines([
965 "┌Playlist View sorted by: Artist───────────────────────────┐",
966 "│ Test Playlist │",
967 "│ Songs: 1 Duration: 00:03:00.00 │",
968 "│ │",
969 "│q: add to queue | r: start radio | p: add to playlist─────│",
970 "│Performing operations on entire playlist──────────────────│",
971 "│☐ Test Song Test Artist │",
972 "│s/S: sort | d: remove selected | e: edit──────────────────│",
973 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
974 ]);
975 assert_buffer_eq(&buffer, &expected);
976
977 view.handle_mouse_event(
979 MouseEvent {
980 kind: MouseEventKind::Down(MouseButton::Left),
981 column: 2,
982 row: 6,
983 modifiers: KeyModifiers::empty(),
984 },
985 area,
986 );
987 let buffer = terminal
988 .draw(|frame| view.render(frame, props))
989 .unwrap()
990 .buffer
991 .clone();
992 let expected = Buffer::with_lines([
993 "┌Playlist View sorted by: Artist───────────────────────────┐",
994 "│ Test Playlist │",
995 "│ Songs: 1 Duration: 00:03:00.00 │",
996 "│ │",
997 "│q: add to queue | r: start radio | p: add to playlist─────│",
998 "│Performing operations on checked items────────────────────│",
999 "│☑ Test Song Test Artist │",
1000 "│s/S: sort | d: remove selected | e: edit──────────────────│",
1001 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1002 ]);
1003 assert_buffer_eq(&buffer, &expected);
1004
1005 view.handle_mouse_event(
1007 MouseEvent {
1008 kind: MouseEventKind::Down(MouseButton::Left),
1009 column: 2,
1010 row: 6,
1011 modifiers: KeyModifiers::empty(),
1012 },
1013 area,
1014 );
1015 assert_eq!(
1016 rx.blocking_recv().unwrap(),
1017 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1018 );
1019 let expected = Buffer::with_lines([
1020 "┌Playlist View sorted by: Artist───────────────────────────┐",
1021 "│ Test Playlist │",
1022 "│ Songs: 1 Duration: 00:03:00.00 │",
1023 "│ │",
1024 "│q: add to queue | r: start radio | p: add to playlist─────│",
1025 "│Performing operations on entire playlist──────────────────│",
1026 "│☐ Test Song Test Artist │",
1027 "│s/S: sort | d: remove selected | e: edit──────────────────│",
1028 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1029 ]);
1030 let buffer = terminal
1031 .draw(|frame| view.render(frame, props))
1032 .unwrap()
1033 .buffer
1034 .clone();
1035 assert_buffer_eq(&buffer, &expected);
1036
1037 view.handle_mouse_event(
1039 MouseEvent {
1040 kind: MouseEventKind::ScrollDown,
1041 column: 2,
1042 row: 6,
1043 modifiers: KeyModifiers::empty(),
1044 },
1045 area,
1046 );
1047 let buffer = terminal
1048 .draw(|frame| view.render(frame, props))
1049 .unwrap()
1050 .buffer
1051 .clone();
1052 assert_buffer_eq(&buffer, &expected);
1053 view.handle_mouse_event(
1055 MouseEvent {
1056 kind: MouseEventKind::ScrollUp,
1057 column: 2,
1058 row: 7,
1059 modifiers: KeyModifiers::empty(),
1060 },
1061 area,
1062 );
1063 let buffer = terminal
1064 .draw(|frame| view.render(frame, props))
1065 .unwrap()
1066 .buffer
1067 .clone();
1068 assert_buffer_eq(&buffer, &expected);
1069 }
1070}
1071
1072#[cfg(test)]
1073mod library_view_tests {
1074 use super::*;
1075 use crate::{
1076 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
1077 ui::components::content_view::ActiveView,
1078 };
1079 use crossterm::event::KeyModifiers;
1080 use pretty_assertions::assert_eq;
1081 use ratatui::buffer::Buffer;
1082
1083 #[test]
1084 fn test_new() {
1085 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1086 let state = state_with_everything();
1087 let view = LibraryPlaylistsView::new(&state, tx);
1088
1089 assert_eq!(view.name(), "Library Playlists View");
1090 assert_eq!(view.props.playlists, state.library.playlists);
1091 }
1092
1093 #[test]
1094 fn test_move_with_state() {
1095 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1096 let state = AppState::default();
1097 let new_state = state_with_everything();
1098 let view = LibraryPlaylistsView::new(&state, tx).move_with_state(&new_state);
1099
1100 assert_eq!(view.props.playlists, new_state.library.playlists);
1101 }
1102
1103 #[test]
1104 fn test_render() {
1105 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1106 let view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1107
1108 let (mut terminal, area) = setup_test_terminal(60, 6);
1109 let props = RenderProps {
1110 area,
1111 is_focused: true,
1112 };
1113 let buffer = terminal
1114 .draw(|frame| view.render(frame, props))
1115 .unwrap()
1116 .buffer
1117 .clone();
1118 let expected = Buffer::with_lines([
1119 "┌Library Playlists sorted by: Name─────────────────────────┐",
1120 "│n: new playlist | d: delete playlist──────────────────────│",
1121 "│▪ Test Playlist │",
1122 "│ │",
1123 "│ │",
1124 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1125 ]);
1126
1127 assert_buffer_eq(&buffer, &expected);
1128 }
1129
1130 #[test]
1131 fn test_render_input_box() {
1132 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1133 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1134
1135 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1137
1138 let (mut terminal, area) = setup_test_terminal(60, 7);
1139 let props = RenderProps {
1140 area,
1141 is_focused: true,
1142 };
1143 let buffer = terminal
1144 .draw(|frame| view.render(frame, props))
1145 .unwrap()
1146 .buffer
1147 .clone();
1148 let expected = Buffer::with_lines([
1149 "┌Library Playlists sorted by: Name─────────────────────────┐",
1150 "│┌Enter Name:─────────────────────────────────────────────┐│",
1151 "││ ││",
1152 "│└────────────────────────────────────────────────────────┘│",
1153 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1154 "│▪ Test Playlist │",
1155 "└──────────────────────────────────────────────────────────┘",
1156 ]);
1157
1158 assert_buffer_eq(&buffer, &expected);
1159 }
1160
1161 #[test]
1162 fn test_sort_keys() {
1163 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1164 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1165
1166 assert_eq!(view.props.sort_mode, NameSort::default());
1167 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
1168 assert_eq!(view.props.sort_mode, NameSort::default());
1169 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
1170 assert_eq!(view.props.sort_mode, NameSort::default());
1171 }
1172
1173 #[test]
1174 fn smoke_navigation() {
1175 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1176 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1177
1178 view.handle_key_event(KeyEvent::from(KeyCode::Up));
1179 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
1180 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1181 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
1182 view.handle_key_event(KeyEvent::from(KeyCode::Left));
1183 view.handle_key_event(KeyEvent::from(KeyCode::Right));
1184 }
1185
1186 #[test]
1187 fn test_actions() {
1188 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1189 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1190
1191 let (mut terminal, area) = setup_test_terminal(60, 9);
1193 let props = RenderProps {
1194 area,
1195 is_focused: true,
1196 };
1197 terminal.draw(|frame| view.render(frame, props)).unwrap();
1198
1199 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1201
1202 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1204 assert_eq!(
1205 rx.blocking_recv().unwrap(),
1206 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1207 );
1208
1209 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1211 assert_eq!(view.input_box_visible, true);
1212 view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
1213 view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
1214 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1215 assert_eq!(view.input_box_visible, false);
1216 assert_eq!(
1217 rx.blocking_recv().unwrap(),
1218 Action::Library(LibraryAction::CreatePlaylist("ab".to_string()))
1219 );
1220
1221 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
1223 assert_eq!(
1224 rx.blocking_recv().unwrap(),
1225 Action::Library(LibraryAction::RemovePlaylist(
1226 ("playlist", item_id()).into()
1227 ))
1228 );
1229 }
1230
1231 #[test]
1232 fn test_mouse_event() {
1233 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1234 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
1235
1236 let (mut terminal, area) = setup_test_terminal(60, 9);
1238 let props = RenderProps {
1239 area,
1240 is_focused: true,
1241 };
1242 let buffer = terminal
1243 .draw(|frame| view.render(frame, props))
1244 .unwrap()
1245 .buffer
1246 .clone();
1247 let expected = Buffer::with_lines([
1248 "┌Library Playlists sorted by: Name─────────────────────────┐",
1249 "│n: new playlist | d: delete playlist──────────────────────│",
1250 "│▪ Test Playlist │",
1251 "│ │",
1252 "│ │",
1253 "│ │",
1254 "│ │",
1255 "│ │",
1256 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1257 ]);
1258 assert_buffer_eq(&buffer, &expected);
1259
1260 view.handle_mouse_event(
1262 MouseEvent {
1263 kind: MouseEventKind::ScrollDown,
1264 column: 2,
1265 row: 2,
1266 modifiers: KeyModifiers::empty(),
1267 },
1268 area,
1269 );
1270
1271 view.handle_mouse_event(
1273 MouseEvent {
1274 kind: MouseEventKind::Down(MouseButton::Left),
1275 column: 2,
1276 row: 2,
1277 modifiers: KeyModifiers::empty(),
1278 },
1279 area,
1280 );
1281 assert_eq!(
1282 rx.blocking_recv().unwrap(),
1283 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1284 );
1285 let buffer = terminal
1286 .draw(|frame| view.render(frame, props))
1287 .unwrap()
1288 .buffer
1289 .clone();
1290 assert_buffer_eq(&buffer, &expected);
1291
1292 view.handle_mouse_event(
1294 MouseEvent {
1295 kind: MouseEventKind::ScrollUp,
1296 column: 2,
1297 row: 3,
1298 modifiers: KeyModifiers::empty(),
1299 },
1300 area,
1301 );
1302
1303 view.handle_mouse_event(
1305 MouseEvent {
1306 kind: MouseEventKind::Down(MouseButton::Left),
1307 column: 2,
1308 row: 2,
1309 modifiers: KeyModifiers::empty(),
1310 },
1311 area,
1312 );
1313 assert_eq!(
1314 rx.blocking_recv().unwrap(),
1315 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1316 );
1317
1318 let mouse = MouseEvent {
1320 kind: MouseEventKind::Down(MouseButton::Left),
1321 column: 2,
1322 row: 3,
1323 modifiers: KeyModifiers::empty(),
1324 };
1325 view.handle_mouse_event(mouse, area);
1326 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1327 view.handle_mouse_event(mouse, area);
1328 assert_eq!(
1329 rx.try_recv(),
1330 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1331 );
1332 }
1333}