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