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