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