1use std::{ops::Not, sync::Mutex};
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use mecomp_storage::db::schemas::song::Song;
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<[Song]>,
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 = (state.active_view == ActiveView::Songs)
72 .then_some(self.tree_state)
73 .unwrap_or_default();
74
75 Self {
76 props: Props {
77 songs,
78 ..self.props
79 },
80 tree_state,
81 ..self
82 }
83 }
84
85 fn name(&self) -> &'static str {
86 "Library Songs View"
87 }
88
89 fn handle_key_event(&mut self, key: KeyEvent) {
90 match key.code {
91 KeyCode::PageUp => {
93 self.tree_state.lock().unwrap().select_relative(|current| {
94 current.map_or(self.props.songs.len() - 1, |c| c.saturating_sub(10))
95 });
96 }
97 KeyCode::Up => {
98 self.tree_state.lock().unwrap().key_up();
99 }
100 KeyCode::PageDown => {
101 self.tree_state
102 .lock()
103 .unwrap()
104 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
105 }
106 KeyCode::Down => {
107 self.tree_state.lock().unwrap().key_down();
108 }
109 KeyCode::Left => {
110 self.tree_state.lock().unwrap().key_left();
111 }
112 KeyCode::Right => {
113 self.tree_state.lock().unwrap().key_right();
114 }
115 KeyCode::Char(' ') => {
116 self.tree_state.lock().unwrap().key_space();
117 }
118 KeyCode::Enter => {
120 if self.tree_state.lock().unwrap().toggle_selected() {
121 let things = self.tree_state.lock().unwrap().get_selected_thing();
122
123 if let Some(thing) = things {
124 self.action_tx
125 .send(Action::ActiveView(ViewAction::Set(thing.into())))
126 .unwrap();
127 }
128 }
129 }
130 KeyCode::Char('q') => {
132 let things = self.tree_state.lock().unwrap().get_checked_things();
133 if !things.is_empty() {
134 self.action_tx
135 .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
136 .unwrap();
137 }
138 }
139 KeyCode::Char('r') => {
141 let things = self.tree_state.lock().unwrap().get_checked_things();
142 if !things.is_empty() {
143 self.action_tx
144 .send(Action::ActiveView(ViewAction::Set(ActiveView::Radio(
145 things,
146 ))))
147 .unwrap();
148 }
149 }
150 KeyCode::Char('p') => {
152 let things = self.tree_state.lock().unwrap().get_checked_things();
153 if !things.is_empty() {
154 self.action_tx
155 .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
156 things,
157 ))))
158 .unwrap();
159 }
160 }
161 KeyCode::Char('s') => {
163 self.props.sort_mode = self.props.sort_mode.next();
164 self.props.sort_mode.sort_items(&mut self.props.songs);
165 self.tree_state.lock().unwrap().scroll_selected_into_view();
166 }
167 KeyCode::Char('S') => {
168 self.props.sort_mode = self.props.sort_mode.prev();
169 self.props.sort_mode.sort_items(&mut self.props.songs);
170 self.tree_state.lock().unwrap().scroll_selected_into_view();
171 }
172 _ => {}
173 }
174 }
175
176 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
177 let area = area.inner(Margin::new(1, 2));
179
180 let result = self
181 .tree_state
182 .lock()
183 .unwrap()
184 .handle_mouse_event(mouse, area);
185 if let Some(action) = result {
186 self.action_tx.send(action).unwrap();
187 }
188 }
189}
190
191impl ComponentRender<RenderProps> for LibrarySongsView {
192 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
193 let border_style = Style::default().fg(border_color(props.is_focused).into());
194
195 let border = Block::bordered()
197 .title_top(Line::from(vec![
198 Span::styled("Library Songs".to_string(), Style::default().bold()),
199 Span::raw(" sorted by: "),
200 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
201 ]))
202 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
203 .border_style(border_style);
204 let content_area = border.inner(props.area);
205 frame.render_widget(border, props.area);
206
207 let border = Block::new()
209 .borders(Borders::TOP | Borders::BOTTOM)
210 .title_top(
211 self.tree_state
212 .lock()
213 .unwrap()
214 .get_checked_things()
215 .is_empty()
216 .not()
217 .then_some("q: add to queue | r: start radio | p: add to playlist ")
218 .unwrap_or_default(),
219 )
220 .title_bottom("s/S: change sort")
221 .border_style(border_style);
222 frame.render_widget(&border, content_area);
223 let content_area = border.inner(content_area);
224
225 RenderProps {
226 area: content_area,
227 is_focused: props.is_focused,
228 }
229 }
230
231 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
232 let items = self
234 .props
235 .songs
236 .iter()
237 .map(create_song_tree_leaf)
238 .collect::<Vec<_>>();
239
240 frame.render_stateful_widget(
242 CheckTree::new(&items)
243 .unwrap()
244 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
245 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
246 props.area,
247 &mut self.tree_state.lock().unwrap(),
248 );
249 }
250}
251
252#[cfg(test)]
253mod sort_mode_tests {
254 use super::*;
255 use one_or_many::OneOrMany;
256 use pretty_assertions::assert_eq;
257 use rstest::rstest;
258 use std::time::Duration;
259
260 #[rstest]
261 #[case(SongSort::Title, SongSort::Artist)]
262 #[case(SongSort::Artist, SongSort::Album)]
263 #[case(SongSort::Album, SongSort::AlbumArtist)]
264 #[case(SongSort::AlbumArtist, SongSort::Genre)]
265 #[case(SongSort::Genre, SongSort::Title)]
266 fn test_sort_mode_next_prev(#[case] mode: SongSort, #[case] expected: SongSort) {
267 assert_eq!(mode.next(), expected);
268 assert_eq!(mode.next().prev(), mode);
269 }
270
271 #[rstest]
272 #[case(SongSort::Title, "Title")]
273 #[case(SongSort::Artist, "Artist")]
274 #[case(SongSort::Album, "Album")]
275 #[case(SongSort::AlbumArtist, "Album Artist")]
276 #[case(SongSort::Genre, "Genre")]
277 fn test_sort_mode_display(#[case] mode: SongSort, #[case] expected: &str) {
278 assert_eq!(mode.to_string(), expected);
279 }
280
281 #[rstest]
282 fn test_sort_songs() {
283 let mut songs = vec![
284 Song {
285 id: Song::generate_id(),
286 title: "C".into(),
287 artist: OneOrMany::One("B".into()),
288 album: "A".into(),
289 album_artist: OneOrMany::One("C".into()),
290 genre: OneOrMany::One("B".into()),
291 runtime: Duration::from_secs(180),
292 track: Some(1),
293 disc: Some(1),
294 release_year: Some(2021),
295 extension: "mp3".into(),
296 path: "test.mp3".into(),
297 },
298 Song {
299 id: Song::generate_id(),
300 title: "B".into(),
301 artist: OneOrMany::One("A".into()),
302 album: "C".into(),
303 album_artist: OneOrMany::One("B".into()),
304 genre: OneOrMany::One("A".into()),
305 runtime: Duration::from_secs(180),
306 track: Some(1),
307 disc: Some(1),
308 release_year: Some(2021),
309 extension: "mp3".into(),
310 path: "test.mp3".into(),
311 },
312 Song {
313 id: Song::generate_id(),
314 title: "A".into(),
315 artist: OneOrMany::One("C".into()),
316 album: "B".into(),
317 album_artist: OneOrMany::One("A".into()),
318 genre: OneOrMany::One("C".into()),
319 runtime: Duration::from_secs(180),
320 track: Some(1),
321 disc: Some(1),
322 release_year: Some(2021),
323 extension: "mp3".into(),
324 path: "test.mp3".into(),
325 },
326 ];
327
328 SongSort::Title.sort_items(&mut songs);
329 assert_eq!(songs[0].title, "A");
330 assert_eq!(songs[1].title, "B");
331 assert_eq!(songs[2].title, "C");
332
333 SongSort::Artist.sort_items(&mut songs);
334 assert_eq!(songs[0].artist, OneOrMany::One("A".into()));
335 assert_eq!(songs[1].artist, OneOrMany::One("B".into()));
336 assert_eq!(songs[2].artist, OneOrMany::One("C".into()));
337
338 SongSort::Album.sort_items(&mut songs);
339 assert_eq!(songs[0].album, "A");
340 assert_eq!(songs[1].album, "B");
341 assert_eq!(songs[2].album, "C");
342
343 SongSort::AlbumArtist.sort_items(&mut songs);
344 assert_eq!(songs[0].album_artist, OneOrMany::One("A".into()));
345 assert_eq!(songs[1].album_artist, OneOrMany::One("B".into()));
346 assert_eq!(songs[2].album_artist, OneOrMany::One("C".into()));
347
348 SongSort::Genre.sort_items(&mut songs);
349 assert_eq!(songs[0].genre, OneOrMany::One("A".into()));
350 assert_eq!(songs[1].genre, OneOrMany::One("B".into()));
351 assert_eq!(songs[2].genre, OneOrMany::One("C".into()));
352 }
353}
354
355#[cfg(test)]
356mod item_view_tests {
357 use super::*;
358 use crate::test_utils::{
359 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
360 };
361 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
362 use pretty_assertions::assert_eq;
363 use ratatui::buffer::Buffer;
364
365 #[test]
366 fn test_new() {
367 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
368 let state = state_with_everything();
369 let view = SongView::new(&state, tx);
370
371 assert_eq!(view.name(), "Song View");
372 assert_eq!(view.props, Some(state.additional_view_data.song.unwrap()));
373 }
374
375 #[test]
376 fn test_move_with_state() {
377 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
378 let state = AppState::default();
379 let new_state = state_with_everything();
380 let view = SongView::new(&state, tx).move_with_state(&new_state);
381
382 assert_eq!(
383 view.props,
384 Some(new_state.additional_view_data.song.unwrap())
385 );
386 }
387
388 #[test]
389 fn test_render_no_song() {
390 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
391 let view = SongView::new(&AppState::default(), tx);
392
393 let (mut terminal, area) = setup_test_terminal(16, 3);
394 let props = RenderProps {
395 area,
396 is_focused: true,
397 };
398 let buffer = terminal
399 .draw(|frame| view.render(frame, props))
400 .unwrap()
401 .buffer
402 .clone();
403 #[rustfmt::skip]
404 let expected = Buffer::with_lines([
405 "┌Song View─────┐",
406 "│No active song│",
407 "└──────────────┘",
408 ]);
409
410 assert_buffer_eq(&buffer, &expected);
411 }
412
413 #[test]
414 #[allow(clippy::too_many_lines)]
415 fn test_render_no_playlist_no_collection() {
416 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
417 let mut state = state_with_everything();
418 state.additional_view_data.song.as_mut().unwrap().playlists = [].into();
419 state
420 .additional_view_data
421 .song
422 .as_mut()
423 .unwrap()
424 .collections = [].into();
425 let mut view = SongView::new(&state, tx);
426
427 let (mut terminal, area) = setup_test_terminal(60, 12);
428 let props = RenderProps {
429 area,
430 is_focused: true,
431 };
432 let buffer = terminal
433 .draw(|frame| view.render(frame, props))
434 .unwrap()
435 .buffer
436 .clone();
437 let expected = Buffer::with_lines([
438 "┌Song View─────────────────────────────────────────────────┐",
439 "│ Test Song Test Artist │",
440 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
441 "│ │",
442 "│q: add to queue | r: start radio | p: add to playlist─────│",
443 "│Performing operations on the song─────────────────────────│",
444 "│▶ Artists (1): │",
445 "│☐ Album: Test Album Test Artist │",
446 "│▶ Playlists (0): │",
447 "│▶ Collections (0): │",
448 "│ │",
449 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
450 ]);
451
452 assert_buffer_eq(&buffer, &expected);
453 assert!(view.tree_state.lock().unwrap().selected().is_empty());
454
455 view.handle_key_event(KeyEvent::from(KeyCode::Down));
456 assert_eq!(view.tree_state.lock().unwrap().selected(), &["Artists"]);
457 view.handle_key_event(KeyEvent::from(KeyCode::Down));
458 assert_eq!(
459 view.tree_state.lock().unwrap().selected(),
460 &[state.library.albums[0].id.to_string()]
461 );
462 view.handle_key_event(KeyEvent::from(KeyCode::Down));
463 assert_eq!(view.tree_state.lock().unwrap().selected(), &["Playlists"]);
464 view.handle_key_event(KeyEvent::from(KeyCode::Right));
465
466 let buffer = terminal
467 .draw(|frame| view.render(frame, props))
468 .unwrap()
469 .buffer
470 .clone();
471 let expected = Buffer::with_lines([
472 "┌Song View─────────────────────────────────────────────────┐",
473 "│ Test Song Test Artist │",
474 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
475 "│ │",
476 "│q: add to queue | r: start radio | p: add to playlist─────│",
477 "│Performing operations on the song─────────────────────────│",
478 "│▶ Artists (1): │",
479 "│☐ Album: Test Album Test Artist │",
480 "│▼ Playlists (0): │",
481 "│ │",
482 "│▶ Collections (0): │",
483 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
484 ]);
485 assert_buffer_eq(&buffer, &expected);
486
487 view.handle_key_event(KeyEvent::from(KeyCode::Left));
488 let buffer = terminal
489 .draw(|frame| view.render(frame, props))
490 .unwrap()
491 .buffer
492 .clone();
493 let expected = Buffer::with_lines([
494 "┌Song View─────────────────────────────────────────────────┐",
495 "│ Test Song Test Artist │",
496 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
497 "│ │",
498 "│q: add to queue | r: start radio | p: add to playlist─────│",
499 "│Performing operations on the song─────────────────────────│",
500 "│▶ Artists (1): │",
501 "│☐ Album: Test Album Test Artist │",
502 "│▶ Playlists (0): │",
503 "│▶ Collections (0): │",
504 "│ │",
505 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
506 ]);
507 assert_buffer_eq(&buffer, &expected);
508
509 view.handle_key_event(KeyEvent::from(KeyCode::Down));
510 assert_eq!(view.tree_state.lock().unwrap().selected(), &["Collections"]);
511 view.handle_key_event(KeyEvent::from(KeyCode::Right));
512
513 let buffer = terminal
514 .draw(|frame| view.render(frame, props))
515 .unwrap()
516 .buffer
517 .clone();
518 let expected = Buffer::with_lines([
519 "┌Song View─────────────────────────────────────────────────┐",
520 "│ Test Song Test Artist │",
521 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
522 "│ │",
523 "│q: add to queue | r: start radio | p: add to playlist─────│",
524 "│Performing operations on the song─────────────────────────│",
525 "│▶ Artists (1): │",
526 "│☐ Album: Test Album Test Artist │",
527 "│▶ Playlists (0): │",
528 "│▼ Collections (0): │",
529 "│ │",
530 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
531 ]);
532 assert_buffer_eq(&buffer, &expected);
533 }
534
535 #[test]
536 fn test_render() {
537 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
538 let view = SongView::new(&state_with_everything(), tx);
539
540 let (mut terminal, area) = setup_test_terminal(60, 12);
541 let props = RenderProps {
542 area,
543 is_focused: true,
544 };
545 let buffer = terminal
546 .draw(|frame| view.render(frame, props))
547 .unwrap()
548 .buffer
549 .clone();
550 let expected = Buffer::with_lines([
551 "┌Song View─────────────────────────────────────────────────┐",
552 "│ Test Song Test Artist │",
553 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
554 "│ │",
555 "│q: add to queue | r: start radio | p: add to playlist─────│",
556 "│Performing operations on the song─────────────────────────│",
557 "│▶ Artists (1): │",
558 "│☐ Album: Test Album Test Artist │",
559 "│▶ Playlists (1): │",
560 "│▶ Collections (1): │",
561 "│ │",
562 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
563 ]);
564
565 assert_buffer_eq(&buffer, &expected);
566 }
567
568 #[test]
569 fn test_render_with_checked() {
570 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
571 let mut view = SongView::new(&state_with_everything(), tx);
572 let (mut terminal, area) = setup_test_terminal(60, 9);
573 let props = RenderProps {
574 area,
575 is_focused: true,
576 };
577 let buffer = terminal
578 .draw(|frame| view.render(frame, props))
579 .unwrap()
580 .buffer
581 .clone();
582 let expected = Buffer::with_lines([
583 "┌Song View─────────────────────────────────────────────────┐",
584 "│ Test Song Test Artist │",
585 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
586 "│ │",
587 "│q: add to queue | r: start radio | p: add to playlist─────│",
588 "│Performing operations on the song─────────────────────────│",
589 "│▶ Artists (1): │",
590 "│☐ Album: Test Album Test Artist │",
591 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
592 ]);
593 assert_buffer_eq(&buffer, &expected);
594
595 view.handle_key_event(KeyEvent::from(KeyCode::Down));
597 view.handle_key_event(KeyEvent::from(KeyCode::Down));
598 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
599
600 let buffer = terminal
601 .draw(|frame| view.render(frame, props))
602 .unwrap()
603 .buffer
604 .clone();
605 let expected = Buffer::with_lines([
606 "┌Song View─────────────────────────────────────────────────┐",
607 "│ Test Song Test Artist │",
608 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
609 "│ │",
610 "│q: add to queue | r: start radio | p: add to playlist─────│",
611 "│Performing operations on checked items────────────────────│",
612 "│▶ Artists (1): │",
613 "│☑ Album: Test Album Test Artist │",
614 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
615 ]);
616
617 assert_buffer_eq(&buffer, &expected);
618 }
619
620 #[test]
621 fn smoke_navigation() {
622 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
623 let mut view = SongView::new(&state_with_everything(), tx);
624
625 view.handle_key_event(KeyEvent::from(KeyCode::Up));
626 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
627 view.handle_key_event(KeyEvent::from(KeyCode::Down));
628 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
629 view.handle_key_event(KeyEvent::from(KeyCode::Left));
630 view.handle_key_event(KeyEvent::from(KeyCode::Right));
631 }
632
633 #[test]
634 fn test_actions() {
635 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
636 let mut view = SongView::new(&state_with_everything(), tx);
637
638 let (mut terminal, area) = setup_test_terminal(60, 9);
640 let props = RenderProps {
641 area,
642 is_focused: true,
643 };
644 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
645
646 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
649 assert_eq!(
650 rx.blocking_recv().unwrap(),
651 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
652 ("song", item_id()).into()
653 ])))
654 );
655 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
656 assert_eq!(
657 rx.blocking_recv().unwrap(),
658 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
659 ("song", item_id()).into()
660 ],)))
661 );
662 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
663 assert_eq!(
664 rx.blocking_recv().unwrap(),
665 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
666 ("song", item_id()).into()
667 ])))
668 );
669
670 view.handle_key_event(KeyEvent::from(KeyCode::Down));
673 view.handle_key_event(KeyEvent::from(KeyCode::Down));
674 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
675
676 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
678 assert_eq!(
679 rx.blocking_recv().unwrap(),
680 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
681 );
682
683 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
685
686 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
688 assert_eq!(
689 rx.blocking_recv().unwrap(),
690 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
691 ("album", item_id()).into()
692 ])))
693 );
694
695 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
697 assert_eq!(
698 rx.blocking_recv().unwrap(),
699 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
700 ("album", item_id()).into()
701 ],)))
702 );
703
704 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
706 assert_eq!(
707 rx.blocking_recv().unwrap(),
708 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
709 ("album", item_id()).into()
710 ])))
711 );
712 }
713
714 #[test]
715 #[allow(clippy::too_many_lines)]
716 fn test_mouse_event() {
717 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
718 let mut view = SongView::new(&state_with_everything(), tx);
719
720 let (mut terminal, area) = setup_test_terminal(60, 9);
722 let props = RenderProps {
723 area,
724 is_focused: true,
725 };
726 let buffer = terminal
727 .draw(|frame| view.render(frame, props))
728 .unwrap()
729 .buffer
730 .clone();
731 let expected = Buffer::with_lines([
732 "┌Song View─────────────────────────────────────────────────┐",
733 "│ Test Song Test Artist │",
734 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
735 "│ │",
736 "│q: add to queue | r: start radio | p: add to playlist─────│",
737 "│Performing operations on the song─────────────────────────│",
738 "│▶ Artists (1): │",
739 "│☐ Album: Test Album Test Artist │",
740 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
741 ]);
742 assert_buffer_eq(&buffer, &expected);
743
744 view.handle_mouse_event(
746 MouseEvent {
747 kind: MouseEventKind::Down(MouseButton::Left),
748 column: 2,
749 row: 6,
750 modifiers: KeyModifiers::empty(),
751 },
752 area,
753 );
754 let buffer = terminal
755 .draw(|frame| view.render(frame, props))
756 .unwrap()
757 .buffer
758 .clone();
759 let expected = Buffer::with_lines([
760 "┌Song View─────────────────────────────────────────────────┐",
761 "│ Test Song Test Artist │",
762 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
763 "│ │",
764 "│q: add to queue | r: start radio | p: add to playlist─────│",
765 "│Performing operations on the song─────────────────────────│",
766 "│▼ Artists (1): │",
767 "│ ☐ Test Artist │",
768 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
769 ]);
770 assert_buffer_eq(&buffer, &expected);
771
772 view.handle_mouse_event(
774 MouseEvent {
775 kind: MouseEventKind::ScrollDown,
776 column: 2,
777 row: 6,
778 modifiers: KeyModifiers::empty(),
779 },
780 area,
781 );
782 let buffer = terminal
783 .draw(|frame| view.render(frame, props))
784 .unwrap()
785 .buffer
786 .clone();
787 assert_buffer_eq(&buffer, &expected);
788
789 view.handle_mouse_event(
791 MouseEvent {
792 kind: MouseEventKind::Down(MouseButton::Left),
793 column: 2,
794 row: 7,
795 modifiers: KeyModifiers::empty(),
796 },
797 area,
798 );
799 assert_eq!(
800 rx.blocking_recv().unwrap(),
801 Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
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
821 view.handle_mouse_event(
823 MouseEvent {
824 kind: MouseEventKind::ScrollUp,
825 column: 2,
826 row: 7,
827 modifiers: KeyModifiers::empty(),
828 },
829 area,
830 );
831 let buffer = terminal
832 .draw(|frame| view.render(frame, props))
833 .unwrap()
834 .buffer
835 .clone();
836 assert_buffer_eq(&buffer, &expected);
837 }
838}
839
840#[cfg(test)]
841mod library_view_tests {
842 use super::*;
843 use crate::test_utils::{
844 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
845 };
846
847 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
848 use pretty_assertions::assert_eq;
849 use ratatui::buffer::Buffer;
850
851 #[test]
852 fn test_new() {
853 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
854 let state = state_with_everything();
855 let view = LibrarySongsView::new(&state, tx);
856
857 assert_eq!(view.name(), "Library Songs View");
858 assert_eq!(view.props.songs, state.library.songs);
859 }
860
861 #[test]
862 fn test_move_with_state() {
863 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
864 let state = AppState::default();
865 let new_state = state_with_everything();
866 let view = LibrarySongsView::new(&state, tx).move_with_state(&new_state);
867
868 assert_eq!(view.props.songs, new_state.library.songs);
869 }
870
871 #[test]
872 fn test_render() {
873 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
874 let view = LibrarySongsView::new(&state_with_everything(), tx);
875
876 let (mut terminal, area) = setup_test_terminal(60, 6);
877 let props = RenderProps {
878 area,
879 is_focused: true,
880 };
881 let buffer = terminal
882 .draw(|frame| view.render(frame, props))
883 .unwrap()
884 .buffer
885 .clone();
886 let expected = Buffer::with_lines([
887 "┌Library Songs sorted by: Artist───────────────────────────┐",
888 "│──────────────────────────────────────────────────────────│",
889 "│☐ Test Song Test Artist │",
890 "│ │",
891 "│s/S: change sort──────────────────────────────────────────│",
892 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
893 ]);
894
895 assert_buffer_eq(&buffer, &expected);
896 }
897
898 #[test]
899 fn test_render_with_checked() {
900 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
901 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
902 let (mut terminal, area) = setup_test_terminal(60, 6);
903 let props = RenderProps {
904 area,
905 is_focused: true,
906 };
907 let buffer = terminal
908 .draw(|frame| view.render(frame, props))
909 .unwrap()
910 .buffer
911 .clone();
912 let expected = Buffer::with_lines([
913 "┌Library Songs sorted by: Artist───────────────────────────┐",
914 "│──────────────────────────────────────────────────────────│",
915 "│☐ Test Song Test Artist │",
916 "│ │",
917 "│s/S: change sort──────────────────────────────────────────│",
918 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
919 ]);
920 assert_buffer_eq(&buffer, &expected);
921
922 view.handle_key_event(KeyEvent::from(KeyCode::Down));
924 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
925
926 let buffer = terminal
927 .draw(|frame| view.render(frame, props))
928 .unwrap()
929 .buffer
930 .clone();
931 let expected = Buffer::with_lines([
932 "┌Library Songs sorted by: Artist───────────────────────────┐",
933 "│q: add to queue | r: start radio | p: add to playlist ────│",
934 "│☑ Test Song Test Artist │",
935 "│ │",
936 "│s/S: change sort──────────────────────────────────────────│",
937 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
938 ]);
939
940 assert_buffer_eq(&buffer, &expected);
941 }
942
943 #[test]
944 fn test_sort_keys() {
945 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
946 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
947
948 assert_eq!(view.props.sort_mode, SongSort::Artist);
949 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
950 assert_eq!(view.props.sort_mode, SongSort::Album);
951 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
952 assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
953 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
954 assert_eq!(view.props.sort_mode, SongSort::Genre);
955 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
956 assert_eq!(view.props.sort_mode, SongSort::Title);
957 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
958 assert_eq!(view.props.sort_mode, SongSort::Artist);
959 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
960 assert_eq!(view.props.sort_mode, SongSort::Title);
961 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
962 assert_eq!(view.props.sort_mode, SongSort::Genre);
963 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
964 assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
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::Artist);
969 }
970
971 #[test]
972 fn smoke_navigation() {
973 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
974 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
975
976 view.handle_key_event(KeyEvent::from(KeyCode::Up));
977 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
978 view.handle_key_event(KeyEvent::from(KeyCode::Down));
979 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
980 view.handle_key_event(KeyEvent::from(KeyCode::Left));
981 view.handle_key_event(KeyEvent::from(KeyCode::Right));
982 }
983
984 #[test]
985 fn test_actions() {
986 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
987 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
988
989 let (mut terminal, area) = setup_test_terminal(60, 9);
991 let props = RenderProps {
992 area,
993 is_focused: true,
994 };
995 terminal.draw(|frame| view.render(frame, props)).unwrap();
996
997 view.handle_key_event(KeyEvent::from(KeyCode::Down));
999
1000 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1003 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1004 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1005 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1007 let action = rx.blocking_recv().unwrap();
1008 assert_eq!(
1009 action,
1010 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1011 );
1012
1013 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
1015
1016 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1018 let action = rx.blocking_recv().unwrap();
1019 assert_eq!(
1020 action,
1021 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
1022 ("song", item_id()).into()
1023 ])))
1024 );
1025
1026 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1028 let action = rx.blocking_recv().unwrap();
1029 assert_eq!(
1030 action,
1031 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
1032 ("song", item_id()).into()
1033 ],)))
1034 );
1035
1036 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1038 let action = rx.blocking_recv().unwrap();
1039 assert_eq!(
1040 action,
1041 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
1042 ("song", item_id()).into()
1043 ])))
1044 );
1045 }
1046
1047 #[test]
1048 fn test_mouse() {
1049 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1050 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
1051
1052 let (mut terminal, area) = setup_test_terminal(60, 6);
1054 let props = RenderProps {
1055 area,
1056 is_focused: true,
1057 };
1058 let buffer = terminal
1059 .draw(|frame| view.render(frame, props))
1060 .unwrap()
1061 .buffer
1062 .clone();
1063 let expected = Buffer::with_lines([
1064 "┌Library Songs sorted by: Artist───────────────────────────┐",
1065 "│──────────────────────────────────────────────────────────│",
1066 "│☐ Test Song Test Artist │",
1067 "│ │",
1068 "│s/S: change sort──────────────────────────────────────────│",
1069 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1070 ]);
1071 assert_buffer_eq(&buffer, &expected);
1072
1073 view.handle_mouse_event(
1075 MouseEvent {
1076 kind: MouseEventKind::Down(MouseButton::Left),
1077 column: 2,
1078 row: 2,
1079 modifiers: KeyModifiers::empty(),
1080 },
1081 area,
1082 );
1083 let buffer = terminal
1084 .draw(|frame| view.render(frame, props))
1085 .unwrap()
1086 .buffer
1087 .clone();
1088 let expected = Buffer::with_lines([
1089 "┌Library Songs sorted by: Artist───────────────────────────┐",
1090 "│q: add to queue | r: start radio | p: add to playlist ────│",
1091 "│☑ Test Song Test Artist │",
1092 "│ │",
1093 "│s/S: change sort──────────────────────────────────────────│",
1094 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1095 ]);
1096 assert_buffer_eq(&buffer, &expected);
1097
1098 view.handle_mouse_event(
1100 MouseEvent {
1101 kind: MouseEventKind::ScrollDown,
1102 column: 2,
1103 row: 2,
1104 modifiers: KeyModifiers::empty(),
1105 },
1106 area,
1107 );
1108 let buffer = terminal
1109 .draw(|frame| view.render(frame, props))
1110 .unwrap()
1111 .buffer
1112 .clone();
1113 assert_buffer_eq(&buffer, &expected);
1114
1115 view.handle_mouse_event(
1117 MouseEvent {
1118 kind: MouseEventKind::ScrollUp,
1119 column: 2,
1120 row: 2,
1121 modifiers: KeyModifiers::empty(),
1122 },
1123 area,
1124 );
1125 let buffer = terminal
1126 .draw(|frame| view.render(frame, props))
1127 .unwrap()
1128 .buffer
1129 .clone();
1130 assert_buffer_eq(&buffer, &expected);
1131
1132 view.handle_mouse_event(
1134 MouseEvent {
1135 kind: MouseEventKind::Down(MouseButton::Left),
1136 column: 2,
1137 row: 2,
1138 modifiers: KeyModifiers::empty(),
1139 },
1140 area,
1141 );
1142 assert_eq!(
1143 rx.blocking_recv().unwrap(),
1144 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1145 );
1146
1147 let mouse = MouseEvent {
1149 kind: MouseEventKind::Down(MouseButton::Left),
1150 column: 2,
1151 row: 3,
1152 modifiers: KeyModifiers::empty(),
1153 };
1154 view.handle_mouse_event(mouse, area);
1155 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1156 view.handle_mouse_event(mouse, area);
1157 assert_eq!(
1158 rx.try_recv(),
1159 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1160 );
1161 }
1162}