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