1use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
6use mecomp_prost::{PlaylistBrief, SongBrief};
7use ratatui::{
8 layout::{Constraint, Direction, Layout, Margin, Position, 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, LibraryAction, ViewAction},
17 ui::{
18 AppState,
19 colors::{BORDER_FOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, border_color},
20 components::{
21 Component, ComponentRender, RenderProps,
22 content_view::{ActiveView, views::generic::SortableItemView},
23 },
24 widgets::{
25 input_box::{self, InputBox},
26 tree::{CheckTree, state::CheckTreeState},
27 },
28 },
29};
30
31use super::{
32 PlaylistViewProps,
33 checktree_utils::create_playlist_tree_leaf,
34 sort_mode::{NameSort, SongSort},
35 traits::SortMode,
36};
37
38#[allow(clippy::module_name_repetitions)]
39pub type PlaylistView = SortableItemView<PlaylistViewProps, SongSort, SongBrief>;
40
41pub struct LibraryPlaylistsView {
42 pub action_tx: UnboundedSender<Action>,
44 props: Props,
46 tree_state: Mutex<CheckTreeState<String>>,
48 input_box: InputBox,
50 input_box_visible: bool,
52}
53
54#[derive(Debug)]
55pub struct Props {
56 pub playlists: Vec<PlaylistBrief>,
57 sort_mode: NameSort<PlaylistBrief>,
58}
59
60impl From<&AppState> for Props {
61 fn from(state: &AppState) -> Self {
62 let mut playlists = state.library.playlists.clone();
63 let sort_mode = NameSort::default();
64 sort_mode.sort_items(&mut playlists);
65 Self {
66 playlists,
67 sort_mode,
68 }
69 }
70}
71
72impl Component for LibraryPlaylistsView {
73 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
74 where
75 Self: Sized,
76 {
77 Self {
78 input_box: InputBox::new(state, action_tx.clone()),
79 input_box_visible: false,
80 action_tx,
81 props: Props::from(state),
82 tree_state: Mutex::new(CheckTreeState::default()),
83 }
84 }
85
86 fn move_with_state(self, state: &AppState) -> Self
87 where
88 Self: Sized,
89 {
90 let tree_state = if state.active_view == ActiveView::Playlists {
91 self.tree_state
92 } else {
93 Mutex::new(CheckTreeState::default())
94 };
95
96 Self {
97 props: Props::from(state),
98 tree_state,
99 ..self
100 }
101 }
102
103 fn name(&self) -> &'static str {
104 "Library Playlists View"
105 }
106
107 fn handle_key_event(&mut self, key: KeyEvent) {
108 if self.input_box_visible {
112 match key.code {
113 KeyCode::Enter => {
115 let name = self.input_box.text();
116 if !name.is_empty() {
117 self.action_tx
118 .send(Action::Library(LibraryAction::CreatePlaylist(
119 name.to_string(),
120 )))
121 .unwrap();
122 }
123 self.input_box.reset();
124 self.input_box_visible = false;
125 }
126 _ => {
128 self.input_box.handle_key_event(key);
129 }
130 }
131 } else {
132 match key.code {
133 KeyCode::PageUp => {
135 self.tree_state.lock().unwrap().select_relative(|current| {
136 current.map_or(self.props.playlists.len() - 1, |c| c.saturating_sub(10))
137 });
138 }
139 KeyCode::Up => {
140 self.tree_state.lock().unwrap().key_up();
141 }
142 KeyCode::PageDown => {
143 self.tree_state
144 .lock()
145 .unwrap()
146 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
147 }
148 KeyCode::Down => {
149 self.tree_state.lock().unwrap().key_down();
150 }
151 KeyCode::Left => {
152 self.tree_state.lock().unwrap().key_left();
153 }
154 KeyCode::Right => {
155 self.tree_state.lock().unwrap().key_right();
156 }
157 KeyCode::Enter => {
159 if self.tree_state.lock().unwrap().toggle_selected() {
160 let things = self.tree_state.lock().unwrap().get_selected_thing();
161
162 if let Some(thing) = things {
163 self.action_tx
164 .send(Action::ActiveView(ViewAction::Set(thing.into())))
165 .unwrap();
166 }
167 }
168 }
169 KeyCode::Char('s') => {
171 self.props.sort_mode = self.props.sort_mode.next();
172 self.props.sort_mode.sort_items(&mut self.props.playlists);
173 self.tree_state.lock().unwrap().scroll_selected_into_view();
174 }
175 KeyCode::Char('S') => {
176 self.props.sort_mode = self.props.sort_mode.prev();
177 self.props.sort_mode.sort_items(&mut self.props.playlists);
178 self.tree_state.lock().unwrap().scroll_selected_into_view();
179 }
180 KeyCode::Char('n') => {
182 self.input_box_visible = true;
183 }
184 KeyCode::Char('d') => {
186 let things = self.tree_state.lock().unwrap().get_selected_thing();
187
188 if let Some(thing) = things {
189 self.action_tx
190 .send(Action::Library(LibraryAction::RemovePlaylist(thing.ulid())))
191 .unwrap();
192 }
193 }
194 _ => {}
195 }
196 }
197 }
198
199 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
200 let MouseEvent {
201 kind, column, row, ..
202 } = mouse;
203 let mouse_position = Position::new(column, row);
204
205 let area = area.inner(Margin::new(1, 1));
207
208 if self.input_box_visible {
209 let [input_box_area, content_area] = lib_split_area(area);
210 let content_area = Rect {
211 y: content_area.y + 1,
212 height: content_area.height - 1,
213 ..content_area
214 };
215 if input_box_area.contains(mouse_position) {
216 self.input_box.handle_mouse_event(mouse, input_box_area);
217 } else if content_area.contains(mouse_position)
218 && kind == MouseEventKind::Down(MouseButton::Left)
219 {
220 self.input_box_visible = false;
221 }
222 } else {
223 let area = Rect {
224 y: area.y + 1,
225 height: area.height - 1,
226 ..area
227 };
228
229 let result = self
230 .tree_state
231 .lock()
232 .unwrap()
233 .handle_mouse_event(mouse, area, true);
234 if let Some(action) = result {
235 self.action_tx.send(action).unwrap();
236 }
237 }
238 }
239}
240
241fn lib_split_area(area: Rect) -> [Rect; 2] {
242 let [input_box_area, content_area] = *Layout::default()
243 .direction(Direction::Vertical)
244 .constraints([Constraint::Length(3), Constraint::Min(1)])
245 .split(area)
246 else {
247 panic!("Failed to split library playlists view area");
248 };
249 [input_box_area, content_area]
250}
251
252impl ComponentRender<RenderProps> for LibraryPlaylistsView {
253 fn render_border(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
254 let border_style = Style::default().fg(border_color(props.is_focused).into());
255
256 let border_title_bottom = if self.input_box_visible {
258 ""
259 } else {
260 " \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort"
261 };
262 let border = Block::bordered()
263 .title_top(Line::from(vec![
264 Span::styled("Library Playlists".to_string(), Style::default().bold()),
265 Span::raw(" sorted by: "),
266 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
267 ]))
268 .title_bottom(border_title_bottom)
269 .border_style(border_style);
270 let content_area = border.inner(props.area);
271 frame.render_widget(border, props.area);
272
273 let content_area = if self.input_box_visible {
275 let [input_box_area, content_area] = lib_split_area(content_area);
277
278 self.input_box.render(
280 frame,
281 input_box::RenderProps {
282 area: input_box_area,
283 text_color: (*TEXT_HIGHLIGHT_ALT).into(),
284 border: Block::bordered()
285 .title("Enter Name:")
286 .border_style(Style::default().fg((*BORDER_FOCUSED).into())),
287 show_cursor: self.input_box_visible,
288 },
289 );
290
291 content_area
292 } else {
293 content_area
294 };
295
296 let border = Block::new()
298 .borders(Borders::TOP)
299 .title_top(if self.input_box_visible {
300 " \u{23CE} : Create (cancel if empty)"
301 } else {
302 "n: new playlist | d: delete playlist"
303 })
304 .border_style(border_style);
305 let area = border.inner(content_area);
306 frame.render_widget(border, content_area);
307
308 RenderProps { area, ..props }
309 }
310
311 fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
312 let items = self
314 .props
315 .playlists
316 .iter()
317 .map(create_playlist_tree_leaf)
318 .collect::<Vec<_>>();
319
320 frame.render_stateful_widget(
322 CheckTree::new(&items)
323 .unwrap()
324 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
325 .node_unchecked_symbol("▪ ")
327 .node_checked_symbol("▪ ")
328 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
329 props.area,
330 &mut self.tree_state.lock().unwrap(),
331 );
332 }
333}
334
335#[cfg(test)]
336mod sort_mode_tests {
337 use super::*;
338 use mecomp_prost::RecordId;
339 use pretty_assertions::assert_eq;
340 use rstest::rstest;
341
342 #[rstest]
343 #[case(NameSort::default(), NameSort::default())]
344 fn test_sort_mode_next_prev(
345 #[case] mode: NameSort<PlaylistBrief>,
346 #[case] expected: NameSort<PlaylistBrief>,
347 ) {
348 assert_eq!(mode.next(), expected);
349 assert_eq!(mode.next().prev(), mode);
350 }
351
352 #[rstest]
353 #[case(NameSort::default(), "Name")]
354 fn test_sort_mode_display(#[case] mode: NameSort<PlaylistBrief>, #[case] expected: &str) {
355 assert_eq!(mode.to_string(), expected);
356 }
357
358 #[rstest]
359 fn test_sort_items() {
360 let mut songs = vec![
361 PlaylistBrief {
362 id: RecordId::new("playlist", "playlist1"),
363 name: "C".into(),
364 },
365 PlaylistBrief {
366 id: RecordId::new("playlist", "playlist2"),
367 name: "A".into(),
368 },
369 PlaylistBrief {
370 id: RecordId::new("playlist", "playlist3"),
371 name: "B".into(),
372 },
373 ];
374
375 NameSort::default().sort_items(&mut songs);
376 assert_eq!(songs[0].name, "A");
377 assert_eq!(songs[1].name, "B");
378 assert_eq!(songs[2].name, "C");
379 }
380}
381
382#[cfg(test)]
383mod item_view_tests {
384 use super::*;
385 use crate::{
386 state::action::{AudioAction, PopupAction, QueueAction},
387 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
388 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
389 };
390 use crossterm::event::KeyModifiers;
391 use pretty_assertions::assert_eq;
392 use ratatui::buffer::Buffer;
393
394 #[test]
395 fn test_new() {
396 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
397 let state = state_with_everything();
398 let view = PlaylistView::new(&state, tx).item_view;
399
400 assert_eq!(view.name(), "Playlist View");
401 assert_eq!(
402 view.props,
403 Some(state.additional_view_data.playlist.unwrap())
404 );
405 }
406
407 #[test]
408 fn test_move_with_state() {
409 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
410 let state = AppState::default();
411 let new_state = state_with_everything();
412 let view = PlaylistView::new(&state, tx)
413 .move_with_state(&new_state)
414 .item_view;
415
416 assert_eq!(
417 view.props,
418 Some(new_state.additional_view_data.playlist.unwrap())
419 );
420 }
421 #[test]
422 fn test_render_no_playlist() {
423 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
424 let view = PlaylistView::new(&AppState::default(), tx);
425
426 let (mut terminal, area) = setup_test_terminal(20, 3);
427 let props = RenderProps {
428 area,
429 is_focused: true,
430 };
431 let buffer = terminal
432 .draw(|frame| view.render(frame, props))
433 .unwrap()
434 .buffer
435 .clone();
436 #[rustfmt::skip]
437 let expected = Buffer::with_lines([
438 "┌Playlist View─────┐",
439 "│No active playlist│",
440 "└──────────────────┘",
441 ]);
442
443 assert_buffer_eq(&buffer, &expected);
444 }
445
446 #[test]
447 fn test_render() {
448 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
449 let view = PlaylistView::new(&state_with_everything(), tx);
450
451 let (mut terminal, area) = setup_test_terminal(60, 9);
452 let props = RenderProps {
453 area,
454 is_focused: true,
455 };
456 let buffer = terminal
457 .draw(|frame| view.render(frame, props))
458 .unwrap()
459 .buffer
460 .clone();
461 let expected = Buffer::with_lines([
462 "┌Playlist View sorted by: Artist───────────────────────────┐",
463 "│ Test Playlist │",
464 "│ Songs: 1 Duration: 00:03:00.00 │",
465 "│ │",
466 "│q: add to queue | r: start radio | p: add to playlist─────│",
467 "│Performing operations on entire playlist──────────────────│",
468 "│☐ Test Song Test Artist │",
469 "│s/S: sort | d: remove selected | e: edit──────────────────│",
470 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
471 ]);
472
473 assert_buffer_eq(&buffer, &expected);
474 }
475
476 #[test]
477 fn test_render_with_checked() {
478 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
479 let mut view = PlaylistView::new(&state_with_everything(), tx);
480 let (mut terminal, area) = setup_test_terminal(60, 9);
481 let props = RenderProps {
482 area,
483 is_focused: true,
484 };
485 let buffer = terminal
486 .draw(|frame| view.render(frame, props))
487 .unwrap()
488 .buffer
489 .clone();
490 let expected = Buffer::with_lines([
491 "┌Playlist View sorted by: Artist───────────────────────────┐",
492 "│ Test Playlist │",
493 "│ Songs: 1 Duration: 00:03:00.00 │",
494 "│ │",
495 "│q: add to queue | r: start radio | p: add to playlist─────│",
496 "│Performing operations on entire playlist──────────────────│",
497 "│☐ Test Song Test Artist │",
498 "│s/S: sort | d: remove selected | e: edit──────────────────│",
499 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
500 ]);
501 assert_buffer_eq(&buffer, &expected);
502
503 view.handle_key_event(KeyEvent::from(KeyCode::Down));
505 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
506
507 let buffer = terminal
508 .draw(|frame| view.render(frame, props))
509 .unwrap()
510 .buffer
511 .clone();
512 let expected = Buffer::with_lines([
513 "┌Playlist View sorted by: Artist───────────────────────────┐",
514 "│ Test Playlist │",
515 "│ Songs: 1 Duration: 00:03:00.00 │",
516 "│ │",
517 "│q: add to queue | r: start radio | p: add to playlist─────│",
518 "│Performing operations on checked items────────────────────│",
519 "│☑ Test Song Test Artist │",
520 "│s/S: sort | d: remove selected | e: edit──────────────────│",
521 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
522 ]);
523
524 assert_buffer_eq(&buffer, &expected);
525 }
526
527 #[test]
528 fn smoke_navigation_and_sort() {
529 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
530 let mut view = PlaylistView::new(&state_with_everything(), tx);
531
532 view.handle_key_event(KeyEvent::from(KeyCode::Up));
533 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
534 view.handle_key_event(KeyEvent::from(KeyCode::Down));
535 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
536 view.handle_key_event(KeyEvent::from(KeyCode::Left));
537 view.handle_key_event(KeyEvent::from(KeyCode::Right));
538 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
539 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
540 }
541
542 #[test]
543 fn test_actions() {
544 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
545 let mut view = PlaylistView::new(&state_with_everything(), tx);
546
547 let (mut terminal, area) = setup_test_terminal(60, 9);
549 let props = RenderProps {
550 area,
551 is_focused: true,
552 };
553 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
554
555 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
558 assert_eq!(
559 rx.blocking_recv().unwrap(),
560 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
561 ("playlist", item_id()).into()
562 ])))
563 );
564 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
565 assert_eq!(
566 rx.blocking_recv().unwrap(),
567 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
568 ("playlist", item_id()).into()
569 ],)))
570 );
571 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
572 assert_eq!(
573 rx.blocking_recv().unwrap(),
574 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
575 ("playlist", item_id()).into()
576 ])))
577 );
578 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
579
580 view.handle_key_event(KeyEvent::from(KeyCode::Up));
583 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
584
585 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
587 assert_eq!(
588 rx.blocking_recv().unwrap(),
589 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
590 );
591
592 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
594
595 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
597 assert_eq!(
598 rx.blocking_recv().unwrap(),
599 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
600 ("song", item_id()).into()
601 ])))
602 );
603
604 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
606 assert_eq!(
607 rx.blocking_recv().unwrap(),
608 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
609 ("song", item_id()).into()
610 ],)))
611 );
612
613 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
615 assert_eq!(
616 rx.blocking_recv().unwrap(),
617 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
618 ("song", item_id()).into()
619 ])))
620 );
621
622 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
624 assert_eq!(
625 rx.blocking_recv().unwrap(),
626 Action::Library(LibraryAction::RemoveSongsFromPlaylist(
627 item_id(),
628 vec![("song", item_id()).into()]
629 ))
630 );
631 }
632
633 #[test]
634 #[allow(clippy::too_many_lines)]
635 fn test_mouse_event() {
636 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
637 let mut view = PlaylistView::new(&state_with_everything(), tx);
638
639 let (mut terminal, area) = setup_test_terminal(60, 9);
641 let props = RenderProps {
642 area,
643 is_focused: true,
644 };
645 let buffer = terminal
646 .draw(|frame| view.render(frame, props))
647 .unwrap()
648 .buffer
649 .clone();
650 let expected = Buffer::with_lines([
651 "┌Playlist View sorted by: Artist───────────────────────────┐",
652 "│ Test Playlist │",
653 "│ Songs: 1 Duration: 00:03:00.00 │",
654 "│ │",
655 "│q: add to queue | r: start radio | p: add to playlist─────│",
656 "│Performing operations on entire playlist──────────────────│",
657 "│☐ Test Song Test Artist │",
658 "│s/S: sort | d: remove selected | e: edit──────────────────│",
659 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
660 ]);
661 assert_buffer_eq(&buffer, &expected);
662
663 view.handle_mouse_event(
665 MouseEvent {
666 kind: MouseEventKind::Down(MouseButton::Left),
667 column: 2,
668 row: 6,
669 modifiers: KeyModifiers::empty(),
670 },
671 area,
672 );
673 let buffer = terminal
674 .draw(|frame| view.render(frame, props))
675 .unwrap()
676 .buffer
677 .clone();
678 let expected = Buffer::with_lines([
679 "┌Playlist View sorted by: Artist───────────────────────────┐",
680 "│ Test Playlist │",
681 "│ Songs: 1 Duration: 00:03:00.00 │",
682 "│ │",
683 "│q: add to queue | r: start radio | p: add to playlist─────│",
684 "│Performing operations on checked items────────────────────│",
685 "│☑ Test Song Test Artist │",
686 "│s/S: sort | d: remove selected | e: edit──────────────────│",
687 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
688 ]);
689 assert_buffer_eq(&buffer, &expected);
690
691 view.handle_mouse_event(
693 MouseEvent {
694 kind: MouseEventKind::Down(MouseButton::Left),
695 column: 2,
696 row: 6,
697 modifiers: KeyModifiers::empty(),
698 },
699 area,
700 );
701 let expected = Buffer::with_lines([
702 "┌Playlist View sorted by: Artist───────────────────────────┐",
703 "│ Test Playlist │",
704 "│ Songs: 1 Duration: 00:03:00.00 │",
705 "│ │",
706 "│q: add to queue | r: start radio | p: add to playlist─────│",
707 "│Performing operations on entire playlist──────────────────│",
708 "│☐ Test Song Test Artist │",
709 "│s/S: sort | d: remove selected | e: edit──────────────────│",
710 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
711 ]);
712 let buffer = terminal
713 .draw(|frame| view.render(frame, props))
714 .unwrap()
715 .buffer
716 .clone();
717 assert_buffer_eq(&buffer, &expected);
718 for _ in 0..2 {
720 view.handle_mouse_event(
721 MouseEvent {
722 kind: MouseEventKind::Down(MouseButton::Left),
723 column: 2,
724 row: 6,
725 modifiers: KeyModifiers::CONTROL,
726 },
727 area,
728 );
729 assert_eq!(
730 rx.blocking_recv().unwrap(),
731 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
732 );
733 }
734
735 view.handle_mouse_event(
737 MouseEvent {
738 kind: MouseEventKind::ScrollDown,
739 column: 2,
740 row: 6,
741 modifiers: KeyModifiers::empty(),
742 },
743 area,
744 );
745 let buffer = terminal
746 .draw(|frame| view.render(frame, props))
747 .unwrap()
748 .buffer
749 .clone();
750 assert_buffer_eq(&buffer, &expected);
751 view.handle_mouse_event(
753 MouseEvent {
754 kind: MouseEventKind::ScrollUp,
755 column: 2,
756 row: 7,
757 modifiers: KeyModifiers::empty(),
758 },
759 area,
760 );
761 let buffer = terminal
762 .draw(|frame| view.render(frame, props))
763 .unwrap()
764 .buffer
765 .clone();
766 assert_buffer_eq(&buffer, &expected);
767 }
768}
769
770#[cfg(test)]
771mod library_view_tests {
772 use super::*;
773 use crate::{
774 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
775 ui::components::content_view::ActiveView,
776 };
777 use crossterm::event::KeyModifiers;
778 use pretty_assertions::assert_eq;
779 use ratatui::buffer::Buffer;
780
781 #[test]
782 fn test_new() {
783 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
784 let state = state_with_everything();
785 let view = LibraryPlaylistsView::new(&state, tx);
786
787 assert_eq!(view.name(), "Library Playlists View");
788 assert_eq!(view.props.playlists, state.library.playlists);
789 }
790
791 #[test]
792 fn test_move_with_state() {
793 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
794 let state = AppState::default();
795 let new_state = state_with_everything();
796 let view = LibraryPlaylistsView::new(&state, tx).move_with_state(&new_state);
797
798 assert_eq!(view.props.playlists, new_state.library.playlists);
799 }
800
801 #[test]
802 fn test_render() {
803 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
804 let view = LibraryPlaylistsView::new(&state_with_everything(), tx);
805
806 let (mut terminal, area) = setup_test_terminal(60, 6);
807 let props = RenderProps {
808 area,
809 is_focused: true,
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 "┌Library Playlists sorted by: Name─────────────────────────┐",
818 "│n: new playlist | d: delete playlist──────────────────────│",
819 "│▪ Test Playlist │",
820 "│ │",
821 "│ │",
822 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
823 ]);
824
825 assert_buffer_eq(&buffer, &expected);
826 }
827
828 #[test]
829 fn test_render_input_box() {
830 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
831 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
832
833 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
835
836 let (mut terminal, area) = setup_test_terminal(60, 7);
837 let props = RenderProps {
838 area,
839 is_focused: true,
840 };
841 let buffer = terminal
842 .draw(|frame| view.render(frame, props))
843 .unwrap()
844 .buffer
845 .clone();
846 let expected = Buffer::with_lines([
847 "┌Library Playlists sorted by: Name─────────────────────────┐",
848 "│┌Enter Name:─────────────────────────────────────────────┐│",
849 "││ ││",
850 "│└────────────────────────────────────────────────────────┘│",
851 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
852 "│▪ Test Playlist │",
853 "└──────────────────────────────────────────────────────────┘",
854 ]);
855
856 assert_buffer_eq(&buffer, &expected);
857 }
858
859 #[test]
860 fn test_sort_keys() {
861 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
862 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
863
864 assert_eq!(view.props.sort_mode, NameSort::default());
865 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
866 assert_eq!(view.props.sort_mode, NameSort::default());
867 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
868 assert_eq!(view.props.sort_mode, NameSort::default());
869 }
870
871 #[test]
872 fn smoke_navigation() {
873 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
874 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
875
876 view.handle_key_event(KeyEvent::from(KeyCode::Up));
877 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
878 view.handle_key_event(KeyEvent::from(KeyCode::Down));
879 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
880 view.handle_key_event(KeyEvent::from(KeyCode::Left));
881 view.handle_key_event(KeyEvent::from(KeyCode::Right));
882 }
883
884 #[test]
885 fn test_actions() {
886 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
887 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
888
889 let (mut terminal, area) = setup_test_terminal(60, 9);
891 let props = RenderProps {
892 area,
893 is_focused: true,
894 };
895 terminal.draw(|frame| view.render(frame, props)).unwrap();
896
897 view.handle_key_event(KeyEvent::from(KeyCode::Down));
899
900 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
902 assert_eq!(
903 rx.blocking_recv().unwrap(),
904 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
905 );
906
907 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
909 assert_eq!(view.input_box_visible, true);
910 view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
911 view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
912 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
913 assert_eq!(view.input_box_visible, false);
914 assert_eq!(
915 rx.blocking_recv().unwrap(),
916 Action::Library(LibraryAction::CreatePlaylist("ab".to_string()))
917 );
918
919 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
921 assert_eq!(
922 rx.blocking_recv().unwrap(),
923 Action::Library(LibraryAction::RemovePlaylist(item_id()))
924 );
925 }
926
927 #[test]
928 fn test_mouse_event() {
929 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
930 let mut view = LibraryPlaylistsView::new(&state_with_everything(), tx);
931
932 let (mut terminal, area) = setup_test_terminal(60, 9);
934 let props = RenderProps {
935 area,
936 is_focused: true,
937 };
938 let buffer = terminal
939 .draw(|frame| view.render(frame, props))
940 .unwrap()
941 .buffer
942 .clone();
943 let expected = Buffer::with_lines([
944 "┌Library Playlists sorted by: Name─────────────────────────┐",
945 "│n: new playlist | d: delete playlist──────────────────────│",
946 "│▪ Test Playlist │",
947 "│ │",
948 "│ │",
949 "│ │",
950 "│ │",
951 "│ │",
952 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
953 ]);
954 assert_buffer_eq(&buffer, &expected);
955
956 view.handle_mouse_event(
958 MouseEvent {
959 kind: MouseEventKind::Down(MouseButton::Left),
960 column: 2,
961 row: 2,
962 modifiers: KeyModifiers::empty(),
963 },
964 area,
965 );
966 assert_eq!(
967 rx.blocking_recv().unwrap(),
968 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
969 );
970 let buffer = terminal
971 .draw(|frame| view.render(frame, props))
972 .unwrap()
973 .buffer
974 .clone();
975 assert_buffer_eq(&buffer, &expected);
976
977 view.handle_mouse_event(
979 MouseEvent {
980 kind: MouseEventKind::ScrollDown,
981 column: 2,
982 row: 2,
983 modifiers: KeyModifiers::empty(),
984 },
985 area,
986 );
987
988 view.handle_mouse_event(
990 MouseEvent {
991 kind: MouseEventKind::Down(MouseButton::Left),
992 column: 2,
993 row: 2,
994 modifiers: KeyModifiers::empty(),
995 },
996 area,
997 );
998 assert_eq!(
999 rx.blocking_recv().unwrap(),
1000 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1001 );
1002 let buffer = terminal
1003 .draw(|frame| view.render(frame, props))
1004 .unwrap()
1005 .buffer
1006 .clone();
1007 assert_buffer_eq(&buffer, &expected);
1008
1009 view.handle_mouse_event(
1011 MouseEvent {
1012 kind: MouseEventKind::ScrollUp,
1013 column: 2,
1014 row: 3,
1015 modifiers: KeyModifiers::empty(),
1016 },
1017 area,
1018 );
1019
1020 view.handle_mouse_event(
1022 MouseEvent {
1023 kind: MouseEventKind::Down(MouseButton::Left),
1024 column: 2,
1025 row: 2,
1026 modifiers: KeyModifiers::empty(),
1027 },
1028 area,
1029 );
1030 assert_eq!(
1031 rx.blocking_recv().unwrap(),
1032 Action::ActiveView(ViewAction::Set(ActiveView::Playlist(item_id())))
1033 );
1034
1035 let mouse = MouseEvent {
1037 kind: MouseEventKind::Down(MouseButton::Left),
1038 column: 2,
1039 row: 3,
1040 modifiers: KeyModifiers::empty(),
1041 };
1042 view.handle_mouse_event(mouse, area);
1043 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1044 view.handle_mouse_event(mouse, area);
1045 assert_eq!(
1046 rx.try_recv(),
1047 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1048 );
1049 }
1050}