1use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind};
6use mecomp_prost::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 mecomp_prost::RecordId;
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![RecordId::new(
600 "song",
601 item_id()
602 )])))
603 );
604
605 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
606 let action = rx.blocking_recv().unwrap();
607 assert_eq!(
608 action,
609 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![RecordId::new(
610 "song",
611 item_id()
612 )],)))
613 );
614
615 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
616 let action = rx.blocking_recv().unwrap();
617 assert_eq!(
618 action,
619 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![RecordId::new(
620 "song",
621 item_id()
622 )])))
623 );
624 }
625
626 #[test]
627 #[allow(clippy::too_many_lines)]
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 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 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 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 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 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 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 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 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 view.handle_mouse_event(
824 MouseEvent {
825 kind: MouseEventKind::Down(MouseButton::Left),
826 column: 2,
827 row: 5,
828 modifiers: KeyModifiers::CONTROL,
829 },
830 area,
831 );
832 assert_eq!(
833 rx.blocking_recv().unwrap(),
834 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id().into())))
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}