1use std::sync::Mutex;
6
7use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
8use mecomp_core::format_duration;
9use mecomp_storage::db::schemas::collection::CollectionBrief;
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 let first = self
91 .props
92 .as_ref()
93 .map_or(0, |p| p.songs.len().saturating_sub(1));
94 current.map_or(first, |c| c.saturating_sub(10))
95 });
96 }
97 KeyCode::Up => {
98 self.tree_state.lock().unwrap().key_up();
99 }
100 KeyCode::PageDown => {
101 self.tree_state
102 .lock()
103 .unwrap()
104 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
105 }
106 KeyCode::Down => {
107 self.tree_state.lock().unwrap().key_down();
108 }
109 KeyCode::Left => {
110 self.tree_state.lock().unwrap().key_left();
111 }
112 KeyCode::Right => {
113 self.tree_state.lock().unwrap().key_right();
114 }
115 KeyCode::Char(' ') => {
116 self.tree_state.lock().unwrap().key_space();
117 }
118 KeyCode::Char('s') => {
120 self.sort_mode = self.sort_mode.next();
121 if let Some(props) = &mut self.props {
122 self.sort_mode.sort_items(&mut props.songs);
123 self.tree_state.lock().unwrap().scroll_selected_into_view();
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 self.tree_state.lock().unwrap().scroll_selected_into_view();
131 }
132 }
133 KeyCode::Enter => {
135 if self.tree_state.lock().unwrap().toggle_selected() {
136 let things = self.tree_state.lock().unwrap().get_selected_thing();
137
138 if let Some(thing) = things {
139 self.action_tx
140 .send(Action::ActiveView(ViewAction::Set(thing.into())))
141 .unwrap();
142 }
143 }
144 }
145 KeyCode::Char('q') => {
147 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
148 if let Some(action) = construct_add_to_queue_action(
149 checked_things,
150 self.props.as_ref().map(|p| &p.id),
151 ) {
152 self.action_tx.send(action).unwrap();
153 }
154 }
155 KeyCode::Char('p') => {
157 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
158 if let Some(action) = construct_add_to_playlist_action(
159 checked_things,
160 self.props.as_ref().map(|p| &p.id),
161 ) {
162 self.action_tx.send(action).unwrap();
163 }
164 }
165 _ => {}
166 }
167 }
168
169 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
170 let area = area.inner(Margin::new(1, 1));
172 let [_, content_area] = split_area(area);
173 let content_area = content_area.inner(Margin::new(0, 1));
174
175 let result = self
176 .tree_state
177 .lock()
178 .unwrap()
179 .handle_mouse_event(mouse, content_area, false);
180 if let Some(action) = result {
181 self.action_tx.send(action).unwrap();
182 }
183 }
184}
185
186fn split_area(area: Rect) -> [Rect; 2] {
187 let [info_area, content_area] = *Layout::default()
188 .direction(Direction::Vertical)
189 .constraints([Constraint::Length(3), Constraint::Min(4)])
190 .split(area)
191 else {
192 panic!("Failed to split collection view area")
193 };
194
195 [info_area, content_area]
196}
197
198impl ComponentRender<RenderProps> for CollectionView {
199 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
200 let border_style = Style::default().fg(border_color(props.is_focused).into());
201
202 let area = if let Some(state) = &self.props {
203 let border = Block::bordered()
204 .title_top(Line::from(vec![
205 Span::styled("Collection View".to_string(), Style::default().bold()),
206 Span::raw(" sorted by: "),
207 Span::styled(self.sort_mode.to_string(), Style::default().italic()),
208 ]))
209 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
210 .border_style(border_style);
211 frame.render_widget(&border, props.area);
212 let content_area = border.inner(props.area);
213
214 let [info_area, content_area] = split_area(content_area);
216
217 frame.render_widget(
219 Paragraph::new(vec![
220 Line::from(Span::styled(
221 state.collection.name.to_string(),
222 Style::default().bold(),
223 )),
224 Line::from(vec![
225 Span::raw("Songs: "),
226 Span::styled(
227 state.collection.song_count.to_string(),
228 Style::default().italic(),
229 ),
230 Span::raw(" Duration: "),
231 Span::styled(
232 format_duration(&state.collection.runtime),
233 Style::default().italic(),
234 ),
235 ]),
236 ])
237 .alignment(Alignment::Center),
238 info_area,
239 );
240
241 let border = Block::new()
243 .borders(Borders::TOP | Borders::BOTTOM)
244 .title_top("q: add to queue | p: add to playlist")
245 .title_bottom("s/S: change sort")
246 .border_style(border_style);
247 frame.render_widget(&border, content_area);
248 let content_area = border.inner(content_area);
249
250 let border = Block::default()
252 .borders(Borders::TOP)
253 .title_top(Line::from(vec![
254 Span::raw("Performing operations on "),
255 Span::raw(
256 if self
257 .tree_state
258 .lock()
259 .unwrap()
260 .get_checked_things()
261 .is_empty()
262 {
263 "entire collection"
264 } else {
265 "checked items"
266 },
267 )
268 .fg(*TEXT_HIGHLIGHT),
269 ]))
270 .italic()
271 .border_style(border_style);
272 frame.render_widget(&border, content_area);
273 border.inner(content_area)
274 } else {
275 let border = Block::bordered()
276 .title_top("Collection View")
277 .border_style(border_style);
278 frame.render_widget(&border, props.area);
279 border.inner(props.area)
280 };
281
282 RenderProps { area, ..props }
283 }
284
285 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
286 if let Some(state) = &self.props {
287 let items = state
289 .songs
290 .iter()
291 .map(create_song_tree_leaf)
292 .collect::<Vec<_>>();
293
294 frame.render_stateful_widget(
296 CheckTree::new(&items)
297 .unwrap()
298 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
299 .experimental_scrollbar(Some(Scrollbar::new(
300 ScrollbarOrientation::VerticalRight,
301 ))),
302 props.area,
303 &mut self.tree_state.lock().unwrap(),
304 );
305 } else {
306 let text = "No active collection";
307
308 frame.render_widget(
309 Line::from(text)
310 .style(Style::default().fg((*TEXT_NORMAL).into()))
311 .alignment(Alignment::Center),
312 props.area,
313 );
314 }
315 }
316}
317
318pub struct LibraryCollectionsView {
319 pub action_tx: UnboundedSender<Action>,
321 props: Props,
323 tree_state: Mutex<CheckTreeState<String>>,
325}
326
327struct Props {
328 collections: Box<[CollectionBrief]>,
329 sort_mode: NameSort<CollectionBrief>,
330}
331impl Props {
332 fn new(state: &AppState, sort_mode: NameSort<CollectionBrief>) -> Self {
333 let mut collections = state.library.collections.clone();
334 sort_mode.sort_items(&mut collections);
335 Self {
336 collections,
337 sort_mode,
338 }
339 }
340}
341
342impl Component for LibraryCollectionsView {
343 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
344 where
345 Self: Sized,
346 {
347 let sort_mode = NameSort::default();
348 Self {
349 action_tx,
350 props: Props::new(state, sort_mode),
351 tree_state: Mutex::new(CheckTreeState::default()),
352 }
353 }
354
355 fn move_with_state(self, state: &AppState) -> Self
356 where
357 Self: Sized,
358 {
359 let tree_state = if state.active_view == ActiveView::Collections {
360 self.tree_state
361 } else {
362 Mutex::default()
363 };
364
365 Self {
366 props: Props::new(state, self.props.sort_mode),
367 tree_state,
368 ..self
369 }
370 }
371
372 fn name(&self) -> &'static str {
373 "Library Collections View"
374 }
375
376 fn handle_key_event(&mut self, key: KeyEvent) {
377 match key.code {
378 KeyCode::PageUp => {
380 self.tree_state.lock().unwrap().select_relative(|current| {
381 let first = self.props.collections.len().saturating_sub(1);
382 current.map_or(first, |c| c.saturating_sub(10))
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, true);
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 mecomp_storage::db::schemas::collection::Collection;
503 use pretty_assertions::assert_eq;
504 use rstest::rstest;
505
506 #[rstest]
507 #[case(NameSort::default(), NameSort::default())]
508 fn test_sort_mode_next_prev(
509 #[case] mode: NameSort<CollectionBrief>,
510 #[case] expected: NameSort<CollectionBrief>,
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<CollectionBrief>, #[case] expected: &str) {
519 assert_eq!(mode.to_string(), expected);
520 }
521
522 #[rstest]
523 fn test_sort_collectionss() {
524 let mut collections = vec![
525 CollectionBrief {
526 id: Collection::generate_id(),
527 name: "C".into(),
528 },
529 CollectionBrief {
530 id: Collection::generate_id(),
531 name: "A".into(),
532 },
533 CollectionBrief {
534 id: Collection::generate_id(),
535 name: "B".into(),
536 },
537 ];
538
539 NameSort::default().sort_items(&mut collections);
540 assert_eq!(collections[0].name, "A");
541 assert_eq!(collections[1].name, "B");
542 assert_eq!(collections[2].name, "C");
543 }
544}
545
546#[cfg(test)]
547mod item_view_tests {
548 use super::*;
549 use crate::{
550 state::action::{AudioAction, PopupAction, QueueAction},
551 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
552 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
553 };
554 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
555 use pretty_assertions::assert_eq;
556 use ratatui::buffer::Buffer;
557
558 #[test]
559 fn test_new() {
560 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
561 let state = state_with_everything();
562 let view = CollectionView::new(&state, tx);
563
564 assert_eq!(view.name(), "Collection View");
565 assert_eq!(
566 view.props,
567 Some(state.additional_view_data.collection.unwrap())
568 );
569 }
570
571 #[test]
572 fn test_move_with_state() {
573 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
574 let state = AppState::default();
575 let new_state = state_with_everything();
576 let view = CollectionView::new(&state, tx).move_with_state(&new_state);
577
578 assert_eq!(
579 view.props,
580 Some(new_state.additional_view_data.collection.unwrap())
581 );
582 }
583 #[test]
584 fn test_render_no_collection() {
585 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
586 let view = CollectionView::new(&AppState::default(), tx);
587
588 let (mut terminal, area) = setup_test_terminal(22, 3);
589 let props = RenderProps {
590 area,
591 is_focused: true,
592 };
593 let buffer = terminal
594 .draw(|frame| view.render(frame, props))
595 .unwrap()
596 .buffer
597 .clone();
598 #[rustfmt::skip]
599 let expected = Buffer::with_lines([
600 "┌Collection View─────┐",
601 "│No active collection│",
602 "└────────────────────┘",
603 ]);
604
605 assert_buffer_eq(&buffer, &expected);
606 }
607
608 #[test]
609 fn test_render() {
610 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
611 let view = CollectionView::new(&state_with_everything(), tx);
612
613 let (mut terminal, area) = setup_test_terminal(60, 9);
614 let props = RenderProps {
615 area,
616 is_focused: true,
617 };
618 let buffer = terminal
619 .draw(|frame| view.render(frame, props))
620 .unwrap()
621 .buffer
622 .clone();
623 let expected = Buffer::with_lines([
624 "┌Collection View sorted by: Artist─────────────────────────┐",
625 "│ Collection 0 │",
626 "│ Songs: 1 Duration: 00:03:00.00 │",
627 "│ │",
628 "│q: add to queue | p: add to playlist──────────────────────│",
629 "│Performing operations on entire collection────────────────│",
630 "│☐ Test Song Test Artist │",
631 "│s/S: change sort──────────────────────────────────────────│",
632 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
633 ]);
634
635 assert_buffer_eq(&buffer, &expected);
636 }
637
638 #[test]
639 fn test_render_with_checked() {
640 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
641 let mut view = CollectionView::new(&state_with_everything(), tx);
642 let (mut terminal, area) = setup_test_terminal(60, 9);
643 let props = RenderProps {
644 area,
645 is_focused: true,
646 };
647 let buffer = terminal
648 .draw(|frame| view.render(frame, props))
649 .unwrap()
650 .buffer
651 .clone();
652 let expected = Buffer::with_lines([
653 "┌Collection View sorted by: Artist─────────────────────────┐",
654 "│ Collection 0 │",
655 "│ Songs: 1 Duration: 00:03:00.00 │",
656 "│ │",
657 "│q: add to queue | p: add to playlist──────────────────────│",
658 "│Performing operations on entire collection────────────────│",
659 "│☐ Test Song Test Artist │",
660 "│s/S: change sort──────────────────────────────────────────│",
661 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
662 ]);
663 assert_buffer_eq(&buffer, &expected);
664
665 view.handle_key_event(KeyEvent::from(KeyCode::Down));
667 view.handle_key_event(KeyEvent::from(KeyCode::Down));
668 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
669
670 let buffer = terminal
671 .draw(|frame| view.render(frame, props))
672 .unwrap()
673 .buffer
674 .clone();
675 let expected = Buffer::with_lines([
676 "┌Collection View sorted by: Artist─────────────────────────┐",
677 "│ Collection 0 │",
678 "│ Songs: 1 Duration: 00:03:00.00 │",
679 "│ │",
680 "│q: add to queue | p: add to playlist──────────────────────│",
681 "│Performing operations on checked items────────────────────│",
682 "│☑ Test Song Test Artist │",
683 "│s/S: change sort──────────────────────────────────────────│",
684 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
685 ]);
686
687 assert_buffer_eq(&buffer, &expected);
688 }
689
690 #[test]
691 fn smoke_navigation() {
692 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
693 let mut view = CollectionView::new(&state_with_everything(), tx);
694
695 view.handle_key_event(KeyEvent::from(KeyCode::Up));
696 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
697 view.handle_key_event(KeyEvent::from(KeyCode::Down));
698 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
699 view.handle_key_event(KeyEvent::from(KeyCode::Left));
700 view.handle_key_event(KeyEvent::from(KeyCode::Right));
701 }
702
703 #[test]
704 fn test_actions() {
705 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
706 let mut view = CollectionView::new(&state_with_everything(), tx);
707
708 let (mut terminal, area) = setup_test_terminal(60, 9);
710 let props = RenderProps {
711 area,
712 is_focused: true,
713 };
714 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
715
716 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
719 assert_eq!(
720 rx.blocking_recv().unwrap(),
721 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
722 ("collection", item_id()).into()
723 ])))
724 );
725 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
726 assert_eq!(
727 rx.blocking_recv().unwrap(),
728 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
729 ("collection", item_id()).into()
730 ])))
731 );
732 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
733
734 view.handle_key_event(KeyEvent::from(KeyCode::Up));
737 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
738
739 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
741 assert_eq!(
742 rx.blocking_recv().unwrap(),
743 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
744 );
745
746 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
748
749 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
751 assert_eq!(
752 rx.blocking_recv().unwrap(),
753 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
754 ("song", item_id()).into()
755 ])))
756 );
757
758 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
760 assert_eq!(
761 rx.blocking_recv().unwrap(),
762 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
763 ("song", item_id()).into()
764 ])))
765 );
766 }
767
768 #[test]
769 #[allow(clippy::too_many_lines)]
770 fn test_mouse_event() {
771 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
772 let mut view = CollectionView::new(&state_with_everything(), tx);
773
774 let (mut terminal, area) = setup_test_terminal(60, 9);
776 let props = RenderProps {
777 area,
778 is_focused: true,
779 };
780 let buffer = terminal
781 .draw(|frame| view.render(frame, props))
782 .unwrap()
783 .buffer
784 .clone();
785 let expected = Buffer::with_lines([
786 "┌Collection View sorted by: Artist─────────────────────────┐",
787 "│ Collection 0 │",
788 "│ Songs: 1 Duration: 00:03:00.00 │",
789 "│ │",
790 "│q: add to queue | p: add to playlist──────────────────────│",
791 "│Performing operations on entire collection────────────────│",
792 "│☐ Test Song Test Artist │",
793 "│s/S: change sort──────────────────────────────────────────│",
794 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
795 ]);
796 assert_buffer_eq(&buffer, &expected);
797
798 view.handle_mouse_event(
800 MouseEvent {
801 kind: MouseEventKind::Down(MouseButton::Left),
802 column: 2,
803 row: 6,
804 modifiers: KeyModifiers::empty(),
805 },
806 area,
807 );
808 let buffer = terminal
809 .draw(|frame| view.render(frame, props))
810 .unwrap()
811 .buffer
812 .clone();
813 let expected = Buffer::with_lines([
814 "┌Collection View sorted by: Artist─────────────────────────┐",
815 "│ Collection 0 │",
816 "│ Songs: 1 Duration: 00:03:00.00 │",
817 "│ │",
818 "│q: add to queue | p: add to playlist──────────────────────│",
819 "│Performing operations on checked items────────────────────│",
820 "│☑ Test Song Test Artist │",
821 "│s/S: change sort──────────────────────────────────────────│",
822 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
823 ]);
824 assert_buffer_eq(&buffer, &expected);
825
826 view.handle_mouse_event(
828 MouseEvent {
829 kind: MouseEventKind::Down(MouseButton::Left),
830 column: 2,
831 row: 6,
832 modifiers: KeyModifiers::empty(),
833 },
834 area,
835 );
836 let expected = Buffer::with_lines([
837 "┌Collection View sorted by: Artist─────────────────────────┐",
838 "│ Collection 0 │",
839 "│ Songs: 1 Duration: 00:03:00.00 │",
840 "│ │",
841 "│q: add to queue | p: add to playlist──────────────────────│",
842 "│Performing operations on entire collection────────────────│",
843 "│☐ Test Song Test Artist │",
844 "│s/S: change sort──────────────────────────────────────────│",
845 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
846 ]);
847 let buffer = terminal
848 .draw(|frame| view.render(frame, props))
849 .unwrap()
850 .buffer
851 .clone();
852 assert_buffer_eq(&buffer, &expected);
853 for _ in 0..2 {
855 view.handle_mouse_event(
856 MouseEvent {
857 kind: MouseEventKind::Down(MouseButton::Left),
858 column: 2,
859 row: 6,
860 modifiers: KeyModifiers::CONTROL,
861 },
862 area,
863 );
864 assert_eq!(
865 rx.blocking_recv().unwrap(),
866 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
867 );
868 }
869
870 view.handle_mouse_event(
872 MouseEvent {
873 kind: MouseEventKind::ScrollDown,
874 column: 2,
875 row: 6,
876 modifiers: KeyModifiers::empty(),
877 },
878 area,
879 );
880 let buffer = terminal
881 .draw(|frame| view.render(frame, props))
882 .unwrap()
883 .buffer
884 .clone();
885 assert_buffer_eq(&buffer, &expected);
886 view.handle_mouse_event(
888 MouseEvent {
889 kind: MouseEventKind::ScrollUp,
890 column: 2,
891 row: 6,
892 modifiers: KeyModifiers::empty(),
893 },
894 area,
895 );
896 let buffer = terminal
897 .draw(|frame| view.render(frame, props))
898 .unwrap()
899 .buffer
900 .clone();
901 assert_buffer_eq(&buffer, &expected);
902 }
903}
904
905#[cfg(test)]
906mod library_view_tests {
907 use super::*;
908 use crate::{
909 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
910 ui::components::content_view::ActiveView,
911 };
912 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
913 use pretty_assertions::assert_eq;
914 use ratatui::buffer::Buffer;
915
916 #[test]
917 fn test_new() {
918 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
919 let state = state_with_everything();
920 let view = LibraryCollectionsView::new(&state, tx);
921
922 assert_eq!(view.name(), "Library Collections View");
923 assert_eq!(view.props.collections, state.library.collections);
924 }
925
926 #[test]
927 fn test_move_with_state() {
928 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
929 let state = AppState::default();
930 let new_state = state_with_everything();
931 let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
932
933 assert_eq!(view.props.collections, new_state.library.collections);
934 }
935
936 #[test]
937 fn test_render() {
938 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
939 let view = LibraryCollectionsView::new(&state_with_everything(), tx);
940
941 let (mut terminal, area) = setup_test_terminal(60, 6);
942 let props = RenderProps {
943 area,
944 is_focused: true,
945 };
946 let buffer = terminal
947 .draw(|frame| view.render(frame, props))
948 .unwrap()
949 .buffer
950 .clone();
951 let expected = Buffer::with_lines([
952 "┌Library Collections sorted by: Name───────────────────────┐",
953 "│──────────────────────────────────────────────────────────│",
954 "│▪ Collection 0 │",
955 "│ │",
956 "│ │",
957 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
958 ]);
959
960 assert_buffer_eq(&buffer, &expected);
961 }
962
963 #[test]
964 fn test_sort_keys() {
965 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
966 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
967
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 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
972 assert_eq!(view.props.sort_mode, NameSort::default());
973 }
974
975 #[test]
976 fn smoke_navigation() {
977 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
978 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
979
980 view.handle_key_event(KeyEvent::from(KeyCode::Up));
981 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
982 view.handle_key_event(KeyEvent::from(KeyCode::Down));
983 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
984 view.handle_key_event(KeyEvent::from(KeyCode::Left));
985 view.handle_key_event(KeyEvent::from(KeyCode::Right));
986 }
987
988 #[test]
989 fn test_actions() {
990 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
991 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
992
993 let (mut terminal, area) = setup_test_terminal(60, 9);
995 let props = RenderProps {
996 area,
997 is_focused: true,
998 };
999 terminal.draw(|frame| view.render(frame, props)).unwrap();
1000
1001 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1003
1004 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1006 assert_eq!(
1007 rx.blocking_recv().unwrap(),
1008 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1009 );
1010 }
1011
1012 #[test]
1013 fn test_mouse_event() {
1014 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1015 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
1016
1017 let (mut terminal, area) = setup_test_terminal(60, 9);
1019 let props = RenderProps {
1020 area,
1021 is_focused: true,
1022 };
1023 let buffer = terminal
1024 .draw(|frame| view.render(frame, props))
1025 .unwrap()
1026 .buffer
1027 .clone();
1028 let expected = Buffer::with_lines([
1029 "┌Library Collections sorted by: Name───────────────────────┐",
1030 "│──────────────────────────────────────────────────────────│",
1031 "│▪ Collection 0 │",
1032 "│ │",
1033 "│ │",
1034 "│ │",
1035 "│ │",
1036 "│ │",
1037 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1038 ]);
1039 assert_buffer_eq(&buffer, &expected);
1040
1041 view.handle_mouse_event(
1043 MouseEvent {
1044 kind: MouseEventKind::Down(MouseButton::Left),
1045 column: 2,
1046 row: 2,
1047 modifiers: KeyModifiers::empty(),
1048 },
1049 area,
1050 );
1051 assert_eq!(
1052 rx.blocking_recv().unwrap(),
1053 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1054 );
1055 let buffer = terminal
1056 .draw(|frame| view.render(frame, props))
1057 .unwrap()
1058 .buffer
1059 .clone();
1060 assert_buffer_eq(&buffer, &expected);
1061
1062 view.handle_mouse_event(
1064 MouseEvent {
1065 kind: MouseEventKind::ScrollDown,
1066 column: 2,
1067 row: 2,
1068 modifiers: KeyModifiers::empty(),
1069 },
1070 area,
1071 );
1072
1073 view.handle_mouse_event(
1075 MouseEvent {
1076 kind: MouseEventKind::Down(MouseButton::Left),
1077 column: 2,
1078 row: 2,
1079 modifiers: KeyModifiers::empty(),
1080 },
1081 area,
1082 );
1083 assert_eq!(
1084 rx.blocking_recv().unwrap(),
1085 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1086 );
1087 let buffer = terminal
1088 .draw(|frame| view.render(frame, props))
1089 .unwrap()
1090 .buffer
1091 .clone();
1092 assert_buffer_eq(&buffer, &expected);
1093
1094 view.handle_mouse_event(
1096 MouseEvent {
1097 kind: MouseEventKind::ScrollUp,
1098 column: 2,
1099 row: 2,
1100 modifiers: KeyModifiers::empty(),
1101 },
1102 area,
1103 );
1104
1105 view.handle_mouse_event(
1107 MouseEvent {
1108 kind: MouseEventKind::Down(MouseButton::Left),
1109 column: 2,
1110 row: 2,
1111 modifiers: KeyModifiers::empty(),
1112 },
1113 area,
1114 );
1115 assert_eq!(
1116 rx.blocking_recv().unwrap(),
1117 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1118 );
1119
1120 let mouse = MouseEvent {
1122 kind: MouseEventKind::Down(MouseButton::Left),
1123 column: 2,
1124 row: 3,
1125 modifiers: KeyModifiers::empty(),
1126 };
1127 view.handle_mouse_event(mouse, area);
1128 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1129 view.handle_mouse_event(mouse, area);
1130 assert_eq!(
1131 rx.try_recv(),
1132 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1133 );
1134 }
1135}