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