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