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