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