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