1use std::sync::Mutex;
6
7use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
8use mecomp_core::format_duration;
9use mecomp_prost::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,
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(
233 &state
234 .collection
235 .runtime
236 .normalized()
237 .try_into()
238 .unwrap_or_default(),
239 ),
240 Style::default().italic(),
241 ),
242 ]),
243 ])
244 .alignment(Alignment::Center),
245 info_area,
246 );
247
248 let border = Block::new()
250 .borders(Borders::TOP | Borders::BOTTOM)
251 .title_top("q: add to queue | p: add to playlist")
252 .title_bottom("s/S: change sort")
253 .border_style(border_style);
254 frame.render_widget(&border, content_area);
255 let content_area = border.inner(content_area);
256
257 let border = Block::default()
259 .borders(Borders::TOP)
260 .title_top(Line::from(vec![
261 Span::raw("Performing operations on "),
262 Span::raw(
263 if self
264 .tree_state
265 .lock()
266 .unwrap()
267 .get_checked_things()
268 .is_empty()
269 {
270 "entire collection"
271 } else {
272 "checked items"
273 },
274 )
275 .fg(*TEXT_HIGHLIGHT),
276 ]))
277 .italic()
278 .border_style(border_style);
279 frame.render_widget(&border, content_area);
280 border.inner(content_area)
281 } else {
282 let border = Block::bordered()
283 .title_top("Collection View")
284 .border_style(border_style);
285 frame.render_widget(&border, props.area);
286 border.inner(props.area)
287 };
288
289 RenderProps { area, ..props }
290 }
291
292 fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
293 if let Some(state) = &self.props {
294 let items = state
296 .songs
297 .iter()
298 .map(create_song_tree_leaf)
299 .collect::<Vec<_>>();
300
301 frame.render_stateful_widget(
303 CheckTree::new(&items)
304 .unwrap()
305 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
306 .experimental_scrollbar(Some(Scrollbar::new(
307 ScrollbarOrientation::VerticalRight,
308 ))),
309 props.area,
310 &mut self.tree_state.lock().unwrap(),
311 );
312 } else {
313 let text = "No active collection";
314
315 frame.render_widget(
316 Line::from(text)
317 .style(Style::default().fg((*TEXT_NORMAL).into()))
318 .alignment(Alignment::Center),
319 props.area,
320 );
321 }
322 }
323}
324
325pub struct LibraryCollectionsView {
326 pub action_tx: UnboundedSender<Action>,
328 props: Props,
330 tree_state: Mutex<CheckTreeState<String>>,
332}
333
334struct Props {
335 collections: Vec<CollectionBrief>,
336 sort_mode: NameSort<CollectionBrief>,
337}
338impl Props {
339 fn new(state: &AppState, sort_mode: NameSort<CollectionBrief>) -> Self {
340 let mut collections = state.library.collections.clone();
341 sort_mode.sort_items(&mut collections);
342 Self {
343 collections,
344 sort_mode,
345 }
346 }
347}
348
349impl Component for LibraryCollectionsView {
350 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
351 where
352 Self: Sized,
353 {
354 let sort_mode = NameSort::default();
355 Self {
356 action_tx,
357 props: Props::new(state, sort_mode),
358 tree_state: Mutex::new(CheckTreeState::default()),
359 }
360 }
361
362 fn move_with_state(self, state: &AppState) -> Self
363 where
364 Self: Sized,
365 {
366 let tree_state = if state.active_view == ActiveView::Collections {
367 self.tree_state
368 } else {
369 Mutex::default()
370 };
371
372 Self {
373 props: Props::new(state, self.props.sort_mode),
374 tree_state,
375 ..self
376 }
377 }
378
379 fn name(&self) -> &'static str {
380 "Library Collections View"
381 }
382
383 fn handle_key_event(&mut self, key: KeyEvent) {
384 match key.code {
385 KeyCode::PageUp => {
387 self.tree_state.lock().unwrap().select_relative(|current| {
388 let first = self.props.collections.len().saturating_sub(1);
389 current.map_or(first, |c| c.saturating_sub(10))
390 });
391 }
392 KeyCode::Up => {
393 self.tree_state.lock().unwrap().key_up();
394 }
395 KeyCode::PageDown => {
396 self.tree_state
397 .lock()
398 .unwrap()
399 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
400 }
401 KeyCode::Down => {
402 self.tree_state.lock().unwrap().key_down();
403 }
404 KeyCode::Left => {
405 self.tree_state.lock().unwrap().key_left();
406 }
407 KeyCode::Right => {
408 self.tree_state.lock().unwrap().key_right();
409 }
410 KeyCode::Enter => {
412 if self.tree_state.lock().unwrap().toggle_selected() {
413 let things = self.tree_state.lock().unwrap().get_selected_thing();
414
415 if let Some(thing) = things {
416 self.action_tx
417 .send(Action::ActiveView(ViewAction::Set(thing.into())))
418 .unwrap();
419 }
420 }
421 }
422 KeyCode::Char('s') => {
424 self.props.sort_mode = self.props.sort_mode.next();
425 self.props.sort_mode.sort_items(&mut self.props.collections);
426 self.tree_state.lock().unwrap().scroll_selected_into_view();
427 }
428 KeyCode::Char('S') => {
429 self.props.sort_mode = self.props.sort_mode.prev();
430 self.props.sort_mode.sort_items(&mut self.props.collections);
431 self.tree_state.lock().unwrap().scroll_selected_into_view();
432 }
433 _ => {}
434 }
435 }
436
437 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
438 let area = area.inner(Margin::new(1, 2));
440
441 let result = self
442 .tree_state
443 .lock()
444 .unwrap()
445 .handle_mouse_event(mouse, area, true);
446 if let Some(action) = result {
447 self.action_tx.send(action).unwrap();
448 }
449 }
450}
451
452impl ComponentRender<RenderProps> for LibraryCollectionsView {
453 fn render_border(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
454 let border_style = Style::default().fg(border_color(props.is_focused).into());
455
456 let border = Block::bordered()
458 .title_top(Line::from(vec![
459 Span::styled("Library Collections".to_string(), Style::default().bold()),
460 Span::raw(" sorted by: "),
461 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
462 ]))
463 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort")
464 .border_style(border_style);
465 let content_area = border.inner(props.area);
466 frame.render_widget(border, props.area);
467
468 let border = Block::new()
470 .borders(Borders::TOP)
471 .border_style(border_style);
472 frame.render_widget(&border, content_area);
473 let content_area = border.inner(content_area);
474
475 RenderProps {
477 area: content_area,
478 is_focused: props.is_focused,
479 }
480 }
481
482 fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
483 let items = self
485 .props
486 .collections
487 .iter()
488 .map(create_collection_tree_leaf)
489 .collect::<Vec<_>>();
490
491 frame.render_stateful_widget(
493 CheckTree::new(&items)
494 .unwrap()
495 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
496 .node_unchecked_symbol("▪ ")
498 .node_checked_symbol("▪ ")
499 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
500 props.area,
501 &mut self.tree_state.lock().unwrap(),
502 );
503 }
504}
505
506#[cfg(test)]
507mod sort_mode_tests {
508 use super::*;
509 use mecomp_prost::RecordId;
510 use pretty_assertions::assert_eq;
511 use rstest::rstest;
512
513 #[rstest]
514 #[case(NameSort::default(), NameSort::default())]
515 fn test_sort_mode_next_prev(
516 #[case] mode: NameSort<CollectionBrief>,
517 #[case] expected: NameSort<CollectionBrief>,
518 ) {
519 assert_eq!(mode.next(), expected);
520 assert_eq!(mode.next().prev(), mode);
521 }
522
523 #[rstest]
524 #[case(NameSort::default(), "Name")]
525 fn test_sort_mode_display(#[case] mode: NameSort<CollectionBrief>, #[case] expected: &str) {
526 assert_eq!(mode.to_string(), expected);
527 }
528
529 #[rstest]
530 fn test_sort_collectionss() {
531 let mut collections = vec![
532 CollectionBrief {
533 id: RecordId::new("collection", "3"),
534 name: "C".into(),
535 },
536 CollectionBrief {
537 id: RecordId::new("collection", "1"),
538 name: "A".into(),
539 },
540 CollectionBrief {
541 id: RecordId::new("collection", "2"),
542 name: "B".into(),
543 },
544 ];
545
546 NameSort::default().sort_items(&mut collections);
547 assert_eq!(collections[0].name, "A");
548 assert_eq!(collections[1].name, "B");
549 assert_eq!(collections[2].name, "C");
550 }
551}
552
553#[cfg(test)]
554mod item_view_tests {
555 use super::*;
556 use crate::{
557 state::action::{AudioAction, PopupAction, QueueAction},
558 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
559 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
560 };
561 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
562 use pretty_assertions::assert_eq;
563 use ratatui::buffer::Buffer;
564
565 #[test]
566 fn test_new() {
567 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
568 let state = state_with_everything();
569 let view = CollectionView::new(&state, tx);
570
571 assert_eq!(view.name(), "Collection View");
572 assert_eq!(
573 view.props,
574 Some(state.additional_view_data.collection.unwrap())
575 );
576 }
577
578 #[test]
579 fn test_move_with_state() {
580 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
581 let state = AppState::default();
582 let new_state = state_with_everything();
583 let view = CollectionView::new(&state, tx).move_with_state(&new_state);
584
585 assert_eq!(
586 view.props,
587 Some(new_state.additional_view_data.collection.unwrap())
588 );
589 }
590 #[test]
591 fn test_render_no_collection() {
592 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
593 let view = CollectionView::new(&AppState::default(), tx);
594
595 let (mut terminal, area) = setup_test_terminal(22, 3);
596 let props = RenderProps {
597 area,
598 is_focused: true,
599 };
600 let buffer = terminal
601 .draw(|frame| view.render(frame, props))
602 .unwrap()
603 .buffer
604 .clone();
605 #[rustfmt::skip]
606 let expected = Buffer::with_lines([
607 "┌Collection View─────┐",
608 "│No active collection│",
609 "└────────────────────┘",
610 ]);
611
612 assert_buffer_eq(&buffer, &expected);
613 }
614
615 #[test]
616 fn test_render() {
617 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
618 let view = CollectionView::new(&state_with_everything(), tx);
619
620 let (mut terminal, area) = setup_test_terminal(60, 9);
621 let props = RenderProps {
622 area,
623 is_focused: true,
624 };
625 let buffer = terminal
626 .draw(|frame| view.render(frame, props))
627 .unwrap()
628 .buffer
629 .clone();
630 let expected = Buffer::with_lines([
631 "┌Collection View sorted by: Artist─────────────────────────┐",
632 "│ Collection 0 │",
633 "│ Songs: 1 Duration: 00:03:00.00 │",
634 "│ │",
635 "│q: add to queue | p: add to playlist──────────────────────│",
636 "│Performing operations on entire collection────────────────│",
637 "│☐ Test Song Test Artist │",
638 "│s/S: change sort──────────────────────────────────────────│",
639 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
640 ]);
641
642 assert_buffer_eq(&buffer, &expected);
643 }
644
645 #[test]
646 fn test_render_with_checked() {
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
697 #[test]
698 fn smoke_navigation() {
699 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
700 let mut view = CollectionView::new(&state_with_everything(), tx);
701
702 view.handle_key_event(KeyEvent::from(KeyCode::Up));
703 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
704 view.handle_key_event(KeyEvent::from(KeyCode::Down));
705 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
706 view.handle_key_event(KeyEvent::from(KeyCode::Left));
707 view.handle_key_event(KeyEvent::from(KeyCode::Right));
708 }
709
710 #[test]
711 fn test_actions() {
712 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
713 let mut view = CollectionView::new(&state_with_everything(), tx);
714
715 let (mut terminal, area) = setup_test_terminal(60, 9);
717 let props = RenderProps {
718 area,
719 is_focused: true,
720 };
721 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
722
723 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
726 assert_eq!(
727 rx.blocking_recv().unwrap(),
728 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
729 ("collection", item_id()).into()
730 ])))
731 );
732 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
733 assert_eq!(
734 rx.blocking_recv().unwrap(),
735 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
736 ("collection", item_id()).into()
737 ])))
738 );
739 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
740
741 view.handle_key_event(KeyEvent::from(KeyCode::Up));
744 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
745
746 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
748 assert_eq!(
749 rx.blocking_recv().unwrap(),
750 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
751 );
752
753 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
755
756 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
758 assert_eq!(
759 rx.blocking_recv().unwrap(),
760 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
761 ("song", item_id()).into()
762 ])))
763 );
764
765 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
767 assert_eq!(
768 rx.blocking_recv().unwrap(),
769 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
770 ("song", item_id()).into()
771 ])))
772 );
773 }
774
775 #[test]
776 #[allow(clippy::too_many_lines)]
777 fn test_mouse_event() {
778 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
779 let mut view = CollectionView::new(&state_with_everything(), tx);
780
781 let (mut terminal, area) = setup_test_terminal(60, 9);
783 let props = RenderProps {
784 area,
785 is_focused: true,
786 };
787 let buffer = terminal
788 .draw(|frame| view.render(frame, props))
789 .unwrap()
790 .buffer
791 .clone();
792 let expected = Buffer::with_lines([
793 "┌Collection View sorted by: Artist─────────────────────────┐",
794 "│ Collection 0 │",
795 "│ Songs: 1 Duration: 00:03:00.00 │",
796 "│ │",
797 "│q: add to queue | p: add to playlist──────────────────────│",
798 "│Performing operations on entire collection────────────────│",
799 "│☐ Test Song Test Artist │",
800 "│s/S: change sort──────────────────────────────────────────│",
801 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
802 ]);
803 assert_buffer_eq(&buffer, &expected);
804
805 view.handle_mouse_event(
807 MouseEvent {
808 kind: MouseEventKind::Down(MouseButton::Left),
809 column: 2,
810 row: 6,
811 modifiers: KeyModifiers::empty(),
812 },
813 area,
814 );
815 let buffer = terminal
816 .draw(|frame| view.render(frame, props))
817 .unwrap()
818 .buffer
819 .clone();
820 let expected = Buffer::with_lines([
821 "┌Collection View sorted by: Artist─────────────────────────┐",
822 "│ Collection 0 │",
823 "│ Songs: 1 Duration: 00:03:00.00 │",
824 "│ │",
825 "│q: add to queue | p: add to playlist──────────────────────│",
826 "│Performing operations on checked items────────────────────│",
827 "│☑ Test Song Test Artist │",
828 "│s/S: change sort──────────────────────────────────────────│",
829 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
830 ]);
831 assert_buffer_eq(&buffer, &expected);
832
833 view.handle_mouse_event(
835 MouseEvent {
836 kind: MouseEventKind::Down(MouseButton::Left),
837 column: 2,
838 row: 6,
839 modifiers: KeyModifiers::empty(),
840 },
841 area,
842 );
843 let expected = Buffer::with_lines([
844 "┌Collection View sorted by: Artist─────────────────────────┐",
845 "│ Collection 0 │",
846 "│ Songs: 1 Duration: 00:03:00.00 │",
847 "│ │",
848 "│q: add to queue | p: add to playlist──────────────────────│",
849 "│Performing operations on entire collection────────────────│",
850 "│☐ Test Song Test Artist │",
851 "│s/S: change sort──────────────────────────────────────────│",
852 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
853 ]);
854 let buffer = terminal
855 .draw(|frame| view.render(frame, props))
856 .unwrap()
857 .buffer
858 .clone();
859 assert_buffer_eq(&buffer, &expected);
860 for _ in 0..2 {
862 view.handle_mouse_event(
863 MouseEvent {
864 kind: MouseEventKind::Down(MouseButton::Left),
865 column: 2,
866 row: 6,
867 modifiers: KeyModifiers::CONTROL,
868 },
869 area,
870 );
871 assert_eq!(
872 rx.blocking_recv().unwrap(),
873 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
874 );
875 }
876
877 view.handle_mouse_event(
879 MouseEvent {
880 kind: MouseEventKind::ScrollDown,
881 column: 2,
882 row: 6,
883 modifiers: KeyModifiers::empty(),
884 },
885 area,
886 );
887 let buffer = terminal
888 .draw(|frame| view.render(frame, props))
889 .unwrap()
890 .buffer
891 .clone();
892 assert_buffer_eq(&buffer, &expected);
893 view.handle_mouse_event(
895 MouseEvent {
896 kind: MouseEventKind::ScrollUp,
897 column: 2,
898 row: 6,
899 modifiers: KeyModifiers::empty(),
900 },
901 area,
902 );
903 let buffer = terminal
904 .draw(|frame| view.render(frame, props))
905 .unwrap()
906 .buffer
907 .clone();
908 assert_buffer_eq(&buffer, &expected);
909 }
910}
911
912#[cfg(test)]
913mod library_view_tests {
914 use super::*;
915 use crate::{
916 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
917 ui::components::content_view::ActiveView,
918 };
919 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
920 use pretty_assertions::assert_eq;
921 use ratatui::buffer::Buffer;
922
923 #[test]
924 fn test_new() {
925 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
926 let state = state_with_everything();
927 let view = LibraryCollectionsView::new(&state, tx);
928
929 assert_eq!(view.name(), "Library Collections View");
930 assert_eq!(view.props.collections, state.library.collections);
931 }
932
933 #[test]
934 fn test_move_with_state() {
935 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
936 let state = AppState::default();
937 let new_state = state_with_everything();
938 let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
939
940 assert_eq!(view.props.collections, new_state.library.collections);
941 }
942
943 #[test]
944 fn test_render() {
945 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
946 let view = LibraryCollectionsView::new(&state_with_everything(), tx);
947
948 let (mut terminal, area) = setup_test_terminal(60, 6);
949 let props = RenderProps {
950 area,
951 is_focused: true,
952 };
953 let buffer = terminal
954 .draw(|frame| view.render(frame, props))
955 .unwrap()
956 .buffer
957 .clone();
958 let expected = Buffer::with_lines([
959 "┌Library Collections sorted by: Name───────────────────────┐",
960 "│──────────────────────────────────────────────────────────│",
961 "│▪ Collection 0 │",
962 "│ │",
963 "│ │",
964 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
965 ]);
966
967 assert_buffer_eq(&buffer, &expected);
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::Down(MouseButton::Left),
1052 column: 2,
1053 row: 2,
1054 modifiers: KeyModifiers::empty(),
1055 },
1056 area,
1057 );
1058 assert_eq!(
1059 rx.blocking_recv().unwrap(),
1060 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1061 );
1062 let buffer = terminal
1063 .draw(|frame| view.render(frame, props))
1064 .unwrap()
1065 .buffer
1066 .clone();
1067 assert_buffer_eq(&buffer, &expected);
1068
1069 view.handle_mouse_event(
1071 MouseEvent {
1072 kind: MouseEventKind::ScrollDown,
1073 column: 2,
1074 row: 2,
1075 modifiers: KeyModifiers::empty(),
1076 },
1077 area,
1078 );
1079
1080 view.handle_mouse_event(
1082 MouseEvent {
1083 kind: MouseEventKind::Down(MouseButton::Left),
1084 column: 2,
1085 row: 2,
1086 modifiers: KeyModifiers::empty(),
1087 },
1088 area,
1089 );
1090 assert_eq!(
1091 rx.blocking_recv().unwrap(),
1092 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1093 );
1094 let buffer = terminal
1095 .draw(|frame| view.render(frame, props))
1096 .unwrap()
1097 .buffer
1098 .clone();
1099 assert_buffer_eq(&buffer, &expected);
1100
1101 view.handle_mouse_event(
1103 MouseEvent {
1104 kind: MouseEventKind::ScrollUp,
1105 column: 2,
1106 row: 2,
1107 modifiers: KeyModifiers::empty(),
1108 },
1109 area,
1110 );
1111
1112 view.handle_mouse_event(
1114 MouseEvent {
1115 kind: MouseEventKind::Down(MouseButton::Left),
1116 column: 2,
1117 row: 2,
1118 modifiers: KeyModifiers::empty(),
1119 },
1120 area,
1121 );
1122 assert_eq!(
1123 rx.blocking_recv().unwrap(),
1124 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1125 );
1126
1127 let mouse = MouseEvent {
1129 kind: MouseEventKind::Down(MouseButton::Left),
1130 column: 2,
1131 row: 3,
1132 modifiers: KeyModifiers::empty(),
1133 };
1134 view.handle_mouse_event(mouse, area);
1135 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1136 view.handle_mouse_event(mouse, area);
1137 assert_eq!(
1138 rx.try_recv(),
1139 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1140 );
1141 }
1142}