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