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