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