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