1use 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 pub action_tx: UnboundedSender<Action>,
40 pub props: Props,
42 tree_state: Mutex<CheckTreeState<String>>,
44 search_bar: InputBox,
46 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 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 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 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 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 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 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 _ 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 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 let [search_bar_area, content_area] = split_area(props.area);
254
255 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}