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