Skip to main content

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_prost::SearchResult;
7use ratatui::{
8    layout::{Alignment, Constraint, Direction, Layout, Position, Rect},
9    style::Style,
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        .areas(area);
244    [search_bar_area, content_area]
245}
246
247impl ComponentRender<RenderProps> for SearchView {
248    fn render_border(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
249        let border_style =
250            Style::default().fg(border_color(props.is_focused && !self.search_bar_focused).into());
251
252        // split view
253        let [search_bar_area, content_area] = split_area(props.area);
254
255        // render the search bar
256        self.search_bar.render(
257            frame,
258            input_box::RenderProps {
259                area: search_bar_area,
260                text_color: if self.search_bar_focused {
261                    (*TEXT_HIGHLIGHT_ALT).into()
262                } else {
263                    (*TEXT_NORMAL).into()
264                },
265                border: Block::bordered().title("Search").border_style(
266                    Style::default()
267                        .fg(border_color(self.search_bar_focused && props.is_focused).into()),
268                ),
269                show_cursor: self.search_bar_focused,
270            },
271        );
272
273        // put a border around the content area
274        let area = if self.search_bar_focused {
275            let border = Block::bordered()
276                .title_top("Results")
277                .title_bottom(" \u{23CE} : Search")
278                .border_style(border_style);
279            frame.render_widget(&border, content_area);
280            border.inner(content_area)
281        } else {
282            let border = Block::bordered()
283                .title_top("Results")
284                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate")
285                .border_style(border_style);
286            frame.render_widget(&border, content_area);
287            let content_area = border.inner(content_area);
288
289            let border = Block::default()
290                .borders(Borders::BOTTOM)
291                .title_bottom("/: Search | \u{2423} : Check")
292                .border_style(border_style);
293            frame.render_widget(&border, content_area);
294            border.inner(content_area)
295        };
296
297        // if there are checked items, put an additional border around the content area to display additional instructions
298        let area = if self
299            .tree_state
300            .lock()
301            .unwrap()
302            .get_checked_things()
303            .is_empty()
304        {
305            area
306        } else {
307            let border = Block::default()
308                .borders(Borders::TOP)
309                .title_top("q: add to queue | r: start radio | p: add to playlist")
310                .border_style(border_style);
311            frame.render_widget(&border, area);
312            border.inner(area)
313        };
314
315        RenderProps { area, ..props }
316    }
317
318    fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
319        // if there are no search results, render a message
320        if self.props.search_results.is_empty() {
321            frame.render_widget(
322                Line::from("No results found")
323                    .style(Style::default().fg((*TEXT_NORMAL).into()))
324                    .alignment(Alignment::Center),
325                props.area,
326            );
327            return;
328        }
329
330        // create tree to hold results
331        let song_tree = create_song_tree_item(&self.props.search_results.songs).unwrap();
332        let album_tree = create_album_tree_item(&self.props.search_results.albums).unwrap();
333        let artist_tree = create_artist_tree_item(&self.props.search_results.artists).unwrap();
334        let items = &[song_tree, album_tree, artist_tree];
335
336        // render the search results
337        frame.render_stateful_widget(
338            CheckTree::new(items)
339                .unwrap()
340                .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
341                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
342            props.area,
343            &mut self.tree_state.lock().unwrap(),
344        );
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::{
352        test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
353        ui::components::content_view::ActiveView,
354    };
355    use crossterm::event::KeyEvent;
356    use crossterm::event::KeyModifiers;
357    use mecomp_prost::RecordId;
358    use pretty_assertions::assert_eq;
359    use ratatui::buffer::Buffer;
360
361    #[test]
362    fn test_render_search_focused() {
363        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
364        let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
365            active_view: ActiveView::Search,
366            ..state_with_everything()
367        });
368
369        let (mut terminal, area) = setup_test_terminal(24, 8);
370        let props = RenderProps {
371            area,
372            is_focused: true,
373        };
374        let buffer = terminal
375            .draw(|frame| view.render(frame, props))
376            .unwrap()
377            .buffer
378            .clone();
379        let expected = Buffer::with_lines([
380            "┌Search────────────────┐",
381            "│                      │",
382            "└──────────────────────┘",
383            "┌Results───────────────┐",
384            "│▶ Songs (1):          │",
385            "│▶ Albums (1):         │",
386            "│▶ Artists (1):        │",
387            "└ ⏎ : Search───────────┘",
388        ]);
389
390        assert_buffer_eq(&buffer, &expected);
391    }
392
393    #[test]
394    fn test_render_empty() {
395        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
396        let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
397            active_view: ActiveView::Search,
398            search: SearchResult::default(),
399            ..state_with_everything()
400        });
401
402        let (mut terminal, area) = setup_test_terminal(24, 8);
403        let props = RenderProps {
404            area,
405            is_focused: true,
406        };
407        let buffer = terminal
408            .draw(|frame| view.render(frame, props))
409            .unwrap()
410            .buffer
411            .clone();
412        let expected = Buffer::with_lines([
413            "┌Search────────────────┐",
414            "│                      │",
415            "└──────────────────────┘",
416            "┌Results───────────────┐",
417            "│   No results found   │",
418            "│                      │",
419            "│                      │",
420            "└ ⏎ : Search───────────┘",
421        ]);
422
423        assert_buffer_eq(&buffer, &expected);
424    }
425
426    #[test]
427    fn test_render_search_unfocused() {
428        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
429        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
430            active_view: ActiveView::Search,
431            ..state_with_everything()
432        });
433
434        let (mut terminal, area) = setup_test_terminal(32, 9);
435        let props = RenderProps {
436            area,
437            is_focused: true,
438        };
439
440        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
441
442        let buffer = terminal
443            .draw(|frame| view.render(frame, props))
444            .unwrap()
445            .buffer
446            .clone();
447        let expected = Buffer::with_lines([
448            "┌Search────────────────────────┐",
449            "│                              │",
450            "└──────────────────────────────┘",
451            "┌Results───────────────────────┐",
452            "│▶ Songs (1):                  │",
453            "│▶ Albums (1):                 │",
454            "│▶ Artists (1):                │",
455            "│/: Search | ␣ : Check─────────│",
456            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
457        ]);
458        assert_buffer_eq(&buffer, &expected);
459    }
460
461    #[test]
462    fn smoke_navigation() {
463        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
464        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
465            active_view: ActiveView::Search,
466            ..state_with_everything()
467        });
468
469        view.handle_key_event(KeyEvent::from(KeyCode::Up));
470        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
471        view.handle_key_event(KeyEvent::from(KeyCode::Down));
472        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
473        view.handle_key_event(KeyEvent::from(KeyCode::Left));
474        view.handle_key_event(KeyEvent::from(KeyCode::Right));
475    }
476
477    #[test]
478    #[allow(clippy::too_many_lines)]
479    fn test_keys() {
480        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
481        let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
482            active_view: ActiveView::Search,
483            ..state_with_everything()
484        });
485
486        let (mut terminal, area) = setup_test_terminal(32, 10);
487        let props = RenderProps {
488            area,
489            is_focused: true,
490        };
491
492        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
493        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
494        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
495
496        let buffer = terminal
497            .draw(|frame| view.render(frame, props))
498            .unwrap()
499            .buffer
500            .clone();
501        let expected = Buffer::with_lines([
502            "┌Search────────────────────────┐",
503            "│qrp                           │",
504            "└──────────────────────────────┘",
505            "┌Results───────────────────────┐",
506            "│▶ Songs (1):                  │",
507            "│▶ Albums (1):                 │",
508            "│▶ Artists (1):                │",
509            "│                              │",
510            "│                              │",
511            "└ ⏎ : Search───────────────────┘",
512        ]);
513        assert_buffer_eq(&buffer, &expected);
514
515        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
516        let action = rx.blocking_recv().unwrap();
517        assert_eq!(action, Action::Search("qrp".to_string()));
518
519        let buffer = terminal
520            .draw(|frame| view.render(frame, props))
521            .unwrap()
522            .buffer
523            .clone();
524        let expected = Buffer::with_lines([
525            "┌Search────────────────────────┐",
526            "│                              │",
527            "└──────────────────────────────┘",
528            "┌Results───────────────────────┐",
529            "│▶ Songs (1):                  │",
530            "│▶ Albums (1):                 │",
531            "│▶ Artists (1):                │",
532            "│                              │",
533            "│/: Search | ␣ : Check─────────│",
534            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
535        ]);
536        assert_buffer_eq(&buffer, &expected);
537
538        view.handle_key_event(KeyEvent::from(KeyCode::Char('/')));
539        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
540
541        let buffer = terminal
542            .draw(|frame| view.render(frame, props))
543            .unwrap()
544            .buffer
545            .clone();
546        assert_buffer_eq(&buffer, &expected);
547
548        view.handle_key_event(KeyEvent::from(KeyCode::Down));
549        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
550
551        let buffer = terminal
552            .draw(|frame| view.render(frame, props))
553            .unwrap()
554            .buffer
555            .clone();
556        let expected = Buffer::with_lines([
557            "┌Search────────────────────────┐",
558            "│                              │",
559            "└──────────────────────────────┘",
560            "┌Results───────────────────────┐",
561            "│▼ Songs (1):                  │",
562            "│  ☐ Test Song Test Artist     │",
563            "│▶ Albums (1):                 │",
564            "│▶ Artists (1):                │",
565            "│/: Search | ␣ : Check─────────│",
566            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
567        ]);
568        assert_buffer_eq(&buffer, &expected);
569
570        view.handle_key_event(KeyEvent::from(KeyCode::Down));
571        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
572
573        let buffer = terminal
574            .draw(|frame| view.render(frame, props))
575            .unwrap()
576            .buffer
577            .clone();
578        let expected = Buffer::with_lines([
579            "┌Search────────────────────────┐",
580            "│                              │",
581            "└──────────────────────────────┘",
582            "┌Results───────────────────────┐",
583            "│q: add to queue | r: start rad│",
584            "│▼ Songs (1):                 ▲│",
585            "│  ☑ Test Song Test Artist    █│",
586            "│▶ Albums (1):                ▼│",
587            "│/: Search | ␣ : Check─────────│",
588            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
589        ]);
590        assert_buffer_eq(&buffer, &expected);
591
592        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
593        let action = rx.blocking_recv().unwrap();
594        assert_eq!(
595            action,
596            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![RecordId::new(
597                "song",
598                item_id()
599            )])))
600        );
601
602        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
603        let action = rx.blocking_recv().unwrap();
604        assert_eq!(
605            action,
606            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![RecordId::new(
607                "song",
608                item_id()
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![RecordId::new(
617                "song",
618                item_id()
619            )])))
620        );
621    }
622
623    #[test]
624    #[allow(clippy::too_many_lines)]
625    fn test_mouse() {
626        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
627        let mut view = SearchView::new(&state_with_everything(), tx);
628
629        let (mut terminal, area) = setup_test_terminal(32, 10);
630        let props = RenderProps {
631            area,
632            is_focused: true,
633        };
634        let buffer = terminal
635            .draw(|frame| view.render(frame, props))
636            .unwrap()
637            .buffer
638            .clone();
639        let expected = Buffer::with_lines([
640            "┌Search────────────────────────┐",
641            "│                              │",
642            "└──────────────────────────────┘",
643            "┌Results───────────────────────┐",
644            "│▶ Songs (1):                  │",
645            "│▶ Albums (1):                 │",
646            "│▶ Artists (1):                │",
647            "│                              │",
648            "│                              │",
649            "└ ⏎ : Search───────────────────┘",
650        ]);
651        assert_buffer_eq(&buffer, &expected);
652
653        // put some text in the search bar
654        view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
655        view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
656        view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
657
658        let buffer = terminal
659            .draw(|frame| view.render(frame, props))
660            .unwrap()
661            .buffer
662            .clone();
663        let expected = Buffer::with_lines([
664            "┌Search────────────────────────┐",
665            "│abc                           │",
666            "└──────────────────────────────┘",
667            "┌Results───────────────────────┐",
668            "│▶ Songs (1):                  │",
669            "│▶ Albums (1):                 │",
670            "│▶ Artists (1):                │",
671            "│                              │",
672            "│                              │",
673            "└ ⏎ : Search───────────────────┘",
674        ]);
675        assert_buffer_eq(&buffer, &expected);
676
677        // click in the search bar and ensure the cursor is moved to the right place
678        view.handle_mouse_event(
679            MouseEvent {
680                kind: MouseEventKind::Down(MouseButton::Left),
681                column: 2,
682                row: 1,
683                modifiers: KeyModifiers::empty(),
684            },
685            area,
686        );
687        view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
688        let buffer = terminal
689            .draw(|frame| view.render(frame, props))
690            .unwrap()
691            .buffer
692            .clone();
693        let expected = Buffer::with_lines([
694            "┌Search────────────────────────┐",
695            "│acbc                          │",
696            "└──────────────────────────────┘",
697            "┌Results───────────────────────┐",
698            "│▶ Songs (1):                  │",
699            "│▶ Albums (1):                 │",
700            "│▶ Artists (1):                │",
701            "│                              │",
702            "│                              │",
703            "└ ⏎ : Search───────────────────┘",
704        ]);
705
706        assert_buffer_eq(&buffer, &expected);
707
708        // click out of the search bar
709        view.handle_mouse_event(
710            MouseEvent {
711                kind: MouseEventKind::Down(MouseButton::Left),
712                column: 2,
713                row: 5,
714                modifiers: KeyModifiers::empty(),
715            },
716            area,
717        );
718
719        // scroll down
720        view.handle_mouse_event(
721            MouseEvent {
722                kind: MouseEventKind::ScrollDown,
723                column: 2,
724                row: 4,
725                modifiers: KeyModifiers::empty(),
726            },
727            area,
728        );
729        view.handle_mouse_event(
730            MouseEvent {
731                kind: MouseEventKind::ScrollDown,
732                column: 2,
733                row: 4,
734                modifiers: KeyModifiers::empty(),
735            },
736            area,
737        );
738
739        // click on the selected dropdown
740        view.handle_mouse_event(
741            MouseEvent {
742                kind: MouseEventKind::Down(MouseButton::Left),
743                column: 2,
744                row: 5,
745                modifiers: KeyModifiers::empty(),
746            },
747            area,
748        );
749        let expected = Buffer::with_lines([
750            "┌Search────────────────────────┐",
751            "│acbc                          │",
752            "└──────────────────────────────┘",
753            "┌Results───────────────────────┐",
754            "│▶ Songs (1):                  │",
755            "│▼ Albums (1):                 │",
756            "│  ☐ Test Album Test Artist    │",
757            "│▶ Artists (1):                │",
758            "│/: Search | ␣ : Check─────────│",
759            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
760        ]);
761        let buffer = terminal
762            .draw(|frame| view.render(frame, props))
763            .unwrap()
764            .buffer
765            .clone();
766        assert_buffer_eq(&buffer, &expected);
767
768        // scroll up
769        view.handle_mouse_event(
770            MouseEvent {
771                kind: MouseEventKind::ScrollUp,
772                column: 2,
773                row: 4,
774                modifiers: KeyModifiers::empty(),
775            },
776            area,
777        );
778
779        // click on the selected dropdown
780        view.handle_mouse_event(
781            MouseEvent {
782                kind: MouseEventKind::Down(MouseButton::Left),
783                column: 2,
784                row: 4,
785                modifiers: KeyModifiers::empty(),
786            },
787            area,
788        );
789        let expected = Buffer::with_lines([
790            "┌Search────────────────────────┐",
791            "│acbc                          │",
792            "└──────────────────────────────┘",
793            "┌Results───────────────────────┐",
794            "│▼ Songs (1):                 ▲│",
795            "│  ☐ Test Song Test Artist    █│",
796            "│▼ Albums (1):                █│",
797            "│  ☐ Test Album Test Artist   ▼│",
798            "│/: Search | ␣ : Check─────────│",
799            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
800        ]);
801        let buffer = terminal
802            .draw(|frame| view.render(frame, props))
803            .unwrap()
804            .buffer
805            .clone();
806        assert_buffer_eq(&buffer, &expected);
807
808        // scroll down
809        view.handle_mouse_event(
810            MouseEvent {
811                kind: MouseEventKind::ScrollDown,
812                column: 2,
813                row: 4,
814                modifiers: KeyModifiers::empty(),
815            },
816            area,
817        );
818
819        // ctrl-click
820        view.handle_mouse_event(
821            MouseEvent {
822                kind: MouseEventKind::Down(MouseButton::Left),
823                column: 2,
824                row: 5,
825                modifiers: KeyModifiers::CONTROL,
826            },
827            area,
828        );
829        assert_eq!(
830            rx.blocking_recv().unwrap(),
831            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id().into())))
832        );
833        let expected = Buffer::with_lines([
834            "┌Search────────────────────────┐",
835            "│acbc                          │",
836            "└──────────────────────────────┘",
837            "┌Results───────────────────────┐",
838            "│q: add to queue | r: start rad│",
839            "│▼ Songs (1):                 ▲│",
840            "│  ☑ Test Song Test Artist    █│",
841            "│▼ Albums (1):                ▼│",
842            "│/: Search | ␣ : Check─────────│",
843            "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
844        ]);
845        let buffer = terminal
846            .draw(|frame| view.render(frame, props))
847            .unwrap()
848            .buffer
849            .clone();
850        assert_buffer_eq(&buffer, &expected);
851    }
852}