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 anyhow::Result;
557 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
558 use pretty_assertions::assert_eq;
559 use ratatui::buffer::Buffer;
560
561 #[test]
562 fn test_new() {
563 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
564 let state = state_with_everything();
565 let view = CollectionView::new(&state, tx);
566
567 assert_eq!(view.name(), "Collection View");
568 assert_eq!(
569 view.props,
570 Some(state.additional_view_data.collection.unwrap())
571 );
572 }
573
574 #[test]
575 fn test_move_with_state() {
576 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
577 let state = AppState::default();
578 let new_state = state_with_everything();
579 let view = CollectionView::new(&state, tx).move_with_state(&new_state);
580
581 assert_eq!(
582 view.props,
583 Some(new_state.additional_view_data.collection.unwrap())
584 );
585 }
586 #[test]
587 fn test_render_no_collection() -> Result<()> {
588 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
589 let view = CollectionView::new(&AppState::default(), tx);
590
591 let (mut terminal, area) = setup_test_terminal(22, 3);
592 let props = RenderProps {
593 area,
594 is_focused: true,
595 };
596 let buffer = terminal
597 .draw(|frame| view.render(frame, props))
598 .unwrap()
599 .buffer
600 .clone();
601 #[rustfmt::skip]
602 let expected = Buffer::with_lines([
603 "┌Collection View─────┐",
604 "│No active collection│",
605 "└────────────────────┘",
606 ]);
607
608 assert_buffer_eq(&buffer, &expected);
609
610 Ok(())
611 }
612
613 #[test]
614 fn test_render() -> Result<()> {
615 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
616 let view = CollectionView::new(&state_with_everything(), tx);
617
618 let (mut terminal, area) = setup_test_terminal(60, 9);
619 let props = RenderProps {
620 area,
621 is_focused: true,
622 };
623 let buffer = terminal
624 .draw(|frame| view.render(frame, props))
625 .unwrap()
626 .buffer
627 .clone();
628 let expected = Buffer::with_lines([
629 "┌Collection View sorted by: Artist─────────────────────────┐",
630 "│ Collection 0 │",
631 "│ Songs: 1 Duration: 00:03:00.00 │",
632 "│ │",
633 "│q: add to queue | p: add to playlist──────────────────────│",
634 "│Performing operations on entire collection────────────────│",
635 "│☐ Test Song Test Artist │",
636 "│s/S: change sort──────────────────────────────────────────│",
637 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
638 ]);
639
640 assert_buffer_eq(&buffer, &expected);
641
642 Ok(())
643 }
644
645 #[test]
646 fn test_render_with_checked() -> Result<()> {
647 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
648 let mut view = CollectionView::new(&state_with_everything(), tx);
649 let (mut terminal, area) = setup_test_terminal(60, 9);
650 let props = RenderProps {
651 area,
652 is_focused: true,
653 };
654 let buffer = terminal
655 .draw(|frame| view.render(frame, props))
656 .unwrap()
657 .buffer
658 .clone();
659 let expected = Buffer::with_lines([
660 "┌Collection View sorted by: Artist─────────────────────────┐",
661 "│ Collection 0 │",
662 "│ Songs: 1 Duration: 00:03:00.00 │",
663 "│ │",
664 "│q: add to queue | p: add to playlist──────────────────────│",
665 "│Performing operations on entire collection────────────────│",
666 "│☐ Test Song Test Artist │",
667 "│s/S: change sort──────────────────────────────────────────│",
668 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
669 ]);
670 assert_buffer_eq(&buffer, &expected);
671
672 view.handle_key_event(KeyEvent::from(KeyCode::Down));
674 view.handle_key_event(KeyEvent::from(KeyCode::Down));
675 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
676
677 let buffer = terminal
678 .draw(|frame| view.render(frame, props))
679 .unwrap()
680 .buffer
681 .clone();
682 let expected = Buffer::with_lines([
683 "┌Collection View sorted by: Artist─────────────────────────┐",
684 "│ Collection 0 │",
685 "│ Songs: 1 Duration: 00:03:00.00 │",
686 "│ │",
687 "│q: add to queue | p: add to playlist──────────────────────│",
688 "│Performing operations on checked items────────────────────│",
689 "│☑ Test Song Test Artist │",
690 "│s/S: change sort──────────────────────────────────────────│",
691 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
692 ]);
693
694 assert_buffer_eq(&buffer, &expected);
695
696 Ok(())
697 }
698
699 #[test]
700 fn smoke_navigation() {
701 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
702 let mut view = CollectionView::new(&state_with_everything(), tx);
703
704 view.handle_key_event(KeyEvent::from(KeyCode::Up));
705 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
706 view.handle_key_event(KeyEvent::from(KeyCode::Down));
707 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
708 view.handle_key_event(KeyEvent::from(KeyCode::Left));
709 view.handle_key_event(KeyEvent::from(KeyCode::Right));
710 }
711
712 #[test]
713 fn test_actions() {
714 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
715 let mut view = CollectionView::new(&state_with_everything(), tx);
716
717 let (mut terminal, area) = setup_test_terminal(60, 9);
719 let props = RenderProps {
720 area,
721 is_focused: true,
722 };
723 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
724
725 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
728 assert_eq!(
729 rx.blocking_recv().unwrap(),
730 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
731 "collection",
732 item_id()
733 )
734 .into()])))
735 );
736 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
737 assert_eq!(
738 rx.blocking_recv().unwrap(),
739 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
740 "collection",
741 item_id()
742 )
743 .into()])))
744 );
745 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
746
747 view.handle_key_event(KeyEvent::from(KeyCode::Up));
750 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
751
752 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
754 assert_eq!(
755 rx.blocking_recv().unwrap(),
756 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
757 );
758
759 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
761
762 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
764 assert_eq!(
765 rx.blocking_recv().unwrap(),
766 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
767 "song",
768 item_id()
769 )
770 .into()])))
771 );
772
773 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
775 assert_eq!(
776 rx.blocking_recv().unwrap(),
777 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
778 "song",
779 item_id()
780 )
781 .into()])))
782 );
783 }
784
785 #[test]
786 fn test_mouse_event() {
787 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
788 let mut view = CollectionView::new(&state_with_everything(), tx);
789
790 let (mut terminal, area) = setup_test_terminal(60, 9);
792 let props = RenderProps {
793 area,
794 is_focused: true,
795 };
796 let buffer = terminal
797 .draw(|frame| view.render(frame, props))
798 .unwrap()
799 .buffer
800 .clone();
801 let expected = Buffer::with_lines([
802 "┌Collection View sorted by: Artist─────────────────────────┐",
803 "│ Collection 0 │",
804 "│ Songs: 1 Duration: 00:03:00.00 │",
805 "│ │",
806 "│q: add to queue | p: add to playlist──────────────────────│",
807 "│Performing operations on entire collection────────────────│",
808 "│☐ Test Song Test Artist │",
809 "│s/S: change sort──────────────────────────────────────────│",
810 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
811 ]);
812 assert_buffer_eq(&buffer, &expected);
813
814 view.handle_mouse_event(
816 MouseEvent {
817 kind: MouseEventKind::Down(MouseButton::Left),
818 column: 2,
819 row: 6,
820 modifiers: KeyModifiers::empty(),
821 },
822 area,
823 );
824 let buffer = terminal
825 .draw(|frame| view.render(frame, props))
826 .unwrap()
827 .buffer
828 .clone();
829 let expected = Buffer::with_lines([
830 "┌Collection View sorted by: Artist─────────────────────────┐",
831 "│ Collection 0 │",
832 "│ Songs: 1 Duration: 00:03:00.00 │",
833 "│ │",
834 "│q: add to queue | p: add to playlist──────────────────────│",
835 "│Performing operations on checked items────────────────────│",
836 "│☑ Test Song Test Artist │",
837 "│s/S: change sort──────────────────────────────────────────│",
838 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
839 ]);
840 assert_buffer_eq(&buffer, &expected);
841
842 view.handle_mouse_event(
844 MouseEvent {
845 kind: MouseEventKind::Down(MouseButton::Left),
846 column: 2,
847 row: 6,
848 modifiers: KeyModifiers::empty(),
849 },
850 area,
851 );
852 assert_eq!(
853 rx.blocking_recv().unwrap(),
854 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
855 );
856 let expected = Buffer::with_lines([
857 "┌Collection View sorted by: Artist─────────────────────────┐",
858 "│ Collection 0 │",
859 "│ Songs: 1 Duration: 00:03:00.00 │",
860 "│ │",
861 "│q: add to queue | p: add to playlist──────────────────────│",
862 "│Performing operations on entire collection────────────────│",
863 "│☐ Test Song Test Artist │",
864 "│s/S: change sort──────────────────────────────────────────│",
865 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
866 ]);
867 let buffer = terminal
868 .draw(|frame| view.render(frame, props))
869 .unwrap()
870 .buffer
871 .clone();
872 assert_buffer_eq(&buffer, &expected);
873
874 view.handle_mouse_event(
876 MouseEvent {
877 kind: MouseEventKind::ScrollDown,
878 column: 2,
879 row: 6,
880 modifiers: KeyModifiers::empty(),
881 },
882 area,
883 );
884 let buffer = terminal
885 .draw(|frame| view.render(frame, props))
886 .unwrap()
887 .buffer
888 .clone();
889 assert_buffer_eq(&buffer, &expected);
890 view.handle_mouse_event(
892 MouseEvent {
893 kind: MouseEventKind::ScrollUp,
894 column: 2,
895 row: 6,
896 modifiers: KeyModifiers::empty(),
897 },
898 area,
899 );
900 let buffer = terminal
901 .draw(|frame| view.render(frame, props))
902 .unwrap()
903 .buffer
904 .clone();
905 assert_buffer_eq(&buffer, &expected);
906 }
907}
908
909#[cfg(test)]
910mod library_view_tests {
911 use super::*;
912 use crate::{
913 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
914 ui::components::content_view::ActiveView,
915 };
916 use anyhow::Result;
917 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
918 use pretty_assertions::assert_eq;
919 use ratatui::buffer::Buffer;
920
921 #[test]
922 fn test_new() {
923 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
924 let state = state_with_everything();
925 let view = LibraryCollectionsView::new(&state, tx);
926
927 assert_eq!(view.name(), "Library Collections View");
928 assert_eq!(view.props.collections, state.library.collections);
929 }
930
931 #[test]
932 fn test_move_with_state() {
933 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
934 let state = AppState::default();
935 let new_state = state_with_everything();
936 let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
937
938 assert_eq!(view.props.collections, new_state.library.collections);
939 }
940
941 #[test]
942 fn test_render() -> Result<()> {
943 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
944 let view = LibraryCollectionsView::new(&state_with_everything(), tx);
945
946 let (mut terminal, area) = setup_test_terminal(60, 6);
947 let props = RenderProps {
948 area,
949 is_focused: true,
950 };
951 let buffer = terminal
952 .draw(|frame| view.render(frame, props))
953 .unwrap()
954 .buffer
955 .clone();
956 let expected = Buffer::with_lines([
957 "┌Library Collections sorted by: Name───────────────────────┐",
958 "│──────────────────────────────────────────────────────────│",
959 "│▪ Collection 0 │",
960 "│ │",
961 "│ │",
962 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
963 ]);
964
965 assert_buffer_eq(&buffer, &expected);
966
967 Ok(())
968 }
969
970 #[test]
971 fn test_sort_keys() {
972 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
973 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
974
975 assert_eq!(view.props.sort_mode, NameSort::default());
976 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
977 assert_eq!(view.props.sort_mode, NameSort::default());
978 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
979 assert_eq!(view.props.sort_mode, NameSort::default());
980 }
981
982 #[test]
983 fn smoke_navigation() {
984 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
985 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
986
987 view.handle_key_event(KeyEvent::from(KeyCode::Up));
988 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
989 view.handle_key_event(KeyEvent::from(KeyCode::Down));
990 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
991 view.handle_key_event(KeyEvent::from(KeyCode::Left));
992 view.handle_key_event(KeyEvent::from(KeyCode::Right));
993 }
994
995 #[test]
996 fn test_actions() {
997 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
998 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
999
1000 let (mut terminal, area) = setup_test_terminal(60, 9);
1002 let props = RenderProps {
1003 area,
1004 is_focused: true,
1005 };
1006 terminal.draw(|frame| view.render(frame, props)).unwrap();
1007
1008 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1010
1011 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1013 assert_eq!(
1014 rx.blocking_recv().unwrap(),
1015 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1016 );
1017 }
1018
1019 #[test]
1020 fn test_mouse_event() {
1021 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1022 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
1023
1024 let (mut terminal, area) = setup_test_terminal(60, 9);
1026 let props = RenderProps {
1027 area,
1028 is_focused: true,
1029 };
1030 let buffer = terminal
1031 .draw(|frame| view.render(frame, props))
1032 .unwrap()
1033 .buffer
1034 .clone();
1035 let expected = Buffer::with_lines([
1036 "┌Library Collections sorted by: Name───────────────────────┐",
1037 "│──────────────────────────────────────────────────────────│",
1038 "│▪ Collection 0 │",
1039 "│ │",
1040 "│ │",
1041 "│ │",
1042 "│ │",
1043 "│ │",
1044 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1045 ]);
1046 assert_buffer_eq(&buffer, &expected);
1047
1048 view.handle_mouse_event(
1050 MouseEvent {
1051 kind: MouseEventKind::ScrollDown,
1052 column: 2,
1053 row: 2,
1054 modifiers: KeyModifiers::empty(),
1055 },
1056 area,
1057 );
1058
1059 view.handle_mouse_event(
1061 MouseEvent {
1062 kind: MouseEventKind::Down(MouseButton::Left),
1063 column: 2,
1064 row: 2,
1065 modifiers: KeyModifiers::empty(),
1066 },
1067 area,
1068 );
1069 assert_eq!(
1070 rx.blocking_recv().unwrap(),
1071 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1072 );
1073 let buffer = terminal
1074 .draw(|frame| view.render(frame, props))
1075 .unwrap()
1076 .buffer
1077 .clone();
1078 assert_buffer_eq(&buffer, &expected);
1079
1080 view.handle_mouse_event(
1082 MouseEvent {
1083 kind: MouseEventKind::ScrollUp,
1084 column: 2,
1085 row: 2,
1086 modifiers: KeyModifiers::empty(),
1087 },
1088 area,
1089 );
1090
1091 view.handle_mouse_event(
1093 MouseEvent {
1094 kind: MouseEventKind::Down(MouseButton::Left),
1095 column: 2,
1096 row: 2,
1097 modifiers: KeyModifiers::empty(),
1098 },
1099 area,
1100 );
1101 assert_eq!(
1102 rx.blocking_recv().unwrap(),
1103 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1104 );
1105
1106 let mouse = MouseEvent {
1108 kind: MouseEventKind::Down(MouseButton::Left),
1109 column: 2,
1110 row: 3,
1111 modifiers: KeyModifiers::empty(),
1112 };
1113 view.handle_mouse_event(mouse, area);
1114 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1115 view.handle_mouse_event(mouse, area);
1116 assert_eq!(
1117 rx.try_recv(),
1118 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1119 );
1120 }
1121}