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