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