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