Skip to main content

mecomp_tui/ui/components/content_view/views/
dynamic.rs

1use std::{str::FromStr, sync::Mutex};
2
3use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
4use mecomp_prost::{DynamicPlaylist, SongBrief};
5use mecomp_storage::db::schemas::dynamic::query::Query;
6use ratatui::{
7    Frame,
8    layout::{Constraint, Direction, Layout, Margin, Position, Rect},
9    style::Style,
10    text::{Line, Span},
11    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use crate::{
16    state::action::{Action, LibraryAction, 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::{
24            Component, ComponentRender, RenderProps,
25            content_view::{ActiveView, views::generic::SortableItemView},
26        },
27        widgets::{
28            input_box::{self, InputBox},
29            tree::{CheckTree, state::CheckTreeState},
30        },
31    },
32};
33
34use super::{
35    DynamicPlaylistViewProps,
36    checktree_utils::create_dynamic_playlist_tree_leaf,
37    sort_mode::{NameSort, SongSort},
38    traits::SortMode,
39};
40
41/// A Query Building interface for Dynamic Playlists
42///
43/// Currently just a wrapper around an `InputBox`,
44/// but I want it to be more like a advanced search builder from something like airtable or a research database.
45pub struct QueryBuilder {
46    inner: InputBox,
47}
48
49impl QueryBuilder {
50    #[must_use]
51    pub fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self {
52        Self {
53            inner: InputBox::new(state, action_tx),
54        }
55    }
56
57    #[must_use]
58    pub fn text(&self) -> &str {
59        self.inner.text()
60    }
61
62    pub fn handle_key_event(&mut self, key: KeyEvent) {
63        self.inner.handle_key_event(key);
64    }
65
66    pub fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
67        self.inner.handle_mouse_event(mouse, area);
68    }
69
70    pub fn reset(&mut self) {
71        self.inner.reset();
72    }
73}
74
75#[allow(clippy::module_name_repetitions)]
76pub type DynamicView = SortableItemView<DynamicPlaylistViewProps, SongSort, SongBrief>;
77
78pub struct LibraryDynamicView {
79    /// Action Sender
80    pub action_tx: UnboundedSender<Action>,
81    /// Mapped Props from state
82    props: Props,
83    /// tree state
84    tree_state: Mutex<CheckTreeState<String>>,
85    /// Dynamic Playlist Name Input Box
86    name_input_box: InputBox,
87    /// Dynamic Playlist Query Input Box
88    query_builder: QueryBuilder,
89    /// What is currently focused
90    /// Note: name and query input boxes are only visible when one of them is focused
91    focus: Focus,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
95enum Focus {
96    NameInput,
97    QueryInput,
98    Tree,
99}
100
101#[derive(Debug)]
102pub struct Props {
103    pub dynamics: Vec<DynamicPlaylist>,
104    sort_mode: NameSort<DynamicPlaylist>,
105}
106
107impl From<&AppState> for Props {
108    fn from(state: &AppState) -> Self {
109        let mut dynamics = state.library.dynamic_playlists.clone();
110        let sort_mode = NameSort::default();
111        sort_mode.sort_items(&mut dynamics);
112        Self {
113            dynamics,
114            sort_mode,
115        }
116    }
117}
118
119impl Component for LibraryDynamicView {
120    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
121    where
122        Self: Sized,
123    {
124        Self {
125            name_input_box: InputBox::new(state, action_tx.clone()),
126            query_builder: QueryBuilder::new(state, action_tx.clone()),
127            focus: Focus::Tree,
128            action_tx,
129            props: Props::from(state),
130            tree_state: Mutex::new(CheckTreeState::default()),
131        }
132    }
133
134    fn move_with_state(self, state: &AppState) -> Self
135    where
136        Self: Sized,
137    {
138        let tree_state = if state.active_view == ActiveView::DynamicPlaylists {
139            self.tree_state
140        } else {
141            Mutex::new(CheckTreeState::default())
142        };
143
144        Self {
145            props: Props::from(state),
146            tree_state,
147            ..self
148        }
149    }
150
151    fn name(&self) -> &'static str {
152        "Library Dynamic Playlists View"
153    }
154
155    fn handle_key_event(&mut self, key: KeyEvent) {
156        // this page has 2 distinct "modes",
157        // one for navigating the tree when the input boxes are not visible
158        // one for interacting with the input boxes when they are visible
159        if self.focus == Focus::Tree {
160            match key.code {
161                // arrow keys
162                KeyCode::PageUp => {
163                    self.tree_state.lock().unwrap().select_relative(|current| {
164                        current.map_or(self.props.dynamics.len() - 1, |c| c.saturating_sub(10))
165                    });
166                }
167                KeyCode::Up => {
168                    self.tree_state.lock().unwrap().key_up();
169                }
170                KeyCode::PageDown => {
171                    self.tree_state
172                        .lock()
173                        .unwrap()
174                        .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
175                }
176                KeyCode::Down => {
177                    self.tree_state.lock().unwrap().key_down();
178                }
179                KeyCode::Left => {
180                    self.tree_state.lock().unwrap().key_left();
181                }
182                KeyCode::Right => {
183                    self.tree_state.lock().unwrap().key_right();
184                }
185                // Enter key opens selected view
186                KeyCode::Enter => {
187                    if self.tree_state.lock().unwrap().toggle_selected() {
188                        let things = self.tree_state.lock().unwrap().get_selected_thing();
189
190                        if let Some(thing) = things {
191                            self.action_tx
192                                .send(Action::ActiveView(ViewAction::Set(thing.into())))
193                                .unwrap();
194                        }
195                    }
196                }
197                // Change sort mode
198                KeyCode::Char('s') => {
199                    self.props.sort_mode = self.props.sort_mode.next();
200                    self.props.sort_mode.sort_items(&mut self.props.dynamics);
201                    self.tree_state.lock().unwrap().scroll_selected_into_view();
202                }
203                KeyCode::Char('S') => {
204                    self.props.sort_mode = self.props.sort_mode.prev();
205                    self.props.sort_mode.sort_items(&mut self.props.dynamics);
206                    self.tree_state.lock().unwrap().scroll_selected_into_view();
207                }
208                // "n" key to create a new playlist
209                KeyCode::Char('n') => {
210                    self.focus = Focus::NameInput;
211                }
212                // "d" key to delete the selected playlist
213                KeyCode::Char('d') => {
214                    let things = self.tree_state.lock().unwrap().get_selected_thing();
215
216                    if let Some(thing) = things {
217                        self.action_tx
218                            .send(Action::Library(LibraryAction::RemoveDynamicPlaylist(
219                                thing.ulid(),
220                            )))
221                            .unwrap();
222                    }
223                }
224                _ => {}
225            }
226        } else {
227            let query = Query::from_str(self.query_builder.text()).ok();
228            let name = self.name_input_box.text();
229
230            match (key.code, query, self.focus) {
231                // if the user presses Enter with an empty name, we cancel the operation
232                (KeyCode::Enter, _, Focus::NameInput) if name.is_empty() => {
233                    self.focus = Focus::Tree;
234                }
235                // if the user pressed Enter with a valid (non-empty) name, we prompt the user for the query
236                (KeyCode::Enter, _, Focus::NameInput) if !name.is_empty() => {
237                    self.focus = Focus::QueryInput;
238                }
239                // if the user presses Enter with a valid query, we create a new playlist
240                (KeyCode::Enter, Some(query), Focus::QueryInput) => {
241                    self.action_tx
242                        .send(Action::Library(LibraryAction::CreateDynamicPlaylist(
243                            name.to_string(),
244                            query,
245                        )))
246                        .unwrap();
247                    self.name_input_box.reset();
248                    self.query_builder.reset();
249                    self.focus = Focus::Tree;
250                }
251                // otherwise defer to the focused input box
252                (_, _, Focus::NameInput) => self.name_input_box.handle_key_event(key),
253                (_, _, Focus::QueryInput) => self.query_builder.handle_key_event(key),
254                (_, _, Focus::Tree) => unreachable!(),
255            }
256        }
257    }
258
259    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
260        let MouseEvent {
261            kind, column, row, ..
262        } = mouse;
263        let mouse_position = Position::new(column, row);
264
265        // adjust the area to account for the border
266        let area = area.inner(Margin::new(1, 1));
267
268        if self.focus == Focus::Tree {
269            let area = Rect {
270                y: area.y + 1,
271                height: area.height - 1,
272                ..area
273            };
274
275            let result = self
276                .tree_state
277                .lock()
278                .unwrap()
279                .handle_mouse_event(mouse, area, true);
280            if let Some(action) = result {
281                self.action_tx.send(action).unwrap();
282            }
283        } else {
284            let [input_box_area, query_builder_area, content_area] = lib_split_area(area);
285            let content_area = Rect {
286                y: content_area.y + 1,
287                height: content_area.height - 1,
288                ..content_area
289            };
290
291            if input_box_area.contains(mouse_position) {
292                if kind == MouseEventKind::Down(MouseButton::Left) {
293                    self.focus = Focus::NameInput;
294                }
295                self.name_input_box
296                    .handle_mouse_event(mouse, input_box_area);
297            } else if query_builder_area.contains(mouse_position) {
298                if kind == MouseEventKind::Down(MouseButton::Left) {
299                    self.focus = Focus::QueryInput;
300                }
301                self.query_builder
302                    .handle_mouse_event(mouse, query_builder_area);
303            } else if content_area.contains(mouse_position)
304                && kind == MouseEventKind::Down(MouseButton::Left)
305            {
306                self.focus = Focus::Tree;
307            }
308        }
309    }
310}
311
312fn lib_split_area(area: Rect) -> [Rect; 3] {
313    let [input_box_area, query_builder_area, content_area] = *Layout::default()
314        .direction(Direction::Vertical)
315        .constraints([
316            Constraint::Length(3),
317            Constraint::Length(3),
318            Constraint::Min(1),
319        ])
320        .split(area)
321    else {
322        panic!("Failed to split library dynamic playlists view area");
323    };
324    [input_box_area, query_builder_area, content_area]
325}
326
327impl ComponentRender<RenderProps> for LibraryDynamicView {
328    fn render_border(&self, frame: &mut Frame<'_>, props: RenderProps) -> RenderProps {
329        let border_style = Style::default().fg(border_color(props.is_focused).into());
330
331        // render primary border
332        let border_title_bottom = if self.focus == Focus::Tree {
333            " \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort"
334        } else {
335            ""
336        };
337        let border = Block::bordered()
338            .title_top(Line::from(vec![
339                Span::styled(
340                    "Library Dynamic Playlists".to_string(),
341                    Style::default().bold(),
342                ),
343                Span::raw(" sorted by: "),
344                Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
345            ]))
346            .title_bottom(border_title_bottom)
347            .border_style(border_style);
348        let content_area = border.inner(props.area);
349        frame.render_widget(border, props.area);
350
351        // render input box (if visible)
352        let content_area = if self.focus == Focus::Tree {
353            content_area
354        } else {
355            // split content area to make room for the input box
356            let [input_box_area, query_builder_area, content_area] = lib_split_area(content_area);
357
358            let (name_text_color, query_text_color) = match self.focus {
359                Focus::NameInput => ((*TEXT_HIGHLIGHT_ALT).into(), (*TEXT_HIGHLIGHT).into()),
360                Focus::QueryInput => ((*TEXT_HIGHLIGHT).into(), (*TEXT_HIGHLIGHT_ALT).into()),
361                Focus::Tree => ((*TEXT_NORMAL).into(), (*TEXT_NORMAL).into()),
362            };
363
364            let (name_border_color, query_border_color) = match self.focus {
365                Focus::NameInput => ((*BORDER_FOCUSED).into(), (*BORDER_UNFOCUSED).into()),
366                Focus::QueryInput => ((*BORDER_UNFOCUSED).into(), (*BORDER_FOCUSED).into()),
367                Focus::Tree => ((*BORDER_UNFOCUSED).into(), (*BORDER_UNFOCUSED).into()),
368            };
369
370            let (name_show_cursor, query_show_cursor) = match self.focus {
371                Focus::NameInput => (true, false),
372                Focus::QueryInput => (false, true),
373                Focus::Tree => (false, false),
374            };
375
376            // render the name input box
377            self.name_input_box.render(
378                frame,
379                input_box::RenderProps {
380                    area: input_box_area,
381                    text_color: name_text_color,
382                    border: Block::bordered()
383                        .title("Enter Name:")
384                        .border_style(Style::default().fg(name_border_color)),
385                    show_cursor: name_show_cursor,
386                },
387            );
388
389            // render the query input box
390            let query_builder_props = if Query::from_str(self.query_builder.text()).is_ok() {
391                input_box::RenderProps {
392                    area: query_builder_area,
393                    text_color: query_text_color,
394                    border: Block::bordered()
395                        .title("Enter Query:")
396                        .border_style(Style::default().fg(query_border_color)),
397                    show_cursor: query_show_cursor,
398                }
399            } else {
400                input_box::RenderProps {
401                    area: query_builder_area,
402                    text_color: (*TEXT_HIGHLIGHT).into(),
403                    border: Block::bordered()
404                        .title("Invalid Query:")
405                        .border_style(Style::default().fg(query_border_color)),
406                    show_cursor: query_show_cursor,
407                }
408            };
409            self.query_builder.inner.render(frame, query_builder_props);
410
411            content_area
412        };
413
414        // draw additional border around content area to display additional instructions
415        let border = Block::new()
416            .borders(Borders::TOP)
417            .title_top(match self.focus {
418                Focus::NameInput => " \u{23CE} : Set (cancel if empty)",
419                Focus::QueryInput => " \u{23CE} : Create (cancel if empty)",
420                Focus::Tree => "n: new dynamic | d: delete dynamic",
421            })
422            .border_style(border_style);
423        let area = border.inner(content_area);
424        frame.render_widget(border, content_area);
425
426        RenderProps { area, ..props }
427    }
428
429    fn render_content(&self, frame: &mut Frame<'_>, props: RenderProps) {
430        // create a tree for the playlists
431        let items = self
432            .props
433            .dynamics
434            .iter()
435            .map(create_dynamic_playlist_tree_leaf)
436            .collect::<Vec<_>>();
437
438        // render the playlists
439        frame.render_stateful_widget(
440            CheckTree::new(&items)
441                .unwrap()
442                .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
443                // we want this to be rendered like a normal tree, not a check tree, so we don't show the checkboxes
444                .node_unchecked_symbol("▪ ")
445                .node_checked_symbol("▪ ")
446                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
447            props.area,
448            &mut self.tree_state.lock().unwrap(),
449        );
450    }
451}
452
453#[cfg(test)]
454mod item_view_tests {
455    use super::*;
456    use crate::{
457        state::action::{AudioAction, PopupAction, QueueAction},
458        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
459        ui::{components::content_view::ActiveView, widgets::popups::PopupType},
460    };
461    use crossterm::event::KeyModifiers;
462    use mecomp_prost::RecordId;
463    use pretty_assertions::assert_eq;
464    use ratatui::buffer::Buffer;
465    use tokio::sync::mpsc::unbounded_channel;
466
467    #[test]
468    fn test_new() {
469        let (tx, _) = unbounded_channel();
470        let state = state_with_everything();
471        let view = DynamicView::new(&state, tx).item_view;
472
473        assert_eq!(view.name(), "Dynamic Playlist View");
474        assert_eq!(
475            view.props,
476            Some(state.additional_view_data.dynamic_playlist.unwrap())
477        );
478    }
479
480    #[test]
481    fn test_move_with_state() {
482        let (tx, _) = unbounded_channel();
483        let state = AppState::default();
484        let view = DynamicView::new(&state, tx);
485
486        let new_state = state_with_everything();
487        let new_view = view.move_with_state(&new_state).item_view;
488
489        assert_eq!(
490            new_view.props,
491            Some(new_state.additional_view_data.dynamic_playlist.unwrap())
492        );
493    }
494
495    #[test]
496    fn test_name() {
497        let (tx, _) = unbounded_channel();
498        let state = state_with_everything();
499        let view = DynamicView::new(&state, tx);
500
501        assert_eq!(view.name(), "Dynamic Playlist View");
502    }
503
504    #[test]
505    fn smoke_navigation_and_sort() {
506        let (tx, _) = unbounded_channel();
507        let state = state_with_everything();
508        let mut view = DynamicView::new(&state, tx);
509
510        view.handle_key_event(KeyEvent::from(KeyCode::Up));
511        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
512        view.handle_key_event(KeyEvent::from(KeyCode::Down));
513        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
514        view.handle_key_event(KeyEvent::from(KeyCode::Left));
515        view.handle_key_event(KeyEvent::from(KeyCode::Right));
516        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
517        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
518    }
519
520    #[test]
521    fn test_actions() {
522        let (tx, mut rx) = unbounded_channel();
523        let state = state_with_everything();
524        let mut view = DynamicView::new(&state, tx);
525
526        // need to render at least once to load the tree state
527        let (mut terminal, area) = setup_test_terminal(60, 11);
528        let props = RenderProps {
529            area,
530            is_focused: true,
531        };
532        let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
533
534        // when there are no checked items:
535        // - Enter should do nothing
536        // - "q" should add the entire playlist to the queue
537        // - "r" should start radio from the entire playlist
538        // - "p" should add the entire playlist to a playlist
539        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
540        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
541        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
542        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
543        let dynamic_playlists_id = state
544            .additional_view_data
545            .dynamic_playlist
546            .as_ref()
547            .unwrap()
548            .id
549            .clone();
550        assert_eq!(
551            rx.blocking_recv(),
552            Some(Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
553                dynamic_playlists_id.clone()
554            ]))))
555        );
556        assert_eq!(
557            rx.blocking_recv().unwrap(),
558            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
559                dynamic_playlists_id.clone()
560            ],)))
561        );
562        assert_eq!(
563            rx.blocking_recv(),
564            Some(Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
565                dynamic_playlists_id
566            ]))))
567        );
568
569        // check the only item in the playlist
570        view.handle_key_event(KeyEvent::from(KeyCode::Down));
571        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
572
573        // when there are checked items:
574        // - Enter should open the selected view
575        // - "q" should add the checked items to the queue
576        // - "r" should start radio from the checked items
577        // - "p" should add the checked items to a playlist
578        let song_id: RecordId = ("song", item_id()).into();
579        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
580        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
581        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
582        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
583        assert_eq!(
584            rx.blocking_recv().unwrap(),
585            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
586        );
587        assert_eq!(
588            rx.blocking_recv().unwrap(),
589            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![song_id.clone()])))
590        );
591        assert_eq!(
592            rx.blocking_recv().unwrap(),
593            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![song_id.clone()],)))
594        );
595        assert_eq!(
596            rx.blocking_recv().unwrap(),
597            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![song_id])))
598        );
599    }
600
601    #[test]
602    fn test_edit() {
603        let (tx, mut rx) = unbounded_channel();
604        let state = state_with_everything();
605        let mut view = DynamicView::new(&state, tx);
606
607        // need to render at least once to load the tree state
608        let (mut terminal, area) = setup_test_terminal(60, 11);
609        let props = RenderProps {
610            area,
611            is_focused: true,
612        };
613        let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
614
615        // "e" key should open the dynamic playlist editor popup
616        view.handle_key_event(KeyEvent::from(KeyCode::Char('e')));
617        assert_eq!(
618            rx.blocking_recv().unwrap(),
619            Action::Popup(PopupAction::Open(PopupType::DynamicPlaylistEditor(
620                state
621                    .additional_view_data
622                    .dynamic_playlist
623                    .as_ref()
624                    .unwrap()
625                    .dynamic_playlist
626                    .clone()
627            )))
628        );
629    }
630
631    #[test]
632    fn test_mouse_events() {
633        let (tx, mut rx) = unbounded_channel();
634        let state = state_with_everything();
635        let mut view = DynamicView::new(&state, tx);
636
637        // need to render at least once to load the tree state
638        let (mut terminal, area) = setup_test_terminal(60, 11);
639        let props = RenderProps {
640            area,
641            is_focused: true,
642        };
643        let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
644
645        // click on the song (selecting it)
646        assert_eq!(
647            view.item_view
648                .tree_state
649                .lock()
650                .unwrap()
651                .get_selected_thing(),
652            None
653        );
654        view.handle_mouse_event(
655            MouseEvent {
656                kind: MouseEventKind::Down(MouseButton::Left),
657                column: 2,
658                row: 6,
659                modifiers: KeyModifiers::empty(),
660            },
661            area,
662        );
663        assert_eq!(
664            view.item_view
665                .tree_state
666                .lock()
667                .unwrap()
668                .get_selected_thing(),
669            Some(("song", item_id()).into())
670        );
671
672        // ctrl-click on the selected song (opening it)
673        view.handle_mouse_event(
674            MouseEvent {
675                kind: MouseEventKind::Down(MouseButton::Left),
676                column: 2,
677                row: 6,
678                modifiers: KeyModifiers::CONTROL,
679            },
680            area,
681        );
682        assert_eq!(
683            rx.blocking_recv().unwrap(),
684            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
685        );
686    }
687
688    #[test]
689    fn test_render_no_dynamic_playlist() {
690        let (tx, _) = unbounded_channel();
691        let state = AppState::default();
692        let view = DynamicView::new(&state, tx);
693
694        let (mut terminal, area) = setup_test_terminal(28, 3);
695        let props = RenderProps {
696            area,
697            is_focused: true,
698        };
699        let buffer = terminal
700            .draw(|f| view.render(f, props))
701            .unwrap()
702            .buffer
703            .clone();
704        #[rustfmt::skip]
705        let expected = Buffer::with_lines([
706            "┌Dynamic Playlist View─────┐",
707            "│No active dynamic playlist│",
708            "└──────────────────────────┘",
709        ]);
710
711        assert_buffer_eq(&buffer, &expected);
712    }
713
714    #[test]
715    fn test_render() {
716        let (tx, _) = unbounded_channel();
717        let state = state_with_everything();
718        let view = DynamicView::new(&state, tx);
719
720        let (mut terminal, area) = setup_test_terminal(60, 10);
721        let props = RenderProps {
722            area,
723            is_focused: true,
724        };
725        let buffer = terminal
726            .draw(|f| view.render(f, props))
727            .unwrap()
728            .buffer
729            .clone();
730        let expected = Buffer::with_lines([
731            "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
732            "│                       Test Dynamic                       │",
733            "│              Songs: 1  Duration: 00:03:00.00             │",
734            "│                    title = \"Test Song\"                   │",
735            "│q: add to queue | r: start radio | p: add to playlist─────│",
736            "│Performing operations on entire dynamic playlist──────────│",
737            "│☐ Test Song Test Artist                                   │",
738            "│                                                          │",
739            "│s/S: sort | e: edit───────────────────────────────────────│",
740            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
741        ]);
742
743        assert_buffer_eq(&buffer, &expected);
744    }
745
746    #[test]
747    fn test_render_checked() {
748        let (tx, _) = unbounded_channel();
749        let state = state_with_everything();
750        let mut view = DynamicView::new(&state, tx);
751        let (mut terminal, area) = setup_test_terminal(60, 10);
752        let props = RenderProps {
753            area,
754            is_focused: true,
755        };
756        let buffer = terminal
757            .draw(|f| view.render(f, props))
758            .unwrap()
759            .buffer
760            .clone();
761        let expected = Buffer::with_lines([
762            "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
763            "│                       Test Dynamic                       │",
764            "│              Songs: 1  Duration: 00:03:00.00             │",
765            "│                    title = \"Test Song\"                   │",
766            "│q: add to queue | r: start radio | p: add to playlist─────│",
767            "│Performing operations on entire dynamic playlist──────────│",
768            "│☐ Test Song Test Artist                                   │",
769            "│                                                          │",
770            "│s/S: sort | e: edit───────────────────────────────────────│",
771            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
772        ]);
773        assert_buffer_eq(&buffer, &expected);
774
775        // select the song
776        view.handle_key_event(KeyEvent::from(KeyCode::Down));
777        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
778
779        let buffer = terminal
780            .draw(|f| view.render(f, props))
781            .unwrap()
782            .buffer
783            .clone();
784        let expected = Buffer::with_lines([
785            "┌Dynamic Playlist View sorted by: Artist───────────────────┐",
786            "│                       Test Dynamic                       │",
787            "│              Songs: 1  Duration: 00:03:00.00             │",
788            "│                    title = \"Test Song\"                   │",
789            "│q: add to queue | r: start radio | p: add to playlist─────│",
790            "│Performing operations on checked items────────────────────│",
791            "│☑ Test Song Test Artist                                   │",
792            "│                                                          │",
793            "│s/S: sort | e: edit───────────────────────────────────────│",
794            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
795        ]);
796
797        assert_buffer_eq(&buffer, &expected);
798    }
799}
800
801#[cfg(test)]
802mod library_view_tests {
803    use super::*;
804    use crate::{
805        state::action::{LibraryAction, ViewAction},
806        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
807    };
808    use crossterm::event::KeyModifiers;
809    use pretty_assertions::assert_eq;
810    use ratatui::buffer::Buffer;
811    use tokio::sync::mpsc::unbounded_channel;
812
813    #[test]
814    fn test_new() {
815        let (tx, _) = unbounded_channel();
816        let state = state_with_everything();
817        let view = LibraryDynamicView::new(&state, tx);
818
819        assert_eq!(view.name(), "Library Dynamic Playlists View");
820        assert_eq!(view.props.dynamics, state.library.dynamic_playlists);
821    }
822
823    #[test]
824    fn test_move_with_state() {
825        let (tx, _) = unbounded_channel();
826        let state = AppState::default();
827        let view = LibraryDynamicView::new(&state, tx);
828
829        let new_state = state_with_everything();
830        let new_view = view.move_with_state(&new_state);
831
832        assert_eq!(new_view.props.dynamics, new_state.library.dynamic_playlists);
833    }
834
835    #[test]
836    fn test_name() {
837        let (tx, _) = unbounded_channel();
838        let state = state_with_everything();
839        let view = LibraryDynamicView::new(&state, tx);
840
841        assert_eq!(view.name(), "Library Dynamic Playlists View");
842    }
843
844    #[test]
845    fn smoke_navigation_and_sort() {
846        let (tx, _) = unbounded_channel();
847        let state = state_with_everything();
848        let mut view = LibraryDynamicView::new(&state, tx);
849
850        view.handle_key_event(KeyEvent::from(KeyCode::Up));
851        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
852        view.handle_key_event(KeyEvent::from(KeyCode::Down));
853        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
854        view.handle_key_event(KeyEvent::from(KeyCode::Left));
855        view.handle_key_event(KeyEvent::from(KeyCode::Right));
856        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
857        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
858    }
859
860    #[test]
861    fn test_actions() {
862        let (tx, mut rx) = unbounded_channel();
863        let state = state_with_everything();
864        let mut view = LibraryDynamicView::new(&state, tx);
865
866        // need to render at least once to load the tree state
867        let (mut terminal, area) = setup_test_terminal(60, 11);
868        let props = RenderProps {
869            area,
870            is_focused: true,
871        };
872        let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
873
874        // when there are no selected items:
875        // - Enter should do nothing
876        // - "d" should do nothing
877        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
878        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
879
880        // select the dynamic playlist
881        view.handle_key_event(KeyEvent::from(KeyCode::Down));
882
883        // when there are selected items:
884        // - "d" should delete the selected dynamic playlist
885        // - Enter should open the selected view
886        view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
887        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
888
889        assert_eq!(
890            rx.blocking_recv().unwrap(),
891            Action::Library(LibraryAction::RemoveDynamicPlaylist(item_id()))
892        );
893        assert_eq!(
894            rx.blocking_recv().unwrap(),
895            Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
896        );
897    }
898
899    #[test]
900    fn test_actions_with_input_boxes() {
901        let (tx, mut rx) = unbounded_channel();
902        let state = state_with_everything();
903        let mut view = LibraryDynamicView::new(&state, tx);
904
905        // need to render at least once to load the tree state
906        let (mut terminal, area) = setup_test_terminal(60, 11);
907        let props = RenderProps {
908            area,
909            is_focused: true,
910        };
911        let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
912
913        // pressing "n" should reveal the name/query input boxes
914        assert_eq!(view.focus, Focus::Tree);
915        view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
916        assert_eq!(view.focus, Focus::NameInput);
917
918        // when the name input box is focused:
919        // - Enter with an empty name should cancel the operation
920        // - Enter with a valid name should reveal the query input box
921        // - other keys are deferred to the input box
922        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
923        assert_eq!(view.focus, Focus::Tree);
924        view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); // reveal the name input box again
925        assert_eq!(view.focus, Focus::NameInput);
926        view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
927        view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
928        view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
929        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
930
931        assert_eq!(view.name_input_box.text(), "abc");
932        assert_eq!(view.focus, Focus::QueryInput);
933
934        // when the query input box is focused:
935        // - Enter with an invalid query should do nothing
936        // - Enter with a valid query should create a new dynamic playlist
937        // - other keys are deferred to the input box
938        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
939        assert_eq!(view.focus, Focus::QueryInput);
940        let query = "artist CONTAINS 'foo'";
941        for c in query.chars() {
942            view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
943        }
944        assert_eq!(view.query_builder.text(), query);
945        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
946
947        assert_eq!(
948            rx.blocking_recv().unwrap(),
949            Action::Library(LibraryAction::CreateDynamicPlaylist(
950                "abc".to_string(),
951                Query::from_str(query).unwrap()
952            ))
953        );
954    }
955
956    #[test]
957    fn test_mouse_events() {
958        let (tx, mut rx) = unbounded_channel();
959        let state = state_with_everything();
960        let mut view = LibraryDynamicView::new(&state, tx);
961
962        // need to render at least once to load the tree state
963        let (mut terminal, area) = setup_test_terminal(60, 11);
964        let props = RenderProps {
965            area,
966            is_focused: true,
967        };
968        let _frame = terminal.draw(|f| view.render(f, props)).unwrap();
969
970        // without the input boxes visible:
971        // - clicking on the tree should open the clicked item
972        // - clicking on an empty area should do nothing
973        let mouse_event = MouseEvent {
974            kind: MouseEventKind::Down(MouseButton::Left),
975            column: 2,
976            row: 2,
977            modifiers: KeyModifiers::empty(),
978        };
979        view.handle_mouse_event(mouse_event, area); // open
980        assert_eq!(
981            rx.blocking_recv().unwrap(),
982            Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
983        );
984        // clicking on an empty area should clear the selection
985        let mouse_event = MouseEvent {
986            kind: MouseEventKind::Down(MouseButton::Left),
987            column: 2,
988            row: 3,
989            modifiers: KeyModifiers::empty(),
990        };
991        view.handle_mouse_event(mouse_event, area);
992        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
993        view.handle_mouse_event(mouse_event, area);
994        assert_eq!(
995            rx.try_recv(),
996            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
997        );
998        // ctrl-clicking on the tree should just change the selection
999        view.handle_mouse_event(
1000            MouseEvent {
1001                kind: MouseEventKind::Down(MouseButton::Left),
1002                column: 2,
1003                row: 2,
1004                modifiers: KeyModifiers::CONTROL,
1005            },
1006            area,
1007        );
1008        assert_eq!(
1009            view.tree_state.lock().unwrap().get_selected_thing(),
1010            Some(("dynamic", item_id()).into())
1011        );
1012        assert_eq!(
1013            rx.try_recv(),
1014            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1015        );
1016
1017        view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); // reveal the name input box
1018
1019        // with the input boxes visible:
1020        // - clicking on the query input box should focus it
1021        // - clicking on the name input box should focus it
1022        // - clicking on the content area should defocus and hide the input boxes
1023        assert_eq!(view.focus, Focus::NameInput);
1024        view.handle_mouse_event(
1025            // click on the query input box
1026            MouseEvent {
1027                kind: MouseEventKind::Down(MouseButton::Left),
1028                column: 2,
1029                row: 5,
1030                modifiers: KeyModifiers::empty(),
1031            },
1032            area,
1033        );
1034        assert_eq!(view.focus, Focus::QueryInput);
1035        view.handle_mouse_event(
1036            // click on the name input box
1037            MouseEvent {
1038                kind: MouseEventKind::Down(MouseButton::Left),
1039                column: 2,
1040                row: 2,
1041                modifiers: KeyModifiers::empty(),
1042            },
1043            area,
1044        );
1045        assert_eq!(view.focus, Focus::NameInput);
1046        // click on the content area
1047        view.handle_mouse_event(
1048            MouseEvent {
1049                kind: MouseEventKind::Down(MouseButton::Left),
1050                column: 2,
1051                row: 8,
1052                modifiers: KeyModifiers::empty(),
1053            },
1054            area,
1055        );
1056        assert_eq!(view.focus, Focus::Tree);
1057    }
1058
1059    #[test]
1060    fn test_render() {
1061        let (tx, _) = unbounded_channel();
1062        let state = state_with_everything();
1063        let view = LibraryDynamicView::new(&state, tx);
1064
1065        let (mut terminal, area) = setup_test_terminal(60, 6);
1066        let props = RenderProps {
1067            area,
1068            is_focused: true,
1069        };
1070        let buffer = terminal
1071            .draw(|f| view.render(f, props))
1072            .unwrap()
1073            .buffer
1074            .clone();
1075        let expected = Buffer::with_lines([
1076            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1077            "│n: new dynamic | d: delete dynamic────────────────────────│",
1078            "│▪ Test Dynamic                                            │",
1079            "│                                                          │",
1080            "│                                                          │",
1081            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1082        ]);
1083
1084        assert_buffer_eq(&buffer, &expected);
1085    }
1086
1087    #[test]
1088    fn test_render_with_input_boxes_visible() {
1089        let (tx, _) = unbounded_channel();
1090        let state = state_with_everything();
1091        let mut view = LibraryDynamicView::new(&state, tx);
1092
1093        // reveal the name input box
1094        view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1095
1096        let (mut terminal, area) = setup_test_terminal(60, 11);
1097        let props = RenderProps {
1098            area,
1099            is_focused: true,
1100        };
1101        let buffer = terminal
1102            .draw(|f| view.render(f, props))
1103            .unwrap()
1104            .buffer
1105            .clone();
1106        let expected = Buffer::with_lines([
1107            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1108            "│┌Enter Name:─────────────────────────────────────────────┐│",
1109            "││                                                        ││",
1110            "│└────────────────────────────────────────────────────────┘│",
1111            "│┌Invalid Query:──────────────────────────────────────────┐│",
1112            "││                                                        ││",
1113            "│└────────────────────────────────────────────────────────┘│",
1114            "│ ⏎ : Set (cancel if empty)────────────────────────────────│",
1115            "│▪ Test Dynamic                                            │",
1116            "│                                                          │",
1117            "└──────────────────────────────────────────────────────────┘",
1118        ]);
1119        assert_buffer_eq(&buffer, &expected);
1120
1121        let name = "Test";
1122        for c in name.chars() {
1123            view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1124        }
1125        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1126
1127        let buffer = terminal
1128            .draw(|f| view.render(f, props))
1129            .unwrap()
1130            .buffer
1131            .clone();
1132        let expected = Buffer::with_lines([
1133            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1134            "│┌Enter Name:─────────────────────────────────────────────┐│",
1135            "││Test                                                    ││",
1136            "│└────────────────────────────────────────────────────────┘│",
1137            "│┌Invalid Query:──────────────────────────────────────────┐│",
1138            "││                                                        ││",
1139            "│└────────────────────────────────────────────────────────┘│",
1140            "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1141            "│▪ Test Dynamic                                            │",
1142            "│                                                          │",
1143            "└──────────────────────────────────────────────────────────┘",
1144        ]);
1145        assert_buffer_eq(&buffer, &expected);
1146
1147        let query = "artist CONTAINS 'foo'";
1148        for c in query.chars() {
1149            view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1150        }
1151
1152        let buffer = terminal
1153            .draw(|f| view.render(f, props))
1154            .unwrap()
1155            .buffer
1156            .clone();
1157        let expected = Buffer::with_lines([
1158            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1159            "│┌Enter Name:─────────────────────────────────────────────┐│",
1160            "││Test                                                    ││",
1161            "│└────────────────────────────────────────────────────────┘│",
1162            "│┌Enter Query:────────────────────────────────────────────┐│",
1163            "││artist CONTAINS 'foo'                                   ││",
1164            "│└────────────────────────────────────────────────────────┘│",
1165            "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1166            "│▪ Test Dynamic                                            │",
1167            "│                                                          │",
1168            "└──────────────────────────────────────────────────────────┘",
1169        ]);
1170
1171        assert_buffer_eq(&buffer, &expected);
1172    }
1173}