1use std::{str::FromStr, sync::Mutex};
2
3use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
4use mecomp_prost::{DynamicPlaylist, SongBrief};
5use mecomp_storage::db::schemas::dynamic::query::Query;
6use ratatui::{
7 Frame,
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::{
20 BORDER_FOCUSED, BORDER_UNFOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL,
21 border_color,
22 },
23 components::{
24 Component, ComponentRender, RenderProps,
25 content_view::{ActiveView, views::generic::SortableItemView},
26 },
27 widgets::{
28 input_box::{self, InputBox},
29 tree::{CheckTree, state::CheckTreeState},
30 },
31 },
32};
33
34use super::{
35 DynamicPlaylistViewProps,
36 checktree_utils::create_dynamic_playlist_tree_leaf,
37 sort_mode::{NameSort, SongSort},
38 traits::SortMode,
39};
40
41pub struct QueryBuilder {
46 inner: InputBox,
47}
48
49impl QueryBuilder {
50 #[must_use]
51 pub fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self {
52 Self {
53 inner: InputBox::new(state, action_tx),
54 }
55 }
56
57 #[must_use]
58 pub fn text(&self) -> &str {
59 self.inner.text()
60 }
61
62 pub fn handle_key_event(&mut self, key: KeyEvent) {
63 self.inner.handle_key_event(key);
64 }
65
66 pub fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
67 self.inner.handle_mouse_event(mouse, area);
68 }
69
70 pub fn reset(&mut self) {
71 self.inner.reset();
72 }
73}
74
75#[allow(clippy::module_name_repetitions)]
76pub type DynamicView = SortableItemView<DynamicPlaylistViewProps, SongSort, SongBrief>;
77
78pub struct LibraryDynamicView {
79 pub action_tx: UnboundedSender<Action>,
81 props: Props,
83 tree_state: Mutex<CheckTreeState<String>>,
85 name_input_box: InputBox,
87 query_builder: QueryBuilder,
89 focus: Focus,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
95enum Focus {
96 NameInput,
97 QueryInput,
98 Tree,
99}
100
101#[derive(Debug)]
102pub struct Props {
103 pub dynamics: Vec<DynamicPlaylist>,
104 sort_mode: NameSort<DynamicPlaylist>,
105}
106
107impl From<&AppState> for Props {
108 fn from(state: &AppState) -> Self {
109 let mut dynamics = state.library.dynamic_playlists.clone();
110 let sort_mode = NameSort::default();
111 sort_mode.sort_items(&mut dynamics);
112 Self {
113 dynamics,
114 sort_mode,
115 }
116 }
117}
118
119impl Component for LibraryDynamicView {
120 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
121 where
122 Self: Sized,
123 {
124 Self {
125 name_input_box: InputBox::new(state, action_tx.clone()),
126 query_builder: QueryBuilder::new(state, action_tx.clone()),
127 focus: Focus::Tree,
128 action_tx,
129 props: Props::from(state),
130 tree_state: Mutex::new(CheckTreeState::default()),
131 }
132 }
133
134 fn move_with_state(self, state: &AppState) -> Self
135 where
136 Self: Sized,
137 {
138 let tree_state = if state.active_view == ActiveView::DynamicPlaylists {
139 self.tree_state
140 } else {
141 Mutex::new(CheckTreeState::default())
142 };
143
144 Self {
145 props: Props::from(state),
146 tree_state,
147 ..self
148 }
149 }
150
151 fn name(&self) -> &'static str {
152 "Library Dynamic Playlists View"
153 }
154
155 fn handle_key_event(&mut self, key: KeyEvent) {
156 if self.focus == Focus::Tree {
160 match key.code {
161 KeyCode::PageUp => {
163 self.tree_state.lock().unwrap().select_relative(|current| {
164 current.map_or(self.props.dynamics.len() - 1, |c| c.saturating_sub(10))
165 });
166 }
167 KeyCode::Up => {
168 self.tree_state.lock().unwrap().key_up();
169 }
170 KeyCode::PageDown => {
171 self.tree_state
172 .lock()
173 .unwrap()
174 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
175 }
176 KeyCode::Down => {
177 self.tree_state.lock().unwrap().key_down();
178 }
179 KeyCode::Left => {
180 self.tree_state.lock().unwrap().key_left();
181 }
182 KeyCode::Right => {
183 self.tree_state.lock().unwrap().key_right();
184 }
185 KeyCode::Enter => {
187 if self.tree_state.lock().unwrap().toggle_selected() {
188 let things = self.tree_state.lock().unwrap().get_selected_thing();
189
190 if let Some(thing) = things {
191 self.action_tx
192 .send(Action::ActiveView(ViewAction::Set(thing.into())))
193 .unwrap();
194 }
195 }
196 }
197 KeyCode::Char('s') => {
199 self.props.sort_mode = self.props.sort_mode.next();
200 self.props.sort_mode.sort_items(&mut self.props.dynamics);
201 self.tree_state.lock().unwrap().scroll_selected_into_view();
202 }
203 KeyCode::Char('S') => {
204 self.props.sort_mode = self.props.sort_mode.prev();
205 self.props.sort_mode.sort_items(&mut self.props.dynamics);
206 self.tree_state.lock().unwrap().scroll_selected_into_view();
207 }
208 KeyCode::Char('n') => {
210 self.focus = Focus::NameInput;
211 }
212 KeyCode::Char('d') => {
214 let things = self.tree_state.lock().unwrap().get_selected_thing();
215
216 if let Some(thing) = things {
217 self.action_tx
218 .send(Action::Library(LibraryAction::RemoveDynamicPlaylist(
219 thing.ulid(),
220 )))
221 .unwrap();
222 }
223 }
224 _ => {}
225 }
226 } else {
227 let query = Query::from_str(self.query_builder.text()).ok();
228 let name = self.name_input_box.text();
229
230 match (key.code, query, self.focus) {
231 (KeyCode::Enter, _, Focus::NameInput) if name.is_empty() => {
233 self.focus = Focus::Tree;
234 }
235 (KeyCode::Enter, _, Focus::NameInput) if !name.is_empty() => {
237 self.focus = Focus::QueryInput;
238 }
239 (KeyCode::Enter, Some(query), Focus::QueryInput) => {
241 self.action_tx
242 .send(Action::Library(LibraryAction::CreateDynamicPlaylist(
243 name.to_string(),
244 query,
245 )))
246 .unwrap();
247 self.name_input_box.reset();
248 self.query_builder.reset();
249 self.focus = Focus::Tree;
250 }
251 (_, _, Focus::NameInput) => self.name_input_box.handle_key_event(key),
253 (_, _, Focus::QueryInput) => self.query_builder.handle_key_event(key),
254 (_, _, Focus::Tree) => unreachable!(),
255 }
256 }
257 }
258
259 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
260 let MouseEvent {
261 kind, column, row, ..
262 } = mouse;
263 let mouse_position = Position::new(column, row);
264
265 let area = area.inner(Margin::new(1, 1));
267
268 if self.focus == Focus::Tree {
269 let area = Rect {
270 y: area.y + 1,
271 height: area.height - 1,
272 ..area
273 };
274
275 let result = self
276 .tree_state
277 .lock()
278 .unwrap()
279 .handle_mouse_event(mouse, area, true);
280 if let Some(action) = result {
281 self.action_tx.send(action).unwrap();
282 }
283 } else {
284 let [input_box_area, query_builder_area, content_area] = lib_split_area(area);
285 let content_area = Rect {
286 y: content_area.y + 1,
287 height: content_area.height - 1,
288 ..content_area
289 };
290
291 if input_box_area.contains(mouse_position) {
292 if kind == MouseEventKind::Down(MouseButton::Left) {
293 self.focus = Focus::NameInput;
294 }
295 self.name_input_box
296 .handle_mouse_event(mouse, input_box_area);
297 } else if query_builder_area.contains(mouse_position) {
298 if kind == MouseEventKind::Down(MouseButton::Left) {
299 self.focus = Focus::QueryInput;
300 }
301 self.query_builder
302 .handle_mouse_event(mouse, query_builder_area);
303 } else if content_area.contains(mouse_position)
304 && kind == MouseEventKind::Down(MouseButton::Left)
305 {
306 self.focus = Focus::Tree;
307 }
308 }
309 }
310}
311
312fn lib_split_area(area: Rect) -> [Rect; 3] {
313 let [input_box_area, query_builder_area, content_area] = *Layout::default()
314 .direction(Direction::Vertical)
315 .constraints([
316 Constraint::Length(3),
317 Constraint::Length(3),
318 Constraint::Min(1),
319 ])
320 .split(area)
321 else {
322 panic!("Failed to split library dynamic playlists view area");
323 };
324 [input_box_area, query_builder_area, content_area]
325}
326
327impl ComponentRender<RenderProps> for LibraryDynamicView {
328 fn render_border(&self, frame: &mut Frame<'_>, props: RenderProps) -> RenderProps {
329 let border_style = Style::default().fg(border_color(props.is_focused).into());
330
331 let border_title_bottom = if self.focus == Focus::Tree {
333 " \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort"
334 } else {
335 ""
336 };
337 let border = Block::bordered()
338 .title_top(Line::from(vec![
339 Span::styled(
340 "Library Dynamic Playlists".to_string(),
341 Style::default().bold(),
342 ),
343 Span::raw(" sorted by: "),
344 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
345 ]))
346 .title_bottom(border_title_bottom)
347 .border_style(border_style);
348 let content_area = border.inner(props.area);
349 frame.render_widget(border, props.area);
350
351 let content_area = if self.focus == Focus::Tree {
353 content_area
354 } else {
355 let [input_box_area, query_builder_area, content_area] = lib_split_area(content_area);
357
358 let (name_text_color, query_text_color) = match self.focus {
359 Focus::NameInput => ((*TEXT_HIGHLIGHT_ALT).into(), (*TEXT_HIGHLIGHT).into()),
360 Focus::QueryInput => ((*TEXT_HIGHLIGHT).into(), (*TEXT_HIGHLIGHT_ALT).into()),
361 Focus::Tree => ((*TEXT_NORMAL).into(), (*TEXT_NORMAL).into()),
362 };
363
364 let (name_border_color, query_border_color) = match self.focus {
365 Focus::NameInput => ((*BORDER_FOCUSED).into(), (*BORDER_UNFOCUSED).into()),
366 Focus::QueryInput => ((*BORDER_UNFOCUSED).into(), (*BORDER_FOCUSED).into()),
367 Focus::Tree => ((*BORDER_UNFOCUSED).into(), (*BORDER_UNFOCUSED).into()),
368 };
369
370 let (name_show_cursor, query_show_cursor) = match self.focus {
371 Focus::NameInput => (true, false),
372 Focus::QueryInput => (false, true),
373 Focus::Tree => (false, false),
374 };
375
376 self.name_input_box.render(
378 frame,
379 input_box::RenderProps {
380 area: input_box_area,
381 text_color: name_text_color,
382 border: Block::bordered()
383 .title("Enter Name:")
384 .border_style(Style::default().fg(name_border_color)),
385 show_cursor: name_show_cursor,
386 },
387 );
388
389 let query_builder_props = if Query::from_str(self.query_builder.text()).is_ok() {
391 input_box::RenderProps {
392 area: query_builder_area,
393 text_color: query_text_color,
394 border: Block::bordered()
395 .title("Enter Query:")
396 .border_style(Style::default().fg(query_border_color)),
397 show_cursor: query_show_cursor,
398 }
399 } else {
400 input_box::RenderProps {
401 area: query_builder_area,
402 text_color: (*TEXT_HIGHLIGHT).into(),
403 border: Block::bordered()
404 .title("Invalid Query:")
405 .border_style(Style::default().fg(query_border_color)),
406 show_cursor: query_show_cursor,
407 }
408 };
409 self.query_builder.inner.render(frame, query_builder_props);
410
411 content_area
412 };
413
414 let border = Block::new()
416 .borders(Borders::TOP)
417 .title_top(match self.focus {
418 Focus::NameInput => " \u{23CE} : Set (cancel if empty)",
419 Focus::QueryInput => " \u{23CE} : Create (cancel if empty)",
420 Focus::Tree => "n: new dynamic | d: delete dynamic",
421 })
422 .border_style(border_style);
423 let area = border.inner(content_area);
424 frame.render_widget(border, content_area);
425
426 RenderProps { area, ..props }
427 }
428
429 fn render_content(&self, frame: &mut Frame<'_>, props: RenderProps) {
430 let items = self
432 .props
433 .dynamics
434 .iter()
435 .map(create_dynamic_playlist_tree_leaf)
436 .collect::<Vec<_>>();
437
438 frame.render_stateful_widget(
440 CheckTree::new(&items)
441 .unwrap()
442 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
443 .node_unchecked_symbol("▪ ")
445 .node_checked_symbol("▪ ")
446 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
447 props.area,
448 &mut self.tree_state.lock().unwrap(),
449 );
450 }
451}
452
453#[cfg(test)]
454mod item_view_tests {
455 use super::*;
456 use crate::{
457 state::action::{AudioAction, PopupAction, QueueAction},
458 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
459 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
460 };
461 use crossterm::event::KeyModifiers;
462 use mecomp_prost::RecordId;
463 use pretty_assertions::assert_eq;
464 use ratatui::buffer::Buffer;
465 use tokio::sync::mpsc::unbounded_channel;
466
467 #[test]
468 fn test_new() {
469 let (tx, _) = unbounded_channel();
470 let state = state_with_everything();
471 let view = DynamicView::new(&state, tx).item_view;
472
473 assert_eq!(view.name(), "Dynamic Playlist View");
474 assert_eq!(
475 view.props,
476 Some(state.additional_view_data.dynamic_playlist.unwrap())
477 );
478 }
479
480 #[test]
481 fn test_move_with_state() {
482 let (tx, _) = unbounded_channel();
483 let state = AppState::default();
484 let view = DynamicView::new(&state, tx);
485
486 let new_state = state_with_everything();
487 let new_view = view.move_with_state(&new_state).item_view;
488
489 assert_eq!(
490 new_view.props,
491 Some(new_state.additional_view_data.dynamic_playlist.unwrap())
492 );
493 }
494
495 #[test]
496 fn test_name() {
497 let (tx, _) = unbounded_channel();
498 let state = state_with_everything();
499 let view = DynamicView::new(&state, tx);
500
501 assert_eq!(view.name(), "Dynamic Playlist View");
502 }
503
504 #[test]
505 fn smoke_navigation_and_sort() {
506 let (tx, _) = unbounded_channel();
507 let state = state_with_everything();
508 let mut view = DynamicView::new(&state, tx);
509
510 view.handle_key_event(KeyEvent::from(KeyCode::Up));
511 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
512 view.handle_key_event(KeyEvent::from(KeyCode::Down));
513 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
514 view.handle_key_event(KeyEvent::from(KeyCode::Left));
515 view.handle_key_event(KeyEvent::from(KeyCode::Right));
516 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
517 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
518 }
519
520 #[test]
521 fn test_actions() {
522 let (tx, mut rx) = unbounded_channel();
523 let state = state_with_everything();
524 let mut view = DynamicView::new(&state, tx);
525
526 let (mut terminal, area) = setup_test_terminal(60, 11);
528 let props = RenderProps {
529 area,
530 is_focused: true,
531 };
532 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
533
534 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
540 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
541 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
542 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
543 let dynamic_playlists_id = state
544 .additional_view_data
545 .dynamic_playlist
546 .as_ref()
547 .unwrap()
548 .id
549 .clone();
550 assert_eq!(
551 rx.blocking_recv(),
552 Some(Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
553 dynamic_playlists_id.clone()
554 ]))))
555 );
556 assert_eq!(
557 rx.blocking_recv().unwrap(),
558 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
559 dynamic_playlists_id.clone()
560 ],)))
561 );
562 assert_eq!(
563 rx.blocking_recv(),
564 Some(Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
565 dynamic_playlists_id
566 ]))))
567 );
568
569 view.handle_key_event(KeyEvent::from(KeyCode::Down));
571 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
572
573 let song_id: RecordId = ("song", item_id()).into();
579 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
580 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
581 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
582 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
583 assert_eq!(
584 rx.blocking_recv().unwrap(),
585 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
586 );
587 assert_eq!(
588 rx.blocking_recv().unwrap(),
589 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![song_id.clone()])))
590 );
591 assert_eq!(
592 rx.blocking_recv().unwrap(),
593 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![song_id.clone()],)))
594 );
595 assert_eq!(
596 rx.blocking_recv().unwrap(),
597 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![song_id])))
598 );
599 }
600
601 #[test]
602 fn test_edit() {
603 let (tx, mut rx) = unbounded_channel();
604 let state = state_with_everything();
605 let mut view = DynamicView::new(&state, tx);
606
607 let (mut terminal, area) = setup_test_terminal(60, 11);
609 let props = RenderProps {
610 area,
611 is_focused: true,
612 };
613 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
614
615 view.handle_key_event(KeyEvent::from(KeyCode::Char('e')));
617 assert_eq!(
618 rx.blocking_recv().unwrap(),
619 Action::Popup(PopupAction::Open(PopupType::DynamicPlaylistEditor(
620 state
621 .additional_view_data
622 .dynamic_playlist
623 .as_ref()
624 .unwrap()
625 .dynamic_playlist
626 .clone()
627 )))
628 );
629 }
630
631 #[test]
632 fn test_mouse_events() {
633 let (tx, mut rx) = unbounded_channel();
634 let state = state_with_everything();
635 let mut view = DynamicView::new(&state, tx);
636
637 let (mut terminal, area) = setup_test_terminal(60, 11);
639 let props = RenderProps {
640 area,
641 is_focused: true,
642 };
643 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
644
645 assert_eq!(
647 view.item_view
648 .tree_state
649 .lock()
650 .unwrap()
651 .get_selected_thing(),
652 None
653 );
654 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 assert_eq!(
664 view.item_view
665 .tree_state
666 .lock()
667 .unwrap()
668 .get_selected_thing(),
669 Some(("song", item_id()).into())
670 );
671
672 view.handle_mouse_event(
674 MouseEvent {
675 kind: MouseEventKind::Down(MouseButton::Left),
676 column: 2,
677 row: 6,
678 modifiers: KeyModifiers::CONTROL,
679 },
680 area,
681 );
682 assert_eq!(
683 rx.blocking_recv().unwrap(),
684 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
685 );
686 }
687
688 #[test]
689 fn test_render_no_dynamic_playlist() {
690 let (tx, _) = unbounded_channel();
691 let state = AppState::default();
692 let view = DynamicView::new(&state, tx);
693
694 let (mut terminal, area) = setup_test_terminal(28, 3);
695 let props = RenderProps {
696 area,
697 is_focused: true,
698 };
699 let buffer = terminal
700 .draw(|f| view.render(f, props))
701 .unwrap()
702 .buffer
703 .clone();
704 #[rustfmt::skip]
705 let expected = Buffer::with_lines([
706 "┌Dynamic Playlist View─────┐",
707 "│No active dynamic playlist│",
708 "└──────────────────────────┘",
709 ]);
710
711 assert_buffer_eq(&buffer, &expected);
712 }
713
714 #[test]
715 fn test_render() {
716 let (tx, _) = unbounded_channel();
717 let state = state_with_everything();
718 let view = DynamicView::new(&state, tx);
719
720 let (mut terminal, area) = setup_test_terminal(60, 10);
721 let props = RenderProps {
722 area,
723 is_focused: true,
724 };
725 let buffer = terminal
726 .draw(|f| view.render(f, props))
727 .unwrap()
728 .buffer
729 .clone();
730 let expected = Buffer::with_lines([
731 "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
732 "│ Test Dynamic │",
733 "│ Songs: 1 Duration: 00:03:00.00 │",
734 "│ title = \"Test Song\" │",
735 "│q: add to queue | r: start radio | p: add to playlist─────│",
736 "│Performing operations on entire dynamic playlist──────────│",
737 "│☐ Test Song Test Artist │",
738 "│ │",
739 "│s/S: sort | e: edit───────────────────────────────────────│",
740 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
741 ]);
742
743 assert_buffer_eq(&buffer, &expected);
744 }
745
746 #[test]
747 fn test_render_checked() {
748 let (tx, _) = unbounded_channel();
749 let state = state_with_everything();
750 let mut view = DynamicView::new(&state, tx);
751 let (mut terminal, area) = setup_test_terminal(60, 10);
752 let props = RenderProps {
753 area,
754 is_focused: true,
755 };
756 let buffer = terminal
757 .draw(|f| view.render(f, props))
758 .unwrap()
759 .buffer
760 .clone();
761 let expected = Buffer::with_lines([
762 "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
763 "│ Test Dynamic │",
764 "│ Songs: 1 Duration: 00:03:00.00 │",
765 "│ title = \"Test Song\" │",
766 "│q: add to queue | r: start radio | p: add to playlist─────│",
767 "│Performing operations on entire dynamic playlist──────────│",
768 "│☐ Test Song Test Artist │",
769 "│ │",
770 "│s/S: sort | e: edit───────────────────────────────────────│",
771 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
772 ]);
773 assert_buffer_eq(&buffer, &expected);
774
775 view.handle_key_event(KeyEvent::from(KeyCode::Down));
777 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
778
779 let buffer = terminal
780 .draw(|f| view.render(f, props))
781 .unwrap()
782 .buffer
783 .clone();
784 let expected = Buffer::with_lines([
785 "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
786 "│ Test Dynamic │",
787 "│ Songs: 1 Duration: 00:03:00.00 │",
788 "│ title = \"Test Song\" │",
789 "│q: add to queue | r: start radio | p: add to playlist─────│",
790 "│Performing operations on checked items────────────────────│",
791 "│☑ Test Song Test Artist │",
792 "│ │",
793 "│s/S: sort | e: edit───────────────────────────────────────│",
794 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
795 ]);
796
797 assert_buffer_eq(&buffer, &expected);
798 }
799}
800
801#[cfg(test)]
802mod library_view_tests {
803 use super::*;
804 use crate::{
805 state::action::{LibraryAction, ViewAction},
806 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
807 };
808 use crossterm::event::KeyModifiers;
809 use pretty_assertions::assert_eq;
810 use ratatui::buffer::Buffer;
811 use tokio::sync::mpsc::unbounded_channel;
812
813 #[test]
814 fn test_new() {
815 let (tx, _) = unbounded_channel();
816 let state = state_with_everything();
817 let view = LibraryDynamicView::new(&state, tx);
818
819 assert_eq!(view.name(), "Library Dynamic Playlists View");
820 assert_eq!(view.props.dynamics, state.library.dynamic_playlists);
821 }
822
823 #[test]
824 fn test_move_with_state() {
825 let (tx, _) = unbounded_channel();
826 let state = AppState::default();
827 let view = LibraryDynamicView::new(&state, tx);
828
829 let new_state = state_with_everything();
830 let new_view = view.move_with_state(&new_state);
831
832 assert_eq!(new_view.props.dynamics, new_state.library.dynamic_playlists);
833 }
834
835 #[test]
836 fn test_name() {
837 let (tx, _) = unbounded_channel();
838 let state = state_with_everything();
839 let view = LibraryDynamicView::new(&state, tx);
840
841 assert_eq!(view.name(), "Library Dynamic Playlists View");
842 }
843
844 #[test]
845 fn smoke_navigation_and_sort() {
846 let (tx, _) = unbounded_channel();
847 let state = state_with_everything();
848 let mut view = LibraryDynamicView::new(&state, tx);
849
850 view.handle_key_event(KeyEvent::from(KeyCode::Up));
851 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
852 view.handle_key_event(KeyEvent::from(KeyCode::Down));
853 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
854 view.handle_key_event(KeyEvent::from(KeyCode::Left));
855 view.handle_key_event(KeyEvent::from(KeyCode::Right));
856 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
857 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
858 }
859
860 #[test]
861 fn test_actions() {
862 let (tx, mut rx) = unbounded_channel();
863 let state = state_with_everything();
864 let mut view = LibraryDynamicView::new(&state, tx);
865
866 let (mut terminal, area) = setup_test_terminal(60, 11);
868 let props = RenderProps {
869 area,
870 is_focused: true,
871 };
872 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
873
874 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
878 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
879
880 view.handle_key_event(KeyEvent::from(KeyCode::Down));
882
883 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
887 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
888
889 assert_eq!(
890 rx.blocking_recv().unwrap(),
891 Action::Library(LibraryAction::RemoveDynamicPlaylist(item_id()))
892 );
893 assert_eq!(
894 rx.blocking_recv().unwrap(),
895 Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
896 );
897 }
898
899 #[test]
900 fn test_actions_with_input_boxes() {
901 let (tx, mut rx) = unbounded_channel();
902 let state = state_with_everything();
903 let mut view = LibraryDynamicView::new(&state, tx);
904
905 let (mut terminal, area) = setup_test_terminal(60, 11);
907 let props = RenderProps {
908 area,
909 is_focused: true,
910 };
911 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
912
913 assert_eq!(view.focus, Focus::Tree);
915 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
916 assert_eq!(view.focus, Focus::NameInput);
917
918 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
923 assert_eq!(view.focus, Focus::Tree);
924 view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); assert_eq!(view.focus, Focus::NameInput);
926 view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
927 view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
928 view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
929 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
930
931 assert_eq!(view.name_input_box.text(), "abc");
932 assert_eq!(view.focus, Focus::QueryInput);
933
934 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
939 assert_eq!(view.focus, Focus::QueryInput);
940 let query = "artist CONTAINS 'foo'";
941 for c in query.chars() {
942 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
943 }
944 assert_eq!(view.query_builder.text(), query);
945 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
946
947 assert_eq!(
948 rx.blocking_recv().unwrap(),
949 Action::Library(LibraryAction::CreateDynamicPlaylist(
950 "abc".to_string(),
951 Query::from_str(query).unwrap()
952 ))
953 );
954 }
955
956 #[test]
957 fn test_mouse_events() {
958 let (tx, mut rx) = unbounded_channel();
959 let state = state_with_everything();
960 let mut view = LibraryDynamicView::new(&state, tx);
961
962 let (mut terminal, area) = setup_test_terminal(60, 11);
964 let props = RenderProps {
965 area,
966 is_focused: true,
967 };
968 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
969
970 let mouse_event = MouseEvent {
974 kind: MouseEventKind::Down(MouseButton::Left),
975 column: 2,
976 row: 2,
977 modifiers: KeyModifiers::empty(),
978 };
979 view.handle_mouse_event(mouse_event, area); assert_eq!(
981 rx.blocking_recv().unwrap(),
982 Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
983 );
984 let mouse_event = MouseEvent {
986 kind: MouseEventKind::Down(MouseButton::Left),
987 column: 2,
988 row: 3,
989 modifiers: KeyModifiers::empty(),
990 };
991 view.handle_mouse_event(mouse_event, area);
992 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
993 view.handle_mouse_event(mouse_event, area);
994 assert_eq!(
995 rx.try_recv(),
996 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
997 );
998 view.handle_mouse_event(
1000 MouseEvent {
1001 kind: MouseEventKind::Down(MouseButton::Left),
1002 column: 2,
1003 row: 2,
1004 modifiers: KeyModifiers::CONTROL,
1005 },
1006 area,
1007 );
1008 assert_eq!(
1009 view.tree_state.lock().unwrap().get_selected_thing(),
1010 Some(("dynamic", item_id()).into())
1011 );
1012 assert_eq!(
1013 rx.try_recv(),
1014 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1015 );
1016
1017 view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); assert_eq!(view.focus, Focus::NameInput);
1024 view.handle_mouse_event(
1025 MouseEvent {
1027 kind: MouseEventKind::Down(MouseButton::Left),
1028 column: 2,
1029 row: 5,
1030 modifiers: KeyModifiers::empty(),
1031 },
1032 area,
1033 );
1034 assert_eq!(view.focus, Focus::QueryInput);
1035 view.handle_mouse_event(
1036 MouseEvent {
1038 kind: MouseEventKind::Down(MouseButton::Left),
1039 column: 2,
1040 row: 2,
1041 modifiers: KeyModifiers::empty(),
1042 },
1043 area,
1044 );
1045 assert_eq!(view.focus, Focus::NameInput);
1046 view.handle_mouse_event(
1048 MouseEvent {
1049 kind: MouseEventKind::Down(MouseButton::Left),
1050 column: 2,
1051 row: 8,
1052 modifiers: KeyModifiers::empty(),
1053 },
1054 area,
1055 );
1056 assert_eq!(view.focus, Focus::Tree);
1057 }
1058
1059 #[test]
1060 fn test_render() {
1061 let (tx, _) = unbounded_channel();
1062 let state = state_with_everything();
1063 let view = LibraryDynamicView::new(&state, tx);
1064
1065 let (mut terminal, area) = setup_test_terminal(60, 6);
1066 let props = RenderProps {
1067 area,
1068 is_focused: true,
1069 };
1070 let buffer = terminal
1071 .draw(|f| view.render(f, props))
1072 .unwrap()
1073 .buffer
1074 .clone();
1075 let expected = Buffer::with_lines([
1076 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1077 "│n: new dynamic | d: delete dynamic────────────────────────│",
1078 "│▪ Test Dynamic │",
1079 "│ │",
1080 "│ │",
1081 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1082 ]);
1083
1084 assert_buffer_eq(&buffer, &expected);
1085 }
1086
1087 #[test]
1088 fn test_render_with_input_boxes_visible() {
1089 let (tx, _) = unbounded_channel();
1090 let state = state_with_everything();
1091 let mut view = LibraryDynamicView::new(&state, tx);
1092
1093 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1095
1096 let (mut terminal, area) = setup_test_terminal(60, 11);
1097 let props = RenderProps {
1098 area,
1099 is_focused: true,
1100 };
1101 let buffer = terminal
1102 .draw(|f| view.render(f, props))
1103 .unwrap()
1104 .buffer
1105 .clone();
1106 let expected = Buffer::with_lines([
1107 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1108 "│┌Enter Name:─────────────────────────────────────────────┐│",
1109 "││ ││",
1110 "│└────────────────────────────────────────────────────────┘│",
1111 "│┌Invalid Query:──────────────────────────────────────────┐│",
1112 "││ ││",
1113 "│└────────────────────────────────────────────────────────┘│",
1114 "│ ⏎ : Set (cancel if empty)────────────────────────────────│",
1115 "│▪ Test Dynamic │",
1116 "│ │",
1117 "└──────────────────────────────────────────────────────────┘",
1118 ]);
1119 assert_buffer_eq(&buffer, &expected);
1120
1121 let name = "Test";
1122 for c in name.chars() {
1123 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1124 }
1125 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1126
1127 let buffer = terminal
1128 .draw(|f| view.render(f, props))
1129 .unwrap()
1130 .buffer
1131 .clone();
1132 let expected = Buffer::with_lines([
1133 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1134 "│┌Enter Name:─────────────────────────────────────────────┐│",
1135 "││Test ││",
1136 "│└────────────────────────────────────────────────────────┘│",
1137 "│┌Invalid Query:──────────────────────────────────────────┐│",
1138 "││ ││",
1139 "│└────────────────────────────────────────────────────────┘│",
1140 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1141 "│▪ Test Dynamic │",
1142 "│ │",
1143 "└──────────────────────────────────────────────────────────┘",
1144 ]);
1145 assert_buffer_eq(&buffer, &expected);
1146
1147 let query = "artist CONTAINS 'foo'";
1148 for c in query.chars() {
1149 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1150 }
1151
1152 let buffer = terminal
1153 .draw(|f| view.render(f, props))
1154 .unwrap()
1155 .buffer
1156 .clone();
1157 let expected = Buffer::with_lines([
1158 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1159 "│┌Enter Name:─────────────────────────────────────────────┐│",
1160 "││Test ││",
1161 "│└────────────────────────────────────────────────────────┘│",
1162 "│┌Enter Query:────────────────────────────────────────────┐│",
1163 "││artist CONTAINS 'foo' ││",
1164 "│└────────────────────────────────────────────────────────┘│",
1165 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1166 "│▪ Test Dynamic │",
1167 "│ │",
1168 "└──────────────────────────────────────────────────────────┘",
1169 ]);
1170
1171 assert_buffer_eq(&buffer, &expected);
1172 }
1173}