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