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