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                    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                    self.tree_state.lock().unwrap().scroll_selected_into_view();
163                }
164            }
165            KeyCode::Char('S') => {
166                self.sort_mode = self.sort_mode.prev();
167                if let Some(props) = &mut self.props {
168                    self.sort_mode.sort_items(&mut props.songs);
169                    self.tree_state.lock().unwrap().scroll_selected_into_view();
170                }
171            }
172            // Enter key opens selected view
173            KeyCode::Enter => {
174                if self.tree_state.lock().unwrap().toggle_selected() {
175                    let selected_things = self.tree_state.lock().unwrap().get_selected_thing();
176                    if let Some(thing) = selected_things {
177                        self.action_tx
178                            .send(Action::ActiveView(ViewAction::Set(thing.into())))
179                            .unwrap();
180                    }
181                }
182            }
183            // if there are checked items, add them to the queue, otherwise send the whole playlist to the queue
184            KeyCode::Char('q') => {
185                let checked_things = self.tree_state.lock().unwrap().get_checked_things();
186                if let Some(action) = construct_add_to_queue_action(
187                    checked_things,
188                    self.props.as_ref().map(|p| &p.id),
189                ) {
190                    self.action_tx.send(action).unwrap();
191                }
192            }
193            // if there are checked items, start radio from checked items, otherwise start radio from the playlist
194            KeyCode::Char('r') => {
195                let checked_things = self.tree_state.lock().unwrap().get_checked_things();
196                if let Some(action) =
197                    construct_start_radio_action(checked_things, self.props.as_ref().map(|p| &p.id))
198                {
199                    self.action_tx.send(action).unwrap();
200                }
201            }
202            // if there are checked items, add them to the playlist, otherwise add the whole playlist to the playlist
203            KeyCode::Char('p') => {
204                let checked_things = self.tree_state.lock().unwrap().get_checked_things();
205                if let Some(action) = construct_add_to_playlist_action(
206                    checked_things,
207                    self.props.as_ref().map(|p| &p.id),
208                ) {
209                    self.action_tx.send(action).unwrap();
210                }
211            }
212            // "e" key to edit the name/query of the dynamic playlist
213            KeyCode::Char('e') => {
214                if let Some(props) = &self.props {
215                    self.action_tx
216                        .send(Action::Popup(PopupAction::Open(
217                            PopupType::DynamicPlaylistEditor(props.dynamic_playlist.clone()),
218                        )))
219                        .unwrap();
220                }
221            }
222            _ => {}
223        }
224    }
225
226    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
227        // adjust the area to account for the border
228        let area = area.inner(Margin::new(1, 1));
229        let [_, content_area] = split_area(area);
230        let content_area = content_area.inner(Margin::new(0, 1));
231
232        let result = self
233            .tree_state
234            .lock()
235            .unwrap()
236            .handle_mouse_event(mouse, content_area);
237        if let Some(action) = result {
238            self.action_tx.send(action).unwrap();
239        }
240    }
241}
242
243fn split_area(area: Rect) -> [Rect; 2] {
244    let [info_area, content_area] = *Layout::default()
245        .direction(Direction::Vertical)
246        .constraints([Constraint::Length(4), Constraint::Min(4)])
247        .split(area)
248    else {
249        panic!("Failed to split dynamic playlist view area")
250    };
251
252    [info_area, content_area]
253}
254
255impl ComponentRender<RenderProps> for DynamicView {
256    fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
257        let border_style = Style::default().fg(border_color(props.is_focused).into());
258
259        let area = if let Some(state) = &self.props {
260            let border = Block::bordered()
261                .title_top(Line::from(vec![
262                    Span::styled("Dynamic Playlist View".to_string(), Style::default().bold()),
263                    Span::raw(" sorted by: "),
264                    Span::styled(self.sort_mode.to_string(), Style::default().italic()),
265                ]))
266                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
267                .border_style(border_style);
268            frame.render_widget(&border, props.area);
269            let content_area = border.inner(props.area);
270
271            // split content area to make room for dynamic playlist info
272            let [info_area, content_area] = split_area(content_area);
273
274            // render the dynamic playlist info
275            frame.render_widget(
276                Paragraph::new(vec![
277                    Line::from(Span::styled(
278                        state.dynamic_playlist.name.to_string(),
279                        Style::default().bold(),
280                    )),
281                    Line::from(vec![
282                        Span::raw("Songs: "),
283                        Span::styled(state.songs.len().to_string(), Style::default().italic()),
284                        Span::raw("  Duration: "),
285                        Span::styled(
286                            format_duration(&state.songs.iter().map(|s| s.runtime).sum()),
287                            Style::default().italic(),
288                        ),
289                    ]),
290                    Line::from(Span::styled(
291                        state.dynamic_playlist.query.to_string(),
292                        Style::default().italic(),
293                    )),
294                ])
295                .alignment(Alignment::Center),
296                info_area,
297            );
298
299            // draw an additional border around the content area to display additional instructions
300            let border = Block::default()
301                .borders(Borders::TOP | Borders::BOTTOM)
302                .title_top("q: add to queue | r: start radio | p: add to playlist")
303                .title_bottom("s/S: sort | e: edit")
304                .border_style(border_style);
305            frame.render_widget(&border, content_area);
306            let content_area = border.inner(content_area);
307
308            // draw an additional border around the content area to indicate whether operations will be performed on the entire item, or just the checked items
309            let border = Block::default()
310                .borders(Borders::TOP)
311                .title_top(Line::from(vec![
312                    Span::raw("Performing operations on "),
313                    Span::raw(
314                        if self
315                            .tree_state
316                            .lock()
317                            .unwrap()
318                            .get_checked_things()
319                            .is_empty()
320                        {
321                            "entire dynamic playlist"
322                        } else {
323                            "checked items"
324                        },
325                    )
326                    .fg(TEXT_HIGHLIGHT),
327                ]))
328                .italic()
329                .border_style(border_style);
330            frame.render_widget(&border, content_area);
331            border.inner(content_area)
332        } else {
333            let border = Block::bordered()
334                .title_top("Dynamic Playlist View")
335                .border_style(border_style);
336            frame.render_widget(&border, props.area);
337            border.inner(props.area)
338        };
339
340        RenderProps { area, ..props }
341    }
342
343    fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
344        if let Some(state) = &self.props {
345            // create list to hold playlist songs
346            let items = state
347                .songs
348                .iter()
349                .map(create_song_tree_leaf)
350                .collect::<Vec<_>>();
351
352            // render the playlist songs
353            frame.render_stateful_widget(
354                CheckTree::new(&items)
355                    .unwrap()
356                    .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
357                    .experimental_scrollbar(Some(Scrollbar::new(
358                        ScrollbarOrientation::VerticalRight,
359                    ))),
360                props.area,
361                &mut self.tree_state.lock().unwrap(),
362            );
363        } else {
364            let text = "No active dynamic playlist";
365
366            frame.render_widget(
367                Line::from(text)
368                    .style(Style::default().fg(TEXT_NORMAL.into()))
369                    .alignment(Alignment::Center),
370                props.area,
371            );
372        }
373    }
374}
375
376pub struct LibraryDynamicView {
377    /// Action Sender
378    pub action_tx: UnboundedSender<Action>,
379    /// Mapped Props from state
380    props: Props,
381    /// tree state
382    tree_state: Mutex<CheckTreeState<String>>,
383    /// Dynamic Playlist Name Input Box
384    name_input_box: InputBox,
385    /// Dynamic Playlist Query Input Box
386    query_builder: QueryBuilder,
387    /// What is currently focused
388    /// Note: name and query input boxes are only visible when one of them is focused
389    focus: Focus,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq)]
393enum Focus {
394    NameInput,
395    QueryInput,
396    Tree,
397}
398
399#[derive(Debug)]
400pub struct Props {
401    pub dynamics: Box<[DynamicPlaylist]>,
402    sort_mode: NameSort<DynamicPlaylist>,
403}
404
405impl From<&AppState> for Props {
406    fn from(state: &AppState) -> Self {
407        let mut dynamics = state.library.dynamic_playlists.clone();
408        let sort_mode = NameSort::default();
409        sort_mode.sort_items(&mut dynamics);
410        Self {
411            dynamics,
412            sort_mode,
413        }
414    }
415}
416
417impl Component for LibraryDynamicView {
418    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
419    where
420        Self: Sized,
421    {
422        Self {
423            name_input_box: InputBox::new(state, action_tx.clone()),
424            query_builder: QueryBuilder::new(state, action_tx.clone()),
425            focus: Focus::Tree,
426            action_tx,
427            props: Props::from(state),
428            tree_state: Mutex::new(CheckTreeState::default()),
429        }
430    }
431
432    fn move_with_state(self, state: &AppState) -> Self
433    where
434        Self: Sized,
435    {
436        let tree_state = if state.active_view == ActiveView::DynamicPlaylists {
437            self.tree_state
438        } else {
439            Mutex::new(CheckTreeState::default())
440        };
441
442        Self {
443            props: Props::from(state),
444            tree_state,
445            ..self
446        }
447    }
448
449    fn name(&self) -> &'static str {
450        "Library Dynamic Playlists View"
451    }
452
453    fn handle_key_event(&mut self, key: KeyEvent) {
454        // this page has 2 distinct "modes",
455        // one for navigating the tree when the input boxes are not visible
456        // one for interacting with the input boxes when they are visible
457        if self.focus == Focus::Tree {
458            match key.code {
459                // arrow keys
460                KeyCode::PageUp => {
461                    self.tree_state.lock().unwrap().select_relative(|current| {
462                        current.map_or(self.props.dynamics.len() - 1, |c| c.saturating_sub(10))
463                    });
464                }
465                KeyCode::Up => {
466                    self.tree_state.lock().unwrap().key_up();
467                }
468                KeyCode::PageDown => {
469                    self.tree_state
470                        .lock()
471                        .unwrap()
472                        .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
473                }
474                KeyCode::Down => {
475                    self.tree_state.lock().unwrap().key_down();
476                }
477                KeyCode::Left => {
478                    self.tree_state.lock().unwrap().key_left();
479                }
480                KeyCode::Right => {
481                    self.tree_state.lock().unwrap().key_right();
482                }
483                // Enter key opens selected view
484                KeyCode::Enter => {
485                    if self.tree_state.lock().unwrap().toggle_selected() {
486                        let things = self.tree_state.lock().unwrap().get_selected_thing();
487
488                        if let Some(thing) = things {
489                            self.action_tx
490                                .send(Action::ActiveView(ViewAction::Set(thing.into())))
491                                .unwrap();
492                        }
493                    }
494                }
495                // Change sort mode
496                KeyCode::Char('s') => {
497                    self.props.sort_mode = self.props.sort_mode.next();
498                    self.props.sort_mode.sort_items(&mut self.props.dynamics);
499                    self.tree_state.lock().unwrap().scroll_selected_into_view();
500                }
501                KeyCode::Char('S') => {
502                    self.props.sort_mode = self.props.sort_mode.prev();
503                    self.props.sort_mode.sort_items(&mut self.props.dynamics);
504                    self.tree_state.lock().unwrap().scroll_selected_into_view();
505                }
506                // "n" key to create a new playlist
507                KeyCode::Char('n') => {
508                    self.focus = Focus::NameInput;
509                }
510                // "d" key to delete the selected playlist
511                KeyCode::Char('d') => {
512                    let things = self.tree_state.lock().unwrap().get_selected_thing();
513
514                    if let Some(thing) = things {
515                        self.action_tx
516                            .send(Action::Library(LibraryAction::RemoveDynamicPlaylist(thing)))
517                            .unwrap();
518                    }
519                }
520                _ => {}
521            }
522        } else {
523            let query = Query::from_str(self.query_builder.text()).ok();
524            let name = self.name_input_box.text();
525
526            match (key.code, query, self.focus) {
527                // if the user presses Enter with an empty name, we cancel the operation
528                (KeyCode::Enter, _, Focus::NameInput) if name.is_empty() => {
529                    self.focus = Focus::Tree;
530                }
531                // if the user pressed Enter with a valid (non-empty) name, we prompt the user for the query
532                (KeyCode::Enter, _, Focus::NameInput) if !name.is_empty() => {
533                    self.focus = Focus::QueryInput;
534                }
535                // if the user presses Enter with a valid query, we create a new playlist
536                (KeyCode::Enter, Some(query), Focus::QueryInput) => {
537                    self.action_tx
538                        .send(Action::Library(LibraryAction::CreateDynamicPlaylist(
539                            name.to_string(),
540                            query,
541                        )))
542                        .unwrap();
543                    self.name_input_box.reset();
544                    self.query_builder.reset();
545                    self.focus = Focus::Tree;
546                }
547                // otherwise defer to the focused input box
548                (_, _, Focus::NameInput) => self.name_input_box.handle_key_event(key),
549                (_, _, Focus::QueryInput) => self.query_builder.handle_key_event(key),
550                (_, _, Focus::Tree) => unreachable!(),
551            }
552        }
553    }
554
555    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
556        let MouseEvent {
557            kind, column, row, ..
558        } = mouse;
559        let mouse_position = Position::new(column, row);
560
561        // adjust the area to account for the border
562        let area = area.inner(Margin::new(1, 1));
563
564        if self.focus == Focus::Tree {
565            let area = Rect {
566                y: area.y + 1,
567                height: area.height - 1,
568                ..area
569            };
570
571            let result = self
572                .tree_state
573                .lock()
574                .unwrap()
575                .handle_mouse_event(mouse, area);
576            if let Some(action) = result {
577                self.action_tx.send(action).unwrap();
578            }
579        } else {
580            let [input_box_area, query_builder_area, content_area] = lib_split_area(area);
581            let content_area = Rect {
582                y: content_area.y + 1,
583                height: content_area.height - 1,
584                ..content_area
585            };
586
587            if input_box_area.contains(mouse_position) {
588                if kind == MouseEventKind::Down(MouseButton::Left) {
589                    self.focus = Focus::NameInput;
590                }
591                self.name_input_box
592                    .handle_mouse_event(mouse, input_box_area);
593            } else if query_builder_area.contains(mouse_position) {
594                if kind == MouseEventKind::Down(MouseButton::Left) {
595                    self.focus = Focus::QueryInput;
596                }
597                self.query_builder
598                    .handle_mouse_event(mouse, query_builder_area);
599            } else if content_area.contains(mouse_position)
600                && kind == MouseEventKind::Down(MouseButton::Left)
601            {
602                self.focus = Focus::Tree;
603            }
604        }
605    }
606}
607
608fn lib_split_area(area: Rect) -> [Rect; 3] {
609    let [input_box_area, query_builder_area, content_area] = *Layout::default()
610        .direction(Direction::Vertical)
611        .constraints([
612            Constraint::Length(3),
613            Constraint::Length(3),
614            Constraint::Min(1),
615        ])
616        .split(area)
617    else {
618        panic!("Failed to split library dynamic playlists view area");
619    };
620    [input_box_area, query_builder_area, content_area]
621}
622
623impl ComponentRender<RenderProps> for LibraryDynamicView {
624    fn render_border(&self, frame: &mut Frame, props: RenderProps) -> RenderProps {
625        let border_style = Style::default().fg(border_color(props.is_focused).into());
626
627        // render primary border
628        let border = Block::bordered()
629            .title_top(Line::from(vec![
630                Span::styled(
631                    "Library Dynamic Playlists".to_string(),
632                    Style::default().bold(),
633                ),
634                Span::raw(" sorted by: "),
635                Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
636            ]))
637            .title_bottom(
638                (self.focus == Focus::Tree)
639                    .then_some(" \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort")
640                    .unwrap_or_default(),
641            )
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        // 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::empty(),
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 select/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); // select
1265        view.handle_mouse_event(mouse_event, area); // open
1266        assert_eq!(
1267            rx.blocking_recv().unwrap(),
1268            Action::ActiveView(ViewAction::Set(ActiveView::DynamicPlaylist(item_id())))
1269        );
1270        // clicking on an empty area should clear the selection
1271        let mouse_event = MouseEvent {
1272            kind: MouseEventKind::Down(MouseButton::Left),
1273            column: 2,
1274            row: 3,
1275            modifiers: KeyModifiers::empty(),
1276        };
1277        view.handle_mouse_event(mouse_event, area);
1278        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1279        view.handle_mouse_event(mouse_event, area);
1280        assert_eq!(
1281            rx.try_recv(),
1282            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1283        );
1284
1285        view.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); // reveal the name input box
1286
1287        // with the input boxes visible:
1288        // - clicking on the query input box should focus it
1289        // - clicking on the name input box should focus it
1290        // - clicking on the content area should defocus and hide the input boxes
1291        assert_eq!(view.focus, Focus::NameInput);
1292        view.handle_mouse_event(
1293            // click on the query input box
1294            MouseEvent {
1295                kind: MouseEventKind::Down(MouseButton::Left),
1296                column: 2,
1297                row: 5,
1298                modifiers: KeyModifiers::empty(),
1299            },
1300            area,
1301        );
1302        assert_eq!(view.focus, Focus::QueryInput);
1303        view.handle_mouse_event(
1304            // click on the name input box
1305            MouseEvent {
1306                kind: MouseEventKind::Down(MouseButton::Left),
1307                column: 2,
1308                row: 2,
1309                modifiers: KeyModifiers::empty(),
1310            },
1311            area,
1312        );
1313        assert_eq!(view.focus, Focus::NameInput);
1314        // click on the content area
1315        view.handle_mouse_event(
1316            MouseEvent {
1317                kind: MouseEventKind::Down(MouseButton::Left),
1318                column: 2,
1319                row: 8,
1320                modifiers: KeyModifiers::empty(),
1321            },
1322            area,
1323        );
1324        assert_eq!(view.focus, Focus::Tree);
1325    }
1326
1327    #[test]
1328    fn test_render() {
1329        let (tx, _) = unbounded_channel();
1330        let state = state_with_everything();
1331        let view = LibraryDynamicView::new(&state, tx);
1332
1333        let (mut terminal, area) = setup_test_terminal(60, 6);
1334        let props = RenderProps {
1335            area,
1336            is_focused: true,
1337        };
1338        let buffer = terminal
1339            .draw(|f| view.render(f, props))
1340            .unwrap()
1341            .buffer
1342            .clone();
1343        let expected = Buffer::with_lines([
1344            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1345            "│n: new dynamic | d: delete dynamic────────────────────────│",
1346            "│▪ Test Dynamic                                            │",
1347            "│                                                          │",
1348            "│                                                          │",
1349            "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
1350        ]);
1351
1352        assert_buffer_eq(&buffer, &expected);
1353    }
1354
1355    #[test]
1356    fn test_render_with_input_boxes_visible() {
1357        let (tx, _) = unbounded_channel();
1358        let state = state_with_everything();
1359        let mut view = LibraryDynamicView::new(&state, tx);
1360
1361        // reveal the name input box
1362        view.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
1363
1364        let (mut terminal, area) = setup_test_terminal(60, 11);
1365        let props = RenderProps {
1366            area,
1367            is_focused: true,
1368        };
1369        let buffer = terminal
1370            .draw(|f| view.render(f, props))
1371            .unwrap()
1372            .buffer
1373            .clone();
1374        let expected = Buffer::with_lines([
1375            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1376            "│┌Enter Name:─────────────────────────────────────────────┐│",
1377            "││                                                        ││",
1378            "│└────────────────────────────────────────────────────────┘│",
1379            "│┌Invalid Query:──────────────────────────────────────────┐│",
1380            "││                                                        ││",
1381            "│└────────────────────────────────────────────────────────┘│",
1382            "│ ⏎ : Set (cancel if empty)────────────────────────────────│",
1383            "│▪ Test Dynamic                                            │",
1384            "│                                                          │",
1385            "└──────────────────────────────────────────────────────────┘",
1386        ]);
1387        assert_buffer_eq(&buffer, &expected);
1388
1389        let name = "Test";
1390        for c in name.chars() {
1391            view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1392        }
1393        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
1394
1395        let buffer = terminal
1396            .draw(|f| view.render(f, props))
1397            .unwrap()
1398            .buffer
1399            .clone();
1400        let expected = Buffer::with_lines([
1401            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1402            "│┌Enter Name:─────────────────────────────────────────────┐│",
1403            "││Test                                                    ││",
1404            "│└────────────────────────────────────────────────────────┘│",
1405            "│┌Invalid Query:──────────────────────────────────────────┐│",
1406            "││                                                        ││",
1407            "│└────────────────────────────────────────────────────────┘│",
1408            "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1409            "│▪ Test Dynamic                                            │",
1410            "│                                                          │",
1411            "└──────────────────────────────────────────────────────────┘",
1412        ]);
1413        assert_buffer_eq(&buffer, &expected);
1414
1415        let query = "artist CONTAINS 'foo'";
1416        for c in query.chars() {
1417            view.handle_key_event(KeyEvent::from(KeyCode::Char(c)));
1418        }
1419
1420        let buffer = terminal
1421            .draw(|f| view.render(f, props))
1422            .unwrap()
1423            .buffer
1424            .clone();
1425        let expected = Buffer::with_lines([
1426            "┌Library Dynamic Playlists sorted by: Name─────────────────┐",
1427            "│┌Enter Name:─────────────────────────────────────────────┐│",
1428            "││Test                                                    ││",
1429            "│└────────────────────────────────────────────────────────┘│",
1430            "│┌Enter Query:────────────────────────────────────────────┐│",
1431            "││artist CONTAINS 'foo'                                   ││",
1432            "│└────────────────────────────────────────────────────────┘│",
1433            "│ ⏎ : Create (cancel if empty)─────────────────────────────│",
1434            "│▪ Test Dynamic                                            │",
1435            "│                                                          │",
1436            "└──────────────────────────────────────────────────────────┘",
1437        ]);
1438
1439        assert_buffer_eq(&buffer, &expected);
1440    }
1441}