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