1use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use mecomp_storage::db::schemas::song::SongBrief;
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 SongViewProps, checktree_utils::create_song_tree_leaf, generic::ItemView, sort_mode::SongSort,
30 traits::SortMode,
31};
32
33#[allow(clippy::module_name_repetitions)]
34pub type SongView = ItemView<SongViewProps>;
35
36pub struct LibrarySongsView {
37 pub action_tx: UnboundedSender<Action>,
39 pub(crate) props: Props,
41 tree_state: Mutex<CheckTreeState<String>>,
43}
44
45pub(crate) struct Props {
46 pub(crate) songs: Box<[SongBrief]>,
47 pub(crate) sort_mode: SongSort,
48}
49
50impl Component for LibrarySongsView {
51 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
52 where
53 Self: Sized,
54 {
55 let sort_mode = SongSort::default();
56 let mut songs = state.library.songs.clone();
57 sort_mode.sort_items(&mut songs);
58 Self {
59 action_tx,
60 props: Props { songs, sort_mode },
61 tree_state: Mutex::new(CheckTreeState::default()),
62 }
63 }
64
65 fn move_with_state(self, state: &AppState) -> Self
66 where
67 Self: Sized,
68 {
69 let mut songs = state.library.songs.clone();
70 self.props.sort_mode.sort_items(&mut songs);
71 let tree_state = if state.active_view == ActiveView::Songs {
72 self.tree_state
73 } else {
74 Mutex::default()
75 };
76
77 Self {
78 props: Props {
79 songs,
80 ..self.props
81 },
82 tree_state,
83 ..self
84 }
85 }
86
87 fn name(&self) -> &'static str {
88 "Library Songs View"
89 }
90
91 fn handle_key_event(&mut self, key: KeyEvent) {
92 match key.code {
93 KeyCode::PageUp => {
95 self.tree_state.lock().unwrap().select_relative(|current| {
96 current.map_or(self.props.songs.len() - 1, |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.songs);
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.songs);
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 LibrarySongsView {
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 Songs".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::new()
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 frame.render_widget(&border, content_area);
227 let content_area = border.inner(content_area);
228
229 RenderProps {
230 area: content_area,
231 is_focused: props.is_focused,
232 }
233 }
234
235 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
236 let items = self
238 .props
239 .songs
240 .iter()
241 .map(create_song_tree_leaf)
242 .collect::<Vec<_>>();
243
244 frame.render_stateful_widget(
246 CheckTree::new(&items)
247 .unwrap()
248 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
249 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
250 props.area,
251 &mut self.tree_state.lock().unwrap(),
252 );
253 }
254}
255
256#[cfg(test)]
257mod sort_mode_tests {
258 use super::*;
259 use mecomp_storage::db::schemas::song::Song;
260 use pretty_assertions::assert_eq;
261 use rstest::rstest;
262 use std::time::Duration;
263
264 #[rstest]
265 #[case(SongSort::Title, SongSort::Artist)]
266 #[case(SongSort::Artist, SongSort::Album)]
267 #[case(SongSort::Album, SongSort::AlbumArtist)]
268 #[case(SongSort::AlbumArtist, SongSort::Genre)]
269 #[case(SongSort::Genre, SongSort::Title)]
270 fn test_sort_mode_next_prev(#[case] mode: SongSort, #[case] expected: SongSort) {
271 assert_eq!(mode.next(), expected);
272 assert_eq!(mode.next().prev(), mode);
273 }
274
275 #[rstest]
276 #[case(SongSort::Title, "Title")]
277 #[case(SongSort::Artist, "Artist")]
278 #[case(SongSort::Album, "Album")]
279 #[case(SongSort::AlbumArtist, "Album Artist")]
280 #[case(SongSort::Genre, "Genre")]
281 fn test_sort_mode_display(#[case] mode: SongSort, #[case] expected: &str) {
282 assert_eq!(mode.to_string(), expected);
283 }
284
285 #[rstest]
286 fn test_sort_songs() {
287 let mut songs = vec![
288 SongBrief {
289 id: Song::generate_id(),
290 title: "C".into(),
291 artist: "B".to_string().into(),
292 album: "A".into(),
293 album_artist: "C".to_string().into(),
294 genre: "B".to_string().into(),
295 runtime: Duration::from_secs(180),
296 track: Some(1),
297 disc: Some(1),
298 release_year: Some(2021),
299 extension: "mp3".into(),
300 path: "test.mp3".into(),
301 },
302 SongBrief {
303 id: Song::generate_id(),
304 title: "B".into(),
305 artist: "A".to_string().into(),
306 album: "C".into(),
307 album_artist: "B".to_string().into(),
308 genre: "A".to_string().into(),
309 runtime: Duration::from_secs(180),
310 track: Some(1),
311 disc: Some(1),
312 release_year: Some(2021),
313 extension: "mp3".into(),
314 path: "test.mp3".into(),
315 },
316 SongBrief {
317 id: Song::generate_id(),
318 title: "A".into(),
319 artist: "C".to_string().into(),
320 album: "B".into(),
321 album_artist: "A".to_string().into(),
322 genre: "C".to_string().into(),
323 runtime: Duration::from_secs(180),
324 track: Some(1),
325 disc: Some(1),
326 release_year: Some(2021),
327 extension: "mp3".into(),
328 path: "test.mp3".into(),
329 },
330 ];
331
332 SongSort::Title.sort_items(&mut songs);
333 assert_eq!(songs[0].title, "A");
334 assert_eq!(songs[1].title, "B");
335 assert_eq!(songs[2].title, "C");
336
337 SongSort::Artist.sort_items(&mut songs);
338 assert_eq!(songs[0].artist, "A".to_string().into());
339 assert_eq!(songs[1].artist, "B".to_string().into());
340 assert_eq!(songs[2].artist, "C".to_string().into());
341
342 SongSort::Album.sort_items(&mut songs);
343 assert_eq!(songs[0].album, "A");
344 assert_eq!(songs[1].album, "B");
345 assert_eq!(songs[2].album, "C");
346
347 SongSort::AlbumArtist.sort_items(&mut songs);
348 assert_eq!(songs[0].album_artist, "A".to_string().into());
349 assert_eq!(songs[1].album_artist, "B".to_string().into());
350 assert_eq!(songs[2].album_artist, "C".to_string().into());
351
352 SongSort::Genre.sort_items(&mut songs);
353 assert_eq!(songs[0].genre, "A".to_string().into());
354 assert_eq!(songs[1].genre, "B".to_string().into());
355 assert_eq!(songs[2].genre, "C".to_string().into());
356 }
357}
358
359#[cfg(test)]
360mod item_view_tests {
361 use super::*;
362 use crate::test_utils::{
363 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
364 };
365 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
366 use pretty_assertions::assert_eq;
367 use ratatui::buffer::Buffer;
368
369 #[test]
370 fn test_new() {
371 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
372 let state = state_with_everything();
373 let view = SongView::new(&state, tx);
374
375 assert_eq!(view.name(), "Song View");
376 assert_eq!(view.props, Some(state.additional_view_data.song.unwrap()));
377 }
378
379 #[test]
380 fn test_move_with_state() {
381 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
382 let state = AppState::default();
383 let new_state = state_with_everything();
384 let view = SongView::new(&state, tx).move_with_state(&new_state);
385
386 assert_eq!(
387 view.props,
388 Some(new_state.additional_view_data.song.unwrap())
389 );
390 }
391
392 #[test]
393 fn test_render_no_song() {
394 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
395 let view = SongView::new(&AppState::default(), tx);
396
397 let (mut terminal, area) = setup_test_terminal(16, 3);
398 let props = RenderProps {
399 area,
400 is_focused: true,
401 };
402 let buffer = terminal
403 .draw(|frame| view.render(frame, props))
404 .unwrap()
405 .buffer
406 .clone();
407 #[rustfmt::skip]
408 let expected = Buffer::with_lines([
409 "┌Song View─────┐",
410 "│No active song│",
411 "└──────────────┘",
412 ]);
413
414 assert_buffer_eq(&buffer, &expected);
415 }
416
417 #[test]
418 #[allow(clippy::too_many_lines)]
419 fn test_render_no_playlist_no_collection() {
420 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
421 let mut state = state_with_everything();
422 state.additional_view_data.song.as_mut().unwrap().playlists = [].into();
423 state
424 .additional_view_data
425 .song
426 .as_mut()
427 .unwrap()
428 .collections = [].into();
429 let mut view = SongView::new(&state, tx);
430
431 let (mut terminal, area) = setup_test_terminal(60, 12);
432 let props = RenderProps {
433 area,
434 is_focused: true,
435 };
436 let buffer = terminal
437 .draw(|frame| view.render(frame, props))
438 .unwrap()
439 .buffer
440 .clone();
441 let expected = Buffer::with_lines([
442 "┌Song View─────────────────────────────────────────────────┐",
443 "│ Test Song Test Artist │",
444 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
445 "│ │",
446 "│q: add to queue | r: start radio | p: add to playlist─────│",
447 "│Performing operations on the song─────────────────────────│",
448 "│▶ Artists (1): │",
449 "│☐ Album: Test Album Test Artist │",
450 "│▶ Playlists (0): │",
451 "│▶ Collections (0): │",
452 "│ │",
453 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
454 ]);
455
456 assert_buffer_eq(&buffer, &expected);
457 assert!(view.tree_state.lock().unwrap().selected().is_empty());
458
459 view.handle_key_event(KeyEvent::from(KeyCode::Down));
460 assert_eq!(view.tree_state.lock().unwrap().selected(), &["Artists"]);
461 view.handle_key_event(KeyEvent::from(KeyCode::Down));
462 assert_eq!(
463 view.tree_state.lock().unwrap().selected(),
464 &[state.library.albums[0].id.to_string()]
465 );
466 view.handle_key_event(KeyEvent::from(KeyCode::Down));
467 assert_eq!(view.tree_state.lock().unwrap().selected(), &["Playlists"]);
468 view.handle_key_event(KeyEvent::from(KeyCode::Right));
469
470 let buffer = terminal
471 .draw(|frame| view.render(frame, props))
472 .unwrap()
473 .buffer
474 .clone();
475 let expected = Buffer::with_lines([
476 "┌Song View─────────────────────────────────────────────────┐",
477 "│ Test Song Test Artist │",
478 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
479 "│ │",
480 "│q: add to queue | r: start radio | p: add to playlist─────│",
481 "│Performing operations on the song─────────────────────────│",
482 "│▶ Artists (1): │",
483 "│☐ Album: Test Album Test Artist │",
484 "│▼ Playlists (0): │",
485 "│ │",
486 "│▶ Collections (0): │",
487 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
488 ]);
489 assert_buffer_eq(&buffer, &expected);
490
491 view.handle_key_event(KeyEvent::from(KeyCode::Left));
492 let buffer = terminal
493 .draw(|frame| view.render(frame, props))
494 .unwrap()
495 .buffer
496 .clone();
497 let expected = Buffer::with_lines([
498 "┌Song View─────────────────────────────────────────────────┐",
499 "│ Test Song Test Artist │",
500 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
501 "│ │",
502 "│q: add to queue | r: start radio | p: add to playlist─────│",
503 "│Performing operations on the song─────────────────────────│",
504 "│▶ Artists (1): │",
505 "│☐ Album: Test Album Test Artist │",
506 "│▶ Playlists (0): │",
507 "│▶ Collections (0): │",
508 "│ │",
509 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
510 ]);
511 assert_buffer_eq(&buffer, &expected);
512
513 view.handle_key_event(KeyEvent::from(KeyCode::Down));
514 assert_eq!(view.tree_state.lock().unwrap().selected(), &["Collections"]);
515 view.handle_key_event(KeyEvent::from(KeyCode::Right));
516
517 let buffer = terminal
518 .draw(|frame| view.render(frame, props))
519 .unwrap()
520 .buffer
521 .clone();
522 let expected = Buffer::with_lines([
523 "┌Song View─────────────────────────────────────────────────┐",
524 "│ Test Song Test Artist │",
525 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
526 "│ │",
527 "│q: add to queue | r: start radio | p: add to playlist─────│",
528 "│Performing operations on the song─────────────────────────│",
529 "│▶ Artists (1): │",
530 "│☐ Album: Test Album Test Artist │",
531 "│▶ Playlists (0): │",
532 "│▼ Collections (0): │",
533 "│ │",
534 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
535 ]);
536 assert_buffer_eq(&buffer, &expected);
537 }
538
539 #[test]
540 fn test_render() {
541 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
542 let view = SongView::new(&state_with_everything(), tx);
543
544 let (mut terminal, area) = setup_test_terminal(60, 12);
545 let props = RenderProps {
546 area,
547 is_focused: true,
548 };
549 let buffer = terminal
550 .draw(|frame| view.render(frame, props))
551 .unwrap()
552 .buffer
553 .clone();
554 let expected = Buffer::with_lines([
555 "┌Song View─────────────────────────────────────────────────┐",
556 "│ Test Song Test Artist │",
557 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
558 "│ │",
559 "│q: add to queue | r: start radio | p: add to playlist─────│",
560 "│Performing operations on the song─────────────────────────│",
561 "│▶ Artists (1): │",
562 "│☐ Album: Test Album Test Artist │",
563 "│▶ Playlists (1): │",
564 "│▶ Collections (1): │",
565 "│ │",
566 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
567 ]);
568
569 assert_buffer_eq(&buffer, &expected);
570 }
571
572 #[test]
573 fn test_render_with_checked() {
574 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
575 let mut view = SongView::new(&state_with_everything(), tx);
576 let (mut terminal, area) = setup_test_terminal(60, 9);
577 let props = RenderProps {
578 area,
579 is_focused: true,
580 };
581 let buffer = terminal
582 .draw(|frame| view.render(frame, props))
583 .unwrap()
584 .buffer
585 .clone();
586 let expected = Buffer::with_lines([
587 "┌Song View─────────────────────────────────────────────────┐",
588 "│ Test Song Test Artist │",
589 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
590 "│ │",
591 "│q: add to queue | r: start radio | p: add to playlist─────│",
592 "│Performing operations on the song─────────────────────────│",
593 "│▶ Artists (1): │",
594 "│☐ Album: Test Album Test Artist │",
595 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
596 ]);
597 assert_buffer_eq(&buffer, &expected);
598
599 view.handle_key_event(KeyEvent::from(KeyCode::Down));
601 view.handle_key_event(KeyEvent::from(KeyCode::Down));
602 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
603
604 let buffer = terminal
605 .draw(|frame| view.render(frame, props))
606 .unwrap()
607 .buffer
608 .clone();
609 let expected = Buffer::with_lines([
610 "┌Song View─────────────────────────────────────────────────┐",
611 "│ Test Song Test Artist │",
612 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
613 "│ │",
614 "│q: add to queue | r: start radio | p: add to playlist─────│",
615 "│Performing operations on checked items────────────────────│",
616 "│▶ Artists (1): │",
617 "│☑ Album: Test Album Test Artist │",
618 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
619 ]);
620
621 assert_buffer_eq(&buffer, &expected);
622 }
623
624 #[test]
625 fn smoke_navigation() {
626 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
627 let mut view = SongView::new(&state_with_everything(), tx);
628
629 view.handle_key_event(KeyEvent::from(KeyCode::Up));
630 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
631 view.handle_key_event(KeyEvent::from(KeyCode::Down));
632 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
633 view.handle_key_event(KeyEvent::from(KeyCode::Left));
634 view.handle_key_event(KeyEvent::from(KeyCode::Right));
635 }
636
637 #[test]
638 fn test_actions() {
639 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
640 let mut view = SongView::new(&state_with_everything(), tx);
641
642 let (mut terminal, area) = setup_test_terminal(60, 9);
644 let props = RenderProps {
645 area,
646 is_focused: true,
647 };
648 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
649
650 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
653 assert_eq!(
654 rx.blocking_recv().unwrap(),
655 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
656 ("song", item_id()).into()
657 ])))
658 );
659 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
660 assert_eq!(
661 rx.blocking_recv().unwrap(),
662 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
663 ("song", item_id()).into()
664 ],)))
665 );
666 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
667 assert_eq!(
668 rx.blocking_recv().unwrap(),
669 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
670 ("song", item_id()).into()
671 ])))
672 );
673
674 view.handle_key_event(KeyEvent::from(KeyCode::Down));
677 view.handle_key_event(KeyEvent::from(KeyCode::Down));
678 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
679
680 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
682 assert_eq!(
683 rx.blocking_recv().unwrap(),
684 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
685 );
686
687 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
689
690 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
692 assert_eq!(
693 rx.blocking_recv().unwrap(),
694 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
695 ("album", item_id()).into()
696 ])))
697 );
698
699 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
701 assert_eq!(
702 rx.blocking_recv().unwrap(),
703 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
704 ("album", item_id()).into()
705 ],)))
706 );
707
708 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
710 assert_eq!(
711 rx.blocking_recv().unwrap(),
712 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
713 ("album", item_id()).into()
714 ])))
715 );
716 }
717
718 #[test]
719 #[allow(clippy::too_many_lines)]
720 fn test_mouse_event() {
721 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
722 let mut view = SongView::new(&state_with_everything(), tx);
723
724 let (mut terminal, area) = setup_test_terminal(60, 9);
726 let props = RenderProps {
727 area,
728 is_focused: true,
729 };
730 let buffer = terminal
731 .draw(|frame| view.render(frame, props))
732 .unwrap()
733 .buffer
734 .clone();
735 let expected = Buffer::with_lines([
736 "┌Song View─────────────────────────────────────────────────┐",
737 "│ Test Song Test Artist │",
738 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
739 "│ │",
740 "│q: add to queue | r: start radio | p: add to playlist─────│",
741 "│Performing operations on the song─────────────────────────│",
742 "│▶ Artists (1): │",
743 "│☐ Album: Test Album Test Artist │",
744 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
745 ]);
746 assert_buffer_eq(&buffer, &expected);
747
748 view.handle_mouse_event(
750 MouseEvent {
751 kind: MouseEventKind::Down(MouseButton::Left),
752 column: 2,
753 row: 6,
754 modifiers: KeyModifiers::empty(),
755 },
756 area,
757 );
758 let buffer = terminal
759 .draw(|frame| view.render(frame, props))
760 .unwrap()
761 .buffer
762 .clone();
763 let expected = Buffer::with_lines([
764 "┌Song View─────────────────────────────────────────────────┐",
765 "│ Test Song Test Artist │",
766 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
767 "│ │",
768 "│q: add to queue | r: start radio | p: add to playlist─────│",
769 "│Performing operations on the song─────────────────────────│",
770 "│▼ Artists (1): │",
771 "│ ☐ Test Artist │",
772 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
773 ]);
774 assert_buffer_eq(&buffer, &expected);
775
776 view.handle_mouse_event(
778 MouseEvent {
779 kind: MouseEventKind::ScrollDown,
780 column: 2,
781 row: 6,
782 modifiers: KeyModifiers::empty(),
783 },
784 area,
785 );
786 let buffer = terminal
787 .draw(|frame| view.render(frame, props))
788 .unwrap()
789 .buffer
790 .clone();
791 assert_buffer_eq(&buffer, &expected);
792
793 view.handle_mouse_event(
795 MouseEvent {
796 kind: MouseEventKind::Down(MouseButton::Left),
797 column: 2,
798 row: 7,
799 modifiers: KeyModifiers::empty(),
800 },
801 area,
802 );
803 let buffer = terminal
804 .draw(|frame| view.render(frame, props))
805 .unwrap()
806 .buffer
807 .clone();
808 let expected = Buffer::with_lines([
809 "┌Song View─────────────────────────────────────────────────┐",
810 "│ Test Song Test Artist │",
811 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
812 "│ │",
813 "│q: add to queue | r: start radio | p: add to playlist─────│",
814 "│Performing operations on checked items────────────────────│",
815 "│▼ Artists (1): │",
816 "│ ☑ Test Artist │",
817 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
818 ]);
819 assert_buffer_eq(&buffer, &expected);
820 for _ in 0..2 {
822 view.handle_mouse_event(
823 MouseEvent {
824 kind: MouseEventKind::Down(MouseButton::Left),
825 column: 2,
826 row: 7,
827 modifiers: KeyModifiers::CONTROL,
828 },
829 area,
830 );
831 assert_eq!(
832 rx.blocking_recv().unwrap(),
833 Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
834 );
835 }
836
837 view.handle_mouse_event(
839 MouseEvent {
840 kind: MouseEventKind::ScrollUp,
841 column: 2,
842 row: 7,
843 modifiers: KeyModifiers::empty(),
844 },
845 area,
846 );
847 let buffer = terminal
848 .draw(|frame| view.render(frame, props))
849 .unwrap()
850 .buffer
851 .clone();
852 assert_buffer_eq(&buffer, &expected);
853 }
854}
855
856#[cfg(test)]
857mod library_view_tests {
858 use super::*;
859 use crate::test_utils::{
860 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
861 };
862
863 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
864 use pretty_assertions::assert_eq;
865 use ratatui::buffer::Buffer;
866
867 #[test]
868 fn test_new() {
869 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
870 let state = state_with_everything();
871 let view = LibrarySongsView::new(&state, tx);
872
873 assert_eq!(view.name(), "Library Songs View");
874 assert_eq!(view.props.songs, state.library.songs);
875 }
876
877 #[test]
878 fn test_move_with_state() {
879 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
880 let state = AppState::default();
881 let new_state = state_with_everything();
882 let view = LibrarySongsView::new(&state, tx).move_with_state(&new_state);
883
884 assert_eq!(view.props.songs, new_state.library.songs);
885 }
886
887 #[test]
888 fn test_render() {
889 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
890 let view = LibrarySongsView::new(&state_with_everything(), tx);
891
892 let (mut terminal, area) = setup_test_terminal(60, 6);
893 let props = RenderProps {
894 area,
895 is_focused: true,
896 };
897 let buffer = terminal
898 .draw(|frame| view.render(frame, props))
899 .unwrap()
900 .buffer
901 .clone();
902 let expected = Buffer::with_lines([
903 "┌Library Songs sorted by: Artist───────────────────────────┐",
904 "│──────────────────────────────────────────────────────────│",
905 "│☐ Test Song Test Artist │",
906 "│ │",
907 "│s/S: change sort──────────────────────────────────────────│",
908 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
909 ]);
910
911 assert_buffer_eq(&buffer, &expected);
912 }
913
914 #[test]
915 fn test_render_with_checked() {
916 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
917 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
918 let (mut terminal, area) = setup_test_terminal(60, 6);
919 let props = RenderProps {
920 area,
921 is_focused: true,
922 };
923 let buffer = terminal
924 .draw(|frame| view.render(frame, props))
925 .unwrap()
926 .buffer
927 .clone();
928 let expected = Buffer::with_lines([
929 "┌Library Songs sorted by: Artist───────────────────────────┐",
930 "│──────────────────────────────────────────────────────────│",
931 "│☐ Test Song Test Artist │",
932 "│ │",
933 "│s/S: change sort──────────────────────────────────────────│",
934 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
935 ]);
936 assert_buffer_eq(&buffer, &expected);
937
938 view.handle_key_event(KeyEvent::from(KeyCode::Down));
940 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
941
942 let buffer = terminal
943 .draw(|frame| view.render(frame, props))
944 .unwrap()
945 .buffer
946 .clone();
947 let expected = Buffer::with_lines([
948 "┌Library Songs sorted by: Artist───────────────────────────┐",
949 "│q: add to queue | r: start radio | p: add to playlist ────│",
950 "│☑ Test Song Test Artist │",
951 "│ │",
952 "│s/S: change sort──────────────────────────────────────────│",
953 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
954 ]);
955
956 assert_buffer_eq(&buffer, &expected);
957 }
958
959 #[test]
960 fn test_sort_keys() {
961 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
962 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
963
964 assert_eq!(view.props.sort_mode, SongSort::Artist);
965 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
966 assert_eq!(view.props.sort_mode, SongSort::Album);
967 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
968 assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
969 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
970 assert_eq!(view.props.sort_mode, SongSort::Genre);
971 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
972 assert_eq!(view.props.sort_mode, SongSort::Title);
973 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
974 assert_eq!(view.props.sort_mode, SongSort::Artist);
975 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
976 assert_eq!(view.props.sort_mode, SongSort::Title);
977 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
978 assert_eq!(view.props.sort_mode, SongSort::Genre);
979 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
980 assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
981 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
982 assert_eq!(view.props.sort_mode, SongSort::Album);
983 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
984 assert_eq!(view.props.sort_mode, SongSort::Artist);
985 }
986
987 #[test]
988 fn smoke_navigation() {
989 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
990 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
991
992 view.handle_key_event(KeyEvent::from(KeyCode::Up));
993 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
994 view.handle_key_event(KeyEvent::from(KeyCode::Down));
995 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
996 view.handle_key_event(KeyEvent::from(KeyCode::Left));
997 view.handle_key_event(KeyEvent::from(KeyCode::Right));
998 }
999
1000 #[test]
1001 fn test_actions() {
1002 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1003 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
1004
1005 let (mut terminal, area) = setup_test_terminal(60, 9);
1007 let props = RenderProps {
1008 area,
1009 is_focused: true,
1010 };
1011 terminal.draw(|frame| view.render(frame, props)).unwrap();
1012
1013 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1015
1016 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1019 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1020 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1021 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1023 let action = rx.blocking_recv().unwrap();
1024 assert_eq!(
1025 action,
1026 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1027 );
1028
1029 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
1031
1032 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1034 let action = rx.blocking_recv().unwrap();
1035 assert_eq!(
1036 action,
1037 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
1038 ("song", item_id()).into()
1039 ])))
1040 );
1041
1042 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1044 let action = rx.blocking_recv().unwrap();
1045 assert_eq!(
1046 action,
1047 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
1048 ("song", item_id()).into()
1049 ],)))
1050 );
1051
1052 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1054 let action = rx.blocking_recv().unwrap();
1055 assert_eq!(
1056 action,
1057 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
1058 ("song", item_id()).into()
1059 ])))
1060 );
1061 }
1062
1063 #[test]
1064 fn test_mouse() {
1065 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1066 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
1067
1068 let (mut terminal, area) = setup_test_terminal(60, 6);
1070 let props = RenderProps {
1071 area,
1072 is_focused: true,
1073 };
1074 let buffer = terminal
1075 .draw(|frame| view.render(frame, props))
1076 .unwrap()
1077 .buffer
1078 .clone();
1079 let expected = Buffer::with_lines([
1080 "┌Library Songs sorted by: Artist───────────────────────────┐",
1081 "│──────────────────────────────────────────────────────────│",
1082 "│☐ Test Song Test Artist │",
1083 "│ │",
1084 "│s/S: change sort──────────────────────────────────────────│",
1085 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1086 ]);
1087 assert_buffer_eq(&buffer, &expected);
1088
1089 view.handle_mouse_event(
1091 MouseEvent {
1092 kind: MouseEventKind::Down(MouseButton::Left),
1093 column: 2,
1094 row: 2,
1095 modifiers: KeyModifiers::empty(),
1096 },
1097 area,
1098 );
1099 let buffer = terminal
1100 .draw(|frame| view.render(frame, props))
1101 .unwrap()
1102 .buffer
1103 .clone();
1104 let expected = Buffer::with_lines([
1105 "┌Library Songs sorted by: Artist───────────────────────────┐",
1106 "│q: add to queue | r: start radio | p: add to playlist ────│",
1107 "│☑ Test Song Test Artist │",
1108 "│ │",
1109 "│s/S: change sort──────────────────────────────────────────│",
1110 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1111 ]);
1112 assert_buffer_eq(&buffer, &expected);
1113
1114 view.handle_mouse_event(
1116 MouseEvent {
1117 kind: MouseEventKind::ScrollDown,
1118 column: 2,
1119 row: 2,
1120 modifiers: KeyModifiers::empty(),
1121 },
1122 area,
1123 );
1124 let buffer = terminal
1125 .draw(|frame| view.render(frame, props))
1126 .unwrap()
1127 .buffer
1128 .clone();
1129 assert_buffer_eq(&buffer, &expected);
1130
1131 view.handle_mouse_event(
1133 MouseEvent {
1134 kind: MouseEventKind::ScrollUp,
1135 column: 2,
1136 row: 2,
1137 modifiers: KeyModifiers::empty(),
1138 },
1139 area,
1140 );
1141 let buffer = terminal
1142 .draw(|frame| view.render(frame, props))
1143 .unwrap()
1144 .buffer
1145 .clone();
1146 assert_buffer_eq(&buffer, &expected);
1147
1148 view.handle_mouse_event(
1150 MouseEvent {
1151 kind: MouseEventKind::Down(MouseButton::Left),
1152 column: 2,
1153 row: 2,
1154 modifiers: KeyModifiers::CONTROL,
1155 },
1156 area,
1157 );
1158 assert_eq!(
1159 rx.blocking_recv().unwrap(),
1160 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1161 );
1162
1163 let mouse = MouseEvent {
1165 kind: MouseEventKind::Down(MouseButton::Left),
1166 column: 2,
1167 row: 3,
1168 modifiers: KeyModifiers::empty(),
1169 };
1170 view.handle_mouse_event(mouse, area);
1171 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1172 view.handle_mouse_event(mouse, area);
1173 assert_eq!(
1174 rx.try_recv(),
1175 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1176 );
1177 }
1178}