1use std::{str::FromStr, sync::Mutex};
2
3use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
4use mecomp_core::format_duration;
5use mecomp_storage::db::schemas::dynamic::{query::Query, DynamicPlaylist};
6use ratatui::{
7 layout::{Alignment, Constraint, Direction, Layout, Margin, Position, Rect},
8 style::{Style, Stylize},
9 text::{Line, Span},
10 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation},
11 Frame,
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use crate::{
16 state::action::{Action, LibraryAction, PopupAction, ViewAction},
17 ui::{
18 colors::{
19 BORDER_FOCUSED, BORDER_UNFOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL,
20 },
21 components::{content_view::ActiveView, Component, ComponentRender, RenderProps},
22 widgets::{
23 input_box::{self, InputBox},
24 popups::PopupType,
25 tree::{state::CheckTreeState, CheckTree},
26 },
27 AppState,
28 },
29};
30
31use super::{
32 checktree_utils::{
33 construct_add_to_playlist_action, construct_add_to_queue_action,
34 construct_start_radio_action, create_dynamic_playlist_tree_leaf, create_song_tree_leaf,
35 },
36 sort_mode::{NameSort, SongSort},
37 traits::SortMode,
38 DynamicPlaylistViewProps,
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 struct DynamicView {
77 pub action_tx: UnboundedSender<Action>,
79 pub props: Option<DynamicPlaylistViewProps>,
81 tree_state: Mutex<CheckTreeState<String>>,
83 sort_mode: SongSort,
85}
86
87impl Component for DynamicView {
88 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
89 where
90 Self: Sized,
91 {
92 Self {
93 action_tx,
94 props: state.additional_view_data.dynamic_playlist.clone(),
95 tree_state: Mutex::new(CheckTreeState::default()),
96 sort_mode: SongSort::default(),
97 }
98 }
99
100 fn move_with_state(self, state: &AppState) -> Self
101 where
102 Self: Sized,
103 {
104 if let Some(props) = &state.additional_view_data.dynamic_playlist {
105 let mut props = props.clone();
106 self.sort_mode.sort_items(&mut props.songs);
107
108 Self {
109 props: Some(props),
110 tree_state: Mutex::new(CheckTreeState::default()),
111 ..self
112 }
113 } else {
114 self
115 }
116 }
117
118 fn name(&self) -> &'static str {
119 "Dynamic Playlist View"
120 }
121
122 fn handle_key_event(&mut self, key: KeyEvent) {
123 match key.code {
124 KeyCode::PageUp => {
126 self.tree_state.lock().unwrap().select_relative(|current| {
127 current.map_or(
128 self.props
129 .as_ref()
130 .map_or(0, |p| p.songs.len().saturating_sub(1)),
131 |c| c.saturating_sub(10),
132 )
133 });
134 }
135 KeyCode::Up => {
136 self.tree_state.lock().unwrap().key_up();
137 }
138 KeyCode::PageDown => {
139 self.tree_state
140 .lock()
141 .unwrap()
142 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
143 }
144 KeyCode::Down => {
145 self.tree_state.lock().unwrap().key_down();
146 }
147 KeyCode::Left => {
148 self.tree_state.lock().unwrap().key_left();
149 }
150 KeyCode::Right => {
151 self.tree_state.lock().unwrap().key_right();
152 }
153 KeyCode::Char(' ') => {
154 self.tree_state.lock().unwrap().key_space();
155 }
156 KeyCode::Char('s') => {
158 self.sort_mode = self.sort_mode.next();
159 if let Some(props) = &mut self.props {
160 self.sort_mode.sort_items(&mut props.songs);
161 }
162 }
163 KeyCode::Char('S') => {
164 self.sort_mode = self.sort_mode.prev();
165 if let Some(props) = &mut self.props {
166 self.sort_mode.sort_items(&mut props.songs);
167 }
168 }
169 KeyCode::Enter => {
171 if self.tree_state.lock().unwrap().toggle_selected() {
172 let selected_things = self.tree_state.lock().unwrap().get_selected_thing();
173 if let Some(thing) = selected_things {
174 self.action_tx
175 .send(Action::ActiveView(ViewAction::Set(thing.into())))
176 .unwrap();
177 }
178 }
179 }
180 KeyCode::Char('q') => {
182 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
183 if let Some(action) = construct_add_to_queue_action(
184 checked_things,
185 self.props.as_ref().map(|p| &p.id),
186 ) {
187 self.action_tx.send(action).unwrap();
188 }
189 }
190 KeyCode::Char('r') => {
192 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
193 if let Some(action) =
194 construct_start_radio_action(checked_things, self.props.as_ref().map(|p| &p.id))
195 {
196 self.action_tx.send(action).unwrap();
197 }
198 }
199 KeyCode::Char('p') => {
201 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
202 if let Some(action) = construct_add_to_playlist_action(
203 checked_things,
204 self.props.as_ref().map(|p| &p.id),
205 ) {
206 self.action_tx.send(action).unwrap();
207 }
208 }
209 KeyCode::Char('e') => {
211 if let Some(props) = &self.props {
212 self.action_tx
213 .send(Action::Popup(PopupAction::Open(
214 PopupType::DynamicPlaylistEditor(props.dynamic_playlist.clone()),
215 )))
216 .unwrap();
217 }
218 }
219 _ => {}
220 }
221 }
222
223 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
224 let area = area.inner(Margin::new(1, 1));
226 let [_, content_area] = split_area(area);
227 let content_area = content_area.inner(Margin::new(0, 1));
228
229 let result = self
230 .tree_state
231 .lock()
232 .unwrap()
233 .handle_mouse_event(mouse, content_area);
234 if let Some(action) = result {
235 self.action_tx.send(action).unwrap();
236 }
237 }
238}
239
240fn split_area(area: Rect) -> [Rect; 2] {
241 let [info_area, content_area] = *Layout::default()
242 .direction(Direction::Vertical)
243 .constraints([Constraint::Length(4), Constraint::Min(4)])
244 .split(area)
245 else {
246 panic!("Failed to split dynamic playlist view area")
247 };
248
249 [info_area, content_area]
250}
251
252impl ComponentRender<RenderProps> for DynamicView {
253 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
254 let border_style = if props.is_focused {
255 Style::default().fg(BORDER_FOCUSED.into())
256 } else {
257 Style::default().fg(BORDER_UNFOCUSED.into())
258 };
259
260 let area = if let Some(state) = &self.props {
261 let border = Block::bordered()
262 .title_top(Line::from(vec![
263 Span::styled("Dynamic Playlist View".to_string(), Style::default().bold()),
264 Span::raw(" sorted by: "),
265 Span::styled(self.sort_mode.to_string(), Style::default().italic()),
266 ]))
267 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
268 .border_style(border_style);
269 frame.render_widget(&border, props.area);
270 let content_area = border.inner(props.area);
271
272 let [info_area, content_area] = split_area(content_area);
274
275 frame.render_widget(
277 Paragraph::new(vec![
278 Line::from(Span::styled(
279 state.dynamic_playlist.name.to_string(),
280 Style::default().bold(),
281 )),
282 Line::from(vec![
283 Span::raw("Songs: "),
284 Span::styled(state.songs.len().to_string(), Style::default().italic()),
285 Span::raw(" Duration: "),
286 Span::styled(
287 format_duration(&state.songs.iter().map(|s| s.runtime).sum()),
288 Style::default().italic(),
289 ),
290 ]),
291 Line::from(Span::styled(
292 state.dynamic_playlist.query.to_string(),
293 Style::default().italic(),
294 )),
295 ])
296 .alignment(Alignment::Center),
297 info_area,
298 );
299
300 let border = Block::default()
302 .borders(Borders::TOP | Borders::BOTTOM)
303 .title_top("q: add to queue | r: start radio | p: add to playlist")
304 .title_bottom("s/S: sort | e: edit")
305 .border_style(border_style);
306 frame.render_widget(&border, content_area);
307 let content_area = border.inner(content_area);
308
309 let border = Block::default()
311 .borders(Borders::TOP)
312 .title_top(Line::from(vec![
313 Span::raw("Performing operations on "),
314 Span::raw(
315 if self
316 .tree_state
317 .lock()
318 .unwrap()
319 .get_checked_things()
320 .is_empty()
321 {
322 "entire dynamic playlist"
323 } else {
324 "checked items"
325 },
326 )
327 .fg(TEXT_HIGHLIGHT),
328 ]))
329 .italic()
330 .border_style(border_style);
331 frame.render_widget(&border, content_area);
332 border.inner(content_area)
333 } else {
334 let border = Block::bordered()
335 .title_top("Dynamic Playlist View")
336 .border_style(border_style);
337 frame.render_widget(&border, props.area);
338 border.inner(props.area)
339 };
340
341 RenderProps { area, ..props }
342 }
343
344 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
345 if let Some(state) = &self.props {
346 let items = state
348 .songs
349 .iter()
350 .map(create_song_tree_leaf)
351 .collect::<Vec<_>>();
352
353 frame.render_stateful_widget(
355 CheckTree::new(&items)
356 .unwrap()
357 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
358 .experimental_scrollbar(Some(Scrollbar::new(
359 ScrollbarOrientation::VerticalRight,
360 ))),
361 props.area,
362 &mut self.tree_state.lock().unwrap(),
363 );
364 } else {
365 let text = "No active dynamic playlist";
366
367 frame.render_widget(
368 Line::from(text)
369 .style(Style::default().fg(TEXT_NORMAL.into()))
370 .alignment(Alignment::Center),
371 props.area,
372 );
373 }
374 }
375}
376
377pub struct LibraryDynamicView {
378 pub action_tx: UnboundedSender<Action>,
380 props: Props,
382 tree_state: Mutex<CheckTreeState<String>>,
384 name_input_box: InputBox,
386 query_builder: QueryBuilder,
388 focus: Focus,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq)]
394enum Focus {
395 NameInput,
396 QueryInput,
397 Tree,
398}
399
400#[derive(Debug)]
401pub struct Props {
402 pub dynamics: Box<[DynamicPlaylist]>,
403 sort_mode: NameSort<DynamicPlaylist>,
404}
405
406impl From<&AppState> for Props {
407 fn from(state: &AppState) -> Self {
408 let mut dynamics = state.library.dynamic_playlists.clone();
409 let sort_mode = NameSort::default();
410 sort_mode.sort_items(&mut dynamics);
411 Self {
412 dynamics,
413 sort_mode,
414 }
415 }
416}
417
418impl Component for LibraryDynamicView {
419 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
420 where
421 Self: Sized,
422 {
423 Self {
424 name_input_box: InputBox::new(state, action_tx.clone()),
425 query_builder: QueryBuilder::new(state, action_tx.clone()),
426 focus: Focus::Tree,
427 action_tx,
428 props: Props::from(state),
429 tree_state: Mutex::new(CheckTreeState::default()),
430 }
431 }
432
433 fn move_with_state(self, state: &AppState) -> Self
434 where
435 Self: Sized,
436 {
437 let tree_state = if state.active_view == ActiveView::DynamicPlaylists {
438 self.tree_state
439 } else {
440 Mutex::new(CheckTreeState::default())
441 };
442
443 Self {
444 props: Props::from(state),
445 tree_state,
446 ..self
447 }
448 }
449
450 fn name(&self) -> &'static str {
451 "Library Dynamic Playlists View"
452 }
453
454 fn handle_key_event(&mut self, key: KeyEvent) {
455 if self.focus == Focus::Tree {
459 match key.code {
460 KeyCode::PageUp => {
462 self.tree_state.lock().unwrap().select_relative(|current| {
463 current.map_or(self.props.dynamics.len() - 1, |c| c.saturating_sub(10))
464 });
465 }
466 KeyCode::Up => {
467 self.tree_state.lock().unwrap().key_up();
468 }
469 KeyCode::PageDown => {
470 self.tree_state
471 .lock()
472 .unwrap()
473 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
474 }
475 KeyCode::Down => {
476 self.tree_state.lock().unwrap().key_down();
477 }
478 KeyCode::Left => {
479 self.tree_state.lock().unwrap().key_left();
480 }
481 KeyCode::Right => {
482 self.tree_state.lock().unwrap().key_right();
483 }
484 KeyCode::Enter => {
486 if self.tree_state.lock().unwrap().toggle_selected() {
487 let things = self.tree_state.lock().unwrap().get_selected_thing();
488
489 if let Some(thing) = things {
490 self.action_tx
491 .send(Action::ActiveView(ViewAction::Set(thing.into())))
492 .unwrap();
493 }
494 }
495 }
496 KeyCode::Char('s') => {
498 self.props.sort_mode = self.props.sort_mode.next();
499 self.props.sort_mode.sort_items(&mut self.props.dynamics);
500 }
501 KeyCode::Char('S') => {
502 self.props.sort_mode = self.props.sort_mode.prev();
503 self.props.sort_mode.sort_items(&mut self.props.dynamics);
504 }
505 KeyCode::Char('n') => {
507 self.focus = Focus::NameInput;
508 }
509 KeyCode::Char('d') => {
511 let things = self.tree_state.lock().unwrap().get_selected_thing();
512
513 if let Some(thing) = things {
514 self.action_tx
515 .send(Action::Library(LibraryAction::RemoveDynamicPlaylist(thing)))
516 .unwrap();
517 }
518 }
519 _ => {}
520 }
521 } else {
522 let query = Query::from_str(self.query_builder.text()).ok();
523 let name = self.name_input_box.text();
524
525 match (key.code, query, self.focus) {
526 (KeyCode::Enter, _, Focus::NameInput) if name.is_empty() => {
528 self.focus = Focus::Tree;
529 }
530 (KeyCode::Enter, _, Focus::NameInput) if !name.is_empty() => {
532 self.focus = Focus::QueryInput;
533 }
534 (KeyCode::Enter, Some(query), Focus::QueryInput) => {
536 self.action_tx
537 .send(Action::Library(LibraryAction::CreateDynamicPlaylist(
538 name.to_string(),
539 query,
540 )))
541 .unwrap();
542 self.name_input_box.reset();
543 self.query_builder.reset();
544 self.focus = Focus::Tree;
545 }
546 (_, _, Focus::NameInput) => self.name_input_box.handle_key_event(key),
548 (_, _, Focus::QueryInput) => self.query_builder.handle_key_event(key),
549 (_, _, Focus::Tree) => unreachable!(),
550 }
551 }
552 }
553
554 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
555 let MouseEvent {
556 kind, column, row, ..
557 } = mouse;
558 let mouse_position = Position::new(column, row);
559
560 let area = area.inner(Margin::new(1, 1));
562
563 if self.focus == Focus::Tree {
564 let area = Rect {
565 y: area.y + 1,
566 height: area.height - 1,
567 ..area
568 };
569
570 let result = self
571 .tree_state
572 .lock()
573 .unwrap()
574 .handle_mouse_event(mouse, area);
575 if let Some(action) = result {
576 self.action_tx.send(action).unwrap();
577 }
578 } else {
579 let [input_box_area, query_builder_area, content_area] = lib_split_area(area);
580 let content_area = Rect {
581 y: content_area.y + 1,
582 height: content_area.height - 1,
583 ..content_area
584 };
585
586 if input_box_area.contains(mouse_position) {
587 if kind == MouseEventKind::Down(MouseButton::Left) {
588 self.focus = Focus::NameInput;
589 }
590 self.name_input_box
591 .handle_mouse_event(mouse, input_box_area);
592 } else if query_builder_area.contains(mouse_position) {
593 if kind == MouseEventKind::Down(MouseButton::Left) {
594 self.focus = Focus::QueryInput;
595 }
596 self.query_builder
597 .handle_mouse_event(mouse, query_builder_area);
598 } else if content_area.contains(mouse_position)
599 && kind == MouseEventKind::Down(MouseButton::Left)
600 {
601 self.focus = Focus::Tree;
602 }
603 }
604 }
605}
606
607fn lib_split_area(area: Rect) -> [Rect; 3] {
608 let [input_box_area, query_builder_area, content_area] = *Layout::default()
609 .direction(Direction::Vertical)
610 .constraints([
611 Constraint::Length(3),
612 Constraint::Length(3),
613 Constraint::Min(1),
614 ])
615 .split(area)
616 else {
617 panic!("Failed to split library dynamic playlists view area");
618 };
619 [input_box_area, query_builder_area, content_area]
620}
621
622impl ComponentRender<RenderProps> for LibraryDynamicView {
623 fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
624 let border_style = if props.is_focused {
625 Style::default().fg(BORDER_FOCUSED.into())
626 } else {
627 Style::default().fg(BORDER_UNFOCUSED.into())
628 };
629
630 let border = Block::bordered()
632 .title_top(Line::from(vec![
633 Span::styled(
634 "Library Dynamic Playlists".to_string(),
635 Style::default().bold(),
636 ),
637 Span::raw(" sorted by: "),
638 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
639 ]))
640 .title_bottom(if self.focus == Focus::Tree {
641 " \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort"
642 } else {
643 ""
644 })
645 .border_style(border_style);
646 let content_area = border.inner(props.area);
647 frame.render_widget(border, props.area);
648
649 let content_area = if self.focus == Focus::Tree {
651 content_area
652 } else {
653 let [input_box_area, query_builder_area, content_area] = lib_split_area(content_area);
655
656 let (name_text_color, query_text_color) = match self.focus {
657 Focus::NameInput => (TEXT_HIGHLIGHT_ALT.into(), TEXT_HIGHLIGHT.into()),
658 Focus::QueryInput => (TEXT_HIGHLIGHT.into(), TEXT_HIGHLIGHT_ALT.into()),
659 Focus::Tree => (TEXT_NORMAL.into(), TEXT_NORMAL.into()),
660 };
661
662 let (name_border_color, query_border_color) = match self.focus {
663 Focus::NameInput => (BORDER_FOCUSED.into(), BORDER_UNFOCUSED.into()),
664 Focus::QueryInput => (BORDER_UNFOCUSED.into(), BORDER_FOCUSED.into()),
665 Focus::Tree => (BORDER_UNFOCUSED.into(), BORDER_UNFOCUSED.into()),
666 };
667
668 let (name_show_cursor, query_show_cursor) = match self.focus {
669 Focus::NameInput => (true, false),
670 Focus::QueryInput => (false, true),
671 Focus::Tree => (false, false),
672 };
673
674 self.name_input_box.render(
676 frame,
677 input_box::RenderProps {
678 area: input_box_area,
679 text_color: name_text_color,
680 border: Block::bordered()
681 .title("Enter Name:")
682 .border_style(Style::default().fg(name_border_color)),
683 show_cursor: name_show_cursor,
684 },
685 );
686
687 let query_builder_props = if Query::from_str(self.query_builder.text()).is_ok() {
689 input_box::RenderProps {
690 area: query_builder_area,
691 text_color: query_text_color,
692 border: Block::bordered()
693 .title("Enter Query:")
694 .border_style(Style::default().fg(query_border_color)),
695 show_cursor: query_show_cursor,
696 }
697 } else {
698 input_box::RenderProps {
699 area: query_builder_area,
700 text_color: TEXT_HIGHLIGHT.into(),
701 border: Block::bordered()
702 .title("Invalid Query:")
703 .border_style(Style::default().fg(query_border_color)),
704 show_cursor: query_show_cursor,
705 }
706 };
707 self.query_builder.inner.render(frame, query_builder_props);
708
709 content_area
710 };
711
712 let border = Block::new()
714 .borders(Borders::TOP)
715 .title_top(match self.focus {
716 Focus::NameInput => " \u{23CE} : Set (cancel if empty)",
717 Focus::QueryInput => " \u{23CE} : Create (cancel if empty)",
718 Focus::Tree => "n: new dynamic | d: delete dynamic",
719 })
720 .border_style(border_style);
721 let area = border.inner(content_area);
722 frame.render_widget(border, content_area);
723
724 RenderProps { area, ..props }
725 }
726
727 fn render_content(&self, frame: &mut Frame, props: RenderProps) {
728 let items = self
730 .props
731 .dynamics
732 .iter()
733 .map(create_dynamic_playlist_tree_leaf)
734 .collect::<Vec<_>>();
735
736 frame.render_stateful_widget(
738 CheckTree::new(&items)
739 .unwrap()
740 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
741 .node_unchecked_symbol("▪ ")
743 .node_checked_symbol("▪ ")
744 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
745 props.area,
746 &mut self.tree_state.lock().unwrap(),
747 );
748 }
749}
750
751#[cfg(test)]
752mod item_view_tests {
753 use super::*;
754 use crate::{
755 state::action::{AudioAction, PopupAction, QueueAction},
756 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
757 ui::{
758 components::content_view::{views::RADIO_SIZE, ActiveView},
759 widgets::popups::PopupType,
760 },
761 };
762 use crossterm::event::KeyModifiers;
763 use pretty_assertions::assert_eq;
764 use ratatui::buffer::Buffer;
765 use tokio::sync::mpsc::unbounded_channel;
766
767 #[test]
768 fn test_new() {
769 let (tx, _) = unbounded_channel();
770 let state = state_with_everything();
771 let view = DynamicView::new(&state, tx);
772
773 assert_eq!(view.name(), "Dynamic Playlist View");
774 assert_eq!(
775 view.props,
776 Some(state.additional_view_data.dynamic_playlist.unwrap())
777 );
778 }
779
780 #[test]
781 fn test_move_with_state() {
782 let (tx, _) = unbounded_channel();
783 let state = AppState::default();
784 let view = DynamicView::new(&state, tx);
785
786 let new_state = state_with_everything();
787 let new_view = view.move_with_state(&new_state);
788
789 assert_eq!(
790 new_view.props,
791 Some(new_state.additional_view_data.dynamic_playlist.unwrap())
792 );
793 }
794
795 #[test]
796 fn test_name() {
797 let (tx, _) = unbounded_channel();
798 let state = state_with_everything();
799 let view = DynamicView::new(&state, tx);
800
801 assert_eq!(view.name(), "Dynamic Playlist View");
802 }
803
804 #[test]
805 fn smoke_navigation_and_sort() {
806 let (tx, _) = unbounded_channel();
807 let state = state_with_everything();
808 let mut view = DynamicView::new(&state, tx);
809
810 view.handle_key_event(KeyEvent::from(KeyCode::Up));
811 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
812 view.handle_key_event(KeyEvent::from(KeyCode::Down));
813 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
814 view.handle_key_event(KeyEvent::from(KeyCode::Left));
815 view.handle_key_event(KeyEvent::from(KeyCode::Right));
816 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
817 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
818 }
819
820 #[test]
821 fn test_actions() {
822 let (tx, mut rx) = unbounded_channel();
823 let state = state_with_everything();
824 let mut view = DynamicView::new(&state, tx);
825
826 let (mut terminal, area) = setup_test_terminal(60, 11);
828 let props = RenderProps {
829 area,
830 is_focused: true,
831 };
832 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
833
834 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
840 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
841 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
842 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
843 let dynamic_playlists_id = state
844 .additional_view_data
845 .dynamic_playlist
846 .as_ref()
847 .unwrap()
848 .id
849 .clone();
850 assert_eq!(
851 rx.blocking_recv(),
852 Some(Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
853 dynamic_playlists_id.clone()
854 ]))))
855 );
856 assert_eq!(
857 rx.blocking_recv().unwrap(),
858 Action::ActiveView(ViewAction::Set(ActiveView::Radio(
859 vec![dynamic_playlists_id.clone()],
860 RADIO_SIZE
861 )))
862 );
863 assert_eq!(
864 rx.blocking_recv(),
865 Some(Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
866 dynamic_playlists_id
867 ]))))
868 );
869
870 view.handle_key_event(KeyEvent::from(KeyCode::Down));
872 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
873
874 let song_id: mecomp_storage::db::schemas::Thing = ("song", item_id()).into();
880 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
881 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
882 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
883 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
884 assert_eq!(
885 rx.blocking_recv().unwrap(),
886 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
887 );
888 assert_eq!(
889 rx.blocking_recv().unwrap(),
890 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![song_id.clone()])))
891 );
892 assert_eq!(
893 rx.blocking_recv().unwrap(),
894 Action::ActiveView(ViewAction::Set(ActiveView::Radio(
895 vec![song_id.clone()],
896 RADIO_SIZE
897 )))
898 );
899 assert_eq!(
900 rx.blocking_recv().unwrap(),
901 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![song_id])))
902 );
903 }
904
905 #[test]
906 fn test_edit() {
907 let (tx, mut rx) = unbounded_channel();
908 let state = state_with_everything();
909 let mut view = DynamicView::new(&state, tx);
910
911 let (mut terminal, area) = setup_test_terminal(60, 11);
913 let props = RenderProps {
914 area,
915 is_focused: true,
916 };
917 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
918
919 view.handle_key_event(KeyEvent::from(KeyCode::Char('e')));
921 assert_eq!(
922 rx.blocking_recv().unwrap(),
923 Action::Popup(PopupAction::Open(PopupType::DynamicPlaylistEditor(
924 state
925 .additional_view_data
926 .dynamic_playlist
927 .as_ref()
928 .unwrap()
929 .dynamic_playlist
930 .clone()
931 )))
932 );
933 }
934
935 #[test]
936 fn test_mouse_events() {
937 let (tx, mut rx) = unbounded_channel();
938 let state = state_with_everything();
939 let mut view = DynamicView::new(&state, tx);
940
941 let (mut terminal, area) = setup_test_terminal(60, 11);
943 let props = RenderProps {
944 area,
945 is_focused: true,
946 };
947 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
948
949 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
951 view.handle_mouse_event(
952 MouseEvent {
953 kind: MouseEventKind::Down(MouseButton::Left),
954 column: 2,
955 row: 7,
956 modifiers: KeyModifiers::empty(),
957 },
958 area,
959 );
960 assert_eq!(
961 view.tree_state.lock().unwrap().get_selected_thing(),
962 Some(("song", item_id()).into())
963 );
964
965 view.handle_mouse_event(
967 MouseEvent {
968 kind: MouseEventKind::Down(MouseButton::Left),
969 column: 2,
970 row: 7,
971 modifiers: KeyModifiers::empty(),
972 },
973 area,
974 );
975 assert_eq!(
976 rx.blocking_recv().unwrap(),
977 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
978 );
979 }
980
981 #[test]
982 fn test_render_no_dynamic_playlist() {
983 let (tx, _) = unbounded_channel();
984 let state = AppState::default();
985 let view = DynamicView::new(&state, tx);
986
987 let (mut terminal, area) = setup_test_terminal(28, 3);
988 let props = RenderProps {
989 area,
990 is_focused: true,
991 };
992 let buffer = terminal
993 .draw(|f| view.render(f, props))
994 .unwrap()
995 .buffer
996 .clone();
997 #[rustfmt::skip]
998 let expected = Buffer::with_lines([
999 "┌Dynamic Playlist View─────┐",
1000 "│No active dynamic playlist│",
1001 "└──────────────────────────┘",
1002 ]);
1003
1004 assert_buffer_eq(&buffer, &expected);
1005 }
1006
1007 #[test]
1008 fn test_render() {
1009 let (tx, _) = unbounded_channel();
1010 let state = state_with_everything();
1011 let view = DynamicView::new(&state, tx);
1012
1013 let (mut terminal, area) = setup_test_terminal(60, 10);
1014 let props = RenderProps {
1015 area,
1016 is_focused: true,
1017 };
1018 let buffer = terminal
1019 .draw(|f| view.render(f, props))
1020 .unwrap()
1021 .buffer
1022 .clone();
1023 let expected = Buffer::with_lines([
1024 "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
1025 "│ Test Dynamic │",
1026 "│ Songs: 1 Duration: 00:03:00.00 │",
1027 "│ title = \"Test Song\" │",
1028 "│ │",
1029 "│q: add to queue | r: start radio | p: add to playlist─────│",
1030 "│Performing operations on entire dynamic playlist──────────│",
1031 "│☐ Test Song Test Artist │",
1032 "│s/S: sort | e: edit───────────────────────────────────────│",
1033 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1034 ]);
1035
1036 assert_buffer_eq(&buffer, &expected);
1037 }
1038
1039 #[test]
1040 fn test_render_checked() {
1041 let (tx, _) = unbounded_channel();
1042 let state = state_with_everything();
1043 let mut view = DynamicView::new(&state, tx);
1044 let (mut terminal, area) = setup_test_terminal(60, 10);
1045 let props = RenderProps {
1046 area,
1047 is_focused: true,
1048 };
1049 let buffer = terminal
1050 .draw(|f| view.render(f, props))
1051 .unwrap()
1052 .buffer
1053 .clone();
1054 let expected = Buffer::with_lines([
1055 "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
1056 "│ Test Dynamic │",
1057 "│ Songs: 1 Duration: 00:03:00.00 │",
1058 "│ title = \"Test Song\" │",
1059 "│ │",
1060 "│q: add to queue | r: start radio | p: add to playlist─────│",
1061 "│Performing operations on entire dynamic playlist──────────│",
1062 "│☐ Test Song Test Artist │",
1063 "│s/S: sort | e: edit───────────────────────────────────────│",
1064 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1065 ]);
1066 assert_buffer_eq(&buffer, &expected);
1067
1068 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1070 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
1071
1072 let buffer = terminal
1073 .draw(|f| view.render(f, props))
1074 .unwrap()
1075 .buffer
1076 .clone();
1077 let expected = Buffer::with_lines([
1078 "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
1079 "│ Test Dynamic │",
1080 "│ Songs: 1 Duration: 00:03:00.00 │",
1081 "│ title = \"Test Song\" │",
1082 "│ │",
1083 "│q: add to queue | r: start radio | p: add to playlist─────│",
1084 "│Performing operations on checked items────────────────────│",
1085 "│☑ Test Song Test Artist │",
1086 "│s/S: sort | e: edit───────────────────────────────────────│",
1087 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
1088 ]);
1089
1090 assert_buffer_eq(&buffer, &expected);
1091 }
1092}
1093
1094#[cfg(test)]
1095mod library_view_tests {
1096 use super::*;
1097 use crate::{
1098 state::action::{LibraryAction, ViewAction},
1099 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
1100 };
1101 use crossterm::event::KeyModifiers;
1102 use pretty_assertions::assert_eq;
1103 use ratatui::buffer::Buffer;
1104 use tokio::sync::mpsc::unbounded_channel;
1105
1106 #[test]
1107 fn test_new() {
1108 let (tx, _) = unbounded_channel();
1109 let state = state_with_everything();
1110 let view = LibraryDynamicView::new(&state, tx);
1111
1112 assert_eq!(view.name(), "Library Dynamic Playlists View");
1113 assert_eq!(view.props.dynamics, state.library.dynamic_playlists);
1114 }
1115
1116 #[test]
1117 fn test_move_with_state() {
1118 let (tx, _) = unbounded_channel();
1119 let state = AppState::default();
1120 let view = LibraryDynamicView::new(&state, tx);
1121
1122 let new_state = state_with_everything();
1123 let new_view = view.move_with_state(&new_state);
1124
1125 assert_eq!(new_view.props.dynamics, new_state.library.dynamic_playlists);
1126 }
1127
1128 #[test]
1129 fn test_name() {
1130 let (tx, _) = unbounded_channel();
1131 let state = state_with_everything();
1132 let view = LibraryDynamicView::new(&state, tx);
1133
1134 assert_eq!(view.name(), "Library Dynamic Playlists View");
1135 }
1136
1137 #[test]
1138 fn smoke_navigation_and_sort() {
1139 let (tx, _) = unbounded_channel();
1140 let state = state_with_everything();
1141 let mut view = LibraryDynamicView::new(&state, tx);
1142
1143 view.handle_key_event(KeyEvent::from(KeyCode::Up));
1144 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
1145 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1146 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
1147 view.handle_key_event(KeyEvent::from(KeyCode::Left));
1148 view.handle_key_event(KeyEvent::from(KeyCode::Right));
1149 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
1150 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
1151 }
1152
1153 #[test]
1154 fn test_actions() {
1155 let (tx, mut rx) = unbounded_channel();
1156 let state = state_with_everything();
1157 let mut view = LibraryDynamicView::new(&state, tx);
1158
1159 let (mut terminal, area) = setup_test_terminal(60, 11);
1161 let props = RenderProps {
1162 area,
1163 is_focused: true,
1164 };
1165 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
1166
1167 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1171 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
1172
1173 view.handle_key_event(KeyEvent::from(KeyCode::Down));
1175
1176 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
1180 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1181
1182 assert_eq!(
1183 rx.blocking_recv().unwrap(),
1184 Action::Library(LibraryAction::RemoveDynamicPlaylist(
1185 ("dynamic", item_id()).into()
1186 ))
1187 );
1188 assert_eq!(
1189 rx.blocking_recv().unwrap(),
1190 Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
1191 );
1192 }
1193
1194 #[test]
1195 fn test_actions_with_input_boxes() {
1196 let (tx, mut rx) = unbounded_channel();
1197 let state = state_with_everything();
1198 let mut view = LibraryDynamicView::new(&state, tx);
1199
1200 let (mut terminal, area) = setup_test_terminal(60, 11);
1202 let props = RenderProps {
1203 area,
1204 is_focused: true,
1205 };
1206 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
1207
1208 assert_eq!(view.focus, Focus::Tree);
1210 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1211 assert_eq!(view.focus, Focus::NameInput);
1212
1213 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1218 assert_eq!(view.focus, Focus::Tree);
1219 view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); assert_eq!(view.focus, Focus::NameInput);
1221 view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
1222 view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
1223 view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
1224 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1225
1226 assert_eq!(view.name_input_box.text(), "abc");
1227 assert_eq!(view.focus, Focus::QueryInput);
1228
1229 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1234 assert_eq!(view.focus, Focus::QueryInput);
1235 let query = "artist CONTAINS 'foo'";
1236 for c in query.chars() {
1237 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1238 }
1239 assert_eq!(view.query_builder.text(), query);
1240 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1241
1242 assert_eq!(
1243 rx.blocking_recv().unwrap(),
1244 Action::Library(LibraryAction::CreateDynamicPlaylist(
1245 "abc".to_string(),
1246 Query::from_str(query).unwrap()
1247 ))
1248 );
1249 }
1250
1251 #[test]
1252 fn test_mouse_events() {
1253 let (tx, mut rx) = unbounded_channel();
1254 let state = state_with_everything();
1255 let mut view = LibraryDynamicView::new(&state, tx);
1256
1257 let (mut terminal, area) = setup_test_terminal(60, 11);
1259 let props = RenderProps {
1260 area,
1261 is_focused: true,
1262 };
1263 let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
1264
1265 let mouse_event = MouseEvent {
1269 kind: MouseEventKind::Down(MouseButton::Left),
1270 column: 2,
1271 row: 2,
1272 modifiers: KeyModifiers::empty(),
1273 };
1274 view.handle_mouse_event(mouse_event, area); view.handle_mouse_event(mouse_event, area); assert_eq!(
1277 rx.blocking_recv().unwrap(),
1278 Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
1279 );
1280 let mouse_event = MouseEvent {
1282 kind: MouseEventKind::Down(MouseButton::Left),
1283 column: 2,
1284 row: 3,
1285 modifiers: KeyModifiers::empty(),
1286 };
1287 view.handle_mouse_event(mouse_event, area);
1288 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1289 view.handle_mouse_event(mouse_event, area);
1290 assert_eq!(
1291 rx.try_recv(),
1292 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1293 );
1294
1295 view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); assert_eq!(view.focus, Focus::NameInput);
1302 view.handle_mouse_event(
1303 MouseEvent {
1305 kind: MouseEventKind::Down(MouseButton::Left),
1306 column: 2,
1307 row: 5,
1308 modifiers: KeyModifiers::empty(),
1309 },
1310 area,
1311 );
1312 assert_eq!(view.focus, Focus::QueryInput);
1313 view.handle_mouse_event(
1314 MouseEvent {
1316 kind: MouseEventKind::Down(MouseButton::Left),
1317 column: 2,
1318 row: 2,
1319 modifiers: KeyModifiers::empty(),
1320 },
1321 area,
1322 );
1323 assert_eq!(view.focus, Focus::NameInput);
1324 view.handle_mouse_event(
1326 MouseEvent {
1327 kind: MouseEventKind::Down(MouseButton::Left),
1328 column: 2,
1329 row: 8,
1330 modifiers: KeyModifiers::empty(),
1331 },
1332 area,
1333 );
1334 assert_eq!(view.focus, Focus::Tree);
1335 }
1336
1337 #[test]
1338 fn test_render() {
1339 let (tx, _) = unbounded_channel();
1340 let state = state_with_everything();
1341 let view = LibraryDynamicView::new(&state, tx);
1342
1343 let (mut terminal, area) = setup_test_terminal(60, 6);
1344 let props = RenderProps {
1345 area,
1346 is_focused: true,
1347 };
1348 let buffer = terminal
1349 .draw(|f| view.render(f, props))
1350 .unwrap()
1351 .buffer
1352 .clone();
1353 let expected = Buffer::with_lines([
1354 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1355 "│n: new dynamic | d: delete dynamic────────────────────────│",
1356 "│▪ Test Dynamic │",
1357 "│ │",
1358 "│ │",
1359 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1360 ]);
1361
1362 assert_buffer_eq(&buffer, &expected);
1363 }
1364
1365 #[test]
1366 fn test_render_with_input_boxes_visible() {
1367 let (tx, _) = unbounded_channel();
1368 let state = state_with_everything();
1369 let mut view = LibraryDynamicView::new(&state, tx);
1370
1371 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1373
1374 let (mut terminal, area) = setup_test_terminal(60, 11);
1375 let props = RenderProps {
1376 area,
1377 is_focused: true,
1378 };
1379 let buffer = terminal
1380 .draw(|f| view.render(f, props))
1381 .unwrap()
1382 .buffer
1383 .clone();
1384 let expected = Buffer::with_lines([
1385 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1386 "│┌Enter Name:─────────────────────────────────────────────┐│",
1387 "││ ││",
1388 "│└────────────────────────────────────────────────────────┘│",
1389 "│┌Invalid Query:──────────────────────────────────────────┐│",
1390 "││ ││",
1391 "│└────────────────────────────────────────────────────────┘│",
1392 "│ ⏎ : Set (cancel if empty)────────────────────────────────│",
1393 "│▪ Test Dynamic │",
1394 "│ │",
1395 "└──────────────────────────────────────────────────────────┘",
1396 ]);
1397 assert_buffer_eq(&buffer, &expected);
1398
1399 let name = "Test";
1400 for c in name.chars() {
1401 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1402 }
1403 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1404
1405 let buffer = terminal
1406 .draw(|f| view.render(f, props))
1407 .unwrap()
1408 .buffer
1409 .clone();
1410 let expected = Buffer::with_lines([
1411 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1412 "│┌Enter Name:─────────────────────────────────────────────┐│",
1413 "││Test ││",
1414 "│└────────────────────────────────────────────────────────┘│",
1415 "│┌Invalid Query:──────────────────────────────────────────┐│",
1416 "││ ││",
1417 "│└────────────────────────────────────────────────────────┘│",
1418 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1419 "│▪ Test Dynamic │",
1420 "│ │",
1421 "└──────────────────────────────────────────────────────────┘",
1422 ]);
1423 assert_buffer_eq(&buffer, &expected);
1424
1425 let query = "artist CONTAINS 'foo'";
1426 for c in query.chars() {
1427 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1428 }
1429
1430 let buffer = terminal
1431 .draw(|f| view.render(f, props))
1432 .unwrap()
1433 .buffer
1434 .clone();
1435 let expected = Buffer::with_lines([
1436 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1437 "│┌Enter Name:─────────────────────────────────────────────┐│",
1438 "││Test ││",
1439 "│└────────────────────────────────────────────────────────┘│",
1440 "│┌Enter Query:────────────────────────────────────────────┐│",
1441 "││artist CONTAINS 'foo' ││",
1442 "│└────────────────────────────────────────────────────────┘│",
1443 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1444 "│▪ Test Dynamic │",
1445 "│ │",
1446 "└──────────────────────────────────────────────────────────┘",
1447 ]);
1448
1449 assert_buffer_eq(&buffer, &expected);
1450 }
1451}