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