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