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