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 let first = self
129 .props
130 .as_ref()
131 .map_or(0, |p| p.songs.len().saturating_sub(1));
132 current.map_or(first, |c| c.saturating_sub(10))
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 self.tree_state.lock().unwrap().scroll_selected_into_view();
162 }
163 }
164 KeyCode::Char('S') => {
165 self.sort_mode = self.sort_mode.prev();
166 if let Some(props) = &mut self.props {
167 self.sort_mode.sort_items(&mut props.songs);
168 self.tree_state.lock().unwrap().scroll_selected_into_view();
169 }
170 }
171 KeyCode::Enter => {
173 if self.tree_state.lock().unwrap().toggle_selected() {
174 let selected_things = self.tree_state.lock().unwrap().get_selected_thing();
175 if let Some(thing) = selected_things {
176 self.action_tx
177 .send(Action::ActiveView(ViewAction::Set(thing.into())))
178 .unwrap();
179 }
180 }
181 }
182 KeyCode::Char('q') => {
184 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
185 if let Some(action) = construct_add_to_queue_action(
186 checked_things,
187 self.props.as_ref().map(|p| &p.id),
188 ) {
189 self.action_tx.send(action).unwrap();
190 }
191 }
192 KeyCode::Char('r') => {
194 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
195 if let Some(action) =
196 construct_start_radio_action(checked_things, self.props.as_ref().map(|p| &p.id))
197 {
198 self.action_tx.send(action).unwrap();
199 }
200 }
201 KeyCode::Char('p') => {
203 let checked_things = self.tree_state.lock().unwrap().get_checked_things();
204 if let Some(action) = construct_add_to_playlist_action(
205 checked_things,
206 self.props.as_ref().map(|p| &p.id),
207 ) {
208 self.action_tx.send(action).unwrap();
209 }
210 }
211 KeyCode::Char('e') => {
213 if let Some(props) = &self.props {
214 self.action_tx
215 .send(Action::Popup(PopupAction::Open(
216 PopupType::DynamicPlaylistEditor(props.dynamic_playlist.clone()),
217 )))
218 .unwrap();
219 }
220 }
221 _ => {}
222 }
223 }
224
225 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
226 let area = area.inner(Margin::new(1, 1));
228 let [_, content_area] = split_area(area);
229 let content_area = content_area.inner(Margin::new(0, 1));
230
231 let result = self
232 .tree_state
233 .lock()
234 .unwrap()
235 .handle_mouse_event(mouse, content_area, false);
236 if let Some(action) = result {
237 self.action_tx.send(action).unwrap();
238 }
239 }
240}
241
242fn split_area(area: Rect) -> [Rect; 2] {
243 let [info_area, content_area] = *Layout::default()
244 .direction(Direction::Vertical)
245 .constraints([Constraint::Length(4), Constraint::Min(4)])
246 .split(area)
247 else {
248 panic!("Failed to split dynamic playlist view area")
249 };
250
251 [info_area, content_area]
252}
253
254impl ComponentRender<RenderProps> for DynamicView {
255 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
256 let border_style = Style::default().fg(border_color(props.is_focused).into());
257
258 let area = if let Some(state) = &self.props {
259 let border = Block::bordered()
260 .title_top(Line::from(vec![
261 Span::styled("Dynamic Playlist View".to_string(), Style::default().bold()),
262 Span::raw(" sorted by: "),
263 Span::styled(self.sort_mode.to_string(), Style::default().italic()),
264 ]))
265 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
266 .border_style(border_style);
267 frame.render_widget(&border, props.area);
268 let content_area = border.inner(props.area);
269
270 let [info_area, content_area] = split_area(content_area);
272
273 frame.render_widget(
275 Paragraph::new(vec![
276 Line::from(Span::styled(
277 state.dynamic_playlist.name.to_string(),
278 Style::default().bold(),
279 )),
280 Line::from(vec![
281 Span::raw("Songs: "),
282 Span::styled(state.songs.len().to_string(), Style::default().italic()),
283 Span::raw(" Duration: "),
284 Span::styled(
285 format_duration(&state.songs.iter().map(|s| s.runtime).sum()),
286 Style::default().italic(),
287 ),
288 ]),
289 Line::from(Span::styled(
290 state.dynamic_playlist.query.to_string(),
291 Style::default().italic(),
292 )),
293 ])
294 .alignment(Alignment::Center),
295 info_area,
296 );
297
298 let border = Block::default()
300 .borders(Borders::TOP | Borders::BOTTOM)
301 .title_top("q: add to queue | r: start radio | p: add to playlist")
302 .title_bottom("s/S: sort | e: edit")
303 .border_style(border_style);
304 frame.render_widget(&border, content_area);
305 let content_area = border.inner(content_area);
306
307 let border = Block::default()
309 .borders(Borders::TOP)
310 .title_top(Line::from(vec![
311 Span::raw("Performing operations on "),
312 Span::raw(
313 if self
314 .tree_state
315 .lock()
316 .unwrap()
317 .get_checked_things()
318 .is_empty()
319 {
320 "entire dynamic playlist"
321 } else {
322 "checked items"
323 },
324 )
325 .fg(*TEXT_HIGHLIGHT),
326 ]))
327 .italic()
328 .border_style(border_style);
329 frame.render_widget(&border, content_area);
330 border.inner(content_area)
331 } else {
332 let border = Block::bordered()
333 .title_top("Dynamic Playlist View")
334 .border_style(border_style);
335 frame.render_widget(&border, props.area);
336 border.inner(props.area)
337 };
338
339 RenderProps { area, ..props }
340 }
341
342 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
343 if let Some(state) = &self.props {
344 let items = state
346 .songs
347 .iter()
348 .map(create_song_tree_leaf)
349 .collect::<Vec<_>>();
350
351 frame.render_stateful_widget(
353 CheckTree::new(&items)
354 .unwrap()
355 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
356 .experimental_scrollbar(Some(Scrollbar::new(
357 ScrollbarOrientation::VerticalRight,
358 ))),
359 props.area,
360 &mut self.tree_state.lock().unwrap(),
361 );
362 } else {
363 let text = "No active dynamic playlist";
364
365 frame.render_widget(
366 Line::from(text)
367 .style(Style::default().fg((*TEXT_NORMAL).into()))
368 .alignment(Alignment::Center),
369 props.area,
370 );
371 }
372 }
373}
374
375pub struct LibraryDynamicView {
376 pub action_tx: UnboundedSender<Action>,
378 props: Props,
380 tree_state: Mutex<CheckTreeState<String>>,
382 name_input_box: InputBox,
384 query_builder: QueryBuilder,
386 focus: Focus,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq)]
392enum Focus {
393 NameInput,
394 QueryInput,
395 Tree,
396}
397
398#[derive(Debug)]
399pub struct Props {
400 pub dynamics: Box<[DynamicPlaylist]>,
401 sort_mode: NameSort<DynamicPlaylist>,
402}
403
404impl From<&AppState> for Props {
405 fn from(state: &AppState) -> Self {
406 let mut dynamics = state.library.dynamic_playlists.clone();
407 let sort_mode = NameSort::default();
408 sort_mode.sort_items(&mut dynamics);
409 Self {
410 dynamics,
411 sort_mode,
412 }
413 }
414}
415
416impl Component for LibraryDynamicView {
417 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
418 where
419 Self: Sized,
420 {
421 Self {
422 name_input_box: InputBox::new(state, action_tx.clone()),
423 query_builder: QueryBuilder::new(state, action_tx.clone()),
424 focus: Focus::Tree,
425 action_tx,
426 props: Props::from(state),
427 tree_state: Mutex::new(CheckTreeState::default()),
428 }
429 }
430
431 fn move_with_state(self, state: &AppState) -> Self
432 where
433 Self: Sized,
434 {
435 let tree_state = if state.active_view == ActiveView::DynamicPlaylists {
436 self.tree_state
437 } else {
438 Mutex::new(CheckTreeState::default())
439 };
440
441 Self {
442 props: Props::from(state),
443 tree_state,
444 ..self
445 }
446 }
447
448 fn name(&self) -> &'static str {
449 "Library Dynamic Playlists View"
450 }
451
452 fn handle_key_event(&mut self, key: KeyEvent) {
453 if self.focus == Focus::Tree {
457 match key.code {
458 KeyCode::PageUp => {
460 self.tree_state.lock().unwrap().select_relative(|current| {
461 current.map_or(self.props.dynamics.len() - 1, |c| c.saturating_sub(10))
462 });
463 }
464 KeyCode::Up => {
465 self.tree_state.lock().unwrap().key_up();
466 }
467 KeyCode::PageDown => {
468 self.tree_state
469 .lock()
470 .unwrap()
471 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
472 }
473 KeyCode::Down => {
474 self.tree_state.lock().unwrap().key_down();
475 }
476 KeyCode::Left => {
477 self.tree_state.lock().unwrap().key_left();
478 }
479 KeyCode::Right => {
480 self.tree_state.lock().unwrap().key_right();
481 }
482 KeyCode::Enter => {
484 if self.tree_state.lock().unwrap().toggle_selected() {
485 let things = self.tree_state.lock().unwrap().get_selected_thing();
486
487 if let Some(thing) = things {
488 self.action_tx
489 .send(Action::ActiveView(ViewAction::Set(thing.into())))
490 .unwrap();
491 }
492 }
493 }
494 KeyCode::Char('s') => {
496 self.props.sort_mode = self.props.sort_mode.next();
497 self.props.sort_mode.sort_items(&mut self.props.dynamics);
498 self.tree_state.lock().unwrap().scroll_selected_into_view();
499 }
500 KeyCode::Char('S') => {
501 self.props.sort_mode = self.props.sort_mode.prev();
502 self.props.sort_mode.sort_items(&mut self.props.dynamics);
503 self.tree_state.lock().unwrap().scroll_selected_into_view();
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, true);
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 = Style::default().fg(border_color(props.is_focused).into());
625
626 let border_title_bottom = if self.focus == Focus::Tree {
628 " \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort"
629 } else {
630 ""
631 };
632 let border = Block::bordered()
633 .title_top(Line::from(vec![
634 Span::styled(
635 "Library Dynamic Playlists".to_string(),
636 Style::default().bold(),
637 ),
638 Span::raw(" sorted by: "),
639 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
640 ]))
641 .title_bottom(border_title_bottom)
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::CONTROL,
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); assert_eq!(
1266 rx.blocking_recv().unwrap(),
1267 Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
1268 );
1269 let mouse_event = MouseEvent {
1271 kind: MouseEventKind::Down(MouseButton::Left),
1272 column: 2,
1273 row: 3,
1274 modifiers: KeyModifiers::empty(),
1275 };
1276 view.handle_mouse_event(mouse_event, area);
1277 assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1278 view.handle_mouse_event(mouse_event, area);
1279 assert_eq!(
1280 rx.try_recv(),
1281 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1282 );
1283 view.handle_mouse_event(
1285 MouseEvent {
1286 kind: MouseEventKind::Down(MouseButton::Left),
1287 column: 2,
1288 row: 2,
1289 modifiers: KeyModifiers::CONTROL,
1290 },
1291 area,
1292 );
1293 assert_eq!(
1294 view.tree_state.lock().unwrap().get_selected_thing(),
1295 Some(("dynamic", item_id()).into())
1296 );
1297 assert_eq!(
1298 rx.try_recv(),
1299 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1300 );
1301
1302 view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); assert_eq!(view.focus, Focus::NameInput);
1309 view.handle_mouse_event(
1310 MouseEvent {
1312 kind: MouseEventKind::Down(MouseButton::Left),
1313 column: 2,
1314 row: 5,
1315 modifiers: KeyModifiers::empty(),
1316 },
1317 area,
1318 );
1319 assert_eq!(view.focus, Focus::QueryInput);
1320 view.handle_mouse_event(
1321 MouseEvent {
1323 kind: MouseEventKind::Down(MouseButton::Left),
1324 column: 2,
1325 row: 2,
1326 modifiers: KeyModifiers::empty(),
1327 },
1328 area,
1329 );
1330 assert_eq!(view.focus, Focus::NameInput);
1331 view.handle_mouse_event(
1333 MouseEvent {
1334 kind: MouseEventKind::Down(MouseButton::Left),
1335 column: 2,
1336 row: 8,
1337 modifiers: KeyModifiers::empty(),
1338 },
1339 area,
1340 );
1341 assert_eq!(view.focus, Focus::Tree);
1342 }
1343
1344 #[test]
1345 fn test_render() {
1346 let (tx, _) = unbounded_channel();
1347 let state = state_with_everything();
1348 let view = LibraryDynamicView::new(&state, tx);
1349
1350 let (mut terminal, area) = setup_test_terminal(60, 6);
1351 let props = RenderProps {
1352 area,
1353 is_focused: true,
1354 };
1355 let buffer = terminal
1356 .draw(|f| view.render(f, props))
1357 .unwrap()
1358 .buffer
1359 .clone();
1360 let expected = Buffer::with_lines([
1361 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1362 "│n: new dynamic | d: delete dynamic────────────────────────│",
1363 "│▪ Test Dynamic │",
1364 "│ │",
1365 "│ │",
1366 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1367 ]);
1368
1369 assert_buffer_eq(&buffer, &expected);
1370 }
1371
1372 #[test]
1373 fn test_render_with_input_boxes_visible() {
1374 let (tx, _) = unbounded_channel();
1375 let state = state_with_everything();
1376 let mut view = LibraryDynamicView::new(&state, tx);
1377
1378 view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1380
1381 let (mut terminal, area) = setup_test_terminal(60, 11);
1382 let props = RenderProps {
1383 area,
1384 is_focused: true,
1385 };
1386 let buffer = terminal
1387 .draw(|f| view.render(f, props))
1388 .unwrap()
1389 .buffer
1390 .clone();
1391 let expected = Buffer::with_lines([
1392 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1393 "│┌Enter Name:─────────────────────────────────────────────┐│",
1394 "││ ││",
1395 "│└────────────────────────────────────────────────────────┘│",
1396 "│┌Invalid Query:──────────────────────────────────────────┐│",
1397 "││ ││",
1398 "│└────────────────────────────────────────────────────────┘│",
1399 "│ ⏎ : Set (cancel if empty)────────────────────────────────│",
1400 "│▪ Test Dynamic │",
1401 "│ │",
1402 "└──────────────────────────────────────────────────────────┘",
1403 ]);
1404 assert_buffer_eq(&buffer, &expected);
1405
1406 let name = "Test";
1407 for c in name.chars() {
1408 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1409 }
1410 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1411
1412 let buffer = terminal
1413 .draw(|f| view.render(f, props))
1414 .unwrap()
1415 .buffer
1416 .clone();
1417 let expected = Buffer::with_lines([
1418 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1419 "│┌Enter Name:─────────────────────────────────────────────┐│",
1420 "││Test ││",
1421 "│└────────────────────────────────────────────────────────┘│",
1422 "│┌Invalid Query:──────────────────────────────────────────┐│",
1423 "││ ││",
1424 "│└────────────────────────────────────────────────────────┘│",
1425 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1426 "│▪ Test Dynamic │",
1427 "│ │",
1428 "└──────────────────────────────────────────────────────────┘",
1429 ]);
1430 assert_buffer_eq(&buffer, &expected);
1431
1432 let query = "artist CONTAINS 'foo'";
1433 for c in query.chars() {
1434 view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1435 }
1436
1437 let buffer = terminal
1438 .draw(|f| view.render(f, props))
1439 .unwrap()
1440 .buffer
1441 .clone();
1442 let expected = Buffer::with_lines([
1443 "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1444 "│┌Enter Name:─────────────────────────────────────────────┐│",
1445 "││Test ││",
1446 "│└────────────────────────────────────────────────────────┘│",
1447 "│┌Enter Query:────────────────────────────────────────────┐│",
1448 "││artist CONTAINS 'foo' ││",
1449 "│└────────────────────────────────────────────────────────┘│",
1450 "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1451 "│▪ Test Dynamic │",
1452 "│ │",
1453 "└──────────────────────────────────────────────────────────┘",
1454 ]);
1455
1456 assert_buffer_eq(&buffer, &expected);
1457 }
1458}