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 colors::{border_color, 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,
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 }
166 KeyCode::Char('S') => {
167 self.props.sort_mode = self.props.sort_mode.prev();
168 self.props.sort_mode.sort_items(&mut self.props.songs);
169 }
170 _ => {}
171 }
172 }
173
174 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
175 let area = area.inner(Margin::new(1, 2));
177
178 let result = self
179 .tree_state
180 .lock()
181 .unwrap()
182 .handle_mouse_event(mouse, area);
183 if let Some(action) = result {
184 self.action_tx.send(action).unwrap();
185 }
186 }
187}
188
189impl ComponentRender<RenderProps> for LibrarySongsView {
190 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
191 let border_style = Style::default().fg(border_color(props.is_focused).into());
192
193 let border = Block::bordered()
195 .title_top(Line::from(vec![
196 Span::styled("Library Songs".to_string(), Style::default().bold()),
197 Span::raw(" sorted by: "),
198 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
199 ]))
200 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
201 .border_style(border_style);
202 let content_area = border.inner(props.area);
203 frame.render_widget(border, props.area);
204
205 let border = Block::new()
207 .borders(Borders::TOP | Borders::BOTTOM)
208 .title_top(
209 self.tree_state
210 .lock()
211 .unwrap()
212 .get_checked_things()
213 .is_empty()
214 .not()
215 .then_some("q: add to queue | r: start radio | p: add to playlist ")
216 .unwrap_or_default(),
217 )
218 .title_bottom("s/S: change sort")
219 .border_style(border_style);
220 frame.render_widget(&border, content_area);
221 let content_area = border.inner(content_area);
222
223 RenderProps {
224 area: content_area,
225 is_focused: props.is_focused,
226 }
227 }
228
229 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
230 let items = self
232 .props
233 .songs
234 .iter()
235 .map(create_song_tree_leaf)
236 .collect::<Vec<_>>();
237
238 frame.render_stateful_widget(
240 CheckTree::new(&items)
241 .unwrap()
242 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
243 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
244 props.area,
245 &mut self.tree_state.lock().unwrap(),
246 );
247 }
248}
249
250#[cfg(test)]
251mod sort_mode_tests {
252 use super::*;
253 use one_or_many::OneOrMany;
254 use pretty_assertions::assert_eq;
255 use rstest::rstest;
256 use std::time::Duration;
257
258 #[rstest]
259 #[case(SongSort::Title, SongSort::Artist)]
260 #[case(SongSort::Artist, SongSort::Album)]
261 #[case(SongSort::Album, SongSort::AlbumArtist)]
262 #[case(SongSort::AlbumArtist, SongSort::Genre)]
263 #[case(SongSort::Genre, SongSort::Title)]
264 fn test_sort_mode_next_prev(#[case] mode: SongSort, #[case] expected: SongSort) {
265 assert_eq!(mode.next(), expected);
266 assert_eq!(mode.next().prev(), mode);
267 }
268
269 #[rstest]
270 #[case(SongSort::Title, "Title")]
271 #[case(SongSort::Artist, "Artist")]
272 #[case(SongSort::Album, "Album")]
273 #[case(SongSort::AlbumArtist, "Album Artist")]
274 #[case(SongSort::Genre, "Genre")]
275 fn test_sort_mode_display(#[case] mode: SongSort, #[case] expected: &str) {
276 assert_eq!(mode.to_string(), expected);
277 }
278
279 #[rstest]
280 fn test_sort_songs() {
281 let mut songs = vec![
282 Song {
283 id: Song::generate_id(),
284 title: "C".into(),
285 artist: OneOrMany::One("B".into()),
286 album: "A".into(),
287 album_artist: OneOrMany::One("C".into()),
288 genre: OneOrMany::One("B".into()),
289 runtime: Duration::from_secs(180),
290 track: Some(1),
291 disc: Some(1),
292 release_year: Some(2021),
293 extension: "mp3".into(),
294 path: "test.mp3".into(),
295 },
296 Song {
297 id: Song::generate_id(),
298 title: "B".into(),
299 artist: OneOrMany::One("A".into()),
300 album: "C".into(),
301 album_artist: OneOrMany::One("B".into()),
302 genre: OneOrMany::One("A".into()),
303 runtime: Duration::from_secs(180),
304 track: Some(1),
305 disc: Some(1),
306 release_year: Some(2021),
307 extension: "mp3".into(),
308 path: "test.mp3".into(),
309 },
310 Song {
311 id: Song::generate_id(),
312 title: "A".into(),
313 artist: OneOrMany::One("C".into()),
314 album: "B".into(),
315 album_artist: OneOrMany::One("A".into()),
316 genre: OneOrMany::One("C".into()),
317 runtime: Duration::from_secs(180),
318 track: Some(1),
319 disc: Some(1),
320 release_year: Some(2021),
321 extension: "mp3".into(),
322 path: "test.mp3".into(),
323 },
324 ];
325
326 SongSort::Title.sort_items(&mut songs);
327 assert_eq!(songs[0].title, "A");
328 assert_eq!(songs[1].title, "B");
329 assert_eq!(songs[2].title, "C");
330
331 SongSort::Artist.sort_items(&mut songs);
332 assert_eq!(songs[0].artist, OneOrMany::One("A".into()));
333 assert_eq!(songs[1].artist, OneOrMany::One("B".into()));
334 assert_eq!(songs[2].artist, OneOrMany::One("C".into()));
335
336 SongSort::Album.sort_items(&mut songs);
337 assert_eq!(songs[0].album, "A");
338 assert_eq!(songs[1].album, "B");
339 assert_eq!(songs[2].album, "C");
340
341 SongSort::AlbumArtist.sort_items(&mut songs);
342 assert_eq!(songs[0].album_artist, OneOrMany::One("A".into()));
343 assert_eq!(songs[1].album_artist, OneOrMany::One("B".into()));
344 assert_eq!(songs[2].album_artist, OneOrMany::One("C".into()));
345
346 SongSort::Genre.sort_items(&mut songs);
347 assert_eq!(songs[0].genre, OneOrMany::One("A".into()));
348 assert_eq!(songs[1].genre, OneOrMany::One("B".into()));
349 assert_eq!(songs[2].genre, OneOrMany::One("C".into()));
350 }
351}
352
353#[cfg(test)]
354mod item_view_tests {
355 use super::*;
356 use crate::test_utils::{
357 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
358 };
359 use anyhow::Result;
360 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
361 use pretty_assertions::assert_eq;
362 use ratatui::buffer::Buffer;
363
364 #[test]
365 fn test_new() {
366 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
367 let state = state_with_everything();
368 let view = SongView::new(&state, tx);
369
370 assert_eq!(view.name(), "Song View");
371 assert_eq!(view.props, Some(state.additional_view_data.song.unwrap()));
372 }
373
374 #[test]
375 fn test_move_with_state() {
376 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
377 let state = AppState::default();
378 let new_state = state_with_everything();
379 let view = SongView::new(&state, tx).move_with_state(&new_state);
380
381 assert_eq!(
382 view.props,
383 Some(new_state.additional_view_data.song.unwrap())
384 );
385 }
386
387 #[test]
388 fn test_render_no_song() -> Result<()> {
389 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
390 let view = SongView::new(&AppState::default(), tx);
391
392 let (mut terminal, area) = setup_test_terminal(16, 3);
393 let props = RenderProps {
394 area,
395 is_focused: true,
396 };
397 let buffer = terminal
398 .draw(|frame| view.render(frame, props))
399 .unwrap()
400 .buffer
401 .clone();
402 #[rustfmt::skip]
403 let expected = Buffer::with_lines([
404 "┌Song View─────┐",
405 "│No active song│",
406 "└──────────────┘",
407 ]);
408
409 assert_buffer_eq(&buffer, &expected);
410
411 Ok(())
412 }
413
414 #[test]
415 fn test_render_no_playlist_no_collection() -> Result<()> {
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 Ok(())
535 }
536
537 #[test]
538 fn test_render() {
539 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
540 let view = SongView::new(&state_with_everything(), tx);
541
542 let (mut terminal, area) = setup_test_terminal(60, 12);
543 let props = RenderProps {
544 area,
545 is_focused: true,
546 };
547 let buffer = terminal
548 .draw(|frame| view.render(frame, props))
549 .unwrap()
550 .buffer
551 .clone();
552 let expected = Buffer::with_lines([
553 "┌Song View─────────────────────────────────────────────────┐",
554 "│ Test Song Test Artist │",
555 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
556 "│ │",
557 "│q: add to queue | r: start radio | p: add to playlist─────│",
558 "│Performing operations on the song─────────────────────────│",
559 "│▶ Artists (1): │",
560 "│☐ Album: Test Album Test Artist │",
561 "│▶ Playlists (1): │",
562 "│▶ Collections (1): │",
563 "│ │",
564 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
565 ]);
566
567 assert_buffer_eq(&buffer, &expected);
568 }
569
570 #[test]
571 fn test_render_with_checked() {
572 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
573 let mut view = SongView::new(&state_with_everything(), tx);
574 let (mut terminal, area) = setup_test_terminal(60, 9);
575 let props = RenderProps {
576 area,
577 is_focused: true,
578 };
579 let buffer = terminal
580 .draw(|frame| view.render(frame, props))
581 .unwrap()
582 .buffer
583 .clone();
584 let expected = Buffer::with_lines([
585 "┌Song View─────────────────────────────────────────────────┐",
586 "│ Test Song Test Artist │",
587 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
588 "│ │",
589 "│q: add to queue | r: start radio | p: add to playlist─────│",
590 "│Performing operations on the song─────────────────────────│",
591 "│▶ Artists (1): │",
592 "│☐ Album: Test Album Test Artist │",
593 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
594 ]);
595 assert_buffer_eq(&buffer, &expected);
596
597 view.handle_key_event(KeyEvent::from(KeyCode::Down));
599 view.handle_key_event(KeyEvent::from(KeyCode::Down));
600 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
601
602 let buffer = terminal
603 .draw(|frame| view.render(frame, props))
604 .unwrap()
605 .buffer
606 .clone();
607 let expected = Buffer::with_lines([
608 "┌Song View─────────────────────────────────────────────────┐",
609 "│ Test Song Test Artist │",
610 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
611 "│ │",
612 "│q: add to queue | r: start radio | p: add to playlist─────│",
613 "│Performing operations on checked items────────────────────│",
614 "│▶ Artists (1): │",
615 "│☑ Album: Test Album Test Artist │",
616 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
617 ]);
618
619 assert_buffer_eq(&buffer, &expected);
620 }
621
622 #[test]
623 fn smoke_navigation() {
624 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
625 let mut view = SongView::new(&state_with_everything(), tx);
626
627 view.handle_key_event(KeyEvent::from(KeyCode::Up));
628 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
629 view.handle_key_event(KeyEvent::from(KeyCode::Down));
630 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
631 view.handle_key_event(KeyEvent::from(KeyCode::Left));
632 view.handle_key_event(KeyEvent::from(KeyCode::Right));
633 }
634
635 #[test]
636 fn test_actions() {
637 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
638 let mut view = SongView::new(&state_with_everything(), tx);
639
640 let (mut terminal, area) = setup_test_terminal(60, 9);
642 let props = RenderProps {
643 area,
644 is_focused: true,
645 };
646 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
647
648 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
651 assert_eq!(
652 rx.blocking_recv().unwrap(),
653 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
654 "song",
655 item_id()
656 )
657 .into()])))
658 );
659 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
660 assert_eq!(
661 rx.blocking_recv().unwrap(),
662 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
663 "song",
664 item_id()
665 )
666 .into()],)))
667 );
668 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
669 assert_eq!(
670 rx.blocking_recv().unwrap(),
671 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
672 "song",
673 item_id()
674 )
675 .into()])))
676 );
677
678 view.handle_key_event(KeyEvent::from(KeyCode::Down));
681 view.handle_key_event(KeyEvent::from(KeyCode::Down));
682 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
683
684 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
686 assert_eq!(
687 rx.blocking_recv().unwrap(),
688 Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
689 );
690
691 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
693
694 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
696 assert_eq!(
697 rx.blocking_recv().unwrap(),
698 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
699 "album",
700 item_id()
701 )
702 .into()])))
703 );
704
705 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
707 assert_eq!(
708 rx.blocking_recv().unwrap(),
709 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
710 "album",
711 item_id()
712 )
713 .into()],)))
714 );
715
716 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
718 assert_eq!(
719 rx.blocking_recv().unwrap(),
720 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
721 "album",
722 item_id()
723 )
724 .into()])))
725 );
726 }
727
728 #[test]
729 #[allow(clippy::too_many_lines)]
730 fn test_mouse_event() {
731 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
732 let mut view = SongView::new(&state_with_everything(), tx);
733
734 let (mut terminal, area) = setup_test_terminal(60, 9);
736 let props = RenderProps {
737 area,
738 is_focused: true,
739 };
740 let buffer = terminal
741 .draw(|frame| view.render(frame, props))
742 .unwrap()
743 .buffer
744 .clone();
745 let expected = Buffer::with_lines([
746 "┌Song View─────────────────────────────────────────────────┐",
747 "│ Test Song Test Artist │",
748 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
749 "│ │",
750 "│q: add to queue | r: start radio | p: add to playlist─────│",
751 "│Performing operations on the song─────────────────────────│",
752 "│▶ Artists (1): │",
753 "│☐ Album: Test Album Test Artist │",
754 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
755 ]);
756 assert_buffer_eq(&buffer, &expected);
757
758 view.handle_mouse_event(
760 MouseEvent {
761 kind: MouseEventKind::Down(MouseButton::Left),
762 column: 2,
763 row: 6,
764 modifiers: KeyModifiers::empty(),
765 },
766 area,
767 );
768 let buffer = terminal
769 .draw(|frame| view.render(frame, props))
770 .unwrap()
771 .buffer
772 .clone();
773 let expected = Buffer::with_lines([
774 "┌Song View─────────────────────────────────────────────────┐",
775 "│ Test Song Test Artist │",
776 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
777 "│ │",
778 "│q: add to queue | r: start radio | p: add to playlist─────│",
779 "│Performing operations on the song─────────────────────────│",
780 "│▼ Artists (1): │",
781 "│ ☐ Test Artist │",
782 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
783 ]);
784 assert_buffer_eq(&buffer, &expected);
785
786 view.handle_mouse_event(
788 MouseEvent {
789 kind: MouseEventKind::ScrollDown,
790 column: 2,
791 row: 6,
792 modifiers: KeyModifiers::empty(),
793 },
794 area,
795 );
796 let buffer = terminal
797 .draw(|frame| view.render(frame, props))
798 .unwrap()
799 .buffer
800 .clone();
801 assert_buffer_eq(&buffer, &expected);
802
803 view.handle_mouse_event(
805 MouseEvent {
806 kind: MouseEventKind::Down(MouseButton::Left),
807 column: 2,
808 row: 7,
809 modifiers: KeyModifiers::empty(),
810 },
811 area,
812 );
813 assert_eq!(
814 rx.blocking_recv().unwrap(),
815 Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
816 );
817 let buffer = terminal
818 .draw(|frame| view.render(frame, props))
819 .unwrap()
820 .buffer
821 .clone();
822 let expected = Buffer::with_lines([
823 "┌Song View─────────────────────────────────────────────────┐",
824 "│ Test Song Test Artist │",
825 "│ Track/Disc: 0/0 Duration: 3:00.0 Genre(s): Test Genre │",
826 "│ │",
827 "│q: add to queue | r: start radio | p: add to playlist─────│",
828 "│Performing operations on checked items────────────────────│",
829 "│▼ Artists (1): │",
830 "│ ☑ Test Artist │",
831 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
832 ]);
833 assert_buffer_eq(&buffer, &expected);
834
835 view.handle_mouse_event(
837 MouseEvent {
838 kind: MouseEventKind::ScrollUp,
839 column: 2,
840 row: 7,
841 modifiers: KeyModifiers::empty(),
842 },
843 area,
844 );
845 let buffer = terminal
846 .draw(|frame| view.render(frame, props))
847 .unwrap()
848 .buffer
849 .clone();
850 assert_buffer_eq(&buffer, &expected);
851 }
852}
853
854#[cfg(test)]
855mod library_view_tests {
856 use super::*;
857 use crate::test_utils::{
858 assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
859 };
860
861 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
862 use pretty_assertions::assert_eq;
863 use ratatui::buffer::Buffer;
864
865 #[test]
866 fn test_new() {
867 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
868 let state = state_with_everything();
869 let view = LibrarySongsView::new(&state, tx);
870
871 assert_eq!(view.name(), "Library Songs View");
872 assert_eq!(view.props.songs, state.library.songs);
873 }
874
875 #[test]
876 fn test_move_with_state() {
877 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
878 let state = AppState::default();
879 let new_state = state_with_everything();
880 let view = LibrarySongsView::new(&state, tx).move_with_state(&new_state);
881
882 assert_eq!(view.props.songs, new_state.library.songs);
883 }
884
885 #[test]
886 fn test_render() {
887 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
888 let view = LibrarySongsView::new(&state_with_everything(), tx);
889
890 let (mut terminal, area) = setup_test_terminal(60, 6);
891 let props = RenderProps {
892 area,
893 is_focused: true,
894 };
895 let buffer = terminal
896 .draw(|frame| view.render(frame, props))
897 .unwrap()
898 .buffer
899 .clone();
900 let expected = Buffer::with_lines([
901 "┌Library Songs sorted by: Artist───────────────────────────┐",
902 "│──────────────────────────────────────────────────────────│",
903 "│☐ Test Song Test Artist │",
904 "│ │",
905 "│s/S: change sort──────────────────────────────────────────│",
906 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
907 ]);
908
909 assert_buffer_eq(&buffer, &expected);
910 }
911
912 #[test]
913 fn test_render_with_checked() {
914 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
915 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
916 let (mut terminal, area) = setup_test_terminal(60, 6);
917 let props = RenderProps {
918 area,
919 is_focused: true,
920 };
921 let buffer = terminal
922 .draw(|frame| view.render(frame, props))
923 .unwrap()
924 .buffer
925 .clone();
926 let expected = Buffer::with_lines([
927 "┌Library Songs sorted by: Artist───────────────────────────┐",
928 "│──────────────────────────────────────────────────────────│",
929 "│☐ Test Song Test Artist │",
930 "│ │",
931 "│s/S: change sort──────────────────────────────────────────│",
932 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
933 ]);
934 assert_buffer_eq(&buffer, &expected);
935
936 view.handle_key_event(KeyEvent::from(KeyCode::Down));
938 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
939
940 let buffer = terminal
941 .draw(|frame| view.render(frame, props))
942 .unwrap()
943 .buffer
944 .clone();
945 let expected = Buffer::with_lines([
946 "┌Library Songs sorted by: Artist───────────────────────────┐",
947 "│q: add to queue | r: start radio | p: add to playlist ────│",
948 "│☑ Test Song Test Artist │",
949 "│ │",
950 "│s/S: change sort──────────────────────────────────────────│",
951 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
952 ]);
953
954 assert_buffer_eq(&buffer, &expected);
955 }
956
957 #[test]
958 fn test_sort_keys() {
959 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
960 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
961
962 assert_eq!(view.props.sort_mode, SongSort::Artist);
963 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
964 assert_eq!(view.props.sort_mode, SongSort::Album);
965 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
966 assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
967 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
968 assert_eq!(view.props.sort_mode, SongSort::Genre);
969 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
970 assert_eq!(view.props.sort_mode, SongSort::Title);
971 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
972 assert_eq!(view.props.sort_mode, SongSort::Artist);
973 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
974 assert_eq!(view.props.sort_mode, SongSort::Title);
975 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
976 assert_eq!(view.props.sort_mode, SongSort::Genre);
977 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
978 assert_eq!(view.props.sort_mode, SongSort::AlbumArtist);
979 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
980 assert_eq!(view.props.sort_mode, SongSort::Album);
981 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
982 assert_eq!(view.props.sort_mode, SongSort::Artist);
983 }
984
985 #[test]
986 fn smoke_navigation() {
987 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
988 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
989
990 view.handle_key_event(KeyEvent::from(KeyCode::Up));
991 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
992 view.handle_key_event(KeyEvent::from(KeyCode::Down));
993 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
994 view.handle_key_event(KeyEvent::from(KeyCode::Left));
995 view.handle_key_event(KeyEvent::from(KeyCode::Right));
996 }
997
998 #[test]
999 fn test_actions() {
1000 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1001 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
1002
1003 let (mut terminal, area) = setup_test_terminal(60, 9);
1005 let props = RenderProps {
1006 area,
1007 is_focused: true,
1008 };
1009 terminal.draw(|frame| view.render(frame, props)).unwrap();
1010
1011 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1013
1014 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1017 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1018 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1019 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1021 let action = rx.blocking_recv().unwrap();
1022 assert_eq!(
1023 action,
1024 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1025 );
1026
1027 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
1029
1030 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
1032 let action = rx.blocking_recv().unwrap();
1033 assert_eq!(
1034 action,
1035 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
1036 "song",
1037 item_id()
1038 )
1039 .into()])))
1040 );
1041
1042 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
1044 let action = rx.blocking_recv().unwrap();
1045 assert_eq!(
1046 action,
1047 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
1048 "song",
1049 item_id()
1050 )
1051 .into()],)))
1052 );
1053
1054 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
1056 let action = rx.blocking_recv().unwrap();
1057 assert_eq!(
1058 action,
1059 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
1060 "song",
1061 item_id()
1062 )
1063 .into()])))
1064 );
1065 }
1066
1067 #[test]
1068 fn test_mouse() {
1069 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1070 let mut view = LibrarySongsView::new(&state_with_everything(), tx);
1071
1072 let (mut terminal, area) = setup_test_terminal(60, 6);
1074 let props = RenderProps {
1075 area,
1076 is_focused: true,
1077 };
1078 let buffer = terminal
1079 .draw(|frame| view.render(frame, props))
1080 .unwrap()
1081 .buffer
1082 .clone();
1083 let expected = Buffer::with_lines([
1084 "┌Library Songs sorted by: Artist───────────────────────────┐",
1085 "│──────────────────────────────────────────────────────────│",
1086 "│☐ Test Song Test Artist │",
1087 "│ │",
1088 "│s/S: change sort──────────────────────────────────────────│",
1089 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1090 ]);
1091 assert_buffer_eq(&buffer, &expected);
1092
1093 view.handle_mouse_event(
1095 MouseEvent {
1096 kind: MouseEventKind::Down(MouseButton::Left),
1097 column: 2,
1098 row: 2,
1099 modifiers: KeyModifiers::empty(),
1100 },
1101 area,
1102 );
1103 let buffer = terminal
1104 .draw(|frame| view.render(frame, props))
1105 .unwrap()
1106 .buffer
1107 .clone();
1108 let expected = Buffer::with_lines([
1109 "┌Library Songs sorted by: Artist───────────────────────────┐",
1110 "│q: add to queue | r: start radio | p: add to playlist ────│",
1111 "│☑ Test Song Test Artist │",
1112 "│ │",
1113 "│s/S: change sort──────────────────────────────────────────│",
1114 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1115 ]);
1116 assert_buffer_eq(&buffer, &expected);
1117
1118 view.handle_mouse_event(
1120 MouseEvent {
1121 kind: MouseEventKind::ScrollDown,
1122 column: 2,
1123 row: 2,
1124 modifiers: KeyModifiers::empty(),
1125 },
1126 area,
1127 );
1128 let buffer = terminal
1129 .draw(|frame| view.render(frame, props))
1130 .unwrap()
1131 .buffer
1132 .clone();
1133 assert_buffer_eq(&buffer, &expected);
1134
1135 view.handle_mouse_event(
1137 MouseEvent {
1138 kind: MouseEventKind::ScrollUp,
1139 column: 2,
1140 row: 2,
1141 modifiers: KeyModifiers::empty(),
1142 },
1143 area,
1144 );
1145 let buffer = terminal
1146 .draw(|frame| view.render(frame, props))
1147 .unwrap()
1148 .buffer
1149 .clone();
1150 assert_buffer_eq(&buffer, &expected);
1151
1152 view.handle_mouse_event(
1154 MouseEvent {
1155 kind: MouseEventKind::Down(MouseButton::Left),
1156 column: 2,
1157 row: 2,
1158 modifiers: KeyModifiers::empty(),
1159 },
1160 area,
1161 );
1162 assert_eq!(
1163 rx.blocking_recv().unwrap(),
1164 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
1165 );
1166
1167 let mouse = MouseEvent {
1169 kind: MouseEventKind::Down(MouseButton::Left),
1170 column: 2,
1171 row: 3,
1172 modifiers: KeyModifiers::empty(),
1173 };
1174 view.handle_mouse_event(mouse, area);
1175 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1176 view.handle_mouse_event(mouse, area);
1177 assert_eq!(
1178 rx.try_recv(),
1179 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1180 );
1181 }
1182}