1use std::{ops::Not, sync::Mutex};
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use mecomp_storage::db::schemas::album::Album;
7use ratatui::{
8 layout::{Margin, Rect},
9 style::{Style, Stylize},
10 text::{Line, Span},
11 widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use crate::{
16 state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
17 ui::{
18 colors::{border_color, TEXT_HIGHLIGHT},
19 components::{content_view::ActiveView, Component, ComponentRender, RenderProps},
20 widgets::{
21 popups::PopupType,
22 tree::{state::CheckTreeState, CheckTree},
23 },
24 AppState,
25 },
26};
27
28use super::{
29 checktree_utils::create_album_tree_leaf, generic::ItemView, sort_mode::AlbumSort,
30 traits::SortMode, AlbumViewProps,
31};
32
33#[allow(clippy::module_name_repetitions)]
34pub type AlbumView = ItemView<AlbumViewProps>;
35
36pub struct LibraryAlbumsView {
37 pub action_tx: UnboundedSender<Action>,
39 props: Props,
41 tree_state: Mutex<CheckTreeState<String>>,
43}
44
45struct Props {
46 albums: Box<[Album]>,
47 sort_mode: AlbumSort,
48}
49
50impl Component for LibraryAlbumsView {
51 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
52 where
53 Self: Sized,
54 {
55 let sort_mode = AlbumSort::default();
56 let mut albums = state.library.albums.clone();
57 sort_mode.sort_items(&mut albums);
58 Self {
59 action_tx,
60 props: Props { albums, sort_mode },
61 tree_state: Mutex::new(CheckTreeState::default()),
62 }
63 }
64
65 fn move_with_state(self, state: &AppState) -> Self
66 where
67 Self: Sized,
68 {
69 let mut albums = state.library.albums.clone();
70 self.props.sort_mode.sort_items(&mut albums);
71 let tree_state = (state.active_view == ActiveView::Albums)
72 .then_some(self.tree_state)
73 .unwrap_or_default();
74
75 Self {
76 props: Props {
77 albums,
78 ..self.props
79 },
80 tree_state,
81 ..self
82 }
83 }
84
85 fn name(&self) -> &'static str {
86 "Library Albums View"
87 }
88
89 fn handle_key_event(&mut self, key: KeyEvent) {
90 match key.code {
91 KeyCode::PageUp => {
93 self.tree_state.lock().unwrap().select_relative(|current| {
94 current.map_or(self.props.albums.len().saturating_sub(1), |c| {
95 c.saturating_sub(10)
96 })
97 });
98 }
99 KeyCode::Up => {
100 self.tree_state.lock().unwrap().key_up();
101 }
102 KeyCode::PageDown => {
103 self.tree_state
104 .lock()
105 .unwrap()
106 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
107 }
108 KeyCode::Down => {
109 self.tree_state.lock().unwrap().key_down();
110 }
111 KeyCode::Left => {
112 self.tree_state.lock().unwrap().key_left();
113 }
114 KeyCode::Right => {
115 self.tree_state.lock().unwrap().key_right();
116 }
117 KeyCode::Char(' ') => {
118 self.tree_state.lock().unwrap().key_space();
119 }
120 KeyCode::Enter => {
122 if self.tree_state.lock().unwrap().toggle_selected() {
123 let things = self.tree_state.lock().unwrap().get_selected_thing();
124
125 if let Some(thing) = things {
126 self.action_tx
127 .send(Action::ActiveView(ViewAction::Set(thing.into())))
128 .unwrap();
129 }
130 }
131 }
132 KeyCode::Char('q') => {
134 let things = self.tree_state.lock().unwrap().get_checked_things();
135 if !things.is_empty() {
136 self.action_tx
137 .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
138 .unwrap();
139 }
140 }
141 KeyCode::Char('r') => {
143 let things = self.tree_state.lock().unwrap().get_checked_things();
144 if !things.is_empty() {
145 self.action_tx
146 .send(Action::ActiveView(ViewAction::Set(ActiveView::Radio(
147 things,
148 ))))
149 .unwrap();
150 }
151 }
152 KeyCode::Char('p') => {
154 let things = self.tree_state.lock().unwrap().get_checked_things();
155 if !things.is_empty() {
156 self.action_tx
157 .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
158 things,
159 ))))
160 .unwrap();
161 }
162 }
163 KeyCode::Char('s') => {
165 self.props.sort_mode = self.props.sort_mode.next();
166 self.props.sort_mode.sort_items(&mut self.props.albums);
167 }
168 KeyCode::Char('S') => {
169 self.props.sort_mode = self.props.sort_mode.prev();
170 self.props.sort_mode.sort_items(&mut self.props.albums);
171 }
172 _ => {}
173 }
174 }
175
176 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
177 let area = area.inner(Margin::new(1, 2));
179
180 let result = self
181 .tree_state
182 .lock()
183 .unwrap()
184 .handle_mouse_event(mouse, area);
185 if let Some(action) = result {
186 self.action_tx.send(action).unwrap();
187 }
188 }
189}
190
191impl ComponentRender<RenderProps> for LibraryAlbumsView {
192 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
193 let border_style = Style::default().fg(border_color(props.is_focused).into());
194
195 let border = Block::bordered()
197 .title_top(Line::from(vec![
198 Span::styled("Library Albums".to_string(), Style::default().bold()),
199 Span::raw(" sorted by: "),
200 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
201 ]))
202 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
203 .border_style(border_style);
204 let content_area = border.inner(props.area);
205 frame.render_widget(border, props.area);
206
207 let border = Block::default()
209 .borders(Borders::TOP | Borders::BOTTOM)
210 .title_top(
211 self.tree_state
212 .lock()
213 .unwrap()
214 .get_checked_things()
215 .is_empty()
216 .not()
217 .then_some("q: add to queue | r: start radio | p: add to playlist ")
218 .unwrap_or_default(),
219 )
220 .title_bottom("s/S: change sort")
221 .border_style(border_style);
222 let area = border.inner(content_area);
223 frame.render_widget(border, content_area);
224
225 RenderProps { area, ..props }
226 }
227
228 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
229 let items = self
231 .props
232 .albums
233 .iter()
234 .map(|album| create_album_tree_leaf(album, None))
235 .collect::<Vec<_>>();
236
237 frame.render_stateful_widget(
239 CheckTree::new(&items)
240 .unwrap()
241 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
242 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
243 props.area,
244 &mut self.tree_state.lock().unwrap(),
245 );
246 }
247}
248
249#[cfg(test)]
250mod sort_mode_tests {
251 use super::*;
252 use one_or_many::OneOrMany;
253 use pretty_assertions::assert_eq;
254 use rstest::rstest;
255 use std::time::Duration;
256
257 #[rstest]
258 #[case(AlbumSort::Title, AlbumSort::Artist)]
259 #[case(AlbumSort::Artist, AlbumSort::ReleaseYear)]
260 #[case(AlbumSort::ReleaseYear, AlbumSort::Title)]
261 fn test_sort_mode_next_prev(#[case] mode: AlbumSort, #[case] expected: AlbumSort) {
262 assert_eq!(mode.next(), expected);
263 assert_eq!(mode.next().prev(), mode);
264 }
265
266 #[rstest]
267 #[case(AlbumSort::Title, "Title")]
268 #[case(AlbumSort::Artist, "Artist")]
269 #[case(AlbumSort::ReleaseYear, "Year")]
270 fn test_sort_mode_display(#[case] mode: AlbumSort, #[case] expected: &str) {
271 assert_eq!(mode.to_string(), expected);
272 }
273
274 #[rstest]
275 fn test_sort_items() {
276 let mut albums = vec![
277 Album {
278 id: Album::generate_id(),
279 title: "C".into(),
280 artist: OneOrMany::One("B".into()),
281 release: Some(2021),
282 song_count: 1,
283 runtime: Duration::from_secs(180),
284 discs: 1,
285 genre: OneOrMany::One("A".into()),
286 },
287 Album {
288 id: Album::generate_id(),
289 title: "B".into(),
290 artist: OneOrMany::One("A".into()),
291 release: Some(2022),
292 song_count: 1,
293 runtime: Duration::from_secs(180),
294 discs: 1,
295 genre: OneOrMany::One("C".into()),
296 },
297 Album {
298 id: Album::generate_id(),
299 title: "A".into(),
300 artist: OneOrMany::One("C".into()),
301 release: Some(2023),
302 song_count: 1,
303 runtime: Duration::from_secs(180),
304 discs: 1,
305 genre: OneOrMany::One("B".into()),
306 },
307 ];
308
309 AlbumSort::Title.sort_items(&mut albums);
310 assert_eq!(albums[0].title, "A");
311 assert_eq!(albums[1].title, "B");
312 assert_eq!(albums[2].title, "C");
313
314 AlbumSort::Artist.sort_items(&mut albums);
315 assert_eq!(albums[0].artist, OneOrMany::One("A".into()));
316 assert_eq!(albums[1].artist, OneOrMany::One("B".into()));
317 assert_eq!(albums[2].artist, OneOrMany::One("C".into()));
318
319 AlbumSort::ReleaseYear.sort_items(&mut albums);
320 assert_eq!(albums[0].release, Some(2023));
321 assert_eq!(albums[1].release, Some(2022));
322 assert_eq!(albums[2].release, Some(2021));
323 }
324}
325
326#[cfg(test)]
327mod item_view_tests {
328 use super::*;
329 use crate::test_utils::{
330 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
331 };
332 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
333 use pretty_assertions::assert_eq;
334 use ratatui::buffer::Buffer;
335
336 #[test]
337 fn test_new() {
338 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
339 let state = state_with_everything();
340 let view = AlbumView::new(&state, tx);
341
342 assert_eq!(view.name(), "Album View");
343 assert_eq!(view.props, Some(state.additional_view_data.album.unwrap()));
344 }
345
346 #[test]
347 fn test_move_with_state() {
348 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
349 let state = AppState::default();
350 let new_state = state_with_everything();
351 let view = AlbumView::new(&state, tx).move_with_state(&new_state);
352
353 assert_eq!(
354 view.props,
355 Some(new_state.additional_view_data.album.unwrap())
356 );
357 }
358
359 #[test]
360 fn test_render_no_album() {
361 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
362 let view = AlbumView::new(&AppState::default(), tx);
363
364 let (mut terminal, area) = setup_test_terminal(17, 3);
365 let props = RenderProps {
366 area,
367 is_focused: true,
368 };
369 let buffer = terminal
370 .draw(|frame| view.render(frame, props))
371 .unwrap()
372 .buffer
373 .clone();
374 #[rustfmt::skip]
375 let expected = Buffer::with_lines([
376 "┌Album View─────┐",
377 "│No active album│",
378 "└───────────────┘",
379 ]);
380
381 assert_buffer_eq(&buffer, &expected);
382 }
383
384 #[test]
385 fn test_render() {
386 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
387 let view = AlbumView::new(&state_with_everything(), tx);
388
389 let (mut terminal, area) = setup_test_terminal(60, 9);
390 let props = RenderProps {
391 area,
392 is_focused: true,
393 };
394 let buffer = terminal
395 .draw(|frame| view.render(frame, props))
396 .unwrap()
397 .buffer
398 .clone();
399 let expected = Buffer::with_lines([
400 "┌Album View────────────────────────────────────────────────┐",
401 "│ Test Album Test Artist │",
402 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
403 "│ │",
404 "│q: add to queue | r: start radio | p: add to playlist─────│",
405 "│Performing operations on entire album─────────────────────│",
406 "│▶ Artists (1): │",
407 "│▶ Songs (1): │",
408 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
409 ]);
410
411 assert_buffer_eq(&buffer, &expected);
412 }
413
414 #[test]
415 fn test_render_with_checked() {
416 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
417 let mut view = AlbumView::new(&state_with_everything(), tx);
418 let (mut terminal, area) = setup_test_terminal(60, 9);
419 let props = RenderProps {
420 area,
421 is_focused: true,
422 };
423 let buffer = terminal
424 .draw(|frame| view.render(frame, props))
425 .unwrap()
426 .buffer
427 .clone();
428 let expected = Buffer::with_lines([
429 "┌Album View────────────────────────────────────────────────┐",
430 "│ Test Album Test Artist │",
431 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
432 "│ │",
433 "│q: add to queue | r: start radio | p: add to playlist─────│",
434 "│Performing operations on entire album─────────────────────│",
435 "│▶ Artists (1): │",
436 "│▶ Songs (1): │",
437 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
438 ]);
439 assert_buffer_eq(&buffer, &expected);
440
441 view.handle_key_event(KeyEvent::from(KeyCode::Down));
443 view.handle_key_event(KeyEvent::from(KeyCode::Down));
444 view.handle_key_event(KeyEvent::from(KeyCode::Right));
445 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
446 view.handle_key_event(KeyEvent::from(KeyCode::Down));
447 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
448
449 let buffer = terminal
450 .draw(|frame| view.render(frame, props))
451 .unwrap()
452 .buffer
453 .clone();
454 let expected = Buffer::with_lines([
455 "┌Album View────────────────────────────────────────────────┐",
456 "│ Test Album Test Artist │",
457 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
458 "│ │",
459 "│q: add to queue | r: start radio | p: add to playlist─────│",
460 "│Performing operations on checked items────────────────────│",
461 "│▼ Songs (1): │",
462 "│ ☑ Test Song Test Artist │",
463 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
464 ]);
465
466 assert_buffer_eq(&buffer, &expected);
467 }
468
469 #[test]
470 fn smoke_navigation() {
471 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
472 let mut view = AlbumView::new(&state_with_everything(), tx);
473
474 view.handle_key_event(KeyEvent::from(KeyCode::Up));
475 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
476 view.handle_key_event(KeyEvent::from(KeyCode::Down));
477 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
478 view.handle_key_event(KeyEvent::from(KeyCode::Left));
479 view.handle_key_event(KeyEvent::from(KeyCode::Right));
480 }
481
482 #[test]
483 fn test_actions() {
484 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
485 let mut view = AlbumView::new(&state_with_everything(), tx);
486
487 let (mut terminal, area) = setup_test_terminal(60, 9);
489 let props = RenderProps {
490 area,
491 is_focused: true,
492 };
493 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
494
495 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
498 assert_eq!(
499 rx.blocking_recv().unwrap(),
500 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
501 "album",
502 item_id()
503 )
504 .into()])))
505 );
506 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
507 assert_eq!(
508 rx.blocking_recv().unwrap(),
509 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
510 "album",
511 item_id()
512 )
513 .into()],)))
514 );
515 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
516 assert_eq!(
517 rx.blocking_recv().unwrap(),
518 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
519 "album",
520 item_id()
521 )
522 .into()])))
523 );
524
525 view.handle_key_event(KeyEvent::from(KeyCode::Down));
528 view.handle_key_event(KeyEvent::from(KeyCode::Down));
529 view.handle_key_event(KeyEvent::from(KeyCode::Right));
530 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
531 view.handle_key_event(KeyEvent::from(KeyCode::Down));
532
533 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
535 assert_eq!(
536 rx.blocking_recv().unwrap(),
537 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
538 );
539
540 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
542
543 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
545 assert_eq!(
546 rx.blocking_recv().unwrap(),
547 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
548 "song",
549 item_id()
550 )
551 .into()])))
552 );
553
554 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
556 assert_eq!(
557 rx.blocking_recv().unwrap(),
558 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
559 "song",
560 item_id()
561 )
562 .into()],)))
563 );
564
565 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
567 assert_eq!(
568 rx.blocking_recv().unwrap(),
569 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
570 "song",
571 item_id()
572 )
573 .into()])))
574 );
575 }
576
577 #[test]
578 #[allow(clippy::too_many_lines)]
579 fn test_mouse() {
580 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
581 let mut view = AlbumView::new(&state_with_everything(), tx);
582
583 let (mut terminal, area) = setup_test_terminal(60, 9);
585 let props = RenderProps {
586 area,
587 is_focused: true,
588 };
589 let buffer = terminal
590 .draw(|frame| view.render(frame, props))
591 .unwrap()
592 .buffer
593 .clone();
594 let expected = Buffer::with_lines([
595 "┌Album View────────────────────────────────────────────────┐",
596 "│ Test Album Test Artist │",
597 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
598 "│ │",
599 "│q: add to queue | r: start radio | p: add to playlist─────│",
600 "│Performing operations on entire album─────────────────────│",
601 "│▶ Artists (1): │",
602 "│▶ Songs (1): │",
603 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
604 ]);
605 assert_buffer_eq(&buffer, &expected);
606
607 view.handle_mouse_event(
609 MouseEvent {
610 kind: MouseEventKind::Down(MouseButton::Left),
611 column: 2,
612 row: 6,
613 modifiers: KeyModifiers::empty(),
614 },
615 area,
616 );
617 let buffer = terminal
618 .draw(|frame| view.render(frame, props))
619 .unwrap()
620 .buffer
621 .clone();
622 let expected = Buffer::with_lines([
623 "┌Album View────────────────────────────────────────────────┐",
624 "│ Test Album Test Artist │",
625 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
626 "│ │",
627 "│q: add to queue | r: start radio | p: add to playlist─────│",
628 "│Performing operations on entire album─────────────────────│",
629 "│▼ Artists (1): │",
630 "│ ☐ Test Artist │",
631 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
632 ]);
633 assert_buffer_eq(&buffer, &expected);
634
635 view.handle_mouse_event(
637 MouseEvent {
638 kind: MouseEventKind::ScrollDown,
639 column: 2,
640 row: 6,
641 modifiers: KeyModifiers::empty(),
642 },
643 area,
644 );
645 let buffer = terminal
646 .draw(|frame| view.render(frame, props))
647 .unwrap()
648 .buffer
649 .clone();
650 assert_buffer_eq(&buffer, &expected);
651
652 view.handle_mouse_event(
654 MouseEvent {
655 kind: MouseEventKind::Down(MouseButton::Left),
656 column: 2,
657 row: 7,
658 modifiers: KeyModifiers::empty(),
659 },
660 area,
661 );
662 assert_eq!(
663 rx.blocking_recv().unwrap(),
664 Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
665 );
666 let buffer = terminal
667 .draw(|frame| view.render(frame, props))
668 .unwrap()
669 .buffer
670 .clone();
671 let expected = Buffer::with_lines([
672 "┌Album View────────────────────────────────────────────────┐",
673 "│ Test Album Test Artist │",
674 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
675 "│ │",
676 "│q: add to queue | r: start radio | p: add to playlist─────│",
677 "│Performing operations on checked items────────────────────│",
678 "│▼ Artists (1): │",
679 "│ ☑ Test Artist │",
680 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
681 ]);
682 assert_buffer_eq(&buffer, &expected);
683
684 view.handle_mouse_event(
686 MouseEvent {
687 kind: MouseEventKind::ScrollUp,
688 column: 2,
689 row: 7,
690 modifiers: KeyModifiers::empty(),
691 },
692 area,
693 );
694 let buffer = terminal
695 .draw(|frame| view.render(frame, props))
696 .unwrap()
697 .buffer
698 .clone();
699 assert_buffer_eq(&buffer, &expected);
700 }
701}
702
703#[cfg(test)]
704mod library_view_tests {
705 use super::*;
706 use crate::test_utils::{
707 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
708 };
709 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
710 use pretty_assertions::assert_eq;
711 use ratatui::buffer::Buffer;
712
713 #[test]
714 fn test_new() {
715 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
716 let state = state_with_everything();
717 let view = LibraryAlbumsView::new(&state, tx);
718
719 assert_eq!(view.name(), "Library Albums View");
720 assert_eq!(view.props.albums, state.library.albums);
721 }
722
723 #[test]
724 fn test_move_with_state() {
725 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
726 let state = AppState::default();
727 let new_state = state_with_everything();
728 let view = LibraryAlbumsView::new(&state, tx).move_with_state(&new_state);
729
730 assert_eq!(view.props.albums, new_state.library.albums);
731 }
732
733 #[test]
734 fn test_render() {
735 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
736 let view = LibraryAlbumsView::new(&state_with_everything(), tx);
737
738 let (mut terminal, area) = setup_test_terminal(60, 6);
739 let props = RenderProps {
740 area,
741 is_focused: true,
742 };
743 let buffer = terminal
744 .draw(|frame| view.render(frame, props))
745 .unwrap()
746 .buffer
747 .clone();
748 let expected = Buffer::with_lines([
749 "┌Library Albums sorted by: Artist──────────────────────────┐",
750 "│──────────────────────────────────────────────────────────│",
751 "│☐ Test Album Test Artist │",
752 "│ │",
753 "│s/S: change sort──────────────────────────────────────────│",
754 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
755 ]);
756
757 assert_buffer_eq(&buffer, &expected);
758 }
759
760 #[test]
761 fn test_render_with_checked() {
762 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
763 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
764 let (mut terminal, area) = setup_test_terminal(60, 6);
765 let props = RenderProps {
766 area,
767 is_focused: true,
768 };
769 let buffer = terminal
770 .draw(|frame| view.render(frame, props))
771 .unwrap()
772 .buffer
773 .clone();
774 let expected = Buffer::with_lines([
775 "┌Library Albums sorted by: Artist──────────────────────────┐",
776 "│──────────────────────────────────────────────────────────│",
777 "│☐ Test Album Test Artist │",
778 "│ │",
779 "│s/S: change sort──────────────────────────────────────────│",
780 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
781 ]);
782 assert_buffer_eq(&buffer, &expected);
783
784 view.handle_key_event(KeyEvent::from(KeyCode::Down));
786 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
787
788 let buffer = terminal
789 .draw(|frame| view.render(frame, props))
790 .unwrap()
791 .buffer
792 .clone();
793 let expected = Buffer::with_lines([
794 "┌Library Albums sorted by: Artist──────────────────────────┐",
795 "│q: add to queue | r: start radio | p: add to playlist ────│",
796 "│☑ Test Album Test Artist │",
797 "│ │",
798 "│s/S: change sort──────────────────────────────────────────│",
799 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
800 ]);
801
802 assert_buffer_eq(&buffer, &expected);
803 }
804
805 #[test]
806 fn test_sort_keys() {
807 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
808 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
809
810 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
811 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
812 assert_eq!(view.props.sort_mode, AlbumSort::ReleaseYear);
813 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
814 assert_eq!(view.props.sort_mode, AlbumSort::Title);
815 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
816 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
817 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
818 assert_eq!(view.props.sort_mode, AlbumSort::Title);
819 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
820 assert_eq!(view.props.sort_mode, AlbumSort::ReleaseYear);
821 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
822 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
823 }
824
825 #[test]
826 fn smoke_navigation() {
827 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
828 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
829
830 view.handle_key_event(KeyEvent::from(KeyCode::Up));
831 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
832 view.handle_key_event(KeyEvent::from(KeyCode::Down));
833 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
834 view.handle_key_event(KeyEvent::from(KeyCode::Left));
835 view.handle_key_event(KeyEvent::from(KeyCode::Right));
836 }
837
838 #[test]
839 fn test_actions() {
840 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
841 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
842
843 let (mut terminal, area) = setup_test_terminal(60, 9);
845 let props = RenderProps {
846 area,
847 is_focused: true,
848 };
849 terminal.draw(|frame| view.render(frame, props)).unwrap();
850
851 view.handle_key_event(KeyEvent::from(KeyCode::Down));
853
854 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
857 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
858 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
859 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
861 let action = rx.blocking_recv().unwrap();
862 assert_eq!(
863 action,
864 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
865 );
866
867 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
869
870 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
872 let action = rx.blocking_recv().unwrap();
873 assert_eq!(
874 action,
875 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
876 "album",
877 item_id()
878 )
879 .into()])))
880 );
881
882 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
884 let action = rx.blocking_recv().unwrap();
885 assert_eq!(
886 action,
887 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
888 "album",
889 item_id()
890 )
891 .into()],)))
892 );
893
894 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
896 let action = rx.blocking_recv().unwrap();
897 assert_eq!(
898 action,
899 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
900 "album",
901 item_id()
902 )
903 .into()])))
904 );
905 }
906
907 #[test]
908 fn test_mouse() {
909 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
910 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
911
912 let (mut terminal, area) = setup_test_terminal(60, 6);
914 let props = RenderProps {
915 area,
916 is_focused: true,
917 };
918 let buffer = terminal
919 .draw(|frame| view.render(frame, props))
920 .unwrap()
921 .buffer
922 .clone();
923 let expected = Buffer::with_lines([
924 "┌Library Albums sorted by: Artist──────────────────────────┐",
925 "│──────────────────────────────────────────────────────────│",
926 "│☐ Test Album Test Artist │",
927 "│ │",
928 "│s/S: change sort──────────────────────────────────────────│",
929 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
930 ]);
931 assert_buffer_eq(&buffer, &expected);
932
933 view.handle_mouse_event(
935 MouseEvent {
936 kind: MouseEventKind::Down(MouseButton::Left),
937 column: 2,
938 row: 2,
939 modifiers: KeyModifiers::empty(),
940 },
941 area,
942 );
943 let buffer = terminal
944 .draw(|frame| view.render(frame, props))
945 .unwrap()
946 .buffer
947 .clone();
948 let expected = Buffer::with_lines([
949 "┌Library Albums sorted by: Artist──────────────────────────┐",
950 "│q: add to queue | r: start radio | p: add to playlist ────│",
951 "│☑ Test Album Test Artist │",
952 "│ │",
953 "│s/S: change sort──────────────────────────────────────────│",
954 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
955 ]);
956 assert_buffer_eq(&buffer, &expected);
957
958 view.handle_mouse_event(
960 MouseEvent {
961 kind: MouseEventKind::ScrollDown,
962 column: 2,
963 row: 2,
964 modifiers: KeyModifiers::empty(),
965 },
966 area,
967 );
968 let buffer = terminal
969 .draw(|frame| view.render(frame, props))
970 .unwrap()
971 .buffer
972 .clone();
973 assert_buffer_eq(&buffer, &expected);
974
975 view.handle_mouse_event(
977 MouseEvent {
978 kind: MouseEventKind::ScrollUp,
979 column: 2,
980 row: 2,
981 modifiers: KeyModifiers::empty(),
982 },
983 area,
984 );
985 let buffer = terminal
986 .draw(|frame| view.render(frame, props))
987 .unwrap()
988 .buffer
989 .clone();
990 assert_buffer_eq(&buffer, &expected);
991
992 view.handle_mouse_event(
994 MouseEvent {
995 kind: MouseEventKind::Down(MouseButton::Left),
996 column: 2,
997 row: 2,
998 modifiers: KeyModifiers::empty(),
999 },
1000 area,
1001 );
1002 assert_eq!(
1003 rx.blocking_recv().unwrap(),
1004 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
1005 );
1006
1007 let mouse = MouseEvent {
1009 kind: MouseEventKind::Down(MouseButton::Left),
1010 column: 2,
1011 row: 3,
1012 modifiers: KeyModifiers::empty(),
1013 };
1014 view.handle_mouse_event(mouse, area);
1015 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1016 view.handle_mouse_event(mouse, area);
1017 assert_eq!(
1018 rx.try_recv(),
1019 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1020 );
1021 }
1022}