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