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