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