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