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