1use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
3use mecomp_prost::AlbumBrief;
4use ratatui::{
5 layout::{Margin, Rect},
6 style::Style,
7 text::{Line, Span},
8 widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
9};
10use tokio::sync::mpsc::UnboundedSender;
11
12use crate::{
13 state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
14 ui::{
15 AppState,
16 colors::{TEXT_HIGHLIGHT, border_color},
17 components::{Component, ComponentRender, RenderProps, content_view::ActiveView},
18 widgets::{
19 popups::PopupType,
20 tree::{CheckTree, state::CheckTreeState},
21 },
22 },
23};
24
25use super::{
26 AlbumViewProps, checktree_utils::create_album_tree_leaf, generic::ItemView,
27 sort_mode::AlbumSort, traits::SortMode,
28};
29
30#[allow(clippy::module_name_repetitions)]
31pub type AlbumView = ItemView<AlbumViewProps>;
32
33pub struct LibraryAlbumsView {
34 pub action_tx: UnboundedSender<Action>,
36 props: Props,
38 tree_state: CheckTreeState<String>,
40}
41
42struct Props {
43 albums: Vec<AlbumBrief>,
44 sort_mode: AlbumSort,
45}
46impl Props {
47 fn new(state: &AppState, sort_mode: AlbumSort) -> Self {
48 let mut albums = state.library.albums.clone();
49 sort_mode.sort_items(&mut albums);
50 Self { albums, sort_mode }
51 }
52}
53
54impl Component for LibraryAlbumsView {
55 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
56 where
57 Self: Sized,
58 {
59 let sort_mode = AlbumSort::default();
60 Self {
61 action_tx,
62 props: Props::new(state, sort_mode),
63 tree_state: CheckTreeState::default(),
64 }
65 }
66
67 fn move_with_state(self, state: &AppState) -> Self
68 where
69 Self: Sized,
70 {
71 let tree_state = if state.active_view == ActiveView::Albums {
72 self.tree_state
73 } else {
74 CheckTreeState::default()
75 };
76
77 Self {
78 props: Props::new(state, self.props.sort_mode),
79 tree_state,
80 ..self
81 }
82 }
83
84 fn name(&self) -> &'static str {
85 "Library Albums View"
86 }
87
88 fn handle_key_event(&mut self, key: KeyEvent) {
89 match key.code {
90 KeyCode::PageUp => {
92 self.tree_state.select_relative(|current| {
93 let first = self.props.albums.len().saturating_sub(1);
94 current.map_or(first, |c| c.saturating_sub(10))
95 });
96 }
97 KeyCode::Up => {
98 self.tree_state.key_up();
99 }
100 KeyCode::PageDown => {
101 self.tree_state
102 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
103 }
104 KeyCode::Down => {
105 self.tree_state.key_down();
106 }
107 KeyCode::Left => {
108 self.tree_state.key_left();
109 }
110 KeyCode::Right => {
111 self.tree_state.key_right();
112 }
113 KeyCode::Char(' ') => {
114 self.tree_state.key_space();
115 }
116 KeyCode::Enter => {
118 if self.tree_state.toggle_selected() {
119 let things = self.tree_state.get_selected_thing();
120
121 if let Some(thing) = things {
122 self.action_tx
123 .send(Action::ActiveView(ViewAction::Set(thing.into())))
124 .unwrap();
125 }
126 }
127 }
128 KeyCode::Char('q') => {
130 let things = self.tree_state.get_checked_things();
131 if !things.is_empty() {
132 self.action_tx
133 .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
134 .unwrap();
135 }
136 }
137 KeyCode::Char('r') => {
139 let things = self.tree_state.get_checked_things();
140 if !things.is_empty() {
141 self.action_tx
142 .send(Action::ActiveView(ViewAction::Set(ActiveView::Radio(
143 things,
144 ))))
145 .unwrap();
146 }
147 }
148 KeyCode::Char('p') => {
150 let things = self.tree_state.get_checked_things();
151 if !things.is_empty() {
152 self.action_tx
153 .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
154 things,
155 ))))
156 .unwrap();
157 }
158 }
159 KeyCode::Char('s') => {
161 self.props.sort_mode = self.props.sort_mode.next();
162 self.props.sort_mode.sort_items(&mut self.props.albums);
163 self.tree_state.scroll_selected_into_view();
164 }
165 KeyCode::Char('S') => {
166 self.props.sort_mode = self.props.sort_mode.prev();
167 self.props.sort_mode.sort_items(&mut self.props.albums);
168 self.tree_state.scroll_selected_into_view();
169 }
170 _ => {}
171 }
172 }
173
174 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
175 let area = area.inner(Margin::new(1, 2));
177
178 let result = self.tree_state.handle_mouse_event(mouse, area, false);
179 if let Some(action) = result {
180 self.action_tx.send(action).unwrap();
181 }
182 }
183}
184
185impl ComponentRender<RenderProps> for LibraryAlbumsView {
186 fn render_border(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
187 let border_style = Style::default().fg(border_color(props.is_focused).into());
188
189 let border = Block::bordered()
191 .title_top(Line::from(vec![
192 Span::styled("Library Albums".to_string(), Style::default().bold()),
193 Span::raw(" sorted by: "),
194 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
195 ]))
196 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
197 .border_style(border_style);
198 let content_area = border.inner(props.area);
199 frame.render_widget(border, props.area);
200
201 let tree_checked_things_empty = self.tree_state.get_checked_things().is_empty();
203 let border_title_top = if tree_checked_things_empty {
204 ""
205 } else {
206 "q: add to queue | r: start radio | p: add to playlist "
207 };
208 let border = Block::default()
209 .borders(Borders::TOP | Borders::BOTTOM)
210 .title_top(border_title_top)
211 .title_bottom("s/S: change sort")
212 .border_style(border_style);
213 let area = border.inner(content_area);
214 frame.render_widget(border, content_area);
215
216 RenderProps { area, ..props }
217 }
218
219 fn render_content(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
220 let items = self
222 .props
223 .albums
224 .iter()
225 .map(|album| create_album_tree_leaf(album, None))
226 .collect::<Vec<_>>();
227
228 frame.render_stateful_widget(
230 CheckTree::new(&items)
231 .unwrap()
232 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
233 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
234 props.area,
235 &mut self.tree_state,
236 );
237 }
238}
239
240#[cfg(test)]
241mod sort_mode_tests {
242 use super::*;
243 use mecomp_prost::RecordId;
244 use pretty_assertions::assert_eq;
245 use rstest::rstest;
246
247 #[rstest]
248 #[case(AlbumSort::Title, AlbumSort::Artist)]
249 #[case(AlbumSort::Artist, AlbumSort::ReleaseYear)]
250 #[case(AlbumSort::ReleaseYear, AlbumSort::Title)]
251 fn test_sort_mode_next_prev(#[case] mode: AlbumSort, #[case] expected: AlbumSort) {
252 assert_eq!(mode.next(), expected);
253 assert_eq!(mode.next().prev(), mode);
254 }
255
256 #[rstest]
257 #[case(AlbumSort::Title, "Title")]
258 #[case(AlbumSort::Artist, "Artist")]
259 #[case(AlbumSort::ReleaseYear, "Year")]
260 fn test_sort_mode_display(#[case] mode: AlbumSort, #[case] expected: &str) {
261 assert_eq!(mode.to_string(), expected);
262 }
263
264 #[rstest]
265 fn test_sort_items() {
266 let mut albums = vec![
267 AlbumBrief {
268 id: RecordId::new("album", "1"),
269 title: "C".into(),
270 artists: vec!["B".to_string()],
271 release: Some(2021),
272 discs: 1,
273 genres: vec!["A".to_string()],
274 },
275 AlbumBrief {
276 id: RecordId::new("album", "2"),
277 title: "B".into(),
278 artists: vec!["A".to_string()],
279 release: Some(2022),
280 discs: 1,
281 genres: vec!["C".to_string()],
282 },
283 AlbumBrief {
284 id: RecordId::new("album", "3"),
285 title: "A".into(),
286 artists: vec!["C".to_string()],
287 release: Some(2023),
288 discs: 1,
289 genres: vec!["B".to_string()],
290 },
291 ];
292
293 AlbumSort::Title.sort_items(&mut albums);
294 assert_eq!(albums[0].title, "A");
295 assert_eq!(albums[1].title, "B");
296 assert_eq!(albums[2].title, "C");
297
298 AlbumSort::Artist.sort_items(&mut albums);
299 assert_eq!(albums[0].artists, vec!["A".to_string()]);
300 assert_eq!(albums[1].artists, vec!["B".to_string()]);
301 assert_eq!(albums[2].artists, vec!["C".to_string()]);
302
303 AlbumSort::ReleaseYear.sort_items(&mut albums);
304 assert_eq!(albums[0].release, Some(2023));
305 assert_eq!(albums[1].release, Some(2022));
306 assert_eq!(albums[2].release, Some(2021));
307 }
308}
309
310#[cfg(test)]
311mod item_view_tests {
312 use super::*;
313 use crate::test_utils::{
314 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
315 };
316 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
317 use pretty_assertions::assert_eq;
318 use ratatui::buffer::Buffer;
319
320 #[test]
321 fn test_new() {
322 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
323 let state = state_with_everything();
324 let view = AlbumView::new(&state, tx);
325
326 assert_eq!(view.name(), "Album View");
327 assert_eq!(view.props, Some(state.additional_view_data.album.unwrap()));
328 }
329
330 #[test]
331 fn test_move_with_state() {
332 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
333 let state = AppState::default();
334 let new_state = state_with_everything();
335 let view = AlbumView::new(&state, tx).move_with_state(&new_state);
336
337 assert_eq!(
338 view.props,
339 Some(new_state.additional_view_data.album.unwrap())
340 );
341 }
342
343 #[test]
344 fn test_render_no_album() {
345 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
346 let mut view = AlbumView::new(&AppState::default(), tx);
347
348 let (mut terminal, area) = setup_test_terminal(17, 3);
349 let props = RenderProps {
350 area,
351 is_focused: true,
352 };
353 let buffer = terminal
354 .draw(|frame| view.render(frame, props))
355 .unwrap()
356 .buffer
357 .clone();
358 #[rustfmt::skip]
359 let expected = Buffer::with_lines([
360 "┌Album View─────┐",
361 "│No active album│",
362 "└───────────────┘",
363 ]);
364
365 assert_buffer_eq(&buffer, &expected);
366 }
367
368 #[test]
369 fn test_render() {
370 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
371 let mut view = AlbumView::new(&state_with_everything(), tx);
372
373 let (mut terminal, area) = setup_test_terminal(60, 9);
374 let props = RenderProps {
375 area,
376 is_focused: true,
377 };
378 let buffer = terminal
379 .draw(|frame| view.render(frame, props))
380 .unwrap()
381 .buffer
382 .clone();
383 let expected = Buffer::with_lines([
384 "┌Album View────────────────────────────────────────────────┐",
385 "│ Test Album Test Artist │",
386 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
387 "│ │",
388 "│q: add to queue | r: start radio | p: add to playlist─────│",
389 "│Performing operations on entire album─────────────────────│",
390 "│▶ Artists (1): │",
391 "│▶ Songs (1): │",
392 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
393 ]);
394
395 assert_buffer_eq(&buffer, &expected);
396 }
397
398 #[test]
399 fn test_render_with_checked() {
400 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
401 let mut view = AlbumView::new(&state_with_everything(), tx);
402 let (mut terminal, area) = setup_test_terminal(60, 9);
403 let props = RenderProps {
404 area,
405 is_focused: true,
406 };
407 let buffer = terminal
408 .draw(|frame| view.render(frame, props))
409 .unwrap()
410 .buffer
411 .clone();
412 let expected = Buffer::with_lines([
413 "┌Album View────────────────────────────────────────────────┐",
414 "│ Test Album Test Artist │",
415 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
416 "│ │",
417 "│q: add to queue | r: start radio | p: add to playlist─────│",
418 "│Performing operations on entire album─────────────────────│",
419 "│▶ Artists (1): │",
420 "│▶ Songs (1): │",
421 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
422 ]);
423 assert_buffer_eq(&buffer, &expected);
424
425 view.handle_key_event(KeyEvent::from(KeyCode::Down));
427 view.handle_key_event(KeyEvent::from(KeyCode::Down));
428 view.handle_key_event(KeyEvent::from(KeyCode::Right));
429 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
430 view.handle_key_event(KeyEvent::from(KeyCode::Down));
431 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
432
433 let buffer = terminal
434 .draw(|frame| view.render(frame, props))
435 .unwrap()
436 .buffer
437 .clone();
438 let expected = Buffer::with_lines([
439 "┌Album View────────────────────────────────────────────────┐",
440 "│ Test Album Test Artist │",
441 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
442 "│ │",
443 "│q: add to queue | r: start radio | p: add to playlist─────│",
444 "│Performing operations on checked items────────────────────│",
445 "│▼ Songs (1): │",
446 "│ ☑ Test Song Test Artist │",
447 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
448 ]);
449
450 assert_buffer_eq(&buffer, &expected);
451 }
452
453 #[test]
454 fn smoke_navigation() {
455 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
456 let mut view = AlbumView::new(&state_with_everything(), tx);
457
458 view.handle_key_event(KeyEvent::from(KeyCode::Up));
459 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
460 view.handle_key_event(KeyEvent::from(KeyCode::Down));
461 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
462 view.handle_key_event(KeyEvent::from(KeyCode::Left));
463 view.handle_key_event(KeyEvent::from(KeyCode::Right));
464 }
465
466 #[test]
467 fn test_actions() {
468 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
469 let mut view = AlbumView::new(&state_with_everything(), tx);
470
471 let (mut terminal, area) = setup_test_terminal(60, 9);
473 let props = RenderProps {
474 area,
475 is_focused: true,
476 };
477 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
478
479 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
482 assert_eq!(
483 rx.blocking_recv().unwrap(),
484 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
485 ("album", item_id()).into()
486 ])))
487 );
488 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
489 assert_eq!(
490 rx.blocking_recv().unwrap(),
491 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
492 ("album", item_id()).into()
493 ],)))
494 );
495 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
496 assert_eq!(
497 rx.blocking_recv().unwrap(),
498 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
499 ("album", item_id()).into()
500 ])))
501 );
502
503 view.handle_key_event(KeyEvent::from(KeyCode::Down));
506 view.handle_key_event(KeyEvent::from(KeyCode::Down));
507 view.handle_key_event(KeyEvent::from(KeyCode::Right));
508 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
509 view.handle_key_event(KeyEvent::from(KeyCode::Down));
510
511 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
513 assert_eq!(
514 rx.blocking_recv().unwrap(),
515 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
516 );
517
518 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
520
521 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
523 assert_eq!(
524 rx.blocking_recv().unwrap(),
525 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
526 ("song", item_id()).into()
527 ])))
528 );
529
530 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
532 assert_eq!(
533 rx.blocking_recv().unwrap(),
534 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
535 ("song", item_id()).into()
536 ],)))
537 );
538
539 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
541 assert_eq!(
542 rx.blocking_recv().unwrap(),
543 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
544 ("song", item_id()).into()
545 ])))
546 );
547 }
548
549 #[test]
550 #[allow(clippy::too_many_lines)]
551 fn test_mouse() {
552 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
553 let mut view = AlbumView::new(&state_with_everything(), tx);
554
555 let (mut terminal, area) = setup_test_terminal(60, 9);
557 let props = RenderProps {
558 area,
559 is_focused: true,
560 };
561 let buffer = terminal
562 .draw(|frame| view.render(frame, props))
563 .unwrap()
564 .buffer
565 .clone();
566 let expected = Buffer::with_lines([
567 "┌Album View────────────────────────────────────────────────┐",
568 "│ Test Album Test Artist │",
569 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
570 "│ │",
571 "│q: add to queue | r: start radio | p: add to playlist─────│",
572 "│Performing operations on entire album─────────────────────│",
573 "│▶ Artists (1): │",
574 "│▶ Songs (1): │",
575 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
576 ]);
577 assert_buffer_eq(&buffer, &expected);
578
579 view.handle_mouse_event(
581 MouseEvent {
582 kind: MouseEventKind::Down(MouseButton::Left),
583 column: 2,
584 row: 6,
585 modifiers: KeyModifiers::empty(),
586 },
587 area,
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 "│ ☐ Test Artist │",
603 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
604 ]);
605 assert_buffer_eq(&buffer, &expected);
606
607 view.handle_mouse_event(
609 MouseEvent {
610 kind: MouseEventKind::ScrollDown,
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 assert_buffer_eq(&buffer, &expected);
623
624 view.handle_mouse_event(
626 MouseEvent {
627 kind: MouseEventKind::Down(MouseButton::Left),
628 column: 2,
629 row: 7,
630 modifiers: KeyModifiers::empty(),
631 },
632 area,
633 );
634 let buffer = terminal
635 .draw(|frame| view.render(frame, props))
636 .unwrap()
637 .buffer
638 .clone();
639 let expected = Buffer::with_lines([
640 "┌Album View────────────────────────────────────────────────┐",
641 "│ Test Album Test Artist │",
642 "│ Release Year: 2021 Songs: 1 Duration: 00:03:00.00 │",
643 "│ │",
644 "│q: add to queue | r: start radio | p: add to playlist─────│",
645 "│Performing operations on checked items────────────────────│",
646 "│▼ Artists (1): │",
647 "│ ☑ Test Artist │",
648 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
649 ]);
650 assert_buffer_eq(&buffer, &expected);
651 for _ in 0..2 {
653 view.handle_mouse_event(
654 MouseEvent {
655 kind: MouseEventKind::Down(MouseButton::Left),
656 column: 2,
657 row: 7,
658 modifiers: KeyModifiers::CONTROL,
659 },
660 area,
661 );
662 assert_eq!(
663 rx.blocking_recv().unwrap(),
664 Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
665 );
666 }
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 mut 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::CONTROL,
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.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}