1use std::{ops::Not, sync::Mutex};
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use mecomp_storage::db::schemas::album::AlbumBrief;
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<[AlbumBrief]>,
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 mecomp_storage::db::schemas::album::Album;
255 use one_or_many::OneOrMany;
256 use pretty_assertions::assert_eq;
257 use rstest::rstest;
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 AlbumBrief {
280 id: Album::generate_id(),
281 title: "C".into(),
282 artist: OneOrMany::One("B".into()),
283 release: Some(2021),
284 discs: 1,
285 genre: OneOrMany::One("A".into()),
286 },
287 AlbumBrief {
288 id: Album::generate_id(),
289 title: "B".into(),
290 artist: OneOrMany::One("A".into()),
291 release: Some(2022),
292 discs: 1,
293 genre: OneOrMany::One("C".into()),
294 },
295 AlbumBrief {
296 id: Album::generate_id(),
297 title: "A".into(),
298 artist: OneOrMany::One("C".into()),
299 release: Some(2023),
300 discs: 1,
301 genre: OneOrMany::One("B".into()),
302 },
303 ];
304
305 AlbumSort::Title.sort_items(&mut albums);
306 assert_eq!(albums[0].title, "A");
307 assert_eq!(albums[1].title, "B");
308 assert_eq!(albums[2].title, "C");
309
310 AlbumSort::Artist.sort_items(&mut albums);
311 assert_eq!(albums[0].artist, OneOrMany::One("A".into()));
312 assert_eq!(albums[1].artist, OneOrMany::One("B".into()));
313 assert_eq!(albums[2].artist, OneOrMany::One("C".into()));
314
315 AlbumSort::ReleaseYear.sort_items(&mut albums);
316 assert_eq!(albums[0].release, Some(2023));
317 assert_eq!(albums[1].release, Some(2022));
318 assert_eq!(albums[2].release, Some(2021));
319 }
320}
321
322#[cfg(test)]
323mod item_view_tests {
324 use super::*;
325 use crate::test_utils::{
326 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
327 };
328 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
329 use pretty_assertions::assert_eq;
330 use ratatui::buffer::Buffer;
331
332 #[test]
333 fn test_new() {
334 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
335 let state = state_with_everything();
336 let view = AlbumView::new(&state, tx);
337
338 assert_eq!(view.name(), "Album View");
339 assert_eq!(view.props, Some(state.additional_view_data.album.unwrap()));
340 }
341
342 #[test]
343 fn test_move_with_state() {
344 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
345 let state = AppState::default();
346 let new_state = state_with_everything();
347 let view = AlbumView::new(&state, tx).move_with_state(&new_state);
348
349 assert_eq!(
350 view.props,
351 Some(new_state.additional_view_data.album.unwrap())
352 );
353 }
354
355 #[test]
356 fn test_render_no_album() {
357 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
358 let view = AlbumView::new(&AppState::default(), tx);
359
360 let (mut terminal, area) = setup_test_terminal(17, 3);
361 let props = RenderProps {
362 area,
363 is_focused: true,
364 };
365 let buffer = terminal
366 .draw(|frame| view.render(frame, props))
367 .unwrap()
368 .buffer
369 .clone();
370 #[rustfmt::skip]
371 let expected = Buffer::with_lines([
372 "┌Album View─────┐",
373 "│No active album│",
374 "└───────────────┘",
375 ]);
376
377 assert_buffer_eq(&buffer, &expected);
378 }
379
380 #[test]
381 fn test_render() {
382 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
383 let view = AlbumView::new(&state_with_everything(), tx);
384
385 let (mut terminal, area) = setup_test_terminal(60, 9);
386 let props = RenderProps {
387 area,
388 is_focused: true,
389 };
390 let buffer = terminal
391 .draw(|frame| view.render(frame, props))
392 .unwrap()
393 .buffer
394 .clone();
395 let expected = Buffer::with_lines([
396 "┌Album View────────────────────────────────────────────────┐",
397 "│ Test Album Test Artist │",
398 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
399 "│ │",
400 "│q: add to queue | r: start radio | p: add to playlist─────│",
401 "│Performing operations on entire album─────────────────────│",
402 "│▶ Artists (1): │",
403 "│▶ Songs (1): │",
404 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
405 ]);
406
407 assert_buffer_eq(&buffer, &expected);
408 }
409
410 #[test]
411 fn test_render_with_checked() {
412 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
413 let mut view = AlbumView::new(&state_with_everything(), tx);
414 let (mut terminal, area) = setup_test_terminal(60, 9);
415 let props = RenderProps {
416 area,
417 is_focused: true,
418 };
419 let buffer = terminal
420 .draw(|frame| view.render(frame, props))
421 .unwrap()
422 .buffer
423 .clone();
424 let expected = Buffer::with_lines([
425 "┌Album View────────────────────────────────────────────────┐",
426 "│ Test Album Test Artist │",
427 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
428 "│ │",
429 "│q: add to queue | r: start radio | p: add to playlist─────│",
430 "│Performing operations on entire album─────────────────────│",
431 "│▶ Artists (1): │",
432 "│▶ Songs (1): │",
433 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
434 ]);
435 assert_buffer_eq(&buffer, &expected);
436
437 view.handle_key_event(KeyEvent::from(KeyCode::Down));
439 view.handle_key_event(KeyEvent::from(KeyCode::Down));
440 view.handle_key_event(KeyEvent::from(KeyCode::Right));
441 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
442 view.handle_key_event(KeyEvent::from(KeyCode::Down));
443 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
444
445 let buffer = terminal
446 .draw(|frame| view.render(frame, props))
447 .unwrap()
448 .buffer
449 .clone();
450 let expected = Buffer::with_lines([
451 "┌Album View────────────────────────────────────────────────┐",
452 "│ Test Album Test Artist │",
453 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
454 "│ │",
455 "│q: add to queue | r: start radio | p: add to playlist─────│",
456 "│Performing operations on checked items────────────────────│",
457 "│▼ Songs (1): │",
458 "│ ☑ Test Song Test Artist │",
459 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
460 ]);
461
462 assert_buffer_eq(&buffer, &expected);
463 }
464
465 #[test]
466 fn smoke_navigation() {
467 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
468 let mut view = AlbumView::new(&state_with_everything(), tx);
469
470 view.handle_key_event(KeyEvent::from(KeyCode::Up));
471 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
472 view.handle_key_event(KeyEvent::from(KeyCode::Down));
473 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
474 view.handle_key_event(KeyEvent::from(KeyCode::Left));
475 view.handle_key_event(KeyEvent::from(KeyCode::Right));
476 }
477
478 #[test]
479 fn test_actions() {
480 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
481 let mut view = AlbumView::new(&state_with_everything(), tx);
482
483 let (mut terminal, area) = setup_test_terminal(60, 9);
485 let props = RenderProps {
486 area,
487 is_focused: true,
488 };
489 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
490
491 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
494 assert_eq!(
495 rx.blocking_recv().unwrap(),
496 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
497 ("album", item_id()).into()
498 ])))
499 );
500 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
501 assert_eq!(
502 rx.blocking_recv().unwrap(),
503 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
504 ("album", item_id()).into()
505 ],)))
506 );
507 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
508 assert_eq!(
509 rx.blocking_recv().unwrap(),
510 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
511 ("album", item_id()).into()
512 ])))
513 );
514
515 view.handle_key_event(KeyEvent::from(KeyCode::Down));
518 view.handle_key_event(KeyEvent::from(KeyCode::Down));
519 view.handle_key_event(KeyEvent::from(KeyCode::Right));
520 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
521 view.handle_key_event(KeyEvent::from(KeyCode::Down));
522
523 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
525 assert_eq!(
526 rx.blocking_recv().unwrap(),
527 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
528 );
529
530 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
532
533 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
535 assert_eq!(
536 rx.blocking_recv().unwrap(),
537 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
538 ("song", item_id()).into()
539 ])))
540 );
541
542 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
544 assert_eq!(
545 rx.blocking_recv().unwrap(),
546 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
547 ("song", item_id()).into()
548 ],)))
549 );
550
551 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
553 assert_eq!(
554 rx.blocking_recv().unwrap(),
555 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
556 ("song", item_id()).into()
557 ])))
558 );
559 }
560
561 #[test]
562 #[allow(clippy::too_many_lines)]
563 fn test_mouse() {
564 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
565 let mut view = AlbumView::new(&state_with_everything(), tx);
566
567 let (mut terminal, area) = setup_test_terminal(60, 9);
569 let props = RenderProps {
570 area,
571 is_focused: true,
572 };
573 let buffer = terminal
574 .draw(|frame| view.render(frame, props))
575 .unwrap()
576 .buffer
577 .clone();
578 let expected = Buffer::with_lines([
579 "┌Album View────────────────────────────────────────────────┐",
580 "│ Test Album Test Artist │",
581 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
582 "│ │",
583 "│q: add to queue | r: start radio | p: add to playlist─────│",
584 "│Performing operations on entire album─────────────────────│",
585 "│▶ Artists (1): │",
586 "│▶ Songs (1): │",
587 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
588 ]);
589 assert_buffer_eq(&buffer, &expected);
590
591 view.handle_mouse_event(
593 MouseEvent {
594 kind: MouseEventKind::Down(MouseButton::Left),
595 column: 2,
596 row: 6,
597 modifiers: KeyModifiers::empty(),
598 },
599 area,
600 );
601 let buffer = terminal
602 .draw(|frame| view.render(frame, props))
603 .unwrap()
604 .buffer
605 .clone();
606 let expected = Buffer::with_lines([
607 "┌Album View────────────────────────────────────────────────┐",
608 "│ Test Album Test Artist │",
609 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
610 "│ │",
611 "│q: add to queue | r: start radio | p: add to playlist─────│",
612 "│Performing operations on entire album─────────────────────│",
613 "│▼ Artists (1): │",
614 "│ ☐ Test Artist │",
615 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
616 ]);
617 assert_buffer_eq(&buffer, &expected);
618
619 view.handle_mouse_event(
621 MouseEvent {
622 kind: MouseEventKind::ScrollDown,
623 column: 2,
624 row: 6,
625 modifiers: KeyModifiers::empty(),
626 },
627 area,
628 );
629 let buffer = terminal
630 .draw(|frame| view.render(frame, props))
631 .unwrap()
632 .buffer
633 .clone();
634 assert_buffer_eq(&buffer, &expected);
635
636 view.handle_mouse_event(
638 MouseEvent {
639 kind: MouseEventKind::Down(MouseButton::Left),
640 column: 2,
641 row: 7,
642 modifiers: KeyModifiers::empty(),
643 },
644 area,
645 );
646 assert_eq!(
647 rx.blocking_recv().unwrap(),
648 Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
649 );
650 let buffer = terminal
651 .draw(|frame| view.render(frame, props))
652 .unwrap()
653 .buffer
654 .clone();
655 let expected = Buffer::with_lines([
656 "┌Album View────────────────────────────────────────────────┐",
657 "│ Test Album Test Artist │",
658 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
659 "│ │",
660 "│q: add to queue | r: start radio | p: add to playlist─────│",
661 "│Performing operations on checked items────────────────────│",
662 "│▼ Artists (1): │",
663 "│ ☑ Test Artist │",
664 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
665 ]);
666 assert_buffer_eq(&buffer, &expected);
667
668 view.handle_mouse_event(
670 MouseEvent {
671 kind: MouseEventKind::ScrollUp,
672 column: 2,
673 row: 7,
674 modifiers: KeyModifiers::empty(),
675 },
676 area,
677 );
678 let buffer = terminal
679 .draw(|frame| view.render(frame, props))
680 .unwrap()
681 .buffer
682 .clone();
683 assert_buffer_eq(&buffer, &expected);
684 }
685}
686
687#[cfg(test)]
688mod library_view_tests {
689 use super::*;
690 use crate::test_utils::{
691 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
692 };
693 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
694 use pretty_assertions::assert_eq;
695 use ratatui::buffer::Buffer;
696
697 #[test]
698 fn test_new() {
699 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
700 let state = state_with_everything();
701 let view = LibraryAlbumsView::new(&state, tx);
702
703 assert_eq!(view.name(), "Library Albums View");
704 assert_eq!(view.props.albums, state.library.albums);
705 }
706
707 #[test]
708 fn test_move_with_state() {
709 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
710 let state = AppState::default();
711 let new_state = state_with_everything();
712 let view = LibraryAlbumsView::new(&state, tx).move_with_state(&new_state);
713
714 assert_eq!(view.props.albums, new_state.library.albums);
715 }
716
717 #[test]
718 fn test_render() {
719 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
720 let view = LibraryAlbumsView::new(&state_with_everything(), tx);
721
722 let (mut terminal, area) = setup_test_terminal(60, 6);
723 let props = RenderProps {
724 area,
725 is_focused: true,
726 };
727 let buffer = terminal
728 .draw(|frame| view.render(frame, props))
729 .unwrap()
730 .buffer
731 .clone();
732 let expected = Buffer::with_lines([
733 "┌Library Albums sorted by: Artist──────────────────────────┐",
734 "│──────────────────────────────────────────────────────────│",
735 "│☐ Test Album Test Artist │",
736 "│ │",
737 "│s/S: change sort──────────────────────────────────────────│",
738 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
739 ]);
740
741 assert_buffer_eq(&buffer, &expected);
742 }
743
744 #[test]
745 fn test_render_with_checked() {
746 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
747 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
748 let (mut terminal, area) = setup_test_terminal(60, 6);
749 let props = RenderProps {
750 area,
751 is_focused: true,
752 };
753 let buffer = terminal
754 .draw(|frame| view.render(frame, props))
755 .unwrap()
756 .buffer
757 .clone();
758 let expected = Buffer::with_lines([
759 "┌Library Albums sorted by: Artist──────────────────────────┐",
760 "│──────────────────────────────────────────────────────────│",
761 "│☐ Test Album Test Artist │",
762 "│ │",
763 "│s/S: change sort──────────────────────────────────────────│",
764 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
765 ]);
766 assert_buffer_eq(&buffer, &expected);
767
768 view.handle_key_event(KeyEvent::from(KeyCode::Down));
770 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
771
772 let buffer = terminal
773 .draw(|frame| view.render(frame, props))
774 .unwrap()
775 .buffer
776 .clone();
777 let expected = Buffer::with_lines([
778 "┌Library Albums sorted by: Artist──────────────────────────┐",
779 "│q: add to queue | r: start radio | p: add to playlist ────│",
780 "│☑ Test Album Test Artist │",
781 "│ │",
782 "│s/S: change sort──────────────────────────────────────────│",
783 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
784 ]);
785
786 assert_buffer_eq(&buffer, &expected);
787 }
788
789 #[test]
790 fn test_sort_keys() {
791 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
792 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
793
794 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
795 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
796 assert_eq!(view.props.sort_mode, AlbumSort::ReleaseYear);
797 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
798 assert_eq!(view.props.sort_mode, AlbumSort::Title);
799 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
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::Title);
803 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
804 assert_eq!(view.props.sort_mode, AlbumSort::ReleaseYear);
805 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
806 assert_eq!(view.props.sort_mode, AlbumSort::Artist);
807 }
808
809 #[test]
810 fn smoke_navigation() {
811 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
812 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
813
814 view.handle_key_event(KeyEvent::from(KeyCode::Up));
815 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
816 view.handle_key_event(KeyEvent::from(KeyCode::Down));
817 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
818 view.handle_key_event(KeyEvent::from(KeyCode::Left));
819 view.handle_key_event(KeyEvent::from(KeyCode::Right));
820 }
821
822 #[test]
823 fn test_actions() {
824 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
825 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
826
827 let (mut terminal, area) = setup_test_terminal(60, 9);
829 let props = RenderProps {
830 area,
831 is_focused: true,
832 };
833 terminal.draw(|frame| view.render(frame, props)).unwrap();
834
835 view.handle_key_event(KeyEvent::from(KeyCode::Down));
837
838 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
841 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
842 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
843 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
845 let action = rx.blocking_recv().unwrap();
846 assert_eq!(
847 action,
848 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
849 );
850
851 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
853
854 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
856 let action = rx.blocking_recv().unwrap();
857 assert_eq!(
858 action,
859 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
860 ("album", item_id()).into()
861 ])))
862 );
863
864 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
866 let action = rx.blocking_recv().unwrap();
867 assert_eq!(
868 action,
869 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
870 ("album", item_id()).into()
871 ],)))
872 );
873
874 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
876 let action = rx.blocking_recv().unwrap();
877 assert_eq!(
878 action,
879 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
880 ("album", item_id()).into()
881 ])))
882 );
883 }
884
885 #[test]
886 fn test_mouse() {
887 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
888 let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
889
890 let (mut terminal, area) = setup_test_terminal(60, 6);
892 let props = RenderProps {
893 area,
894 is_focused: true,
895 };
896 let buffer = terminal
897 .draw(|frame| view.render(frame, props))
898 .unwrap()
899 .buffer
900 .clone();
901 let expected = Buffer::with_lines([
902 "┌Library Albums sorted by: Artist──────────────────────────┐",
903 "│──────────────────────────────────────────────────────────│",
904 "│☐ Test Album Test Artist │",
905 "│ │",
906 "│s/S: change sort──────────────────────────────────────────│",
907 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
908 ]);
909 assert_buffer_eq(&buffer, &expected);
910
911 view.handle_mouse_event(
913 MouseEvent {
914 kind: MouseEventKind::Down(MouseButton::Left),
915 column: 2,
916 row: 2,
917 modifiers: KeyModifiers::empty(),
918 },
919 area,
920 );
921 let buffer = terminal
922 .draw(|frame| view.render(frame, props))
923 .unwrap()
924 .buffer
925 .clone();
926 let expected = Buffer::with_lines([
927 "┌Library Albums sorted by: Artist──────────────────────────┐",
928 "│q: add to queue | r: start radio | p: add to playlist ────│",
929 "│☑ Test Album Test Artist │",
930 "│ │",
931 "│s/S: change sort──────────────────────────────────────────│",
932 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
933 ]);
934 assert_buffer_eq(&buffer, &expected);
935
936 view.handle_mouse_event(
938 MouseEvent {
939 kind: MouseEventKind::ScrollDown,
940 column: 2,
941 row: 2,
942 modifiers: KeyModifiers::empty(),
943 },
944 area,
945 );
946 let buffer = terminal
947 .draw(|frame| view.render(frame, props))
948 .unwrap()
949 .buffer
950 .clone();
951 assert_buffer_eq(&buffer, &expected);
952
953 view.handle_mouse_event(
955 MouseEvent {
956 kind: MouseEventKind::ScrollUp,
957 column: 2,
958 row: 2,
959 modifiers: KeyModifiers::empty(),
960 },
961 area,
962 );
963 let buffer = terminal
964 .draw(|frame| view.render(frame, props))
965 .unwrap()
966 .buffer
967 .clone();
968 assert_buffer_eq(&buffer, &expected);
969
970 view.handle_mouse_event(
972 MouseEvent {
973 kind: MouseEventKind::Down(MouseButton::Left),
974 column: 2,
975 row: 2,
976 modifiers: KeyModifiers::empty(),
977 },
978 area,
979 );
980 assert_eq!(
981 rx.blocking_recv().unwrap(),
982 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
983 );
984
985 let mouse = MouseEvent {
987 kind: MouseEventKind::Down(MouseButton::Left),
988 column: 2,
989 row: 3,
990 modifiers: KeyModifiers::empty(),
991 };
992 view.handle_mouse_event(mouse, area);
993 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
994 view.handle_mouse_event(mouse, area);
995 assert_eq!(
996 rx.try_recv(),
997 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
998 );
999 }
1000}