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

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