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 AppState,
19 colors::{TEXT_HIGHLIGHT, border_color},
20 components::{Component, ComponentRender, RenderProps, content_view::ActiveView},
21 widgets::{
22 popups::PopupType,
23 tree::{CheckTree, state::CheckTreeState},
24 },
25 },
26};
27
28use super::{
29 AlbumViewProps, checktree_utils::create_album_tree_leaf, generic::ItemView,
30 sort_mode::AlbumSort, traits::SortMode,
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 self.tree_state.lock().unwrap().scroll_selected_into_view();
168 }
169 KeyCode::Char('S') => {
170 self.props.sort_mode = self.props.sort_mode.prev();
171 self.props.sort_mode.sort_items(&mut self.props.albums);
172 self.tree_state.lock().unwrap().scroll_selected_into_view();
173 }
174 _ => {}
175 }
176 }
177
178 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
179 let area = area.inner(Margin::new(1, 2));
181
182 let result = self
183 .tree_state
184 .lock()
185 .unwrap()
186 .handle_mouse_event(mouse, area);
187 if let Some(action) = result {
188 self.action_tx.send(action).unwrap();
189 }
190 }
191}
192
193impl ComponentRender<RenderProps> for LibraryAlbumsView {
194 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
195 let border_style = Style::default().fg(border_color(props.is_focused).into());
196
197 let border = Block::bordered()
199 .title_top(Line::from(vec![
200 Span::styled("Library Albums".to_string(), Style::default().bold()),
201 Span::raw(" sorted by: "),
202 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
203 ]))
204 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
205 .border_style(border_style);
206 let content_area = border.inner(props.area);
207 frame.render_widget(border, props.area);
208
209 let border = Block::default()
211 .borders(Borders::TOP | Borders::BOTTOM)
212 .title_top(
213 self.tree_state
214 .lock()
215 .unwrap()
216 .get_checked_things()
217 .is_empty()
218 .not()
219 .then_some("q: add to queue | r: start radio | p: add to playlist ")
220 .unwrap_or_default(),
221 )
222 .title_bottom("s/S: change sort")
223 .border_style(border_style);
224 let area = border.inner(content_area);
225 frame.render_widget(border, content_area);
226
227 RenderProps { area, ..props }
228 }
229
230 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
231 let items = self
233 .props
234 .albums
235 .iter()
236 .map(|album| create_album_tree_leaf(album, None))
237 .collect::<Vec<_>>();
238
239 frame.render_stateful_widget(
241 CheckTree::new(&items)
242 .unwrap()
243 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
244 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
245 props.area,
246 &mut self.tree_state.lock().unwrap(),
247 );
248 }
249}
250
251#[cfg(test)]
252mod sort_mode_tests {
253 use super::*;
254 use one_or_many::OneOrMany;
255 use pretty_assertions::assert_eq;
256 use rstest::rstest;
257 use std::time::Duration;
258
259 #[rstest]
260 #[case(AlbumSort::Title, AlbumSort::Artist)]
261 #[case(AlbumSort::Artist, AlbumSort::ReleaseYear)]
262 #[case(AlbumSort::ReleaseYear, AlbumSort::Title)]
263 fn test_sort_mode_next_prev(#[case] mode: AlbumSort, #[case] expected: AlbumSort) {
264 assert_eq!(mode.next(), expected);
265 assert_eq!(mode.next().prev(), mode);
266 }
267
268 #[rstest]
269 #[case(AlbumSort::Title, "Title")]
270 #[case(AlbumSort::Artist, "Artist")]
271 #[case(AlbumSort::ReleaseYear, "Year")]
272 fn test_sort_mode_display(#[case] mode: AlbumSort, #[case] expected: &str) {
273 assert_eq!(mode.to_string(), expected);
274 }
275
276 #[rstest]
277 fn test_sort_items() {
278 let mut albums = vec![
279 Album {
280 id: Album::generate_id(),
281 title: "C".into(),
282 artist: OneOrMany::One("B".into()),
283 release: Some(2021),
284 song_count: 1,
285 runtime: Duration::from_secs(180),
286 discs: 1,
287 genre: OneOrMany::One("A".into()),
288 },
289 Album {
290 id: Album::generate_id(),
291 title: "B".into(),
292 artist: OneOrMany::One("A".into()),
293 release: Some(2022),
294 song_count: 1,
295 runtime: Duration::from_secs(180),
296 discs: 1,
297 genre: OneOrMany::One("C".into()),
298 },
299 Album {
300 id: Album::generate_id(),
301 title: "A".into(),
302 artist: OneOrMany::One("C".into()),
303 release: Some(2023),
304 song_count: 1,
305 runtime: Duration::from_secs(180),
306 discs: 1,
307 genre: OneOrMany::One("B".into()),
308 },
309 ];
310
311 AlbumSort::Title.sort_items(&mut albums);
312 assert_eq!(albums[0].title, "A");
313 assert_eq!(albums[1].title, "B");
314 assert_eq!(albums[2].title, "C");
315
316 AlbumSort::Artist.sort_items(&mut albums);
317 assert_eq!(albums[0].artist, OneOrMany::One("A".into()));
318 assert_eq!(albums[1].artist, OneOrMany::One("B".into()));
319 assert_eq!(albums[2].artist, OneOrMany::One("C".into()));
320
321 AlbumSort::ReleaseYear.sort_items(&mut albums);
322 assert_eq!(albums[0].release, Some(2023));
323 assert_eq!(albums[1].release, Some(2022));
324 assert_eq!(albums[2].release, Some(2021));
325 }
326}
327
328#[cfg(test)]
329mod item_view_tests {
330 use super::*;
331 use crate::test_utils::{
332 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
333 };
334 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
335 use pretty_assertions::assert_eq;
336 use ratatui::buffer::Buffer;
337
338 #[test]
339 fn test_new() {
340 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
341 let state = state_with_everything();
342 let view = AlbumView::new(&state, tx);
343
344 assert_eq!(view.name(), "Album View");
345 assert_eq!(view.props, Some(state.additional_view_data.album.unwrap()));
346 }
347
348 #[test]
349 fn test_move_with_state() {
350 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
351 let state = AppState::default();
352 let new_state = state_with_everything();
353 let view = AlbumView::new(&state, tx).move_with_state(&new_state);
354
355 assert_eq!(
356 view.props,
357 Some(new_state.additional_view_data.album.unwrap())
358 );
359 }
360
361 #[test]
362 fn test_render_no_album() {
363 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
364 let view = AlbumView::new(&AppState::default(), tx);
365
366 let (mut terminal, area) = setup_test_terminal(17, 3);
367 let props = RenderProps {
368 area,
369 is_focused: true,
370 };
371 let buffer = terminal
372 .draw(|frame| view.render(frame, props))
373 .unwrap()
374 .buffer
375 .clone();
376 #[rustfmt::skip]
377 let expected = Buffer::with_lines([
378 "┌Album View─────┐",
379 "│No active album│",
380 "└───────────────┘",
381 ]);
382
383 assert_buffer_eq(&buffer, &expected);
384 }
385
386 #[test]
387 fn test_render() {
388 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
389 let view = AlbumView::new(&state_with_everything(), tx);
390
391 let (mut terminal, area) = setup_test_terminal(60, 9);
392 let props = RenderProps {
393 area,
394 is_focused: true,
395 };
396 let buffer = terminal
397 .draw(|frame| view.render(frame, props))
398 .unwrap()
399 .buffer
400 .clone();
401 let expected = Buffer::with_lines([
402 "┌Album View────────────────────────────────────────────────┐",
403 "│ Test Album Test Artist │",
404 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
405 "│ │",
406 "│q: add to queue | r: start radio | p: add to playlist─────│",
407 "│Performing operations on entire album─────────────────────│",
408 "│▶ Artists (1): │",
409 "│▶ Songs (1): │",
410 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
411 ]);
412
413 assert_buffer_eq(&buffer, &expected);
414 }
415
416 #[test]
417 fn test_render_with_checked() {
418 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
419 let mut view = AlbumView::new(&state_with_everything(), tx);
420 let (mut terminal, area) = setup_test_terminal(60, 9);
421 let props = RenderProps {
422 area,
423 is_focused: true,
424 };
425 let buffer = terminal
426 .draw(|frame| view.render(frame, props))
427 .unwrap()
428 .buffer
429 .clone();
430 let expected = Buffer::with_lines([
431 "┌Album View────────────────────────────────────────────────┐",
432 "│ Test Album Test Artist │",
433 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
434 "│ │",
435 "│q: add to queue | r: start radio | p: add to playlist─────│",
436 "│Performing operations on entire album─────────────────────│",
437 "│▶ Artists (1): │",
438 "│▶ Songs (1): │",
439 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
440 ]);
441 assert_buffer_eq(&buffer, &expected);
442
443 view.handle_key_event(KeyEvent::from(KeyCode::Down));
445 view.handle_key_event(KeyEvent::from(KeyCode::Down));
446 view.handle_key_event(KeyEvent::from(KeyCode::Right));
447 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
448 view.handle_key_event(KeyEvent::from(KeyCode::Down));
449 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
450
451 let buffer = terminal
452 .draw(|frame| view.render(frame, props))
453 .unwrap()
454 .buffer
455 .clone();
456 let expected = Buffer::with_lines([
457 "┌Album View────────────────────────────────────────────────┐",
458 "│ Test Album Test Artist │",
459 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
460 "│ │",
461 "│q: add to queue | r: start radio | p: add to playlist─────│",
462 "│Performing operations on checked items────────────────────│",
463 "│▼ Songs (1): │",
464 "│ ☑ Test Song Test Artist │",
465 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
466 ]);
467
468 assert_buffer_eq(&buffer, &expected);
469 }
470
471 #[test]
472 fn smoke_navigation() {
473 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
474 let mut view = AlbumView::new(&state_with_everything(), tx);
475
476 view.handle_key_event(KeyEvent::from(KeyCode::Up));
477 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
478 view.handle_key_event(KeyEvent::from(KeyCode::Down));
479 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
480 view.handle_key_event(KeyEvent::from(KeyCode::Left));
481 view.handle_key_event(KeyEvent::from(KeyCode::Right));
482 }
483
484 #[test]
485 fn test_actions() {
486 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
487 let mut view = AlbumView::new(&state_with_everything(), tx);
488
489 let (mut terminal, area) = setup_test_terminal(60, 9);
491 let props = RenderProps {
492 area,
493 is_focused: true,
494 };
495 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
496
497 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
500 assert_eq!(
501 rx.blocking_recv().unwrap(),
502 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
503 ("album", item_id()).into()
504 ])))
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", item_id()).into()
511 ],)))
512 );
513 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
514 assert_eq!(
515 rx.blocking_recv().unwrap(),
516 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
517 ("album", item_id()).into()
518 ])))
519 );
520
521 view.handle_key_event(KeyEvent::from(KeyCode::Down));
524 view.handle_key_event(KeyEvent::from(KeyCode::Down));
525 view.handle_key_event(KeyEvent::from(KeyCode::Right));
526 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
527 view.handle_key_event(KeyEvent::from(KeyCode::Down));
528
529 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
531 assert_eq!(
532 rx.blocking_recv().unwrap(),
533 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
534 );
535
536 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
538
539 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
541 assert_eq!(
542 rx.blocking_recv().unwrap(),
543 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
544 ("song", item_id()).into()
545 ])))
546 );
547
548 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
550 assert_eq!(
551 rx.blocking_recv().unwrap(),
552 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
553 ("song", item_id()).into()
554 ],)))
555 );
556
557 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
559 assert_eq!(
560 rx.blocking_recv().unwrap(),
561 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
562 ("song", item_id()).into()
563 ])))
564 );
565 }
566
567 #[test]
568 #[allow(clippy::too_many_lines)]
569 fn test_mouse() {
570 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
571 let mut view = AlbumView::new(&state_with_everything(), tx);
572
573 let (mut terminal, area) = setup_test_terminal(60, 9);
575 let props = RenderProps {
576 area,
577 is_focused: true,
578 };
579 let buffer = terminal
580 .draw(|frame| view.render(frame, props))
581 .unwrap()
582 .buffer
583 .clone();
584 let expected = Buffer::with_lines([
585 "┌Album View────────────────────────────────────────────────┐",
586 "│ Test Album Test Artist │",
587 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
588 "│ │",
589 "│q: add to queue | r: start radio | p: add to playlist─────│",
590 "│Performing operations on entire album─────────────────────│",
591 "│▶ Artists (1): │",
592 "│▶ Songs (1): │",
593 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
594 ]);
595 assert_buffer_eq(&buffer, &expected);
596
597 view.handle_mouse_event(
599 MouseEvent {
600 kind: MouseEventKind::Down(MouseButton::Left),
601 column: 2,
602 row: 6,
603 modifiers: KeyModifiers::empty(),
604 },
605 area,
606 );
607 let buffer = terminal
608 .draw(|frame| view.render(frame, props))
609 .unwrap()
610 .buffer
611 .clone();
612 let expected = Buffer::with_lines([
613 "┌Album View────────────────────────────────────────────────┐",
614 "│ Test Album Test Artist │",
615 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
616 "│ │",
617 "│q: add to queue | r: start radio | p: add to playlist─────│",
618 "│Performing operations on entire album─────────────────────│",
619 "│▼ Artists (1): │",
620 "│ ☐ Test Artist │",
621 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
622 ]);
623 assert_buffer_eq(&buffer, &expected);
624
625 view.handle_mouse_event(
627 MouseEvent {
628 kind: MouseEventKind::ScrollDown,
629 column: 2,
630 row: 6,
631 modifiers: KeyModifiers::empty(),
632 },
633 area,
634 );
635 let buffer = terminal
636 .draw(|frame| view.render(frame, props))
637 .unwrap()
638 .buffer
639 .clone();
640 assert_buffer_eq(&buffer, &expected);
641
642 view.handle_mouse_event(
644 MouseEvent {
645 kind: MouseEventKind::Down(MouseButton::Left),
646 column: 2,
647 row: 7,
648 modifiers: KeyModifiers::empty(),
649 },
650 area,
651 );
652 assert_eq!(
653 rx.blocking_recv().unwrap(),
654 Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
655 );
656 let buffer = terminal
657 .draw(|frame| view.render(frame, props))
658 .unwrap()
659 .buffer
660 .clone();
661 let expected = Buffer::with_lines([
662 "┌Album View────────────────────────────────────────────────┐",
663 "│ Test Album Test Artist │",
664 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
665 "│ │",
666 "│q: add to queue | r: start radio | p: add to playlist─────│",
667 "│Performing operations on checked items────────────────────│",
668 "│▼ Artists (1): │",
669 "│ ☑ Test Artist │",
670 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
671 ]);
672 assert_buffer_eq(&buffer, &expected);
673
674 view.handle_mouse_event(
676 MouseEvent {
677 kind: MouseEventKind::ScrollUp,
678 column: 2,
679 row: 7,
680 modifiers: KeyModifiers::empty(),
681 },
682 area,
683 );
684 let buffer = terminal
685 .draw(|frame| view.render(frame, props))
686 .unwrap()
687 .buffer
688 .clone();
689 assert_buffer_eq(&buffer, &expected);
690 }
691}
692
693#[cfg(test)]
694mod library_view_tests {
695 use super::*;
696 use crate::test_utils::{
697 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
698 };
699 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
700 use pretty_assertions::assert_eq;
701 use ratatui::buffer::Buffer;
702
703 #[test]
704 fn test_new() {
705 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
706 let state = state_with_everything();
707 let view = LibraryAlbumsView::new(&state, tx);
708
709 assert_eq!(view.name(), "Library Albums View");
710 assert_eq!(view.props.albums, state.library.albums);
711 }
712
713 #[test]
714 fn test_move_with_state() {
715 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
716 let state = AppState::default();
717 let new_state = state_with_everything();
718 let view = LibraryAlbumsView::new(&state, tx).move_with_state(&new_state);
719
720 assert_eq!(view.props.albums, new_state.library.albums);
721 }
722
723 #[test]
724 fn test_render() {
725 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
726 let view = LibraryAlbumsView::new(&state_with_everything(), tx);
727
728 let (mut terminal, area) = setup_test_terminal(60, 6);
729 let props = RenderProps {
730 area,
731 is_focused: true,
732 };
733 let buffer = terminal
734 .draw(|frame| view.render(frame, props))
735 .unwrap()
736 .buffer
737 .clone();
738 let expected = Buffer::with_lines([
739 "┌Library Albums sorted by: Artist──────────────────────────┐",
740 "│──────────────────────────────────────────────────────────│",
741 "│☐ Test Album Test Artist │",
742 "│ │",
743 "│s/S: change sort──────────────────────────────────────────│",
744 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
745 ]);
746
747 assert_buffer_eq(&buffer, &expected);
748 }
749
750 #[test]
751 fn test_render_with_checked() {
752 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
753 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
754 let (mut terminal, area) = setup_test_terminal(60, 6);
755 let props = RenderProps {
756 area,
757 is_focused: true,
758 };
759 let buffer = terminal
760 .draw(|frame| view.render(frame, props))
761 .unwrap()
762 .buffer
763 .clone();
764 let expected = Buffer::with_lines([
765 "┌Library Albums sorted by: Artist──────────────────────────┐",
766 "│──────────────────────────────────────────────────────────│",
767 "│☐ Test Album Test Artist │",
768 "│ │",
769 "│s/S: change sort──────────────────────────────────────────│",
770 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
771 ]);
772 assert_buffer_eq(&buffer, &expected);
773
774 view.handle_key_event(KeyEvent::from(KeyCode::Down));
776 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
777
778 let buffer = terminal
779 .draw(|frame| view.render(frame, props))
780 .unwrap()
781 .buffer
782 .clone();
783 let expected = Buffer::with_lines([
784 "┌Library Albums sorted by: Artist──────────────────────────┐",
785 "│q: add to queue | r: start radio | p: add to playlist ────│",
786 "│☑ Test Album Test Artist │",
787 "│ │",
788 "│s/S: change sort──────────────────────────────────────────│",
789 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
790 ]);
791
792 assert_buffer_eq(&buffer, &expected);
793 }
794
795 #[test]
796 fn test_sort_keys() {
797 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
798 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
799
800 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
801 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
802 assert_eq!(view.props.sort_mode, AlbumSort::ReleaseYear);
803 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
804 assert_eq!(view.props.sort_mode, AlbumSort::Title);
805 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
806 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
807 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
808 assert_eq!(view.props.sort_mode, AlbumSort::Title);
809 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
810 assert_eq!(view.props.sort_mode, AlbumSort::ReleaseYear);
811 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
812 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
813 }
814
815 #[test]
816 fn smoke_navigation() {
817 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
818 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
819
820 view.handle_key_event(KeyEvent::from(KeyCode::Up));
821 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
822 view.handle_key_event(KeyEvent::from(KeyCode::Down));
823 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
824 view.handle_key_event(KeyEvent::from(KeyCode::Left));
825 view.handle_key_event(KeyEvent::from(KeyCode::Right));
826 }
827
828 #[test]
829 fn test_actions() {
830 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
831 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
832
833 let (mut terminal, area) = setup_test_terminal(60, 9);
835 let props = RenderProps {
836 area,
837 is_focused: true,
838 };
839 terminal.draw(|frame| view.render(frame, props)).unwrap();
840
841 view.handle_key_event(KeyEvent::from(KeyCode::Down));
843
844 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
847 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
848 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
849 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
851 let action = rx.blocking_recv().unwrap();
852 assert_eq!(
853 action,
854 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
855 );
856
857 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
859
860 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
862 let action = rx.blocking_recv().unwrap();
863 assert_eq!(
864 action,
865 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
866 ("album", item_id()).into()
867 ])))
868 );
869
870 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
872 let action = rx.blocking_recv().unwrap();
873 assert_eq!(
874 action,
875 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
876 ("album", item_id()).into()
877 ],)))
878 );
879
880 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
882 let action = rx.blocking_recv().unwrap();
883 assert_eq!(
884 action,
885 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
886 ("album", item_id()).into()
887 ])))
888 );
889 }
890
891 #[test]
892 fn test_mouse() {
893 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
894 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
895
896 let (mut terminal, area) = setup_test_terminal(60, 6);
898 let props = RenderProps {
899 area,
900 is_focused: true,
901 };
902 let buffer = terminal
903 .draw(|frame| view.render(frame, props))
904 .unwrap()
905 .buffer
906 .clone();
907 let expected = Buffer::with_lines([
908 "┌Library Albums sorted by: Artist──────────────────────────┐",
909 "│──────────────────────────────────────────────────────────│",
910 "│☐ Test Album Test Artist │",
911 "│ │",
912 "│s/S: change sort──────────────────────────────────────────│",
913 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
914 ]);
915 assert_buffer_eq(&buffer, &expected);
916
917 view.handle_mouse_event(
919 MouseEvent {
920 kind: MouseEventKind::Down(MouseButton::Left),
921 column: 2,
922 row: 2,
923 modifiers: KeyModifiers::empty(),
924 },
925 area,
926 );
927 let buffer = terminal
928 .draw(|frame| view.render(frame, props))
929 .unwrap()
930 .buffer
931 .clone();
932 let expected = Buffer::with_lines([
933 "┌Library Albums sorted by: Artist──────────────────────────┐",
934 "│q: add to queue | r: start radio | p: add to playlist ────│",
935 "│☑ Test Album Test Artist │",
936 "│ │",
937 "│s/S: change sort──────────────────────────────────────────│",
938 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
939 ]);
940 assert_buffer_eq(&buffer, &expected);
941
942 view.handle_mouse_event(
944 MouseEvent {
945 kind: MouseEventKind::ScrollDown,
946 column: 2,
947 row: 2,
948 modifiers: KeyModifiers::empty(),
949 },
950 area,
951 );
952 let buffer = terminal
953 .draw(|frame| view.render(frame, props))
954 .unwrap()
955 .buffer
956 .clone();
957 assert_buffer_eq(&buffer, &expected);
958
959 view.handle_mouse_event(
961 MouseEvent {
962 kind: MouseEventKind::ScrollUp,
963 column: 2,
964 row: 2,
965 modifiers: KeyModifiers::empty(),
966 },
967 area,
968 );
969 let buffer = terminal
970 .draw(|frame| view.render(frame, props))
971 .unwrap()
972 .buffer
973 .clone();
974 assert_buffer_eq(&buffer, &expected);
975
976 view.handle_mouse_event(
978 MouseEvent {
979 kind: MouseEventKind::Down(MouseButton::Left),
980 column: 2,
981 row: 2,
982 modifiers: KeyModifiers::empty(),
983 },
984 area,
985 );
986 assert_eq!(
987 rx.blocking_recv().unwrap(),
988 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
989 );
990
991 let mouse = MouseEvent {
993 kind: MouseEventKind::Down(MouseButton::Left),
994 column: 2,
995 row: 3,
996 modifiers: KeyModifiers::empty(),
997 };
998 view.handle_mouse_event(mouse, area);
999 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1000 view.handle_mouse_event(mouse, area);
1001 assert_eq!(
1002 rx.try_recv(),
1003 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1004 );
1005 }
1006}