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