Skip to main content

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

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