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