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