1use std::sync::Mutex;
6
7use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
8use mecomp_core::format_duration;
9use mecomp_storage::db::schemas::collection::Collection;
10use ratatui::{
11 layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
12 style::{Style, Stylize},
13 text::{Line, Span},
14 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation},
15};
16use tokio::sync::mpsc::UnboundedSender;
17
18use crate::{
19 state::action::{Action, ViewAction},
20 ui::{
21 colors::{border_color, TEXT_HIGHLIGHT, TEXT_NORMAL},
22 components::{content_view::ActiveView, Component, ComponentRender, RenderProps},
23 widgets::tree::{state::CheckTreeState, CheckTree},
24 AppState,
25 },
26};
27
28use super::{
29 checktree_utils::{
30 construct_add_to_playlist_action, construct_add_to_queue_action,
31 create_collection_tree_leaf, create_song_tree_leaf,
32 },
33 sort_mode::{NameSort, SongSort},
34 traits::SortMode,
35 CollectionViewProps,
36};
37
38#[allow(clippy::module_name_repetitions)]
39pub struct CollectionView {
40 pub action_tx: UnboundedSender<Action>,
42 pub props: Option<CollectionViewProps>,
44 tree_state: Mutex<CheckTreeState<String>>,
46 sort_mode: SongSort,
48}
49
50impl Component for CollectionView {
51 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
52 where
53 Self: Sized,
54 {
55 Self {
56 action_tx,
57 props: state.additional_view_data.collection.clone(),
58 tree_state: Mutex::new(CheckTreeState::default()),
59 sort_mode: SongSort::default(),
60 }
61 }
62
63 fn move_with_state(self, state: &AppState) -> Self
64 where
65 Self: Sized,
66 {
67 if let Some(props) = &state.additional_view_data.collection {
68 let mut props = props.clone();
69 self.sort_mode.sort_items(&mut props.songs);
70
71 Self {
72 props: Some(props),
73 tree_state: Mutex::new(CheckTreeState::default()),
74 ..self
75 }
76 } else {
77 self
78 }
79 }
80
81 fn name(&self) -> &'static str {
82 "Collection View"
83 }
84
85 fn handle_key_event(&mut self, key: KeyEvent) {
86 match key.code {
87 KeyCode::PageUp => {
89 self.tree_state.lock().unwrap().select_relative(|current| {
90 current.map_or(
91 self.props
92 .as_ref()
93 .map_or(0, |p| p.songs.len().saturating_sub(1)),
94 |c| c.saturating_sub(10),
95 )
96 });
97 }
98 KeyCode::Up => {
99 self.tree_state.lock().unwrap().key_up();
100 }
101 KeyCode::PageDown => {
102 self.tree_state
103 .lock()
104 .unwrap()
105 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
106 }
107 KeyCode::Down => {
108 self.tree_state.lock().unwrap().key_down();
109 }
110 KeyCode::Left => {
111 self.tree_state.lock().unwrap().key_left();
112 }
113 KeyCode::Right => {
114 self.tree_state.lock().unwrap().key_right();
115 }
116 KeyCode::Char(' ') => {
117 self.tree_state.lock().unwrap().key_space();
118 }
119 KeyCode::Char('s') => {
121 self.sort_mode = self.sort_mode.next();
122 if let Some(props) = &mut self.props {
123 self.sort_mode.sort_items(&mut props.songs);
124 }
125 }
126 KeyCode::Char('S') => {
127 self.sort_mode = self.sort_mode.prev();
128 if let Some(props) = &mut self.props {
129 self.sort_mode.sort_items(&mut props.songs);
130 }
131 }
132 KeyCode::Enter => {
134 if self.tree_state.lock().unwrap().toggle_selected() {
135 let things = self.tree_state.lock().unwrap().get_selected_thing();
136
137 if let Some(thing) = things {
138 self.action_tx
139 .send(Action::ActiveView(ViewAction::Set(thing.into())))
140 .unwrap();
141 }
142 }
143 }
144 KeyCode::Char('q') => {
146 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
147 if let Some(action) = construct_add_to_queue_action(
148 checked_things,
149 self.props.as_ref().map(|p| &p.id),
150 ) {
151 self.action_tx.send(action).unwrap();
152 }
153 }
154 KeyCode::Char('p') => {
156 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
157 if let Some(action) = construct_add_to_playlist_action(
158 checked_things,
159 self.props.as_ref().map(|p| &p.id),
160 ) {
161 self.action_tx.send(action).unwrap();
162 }
163 }
164 _ => {}
165 }
166 }
167
168 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
169 let area = area.inner(Margin::new(1, 1));
171 let [_, content_area] = split_area(area);
172 let content_area = content_area.inner(Margin::new(0, 1));
173
174 let result = self
175 .tree_state
176 .lock()
177 .unwrap()
178 .handle_mouse_event(mouse, content_area);
179 if let Some(action) = result {
180 self.action_tx.send(action).unwrap();
181 }
182 }
183}
184
185fn split_area(area: Rect) -> [Rect; 2] {
186 let [info_area, content_area] = *Layout::default()
187 .direction(Direction::Vertical)
188 .constraints([Constraint::Length(3), Constraint::Min(4)])
189 .split(area)
190 else {
191 panic!("Failed to split collection view area")
192 };
193
194 [info_area, content_area]
195}
196
197impl ComponentRender<RenderProps> for CollectionView {
198 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
199 let border_style = Style::default().fg(border_color(props.is_focused).into());
200
201 let area = if let Some(state) = &self.props {
202 let border = Block::bordered()
203 .title_top(Line::from(vec![
204 Span::styled("Collection View".to_string(), Style::default().bold()),
205 Span::raw(" sorted by: "),
206 Span::styled(self.sort_mode.to_string(), Style::default().italic()),
207 ]))
208 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
209 .border_style(border_style);
210 frame.render_widget(&border, props.area);
211 let content_area = border.inner(props.area);
212
213 let [info_area, content_area] = split_area(content_area);
215
216 frame.render_widget(
218 Paragraph::new(vec![
219 Line::from(Span::styled(
220 state.collection.name.to_string(),
221 Style::default().bold(),
222 )),
223 Line::from(vec![
224 Span::raw("Songs: "),
225 Span::styled(
226 state.collection.song_count.to_string(),
227 Style::default().italic(),
228 ),
229 Span::raw(" Duration: "),
230 Span::styled(
231 format_duration(&state.collection.runtime),
232 Style::default().italic(),
233 ),
234 ]),
235 ])
236 .alignment(Alignment::Center),
237 info_area,
238 );
239
240 let border = Block::new()
242 .borders(Borders::TOP | Borders::BOTTOM)
243 .title_top("q: add to queue | p: add to playlist")
244 .title_bottom("s/S: change sort")
245 .border_style(border_style);
246 frame.render_widget(&border, content_area);
247 let content_area = border.inner(content_area);
248
249 let border = Block::default()
251 .borders(Borders::TOP)
252 .title_top(Line::from(vec![
253 Span::raw("Performing operations on "),
254 Span::raw(
255 if self
256 .tree_state
257 .lock()
258 .unwrap()
259 .get_checked_things()
260 .is_empty()
261 {
262 "entire collection"
263 } else {
264 "checked items"
265 },
266 )
267 .fg(TEXT_HIGHLIGHT),
268 ]))
269 .italic()
270 .border_style(border_style);
271 frame.render_widget(&border, content_area);
272 border.inner(content_area)
273 } else {
274 let border = Block::bordered()
275 .title_top("Collection View")
276 .border_style(border_style);
277 frame.render_widget(&border, props.area);
278 border.inner(props.area)
279 };
280
281 RenderProps { area, ..props }
282 }
283
284 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
285 if let Some(state) = &self.props {
286 let items = state
288 .songs
289 .iter()
290 .map(create_song_tree_leaf)
291 .collect::<Vec<_>>();
292
293 frame.render_stateful_widget(
295 CheckTree::new(&items)
296 .unwrap()
297 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
298 .experimental_scrollbar(Some(Scrollbar::new(
299 ScrollbarOrientation::VerticalRight,
300 ))),
301 props.area,
302 &mut self.tree_state.lock().unwrap(),
303 );
304 } else {
305 let text = "No active collection";
306
307 frame.render_widget(
308 Line::from(text)
309 .style(Style::default().fg(TEXT_NORMAL.into()))
310 .alignment(Alignment::Center),
311 props.area,
312 );
313 }
314 }
315}
316
317pub struct LibraryCollectionsView {
318 pub action_tx: UnboundedSender<Action>,
320 props: Props,
322 tree_state: Mutex<CheckTreeState<String>>,
324}
325
326struct Props {
327 collections: Box<[Collection]>,
328 sort_mode: NameSort<Collection>,
329}
330
331impl Component for LibraryCollectionsView {
332 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
333 where
334 Self: Sized,
335 {
336 let sort_mode = NameSort::default();
337 let mut collections = state.library.collections.clone();
338 sort_mode.sort_items(&mut collections);
339 Self {
340 action_tx,
341 props: Props {
342 collections,
343 sort_mode,
344 },
345 tree_state: Mutex::new(CheckTreeState::default()),
346 }
347 }
348
349 fn move_with_state(self, state: &AppState) -> Self
350 where
351 Self: Sized,
352 {
353 let mut collections = state.library.collections.clone();
354 self.props.sort_mode.sort_items(&mut collections);
355 let tree_state = (state.active_view == ActiveView::Collections)
356 .then_some(self.tree_state)
357 .unwrap_or_default();
358
359 Self {
360 props: Props {
361 collections,
362 ..self.props
363 },
364 tree_state,
365 ..self
366 }
367 }
368
369 fn name(&self) -> &'static str {
370 "Library Collections View"
371 }
372
373 fn handle_key_event(&mut self, key: KeyEvent) {
374 match key.code {
375 KeyCode::PageUp => {
377 self.tree_state.lock().unwrap().select_relative(|current| {
378 current.map_or(self.props.collections.len().saturating_sub(1), |c| {
379 c.saturating_sub(10)
380 })
381 });
382 }
383 KeyCode::Up => {
384 self.tree_state.lock().unwrap().key_up();
385 }
386 KeyCode::PageDown => {
387 self.tree_state
388 .lock()
389 .unwrap()
390 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
391 }
392 KeyCode::Down => {
393 self.tree_state.lock().unwrap().key_down();
394 }
395 KeyCode::Left => {
396 self.tree_state.lock().unwrap().key_left();
397 }
398 KeyCode::Right => {
399 self.tree_state.lock().unwrap().key_right();
400 }
401 KeyCode::Enter => {
403 if self.tree_state.lock().unwrap().toggle_selected() {
404 let things = self.tree_state.lock().unwrap().get_selected_thing();
405
406 if let Some(thing) = things {
407 self.action_tx
408 .send(Action::ActiveView(ViewAction::Set(thing.into())))
409 .unwrap();
410 }
411 }
412 }
413 KeyCode::Char('s') => {
415 self.props.sort_mode = self.props.sort_mode.next();
416 self.props.sort_mode.sort_items(&mut self.props.collections);
417 }
418 KeyCode::Char('S') => {
419 self.props.sort_mode = self.props.sort_mode.prev();
420 self.props.sort_mode.sort_items(&mut self.props.collections);
421 }
422 _ => {}
423 }
424 }
425
426 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
427 let area = area.inner(Margin::new(1, 2));
429
430 let result = self
431 .tree_state
432 .lock()
433 .unwrap()
434 .handle_mouse_event(mouse, area);
435 if let Some(action) = result {
436 self.action_tx.send(action).unwrap();
437 }
438 }
439}
440
441impl ComponentRender<RenderProps> for LibraryCollectionsView {
442 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
443 let border_style = Style::default().fg(border_color(props.is_focused).into());
444
445 let border = Block::bordered()
447 .title_top(Line::from(vec![
448 Span::styled("Library Collections".to_string(), Style::default().bold()),
449 Span::raw(" sorted by: "),
450 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
451 ]))
452 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort")
453 .border_style(border_style);
454 let content_area = border.inner(props.area);
455 frame.render_widget(border, props.area);
456
457 let border = Block::new()
459 .borders(Borders::TOP)
460 .border_style(border_style);
461 frame.render_widget(&border, content_area);
462 let content_area = border.inner(content_area);
463
464 RenderProps {
466 area: content_area,
467 is_focused: props.is_focused,
468 }
469 }
470
471 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
472 let items = self
474 .props
475 .collections
476 .iter()
477 .map(create_collection_tree_leaf)
478 .collect::<Vec<_>>();
479
480 frame.render_stateful_widget(
482 CheckTree::new(&items)
483 .unwrap()
484 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
485 .node_unchecked_symbol("▪ ")
487 .node_checked_symbol("▪ ")
488 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
489 props.area,
490 &mut self.tree_state.lock().unwrap(),
491 );
492 }
493}
494
495#[cfg(test)]
496mod sort_mode_tests {
497 use super::*;
498 use pretty_assertions::assert_eq;
499 use rstest::rstest;
500 use std::time::Duration;
501
502 #[rstest]
503 #[case(NameSort::default(), NameSort::default())]
504 fn test_sort_mode_next_prev(
505 #[case] mode: NameSort<Collection>,
506 #[case] expected: NameSort<Collection>,
507 ) {
508 assert_eq!(mode.next(), expected);
509 assert_eq!(mode.next().prev(), mode);
510 }
511
512 #[rstest]
513 #[case(NameSort::default(), "Name")]
514 fn test_sort_mode_display(#[case] mode: NameSort<Collection>, #[case] expected: &str) {
515 assert_eq!(mode.to_string(), expected);
516 }
517
518 #[rstest]
519 fn test_sort_collectionss() {
520 let mut songs = vec![
521 Collection {
522 id: Collection::generate_id(),
523 name: "C".into(),
524 song_count: 0,
525 runtime: Duration::from_secs(0),
526 },
527 Collection {
528 id: Collection::generate_id(),
529 name: "A".into(),
530 song_count: 0,
531 runtime: Duration::from_secs(0),
532 },
533 Collection {
534 id: Collection::generate_id(),
535 name: "B".into(),
536 song_count: 0,
537 runtime: Duration::from_secs(0),
538 },
539 ];
540
541 NameSort::default().sort_items(&mut songs);
542 assert_eq!(songs[0].name, "A");
543 assert_eq!(songs[1].name, "B");
544 assert_eq!(songs[2].name, "C");
545 }
546}
547
548#[cfg(test)]
549mod item_view_tests {
550 use super::*;
551 use crate::{
552 state::action::{AudioAction, PopupAction, QueueAction},
553 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
554 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
555 };
556 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
557 use pretty_assertions::assert_eq;
558 use ratatui::buffer::Buffer;
559
560 #[test]
561 fn test_new() {
562 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
563 let state = state_with_everything();
564 let view = CollectionView::new(&state, tx);
565
566 assert_eq!(view.name(), "Collection View");
567 assert_eq!(
568 view.props,
569 Some(state.additional_view_data.collection.unwrap())
570 );
571 }
572
573 #[test]
574 fn test_move_with_state() {
575 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
576 let state = AppState::default();
577 let new_state = state_with_everything();
578 let view = CollectionView::new(&state, tx).move_with_state(&new_state);
579
580 assert_eq!(
581 view.props,
582 Some(new_state.additional_view_data.collection.unwrap())
583 );
584 }
585 #[test]
586 fn test_render_no_collection() {
587 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
588 let view = CollectionView::new(&AppState::default(), tx);
589
590 let (mut terminal, area) = setup_test_terminal(22, 3);
591 let props = RenderProps {
592 area,
593 is_focused: true,
594 };
595 let buffer = terminal
596 .draw(|frame| view.render(frame, props))
597 .unwrap()
598 .buffer
599 .clone();
600 #[rustfmt::skip]
601 let expected = Buffer::with_lines([
602 "┌Collection View─────┐",
603 "│No active collection│",
604 "└────────────────────┘",
605 ]);
606
607 assert_buffer_eq(&buffer, &expected);
608 }
609
610 #[test]
611 fn test_render() {
612 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
613 let view = CollectionView::new(&state_with_everything(), tx);
614
615 let (mut terminal, area) = setup_test_terminal(60, 9);
616 let props = RenderProps {
617 area,
618 is_focused: true,
619 };
620 let buffer = terminal
621 .draw(|frame| view.render(frame, props))
622 .unwrap()
623 .buffer
624 .clone();
625 let expected = Buffer::with_lines([
626 "┌Collection View sorted by: Artist─────────────────────────┐",
627 "│ Collection 0 │",
628 "│ Songs: 1 Duration: 00:03:00.00 │",
629 "│ │",
630 "│q: add to queue | p: add to playlist──────────────────────│",
631 "│Performing operations on entire collection────────────────│",
632 "│☐ Test Song Test Artist │",
633 "│s/S: change sort──────────────────────────────────────────│",
634 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
635 ]);
636
637 assert_buffer_eq(&buffer, &expected);
638 }
639
640 #[test]
641 fn test_render_with_checked() {
642 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
643 let mut view = CollectionView::new(&state_with_everything(), tx);
644 let (mut terminal, area) = setup_test_terminal(60, 9);
645 let props = RenderProps {
646 area,
647 is_focused: true,
648 };
649 let buffer = terminal
650 .draw(|frame| view.render(frame, props))
651 .unwrap()
652 .buffer
653 .clone();
654 let expected = Buffer::with_lines([
655 "┌Collection View sorted by: Artist─────────────────────────┐",
656 "│ Collection 0 │",
657 "│ Songs: 1 Duration: 00:03:00.00 │",
658 "│ │",
659 "│q: add to queue | p: add to playlist──────────────────────│",
660 "│Performing operations on entire collection────────────────│",
661 "│☐ Test Song Test Artist │",
662 "│s/S: change sort──────────────────────────────────────────│",
663 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
664 ]);
665 assert_buffer_eq(&buffer, &expected);
666
667 view.handle_key_event(KeyEvent::from(KeyCode::Down));
669 view.handle_key_event(KeyEvent::from(KeyCode::Down));
670 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
671
672 let buffer = terminal
673 .draw(|frame| view.render(frame, props))
674 .unwrap()
675 .buffer
676 .clone();
677 let expected = Buffer::with_lines([
678 "┌Collection View sorted by: Artist─────────────────────────┐",
679 "│ Collection 0 │",
680 "│ Songs: 1 Duration: 00:03:00.00 │",
681 "│ │",
682 "│q: add to queue | p: add to playlist──────────────────────│",
683 "│Performing operations on checked items────────────────────│",
684 "│☑ Test Song Test Artist │",
685 "│s/S: change sort──────────────────────────────────────────│",
686 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
687 ]);
688
689 assert_buffer_eq(&buffer, &expected);
690 }
691
692 #[test]
693 fn smoke_navigation() {
694 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
695 let mut view = CollectionView::new(&state_with_everything(), tx);
696
697 view.handle_key_event(KeyEvent::from(KeyCode::Up));
698 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
699 view.handle_key_event(KeyEvent::from(KeyCode::Down));
700 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
701 view.handle_key_event(KeyEvent::from(KeyCode::Left));
702 view.handle_key_event(KeyEvent::from(KeyCode::Right));
703 }
704
705 #[test]
706 fn test_actions() {
707 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
708 let mut view = CollectionView::new(&state_with_everything(), tx);
709
710 let (mut terminal, area) = setup_test_terminal(60, 9);
712 let props = RenderProps {
713 area,
714 is_focused: true,
715 };
716 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
717
718 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
721 assert_eq!(
722 rx.blocking_recv().unwrap(),
723 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
724 "collection",
725 item_id()
726 )
727 .into()])))
728 );
729 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
730 assert_eq!(
731 rx.blocking_recv().unwrap(),
732 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
733 "collection",
734 item_id()
735 )
736 .into()])))
737 );
738 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
739
740 view.handle_key_event(KeyEvent::from(KeyCode::Up));
743 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
744
745 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
747 assert_eq!(
748 rx.blocking_recv().unwrap(),
749 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
750 );
751
752 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
754
755 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
757 assert_eq!(
758 rx.blocking_recv().unwrap(),
759 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
760 "song",
761 item_id()
762 )
763 .into()])))
764 );
765
766 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
768 assert_eq!(
769 rx.blocking_recv().unwrap(),
770 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
771 "song",
772 item_id()
773 )
774 .into()])))
775 );
776 }
777
778 #[test]
779 #[allow(clippy::too_many_lines)]
780 fn test_mouse_event() {
781 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
782 let mut view = CollectionView::new(&state_with_everything(), tx);
783
784 let (mut terminal, area) = setup_test_terminal(60, 9);
786 let props = RenderProps {
787 area,
788 is_focused: true,
789 };
790 let buffer = terminal
791 .draw(|frame| view.render(frame, props))
792 .unwrap()
793 .buffer
794 .clone();
795 let expected = Buffer::with_lines([
796 "┌Collection View sorted by: Artist─────────────────────────┐",
797 "│ Collection 0 │",
798 "│ Songs: 1 Duration: 00:03:00.00 │",
799 "│ │",
800 "│q: add to queue | p: add to playlist──────────────────────│",
801 "│Performing operations on entire collection────────────────│",
802 "│☐ Test Song Test Artist │",
803 "│s/S: change sort──────────────────────────────────────────│",
804 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
805 ]);
806 assert_buffer_eq(&buffer, &expected);
807
808 view.handle_mouse_event(
810 MouseEvent {
811 kind: MouseEventKind::Down(MouseButton::Left),
812 column: 2,
813 row: 6,
814 modifiers: KeyModifiers::empty(),
815 },
816 area,
817 );
818 let buffer = terminal
819 .draw(|frame| view.render(frame, props))
820 .unwrap()
821 .buffer
822 .clone();
823 let expected = Buffer::with_lines([
824 "┌Collection View sorted by: Artist─────────────────────────┐",
825 "│ Collection 0 │",
826 "│ Songs: 1 Duration: 00:03:00.00 │",
827 "│ │",
828 "│q: add to queue | p: add to playlist──────────────────────│",
829 "│Performing operations on checked items────────────────────│",
830 "│☑ Test Song Test Artist │",
831 "│s/S: change sort──────────────────────────────────────────│",
832 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
833 ]);
834 assert_buffer_eq(&buffer, &expected);
835
836 view.handle_mouse_event(
838 MouseEvent {
839 kind: MouseEventKind::Down(MouseButton::Left),
840 column: 2,
841 row: 6,
842 modifiers: KeyModifiers::empty(),
843 },
844 area,
845 );
846 assert_eq!(
847 rx.blocking_recv().unwrap(),
848 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
849 );
850 let expected = Buffer::with_lines([
851 "┌Collection View sorted by: Artist─────────────────────────┐",
852 "│ Collection 0 │",
853 "│ Songs: 1 Duration: 00:03:00.00 │",
854 "│ │",
855 "│q: add to queue | p: add to playlist──────────────────────│",
856 "│Performing operations on entire collection────────────────│",
857 "│☐ Test Song Test Artist │",
858 "│s/S: change sort──────────────────────────────────────────│",
859 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
860 ]);
861 let buffer = terminal
862 .draw(|frame| view.render(frame, props))
863 .unwrap()
864 .buffer
865 .clone();
866 assert_buffer_eq(&buffer, &expected);
867
868 view.handle_mouse_event(
870 MouseEvent {
871 kind: MouseEventKind::ScrollDown,
872 column: 2,
873 row: 6,
874 modifiers: KeyModifiers::empty(),
875 },
876 area,
877 );
878 let buffer = terminal
879 .draw(|frame| view.render(frame, props))
880 .unwrap()
881 .buffer
882 .clone();
883 assert_buffer_eq(&buffer, &expected);
884 view.handle_mouse_event(
886 MouseEvent {
887 kind: MouseEventKind::ScrollUp,
888 column: 2,
889 row: 6,
890 modifiers: KeyModifiers::empty(),
891 },
892 area,
893 );
894 let buffer = terminal
895 .draw(|frame| view.render(frame, props))
896 .unwrap()
897 .buffer
898 .clone();
899 assert_buffer_eq(&buffer, &expected);
900 }
901}
902
903#[cfg(test)]
904mod library_view_tests {
905 use super::*;
906 use crate::{
907 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
908 ui::components::content_view::ActiveView,
909 };
910 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
911 use pretty_assertions::assert_eq;
912 use ratatui::buffer::Buffer;
913
914 #[test]
915 fn test_new() {
916 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
917 let state = state_with_everything();
918 let view = LibraryCollectionsView::new(&state, tx);
919
920 assert_eq!(view.name(), "Library Collections View");
921 assert_eq!(view.props.collections, state.library.collections);
922 }
923
924 #[test]
925 fn test_move_with_state() {
926 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
927 let state = AppState::default();
928 let new_state = state_with_everything();
929 let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
930
931 assert_eq!(view.props.collections, new_state.library.collections);
932 }
933
934 #[test]
935 fn test_render() {
936 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
937 let view = LibraryCollectionsView::new(&state_with_everything(), tx);
938
939 let (mut terminal, area) = setup_test_terminal(60, 6);
940 let props = RenderProps {
941 area,
942 is_focused: true,
943 };
944 let buffer = terminal
945 .draw(|frame| view.render(frame, props))
946 .unwrap()
947 .buffer
948 .clone();
949 let expected = Buffer::with_lines([
950 "┌Library Collections sorted by: Name───────────────────────┐",
951 "│──────────────────────────────────────────────────────────│",
952 "│▪ Collection 0 │",
953 "│ │",
954 "│ │",
955 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
956 ]);
957
958 assert_buffer_eq(&buffer, &expected);
959 }
960
961 #[test]
962 fn test_sort_keys() {
963 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
964 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
965
966 assert_eq!(view.props.sort_mode, NameSort::default());
967 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
968 assert_eq!(view.props.sort_mode, NameSort::default());
969 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
970 assert_eq!(view.props.sort_mode, NameSort::default());
971 }
972
973 #[test]
974 fn smoke_navigation() {
975 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
976 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
977
978 view.handle_key_event(KeyEvent::from(KeyCode::Up));
979 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
980 view.handle_key_event(KeyEvent::from(KeyCode::Down));
981 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
982 view.handle_key_event(KeyEvent::from(KeyCode::Left));
983 view.handle_key_event(KeyEvent::from(KeyCode::Right));
984 }
985
986 #[test]
987 fn test_actions() {
988 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
989 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
990
991 let (mut terminal, area) = setup_test_terminal(60, 9);
993 let props = RenderProps {
994 area,
995 is_focused: true,
996 };
997 terminal.draw(|frame| view.render(frame, props)).unwrap();
998
999 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1001
1002 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1004 assert_eq!(
1005 rx.blocking_recv().unwrap(),
1006 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1007 );
1008 }
1009
1010 #[test]
1011 fn test_mouse_event() {
1012 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1013 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
1014
1015 let (mut terminal, area) = setup_test_terminal(60, 9);
1017 let props = RenderProps {
1018 area,
1019 is_focused: true,
1020 };
1021 let buffer = terminal
1022 .draw(|frame| view.render(frame, props))
1023 .unwrap()
1024 .buffer
1025 .clone();
1026 let expected = Buffer::with_lines([
1027 "┌Library Collections sorted by: Name───────────────────────┐",
1028 "│──────────────────────────────────────────────────────────│",
1029 "│▪ Collection 0 │",
1030 "│ │",
1031 "│ │",
1032 "│ │",
1033 "│ │",
1034 "│ │",
1035 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1036 ]);
1037 assert_buffer_eq(&buffer, &expected);
1038
1039 view.handle_mouse_event(
1041 MouseEvent {
1042 kind: MouseEventKind::ScrollDown,
1043 column: 2,
1044 row: 2,
1045 modifiers: KeyModifiers::empty(),
1046 },
1047 area,
1048 );
1049
1050 view.handle_mouse_event(
1052 MouseEvent {
1053 kind: MouseEventKind::Down(MouseButton::Left),
1054 column: 2,
1055 row: 2,
1056 modifiers: KeyModifiers::empty(),
1057 },
1058 area,
1059 );
1060 assert_eq!(
1061 rx.blocking_recv().unwrap(),
1062 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1063 );
1064 let buffer = terminal
1065 .draw(|frame| view.render(frame, props))
1066 .unwrap()
1067 .buffer
1068 .clone();
1069 assert_buffer_eq(&buffer, &expected);
1070
1071 view.handle_mouse_event(
1073 MouseEvent {
1074 kind: MouseEventKind::ScrollUp,
1075 column: 2,
1076 row: 2,
1077 modifiers: KeyModifiers::empty(),
1078 },
1079 area,
1080 );
1081
1082 view.handle_mouse_event(
1084 MouseEvent {
1085 kind: MouseEventKind::Down(MouseButton::Left),
1086 column: 2,
1087 row: 2,
1088 modifiers: KeyModifiers::empty(),
1089 },
1090 area,
1091 );
1092 assert_eq!(
1093 rx.blocking_recv().unwrap(),
1094 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1095 );
1096
1097 let mouse = MouseEvent {
1099 kind: MouseEventKind::Down(MouseButton::Left),
1100 column: 2,
1101 row: 3,
1102 modifiers: KeyModifiers::empty(),
1103 };
1104 view.handle_mouse_event(mouse, area);
1105 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1106 view.handle_mouse_event(mouse, area);
1107 assert_eq!(
1108 rx.try_recv(),
1109 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1110 );
1111 }
1112}