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

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