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 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<[CollectionBrief]>,
330 sort_mode: NameSort<CollectionBrief>,
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 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 assert_eq!(
837 rx.blocking_recv().unwrap(),
838 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
839 );
840 let expected = Buffer::with_lines([
841 "┌Collection View sorted by: Artist─────────────────────────┐",
842 "│ Collection 0 │",
843 "│ Songs: 1 Duration: 00:03:00.00 │",
844 "│ │",
845 "│q: add to queue | p: add to playlist──────────────────────│",
846 "│Performing operations on entire collection────────────────│",
847 "│☐ Test Song Test Artist │",
848 "│s/S: change sort──────────────────────────────────────────│",
849 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
850 ]);
851 let buffer = terminal
852 .draw(|frame| view.render(frame, props))
853 .unwrap()
854 .buffer
855 .clone();
856 assert_buffer_eq(&buffer, &expected);
857
858 view.handle_mouse_event(
860 MouseEvent {
861 kind: MouseEventKind::ScrollDown,
862 column: 2,
863 row: 6,
864 modifiers: KeyModifiers::empty(),
865 },
866 area,
867 );
868 let buffer = terminal
869 .draw(|frame| view.render(frame, props))
870 .unwrap()
871 .buffer
872 .clone();
873 assert_buffer_eq(&buffer, &expected);
874 view.handle_mouse_event(
876 MouseEvent {
877 kind: MouseEventKind::ScrollUp,
878 column: 2,
879 row: 6,
880 modifiers: KeyModifiers::empty(),
881 },
882 area,
883 );
884 let buffer = terminal
885 .draw(|frame| view.render(frame, props))
886 .unwrap()
887 .buffer
888 .clone();
889 assert_buffer_eq(&buffer, &expected);
890 }
891}
892
893#[cfg(test)]
894mod library_view_tests {
895 use super::*;
896 use crate::{
897 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
898 ui::components::content_view::ActiveView,
899 };
900 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
901 use pretty_assertions::assert_eq;
902 use ratatui::buffer::Buffer;
903
904 #[test]
905 fn test_new() {
906 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
907 let state = state_with_everything();
908 let view = LibraryCollectionsView::new(&state, tx);
909
910 assert_eq!(view.name(), "Library Collections View");
911 assert_eq!(view.props.collections, state.library.collections);
912 }
913
914 #[test]
915 fn test_move_with_state() {
916 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
917 let state = AppState::default();
918 let new_state = state_with_everything();
919 let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
920
921 assert_eq!(view.props.collections, new_state.library.collections);
922 }
923
924 #[test]
925 fn test_render() {
926 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
927 let view = LibraryCollectionsView::new(&state_with_everything(), tx);
928
929 let (mut terminal, area) = setup_test_terminal(60, 6);
930 let props = RenderProps {
931 area,
932 is_focused: true,
933 };
934 let buffer = terminal
935 .draw(|frame| view.render(frame, props))
936 .unwrap()
937 .buffer
938 .clone();
939 let expected = Buffer::with_lines([
940 "┌Library Collections sorted by: Name───────────────────────┐",
941 "│──────────────────────────────────────────────────────────│",
942 "│▪ Collection 0 │",
943 "│ │",
944 "│ │",
945 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
946 ]);
947
948 assert_buffer_eq(&buffer, &expected);
949 }
950
951 #[test]
952 fn test_sort_keys() {
953 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
954 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
955
956 assert_eq!(view.props.sort_mode, NameSort::default());
957 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
958 assert_eq!(view.props.sort_mode, NameSort::default());
959 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
960 assert_eq!(view.props.sort_mode, NameSort::default());
961 }
962
963 #[test]
964 fn smoke_navigation() {
965 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
966 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
967
968 view.handle_key_event(KeyEvent::from(KeyCode::Up));
969 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
970 view.handle_key_event(KeyEvent::from(KeyCode::Down));
971 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
972 view.handle_key_event(KeyEvent::from(KeyCode::Left));
973 view.handle_key_event(KeyEvent::from(KeyCode::Right));
974 }
975
976 #[test]
977 fn test_actions() {
978 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
979 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
980
981 let (mut terminal, area) = setup_test_terminal(60, 9);
983 let props = RenderProps {
984 area,
985 is_focused: true,
986 };
987 terminal.draw(|frame| view.render(frame, props)).unwrap();
988
989 view.handle_key_event(KeyEvent::from(KeyCode::Down));
991
992 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
994 assert_eq!(
995 rx.blocking_recv().unwrap(),
996 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
997 );
998 }
999
1000 #[test]
1001 fn test_mouse_event() {
1002 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1003 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
1004
1005 let (mut terminal, area) = setup_test_terminal(60, 9);
1007 let props = RenderProps {
1008 area,
1009 is_focused: true,
1010 };
1011 let buffer = terminal
1012 .draw(|frame| view.render(frame, props))
1013 .unwrap()
1014 .buffer
1015 .clone();
1016 let expected = Buffer::with_lines([
1017 "┌Library Collections sorted by: Name───────────────────────┐",
1018 "│──────────────────────────────────────────────────────────│",
1019 "│▪ Collection 0 │",
1020 "│ │",
1021 "│ │",
1022 "│ │",
1023 "│ │",
1024 "│ │",
1025 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1026 ]);
1027 assert_buffer_eq(&buffer, &expected);
1028
1029 view.handle_mouse_event(
1031 MouseEvent {
1032 kind: MouseEventKind::ScrollDown,
1033 column: 2,
1034 row: 2,
1035 modifiers: KeyModifiers::empty(),
1036 },
1037 area,
1038 );
1039
1040 view.handle_mouse_event(
1042 MouseEvent {
1043 kind: MouseEventKind::Down(MouseButton::Left),
1044 column: 2,
1045 row: 2,
1046 modifiers: KeyModifiers::empty(),
1047 },
1048 area,
1049 );
1050 assert_eq!(
1051 rx.blocking_recv().unwrap(),
1052 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1053 );
1054 let buffer = terminal
1055 .draw(|frame| view.render(frame, props))
1056 .unwrap()
1057 .buffer
1058 .clone();
1059 assert_buffer_eq(&buffer, &expected);
1060
1061 view.handle_mouse_event(
1063 MouseEvent {
1064 kind: MouseEventKind::ScrollUp,
1065 column: 2,
1066 row: 2,
1067 modifiers: KeyModifiers::empty(),
1068 },
1069 area,
1070 );
1071
1072 view.handle_mouse_event(
1074 MouseEvent {
1075 kind: MouseEventKind::Down(MouseButton::Left),
1076 column: 2,
1077 row: 2,
1078 modifiers: KeyModifiers::empty(),
1079 },
1080 area,
1081 );
1082 assert_eq!(
1083 rx.blocking_recv().unwrap(),
1084 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
1085 );
1086
1087 let mouse = MouseEvent {
1089 kind: MouseEventKind::Down(MouseButton::Left),
1090 column: 2,
1091 row: 3,
1092 modifiers: KeyModifiers::empty(),
1093 };
1094 view.handle_mouse_event(mouse, area);
1095 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1096 view.handle_mouse_event(mouse, area);
1097 assert_eq!(
1098 rx.try_recv(),
1099 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1100 );
1101 }
1102}