Skip to main content

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

1//! implementation the search view
2use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind};
3use mecomp_prost::SearchResult;
4use ratatui::{
5    layout::{Alignment, Constraint, Direction, Layout, Offset, Position, Rect},
6    style::Style,
7    text::Line,
8    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
9};
10use tokio::sync::mpsc::UnboundedSender;
11
12use crate::{
13    state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
14    ui::{
15        AppState,
16        colors::{TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL, border_color},
17        components::{Component, ComponentRender, RenderProps, content_view::ActiveView},
18        widgets::{
19            input_box::{InputBox, InputBoxState},
20            popups::PopupType,
21            tree::{CheckTree, state::CheckTreeState},
22        },
23    },
24};
25
26use super::checktree_utils::{
27    create_album_tree_item, create_artist_tree_item, create_song_tree_item,
28};
29
30#[allow(clippy::module_name_repetitions)]
31pub struct SearchView {
32    /// Action Sender
33    pub action_tx: UnboundedSender<Action>,
34    /// Mapped Props from state
35    pub props: Props,
36    /// tree state
37    tree_state: CheckTreeState<String>,
38    /// Search Bar
39    search_bar: InputBoxState,
40    /// Is the search bar focused
41    search_bar_focused: bool,
42}
43
44pub struct Props {
45    pub(crate) search_results: SearchResult,
46}
47
48impl From<&AppState> for Props {
49    fn from(value: &AppState) -> Self {
50        Self {
51            search_results: value.search.clone(),
52        }
53    }
54}
55
56impl Component for SearchView {
57    fn new(
58        state: &AppState,
59        action_tx: tokio::sync::mpsc::UnboundedSender<crate::state::action::Action>,
60    ) -> Self
61    where
62        Self: Sized,
63    {
64        let props = Props::from(state);
65        Self {
66            search_bar: InputBoxState::new(),
67            search_bar_focused: true,
68            tree_state: CheckTreeState::default(),
69            action_tx,
70            props,
71        }
72    }
73
74    fn move_with_state(self, state: &AppState) -> Self
75    where
76        Self: Sized,
77    {
78        Self {
79            props: Props::from(state),
80            tree_state: CheckTreeState::default(),
81            ..self
82        }
83    }
84
85    fn name(&self) -> &'static str {
86        "Search"
87    }
88
89    fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
90        match key.code {
91            // arrow keys
92            KeyCode::PageUp => {
93                self.tree_state.select_relative(|current| {
94                    let first = self.props.search_results.len().saturating_sub(1);
95                    current.map_or(first, |c| c.saturating_sub(10))
96                });
97            }
98            KeyCode::Up => {
99                self.tree_state.key_up();
100            }
101            KeyCode::PageDown => {
102                self.tree_state
103                    .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
104            }
105            KeyCode::Down => {
106                self.tree_state.key_down();
107            }
108            KeyCode::Left if !self.search_bar_focused => {
109                self.tree_state.key_left();
110            }
111            KeyCode::Right if !self.search_bar_focused => {
112                self.tree_state.key_right();
113            }
114            KeyCode::Char(' ') if !self.search_bar_focused => {
115                self.tree_state.key_space();
116            }
117            // when searchbar focused, enter key will search
118            KeyCode::Enter if self.search_bar_focused => {
119                self.search_bar_focused = false;
120                self.tree_state.reset();
121                if !self.search_bar.is_empty() {
122                    self.action_tx
123                        .send(Action::Search(self.search_bar.text().to_string()))
124                        .unwrap();
125                    self.search_bar.clear();
126                }
127            }
128            KeyCode::Char('/') if !self.search_bar_focused => {
129                self.search_bar_focused = true;
130            }
131            // when searchbar unfocused, enter key will open the selected node
132            KeyCode::Enter if !self.search_bar_focused => {
133                if self.tree_state.toggle_selected() {
134                    let things = self.tree_state.get_selected_thing();
135
136                    if let Some(thing) = things {
137                        self.action_tx
138                            .send(Action::ActiveView(ViewAction::Set(thing.into())))
139                            .unwrap();
140                    }
141                }
142            }
143            // when search bar unfocused, and there are checked items, "q" will send the checked items to the queue
144            KeyCode::Char('q') if !self.search_bar_focused => {
145                let things = self.tree_state.get_checked_things();
146                if !things.is_empty() {
147                    self.action_tx
148                        .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
149                        .unwrap();
150                }
151            }
152            // when search bar unfocused, and there are checked items, "r" will start a radio with the checked items
153            KeyCode::Char('r') if !self.search_bar_focused => {
154                let things = self.tree_state.get_checked_things();
155                if !things.is_empty() {
156                    self.action_tx
157                        .send(Action::ActiveView(ViewAction::Set(ActiveView::Radio(
158                            things,
159                        ))))
160                        .unwrap();
161                }
162            }
163            // when search bar unfocused, and there are checked items, "p" will send the checked items to the playlist
164            KeyCode::Char('p') if !self.search_bar_focused => {
165                let things = self.tree_state.get_checked_things();
166                if !things.is_empty() {
167                    self.action_tx
168                        .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
169                            things,
170                        ))))
171                        .unwrap();
172                }
173            }
174
175            // defer to the search bar, if it is focused
176            _ if self.search_bar_focused => {
177                self.search_bar.handle_key_event(key);
178            }
179            _ => {}
180        }
181    }
182
183    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
184        let MouseEvent {
185            kind, column, row, ..
186        } = mouse;
187        let mouse_position = Position::new(column, row);
188
189        // split the area into search bar and content area
190        let [search_bar_area, content_area] = split_area(area);
191
192        match (self.search_bar_focused, kind) {
193            // defer to the search bar if mouse event belongs to it
194            (true, _) if search_bar_area.contains(mouse_position) => {
195                self.search_bar.handle_mouse_event(mouse, search_bar_area);
196            }
197            // if the search bar is focused and mouse is clicked outside of it, unfocus the search bar
198            (true, MouseEventKind::Down(MouseButton::Left))
199                if content_area.contains(mouse_position) =>
200            {
201                self.search_bar_focused = false;
202            }
203            // if the search bar is not focused and mouse is clicked inside it, focus the search bar
204            (false, MouseEventKind::Down(MouseButton::Left))
205                if search_bar_area.contains(mouse_position) =>
206            {
207                self.search_bar_focused = true;
208            }
209            // defer to the tree state for mouse events in the content area when the search bar is not focused
210            (false, _) if content_area.contains(mouse_position) => {
211                // adjust the content area to exclude the border
212                let content_area = Rect {
213                    x: content_area.x.saturating_add(1),
214                    y: content_area.y.saturating_add(1),
215                    width: content_area.width.saturating_sub(1),
216                    height: content_area.height.saturating_sub(2),
217                };
218
219                let result = self
220                    .tree_state
221                    .handle_mouse_event(mouse, content_area, false);
222                if let Some(action) = result {
223                    self.action_tx.send(action).unwrap();
224                }
225            }
226            _ => {}
227        }
228    }
229}
230
231fn split_area(area: Rect) -> [Rect; 2] {
232    let [search_bar_area, content_area] = Layout::default()
233        .direction(Direction::Vertical)
234        .constraints([Constraint::Length(3), Constraint::Min(4)].as_ref())
235        .areas(area);
236    [search_bar_area, content_area]
237}
238
239impl ComponentRender<RenderProps> for SearchView {
240    fn render_border(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
241        let border_style =
242            Style::default().fg(border_color(props.is_focused && !self.search_bar_focused).into());
243
244        // split view
245        let [search_bar_area, content_area] = split_area(props.area);
246
247        // render the search bar
248        let search_bar = InputBox::new()
249            .text_color(if self.search_bar_focused {
250                (*TEXT_HIGHLIGHT_ALT).into()
251            } else {
252                (*TEXT_NORMAL).into()
253            })
254            .border(
255                Block::bordered().title("Search").border_style(
256                    Style::default()
257                        .fg(border_color(self.search_bar_focused && props.is_focused).into()),
258                ),
259            );
260        frame.render_stateful_widget(search_bar, search_bar_area, &mut self.search_bar);
261        if self.search_bar_focused {
262            let position = search_bar_area + self.search_bar.cursor_offset() + Offset::new(1, 1);
263            frame.set_cursor_position(position);
264        }
265
266        // put a border around the content area
267        let area = if self.search_bar_focused {
268            let border = Block::bordered()
269                .title_top("Results")
270                .title_bottom(" \u{23CE} : Search")
271                .border_style(border_style);
272            frame.render_widget(&border, content_area);
273            border.inner(content_area)
274        } else {
275            let border = Block::bordered()
276                .title_top("Results")
277                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate")
278                .border_style(border_style);
279            frame.render_widget(&border, content_area);
280            let content_area = border.inner(content_area);
281
282            let border = Block::default()
283                .borders(Borders::BOTTOM)
284                .title_bottom("/: Search | \u{2423} : Check")
285                .border_style(border_style);
286            frame.render_widget(&border, content_area);
287            border.inner(content_area)
288        };
289
290        // if there are checked items, put an additional border around the content area to display additional instructions
291        let area = if self.tree_state.get_checked_things().is_empty() {
292            area
293        } else {
294            let border = Block::default()
295                .borders(Borders::TOP)
296                .title_top("q: add to queue | r: start radio | p: add to playlist")
297                .border_style(border_style);
298            frame.render_widget(&border, area);
299            border.inner(area)
300        };
301
302        RenderProps { area, ..props }
303    }
304
305    fn render_content(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
306        // if there are no search results, render a message
307        if self.props.search_results.is_empty() {
308            frame.render_widget(
309                Line::from("No results found")
310                    .style(Style::default().fg((*TEXT_NORMAL).into()))
311                    .alignment(Alignment::Center),
312                props.area,
313            );
314            return;
315        }
316
317        // create tree to hold results
318        let song_tree = create_song_tree_item(&self.props.search_results.songs).unwrap();
319        let album_tree = create_album_tree_item(&self.props.search_results.albums).unwrap();
320        let artist_tree = create_artist_tree_item(&self.props.search_results.artists).unwrap();
321        let items = &[song_tree, album_tree, artist_tree];
322
323        // render the search results
324        frame.render_stateful_widget(
325            CheckTree::new(items)
326                .unwrap()
327                .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
328                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
329            props.area,
330            &mut self.tree_state,
331        );
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::{
339        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
340        ui::components::content_view::ActiveView,
341    };
342    use crossterm::event::KeyEvent;
343    use crossterm::event::KeyModifiers;
344    use mecomp_prost::RecordId;
345    use pretty_assertions::assert_eq;
346    use ratatui::buffer::Buffer;
347
348    #[test]
349    fn test_render_search_focused() {
350        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
351        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
352            active_view: ActiveView::Search,
353            ..state_with_everything()
354        });
355
356        let (mut terminal, area) = setup_test_terminal(24, 8);
357        let props = RenderProps {
358            area,
359            is_focused: true,
360        };
361        let buffer = terminal
362            .draw(|frame| view.render(frame, props))
363            .unwrap()
364            .buffer
365            .clone();
366        let expected = Buffer::with_lines([
367            "┌Search────────────────┐",
368            "│                      │",
369            "└──────────────────────┘",
370            "┌Results───────────────┐",
371            "│▶ Songs (1):          │",
372            "│▶ Albums (1):         │",
373            "│▶ Artists (1):        │",
374            "└ ⏎ : Search───────────┘",
375        ]);
376
377        assert_buffer_eq(&buffer, &expected);
378    }
379
380    #[test]
381    fn test_render_empty() {
382        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
383        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
384            active_view: ActiveView::Search,
385            search: SearchResult::default(),
386            ..state_with_everything()
387        });
388
389        let (mut terminal, area) = setup_test_terminal(24, 8);
390        let props = RenderProps {
391            area,
392            is_focused: true,
393        };
394        let buffer = terminal
395            .draw(|frame| view.render(frame, props))
396            .unwrap()
397            .buffer
398            .clone();
399        let expected = Buffer::with_lines([
400            "┌Search────────────────┐",
401            "│                      │",
402            "└──────────────────────┘",
403            "┌Results───────────────┐",
404            "│   No results found   │",
405            "│                      │",
406            "│                      │",
407            "└ ⏎ : Search───────────┘",
408        ]);
409
410        assert_buffer_eq(&buffer, &expected);
411    }
412
413    #[test]
414    fn test_render_search_unfocused() {
415        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
416        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
417            active_view: ActiveView::Search,
418            ..state_with_everything()
419        });
420
421        let (mut terminal, area) = setup_test_terminal(32, 9);
422        let props = RenderProps {
423            area,
424            is_focused: true,
425        };
426
427        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
428
429        let buffer = terminal
430            .draw(|frame| view.render(frame, props))
431            .unwrap()
432            .buffer
433            .clone();
434        let expected = Buffer::with_lines([
435            "┌Search────────────────────────┐",
436            "│                              │",
437            "└──────────────────────────────┘",
438            "┌Results───────────────────────┐",
439            "│▶ Songs (1):                  │",
440            "│▶ Albums (1):                 │",
441            "│▶ Artists (1):                │",
442            "│/: Search | ␣ : Check─────────│",
443            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
444        ]);
445        assert_buffer_eq(&buffer, &expected);
446    }
447
448    #[test]
449    fn smoke_navigation() {
450        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
451        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
452            active_view: ActiveView::Search,
453            ..state_with_everything()
454        });
455
456        view.handle_key_event(KeyEvent::from(KeyCode::Up));
457        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
458        view.handle_key_event(KeyEvent::from(KeyCode::Down));
459        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
460        view.handle_key_event(KeyEvent::from(KeyCode::Left));
461        view.handle_key_event(KeyEvent::from(KeyCode::Right));
462    }
463
464    #[test]
465    #[allow(clippy::too_many_lines)]
466    fn test_keys() {
467        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
468        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
469            active_view: ActiveView::Search,
470            ..state_with_everything()
471        });
472
473        let (mut terminal, area) = setup_test_terminal(32, 10);
474        let props = RenderProps {
475            area,
476            is_focused: true,
477        };
478
479        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
480        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
481        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
482
483        let buffer = terminal
484            .draw(|frame| view.render(frame, props))
485            .unwrap()
486            .buffer
487            .clone();
488        let expected = Buffer::with_lines([
489            "┌Search────────────────────────┐",
490            "│qrp                           │",
491            "└──────────────────────────────┘",
492            "┌Results───────────────────────┐",
493            "│▶ Songs (1):                  │",
494            "│▶ Albums (1):                 │",
495            "│▶ Artists (1):                │",
496            "│                              │",
497            "│                              │",
498            "└ ⏎ : Search───────────────────┘",
499        ]);
500        assert_buffer_eq(&buffer, &expected);
501
502        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
503        let action = rx.blocking_recv().unwrap();
504        assert_eq!(action, Action::Search("qrp".to_string()));
505
506        let buffer = terminal
507            .draw(|frame| view.render(frame, props))
508            .unwrap()
509            .buffer
510            .clone();
511        let expected = Buffer::with_lines([
512            "┌Search────────────────────────┐",
513            "│                              │",
514            "└──────────────────────────────┘",
515            "┌Results───────────────────────┐",
516            "│▶ Songs (1):                  │",
517            "│▶ Albums (1):                 │",
518            "│▶ Artists (1):                │",
519            "│                              │",
520            "│/: Search | ␣ : Check─────────│",
521            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
522        ]);
523        assert_buffer_eq(&buffer, &expected);
524
525        view.handle_key_event(KeyEvent::from(KeyCode::Char('/')));
526        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
527
528        let buffer = terminal
529            .draw(|frame| view.render(frame, props))
530            .unwrap()
531            .buffer
532            .clone();
533        assert_buffer_eq(&buffer, &expected);
534
535        view.handle_key_event(KeyEvent::from(KeyCode::Down));
536        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
537
538        let buffer = terminal
539            .draw(|frame| view.render(frame, props))
540            .unwrap()
541            .buffer
542            .clone();
543        let expected = Buffer::with_lines([
544            "┌Search────────────────────────┐",
545            "│                              │",
546            "└──────────────────────────────┘",
547            "┌Results───────────────────────┐",
548            "│▼ Songs (1):                  │",
549            "│  ☐ Test Song Test Artist     │",
550            "│▶ Albums (1):                 │",
551            "│▶ Artists (1):                │",
552            "│/: Search | ␣ : Check─────────│",
553            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
554        ]);
555        assert_buffer_eq(&buffer, &expected);
556
557        view.handle_key_event(KeyEvent::from(KeyCode::Down));
558        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
559
560        let buffer = terminal
561            .draw(|frame| view.render(frame, props))
562            .unwrap()
563            .buffer
564            .clone();
565        let expected = Buffer::with_lines([
566            "┌Search────────────────────────┐",
567            "│                              │",
568            "└──────────────────────────────┘",
569            "┌Results───────────────────────┐",
570            "│q: add to queue | r: start rad│",
571            "│▼ Songs (1):                 ▲│",
572            "│  ☑ Test Song Test Artist    █│",
573            "│▶ Albums (1):                ▼│",
574            "│/: Search | ␣ : Check─────────│",
575            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
576        ]);
577        assert_buffer_eq(&buffer, &expected);
578
579        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
580        let action = rx.blocking_recv().unwrap();
581        assert_eq!(
582            action,
583            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![RecordId::new(
584                "song",
585                item_id()
586            )])))
587        );
588
589        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
590        let action = rx.blocking_recv().unwrap();
591        assert_eq!(
592            action,
593            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![RecordId::new(
594                "song",
595                item_id()
596            )],)))
597        );
598
599        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
600        let action = rx.blocking_recv().unwrap();
601        assert_eq!(
602            action,
603            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![RecordId::new(
604                "song",
605                item_id()
606            )])))
607        );
608    }
609
610    #[test]
611    #[allow(clippy::too_many_lines)]
612    fn test_mouse() {
613        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
614        let mut view = SearchView::new(&state_with_everything(), tx);
615
616        let (mut terminal, area) = setup_test_terminal(32, 10);
617        let props = RenderProps {
618            area,
619            is_focused: true,
620        };
621        let buffer = terminal
622            .draw(|frame| view.render(frame, props))
623            .unwrap()
624            .buffer
625            .clone();
626        let expected = Buffer::with_lines([
627            "┌Search────────────────────────┐",
628            "│                              │",
629            "└──────────────────────────────┘",
630            "┌Results───────────────────────┐",
631            "│▶ Songs (1):                  │",
632            "│▶ Albums (1):                 │",
633            "│▶ Artists (1):                │",
634            "│                              │",
635            "│                              │",
636            "└ ⏎ : Search───────────────────┘",
637        ]);
638        assert_buffer_eq(&buffer, &expected);
639
640        // put some text in the search bar
641        view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
642        view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
643        view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
644
645        let buffer = terminal
646            .draw(|frame| view.render(frame, props))
647            .unwrap()
648            .buffer
649            .clone();
650        let expected = Buffer::with_lines([
651            "┌Search────────────────────────┐",
652            "│abc                           │",
653            "└──────────────────────────────┘",
654            "┌Results───────────────────────┐",
655            "│▶ Songs (1):                  │",
656            "│▶ Albums (1):                 │",
657            "│▶ Artists (1):                │",
658            "│                              │",
659            "│                              │",
660            "└ ⏎ : Search───────────────────┘",
661        ]);
662        assert_buffer_eq(&buffer, &expected);
663
664        // click in the search bar and ensure the cursor is moved to the right place
665        view.handle_mouse_event(
666            MouseEvent {
667                kind: MouseEventKind::Down(MouseButton::Left),
668                column: 2,
669                row: 1,
670                modifiers: KeyModifiers::empty(),
671            },
672            area,
673        );
674        view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
675        let buffer = terminal
676            .draw(|frame| view.render(frame, props))
677            .unwrap()
678            .buffer
679            .clone();
680        let expected = Buffer::with_lines([
681            "┌Search────────────────────────┐",
682            "│acbc                          │",
683            "└──────────────────────────────┘",
684            "┌Results───────────────────────┐",
685            "│▶ Songs (1):                  │",
686            "│▶ Albums (1):                 │",
687            "│▶ Artists (1):                │",
688            "│                              │",
689            "│                              │",
690            "└ ⏎ : Search───────────────────┘",
691        ]);
692
693        assert_buffer_eq(&buffer, &expected);
694
695        // click out of the search bar
696        view.handle_mouse_event(
697            MouseEvent {
698                kind: MouseEventKind::Down(MouseButton::Left),
699                column: 2,
700                row: 5,
701                modifiers: KeyModifiers::empty(),
702            },
703            area,
704        );
705
706        // scroll down
707        view.handle_mouse_event(
708            MouseEvent {
709                kind: MouseEventKind::ScrollDown,
710                column: 2,
711                row: 4,
712                modifiers: KeyModifiers::empty(),
713            },
714            area,
715        );
716        view.handle_mouse_event(
717            MouseEvent {
718                kind: MouseEventKind::ScrollDown,
719                column: 2,
720                row: 4,
721                modifiers: KeyModifiers::empty(),
722            },
723            area,
724        );
725
726        // click on the selected dropdown
727        view.handle_mouse_event(
728            MouseEvent {
729                kind: MouseEventKind::Down(MouseButton::Left),
730                column: 2,
731                row: 5,
732                modifiers: KeyModifiers::empty(),
733            },
734            area,
735        );
736        let expected = Buffer::with_lines([
737            "┌Search────────────────────────┐",
738            "│acbc                          │",
739            "└──────────────────────────────┘",
740            "┌Results───────────────────────┐",
741            "│▶ Songs (1):                  │",
742            "│▼ Albums (1):                 │",
743            "│  ☐ Test Album Test Artist    │",
744            "│▶ Artists (1):                │",
745            "│/: Search | ␣ : Check─────────│",
746            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
747        ]);
748        let buffer = terminal
749            .draw(|frame| view.render(frame, props))
750            .unwrap()
751            .buffer
752            .clone();
753        assert_buffer_eq(&buffer, &expected);
754
755        // scroll up
756        view.handle_mouse_event(
757            MouseEvent {
758                kind: MouseEventKind::ScrollUp,
759                column: 2,
760                row: 4,
761                modifiers: KeyModifiers::empty(),
762            },
763            area,
764        );
765
766        // click on the selected dropdown
767        view.handle_mouse_event(
768            MouseEvent {
769                kind: MouseEventKind::Down(MouseButton::Left),
770                column: 2,
771                row: 4,
772                modifiers: KeyModifiers::empty(),
773            },
774            area,
775        );
776        let expected = Buffer::with_lines([
777            "┌Search────────────────────────┐",
778            "│acbc                          │",
779            "└──────────────────────────────┘",
780            "┌Results───────────────────────┐",
781            "│▼ Songs (1):                 ▲│",
782            "│  ☐ Test Song Test Artist    █│",
783            "│▼ Albums (1):                █│",
784            "│  ☐ Test Album Test Artist   ▼│",
785            "│/: Search | ␣ : Check─────────│",
786            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
787        ]);
788        let buffer = terminal
789            .draw(|frame| view.render(frame, props))
790            .unwrap()
791            .buffer
792            .clone();
793        assert_buffer_eq(&buffer, &expected);
794
795        // scroll down
796        view.handle_mouse_event(
797            MouseEvent {
798                kind: MouseEventKind::ScrollDown,
799                column: 2,
800                row: 4,
801                modifiers: KeyModifiers::empty(),
802            },
803            area,
804        );
805
806        // ctrl-click
807        view.handle_mouse_event(
808            MouseEvent {
809                kind: MouseEventKind::Down(MouseButton::Left),
810                column: 2,
811                row: 5,
812                modifiers: KeyModifiers::CONTROL,
813            },
814            area,
815        );
816        assert_eq!(
817            rx.blocking_recv().unwrap(),
818            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id().into())))
819        );
820        let expected = Buffer::with_lines([
821            "┌Search────────────────────────┐",
822            "│acbc                          │",
823            "└──────────────────────────────┘",
824            "┌Results───────────────────────┐",
825            "│q: add to queue | r: start rad│",
826            "│▼ Songs (1):                 ▲│",
827            "│  ☑ Test Song Test Artist    █│",
828            "│▼ Albums (1):                ▼│",
829            "│/: Search | ␣ : Check─────────│",
830            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
831        ]);
832        let buffer = terminal
833            .draw(|frame| view.render(frame, props))
834            .unwrap()
835            .buffer
836            .clone();
837        assert_buffer_eq(&buffer, &expected);
838    }
839}