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