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,
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 .areas(area);
244 [search_bar_area, content_area]
245}
246
247impl ComponentRender<RenderProps> for SearchView {
248 fn render_border(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
249 let border_style =
250 Style::default().fg(border_color(props.is_focused && !self.search_bar_focused).into());
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().title("Search").border_style(
266 Style::default()
267 .fg(border_color(self.search_bar_focused && props.is_focused).into()),
268 ),
269 show_cursor: self.search_bar_focused,
270 },
271 );
272
273 let area = if self.search_bar_focused {
275 let border = Block::bordered()
276 .title_top("Results")
277 .title_bottom(" \u{23CE} : Search")
278 .border_style(border_style);
279 frame.render_widget(&border, content_area);
280 border.inner(content_area)
281 } else {
282 let border = Block::bordered()
283 .title_top("Results")
284 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate")
285 .border_style(border_style);
286 frame.render_widget(&border, content_area);
287 let content_area = border.inner(content_area);
288
289 let border = Block::default()
290 .borders(Borders::BOTTOM)
291 .title_bottom("/: Search | \u{2423} : Check")
292 .border_style(border_style);
293 frame.render_widget(&border, content_area);
294 border.inner(content_area)
295 };
296
297 let area = if self
299 .tree_state
300 .lock()
301 .unwrap()
302 .get_checked_things()
303 .is_empty()
304 {
305 area
306 } else {
307 let border = Block::default()
308 .borders(Borders::TOP)
309 .title_top("q: add to queue | r: start radio | p: add to playlist")
310 .border_style(border_style);
311 frame.render_widget(&border, area);
312 border.inner(area)
313 };
314
315 RenderProps { area, ..props }
316 }
317
318 fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
319 if self.props.search_results.is_empty() {
321 frame.render_widget(
322 Line::from("No results found")
323 .style(Style::default().fg((*TEXT_NORMAL).into()))
324 .alignment(Alignment::Center),
325 props.area,
326 );
327 return;
328 }
329
330 let song_tree = create_song_tree_item(&self.props.search_results.songs).unwrap();
332 let album_tree = create_album_tree_item(&self.props.search_results.albums).unwrap();
333 let artist_tree = create_artist_tree_item(&self.props.search_results.artists).unwrap();
334 let items = &[song_tree, album_tree, artist_tree];
335
336 frame.render_stateful_widget(
338 CheckTree::new(items)
339 .unwrap()
340 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
341 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
342 props.area,
343 &mut self.tree_state.lock().unwrap(),
344 );
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::{
352 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
353 ui::components::content_view::ActiveView,
354 };
355 use crossterm::event::KeyEvent;
356 use crossterm::event::KeyModifiers;
357 use mecomp_prost::RecordId;
358 use pretty_assertions::assert_eq;
359 use ratatui::buffer::Buffer;
360
361 #[test]
362 fn test_render_search_focused() {
363 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
364 let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
365 active_view: ActiveView::Search,
366 ..state_with_everything()
367 });
368
369 let (mut terminal, area) = setup_test_terminal(24, 8);
370 let props = RenderProps {
371 area,
372 is_focused: true,
373 };
374 let buffer = terminal
375 .draw(|frame| view.render(frame, props))
376 .unwrap()
377 .buffer
378 .clone();
379 let expected = Buffer::with_lines([
380 "┌Search────────────────┐",
381 "│ │",
382 "└──────────────────────┘",
383 "┌Results───────────────┐",
384 "│▶ Songs (1): │",
385 "│▶ Albums (1): │",
386 "│▶ Artists (1): │",
387 "└ ⏎ : Search───────────┘",
388 ]);
389
390 assert_buffer_eq(&buffer, &expected);
391 }
392
393 #[test]
394 fn test_render_empty() {
395 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
396 let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
397 active_view: ActiveView::Search,
398 search: SearchResult::default(),
399 ..state_with_everything()
400 });
401
402 let (mut terminal, area) = setup_test_terminal(24, 8);
403 let props = RenderProps {
404 area,
405 is_focused: true,
406 };
407 let buffer = terminal
408 .draw(|frame| view.render(frame, props))
409 .unwrap()
410 .buffer
411 .clone();
412 let expected = Buffer::with_lines([
413 "┌Search────────────────┐",
414 "│ │",
415 "└──────────────────────┘",
416 "┌Results───────────────┐",
417 "│ No results found │",
418 "│ │",
419 "│ │",
420 "└ ⏎ : Search───────────┘",
421 ]);
422
423 assert_buffer_eq(&buffer, &expected);
424 }
425
426 #[test]
427 fn test_render_search_unfocused() {
428 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
429 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
430 active_view: ActiveView::Search,
431 ..state_with_everything()
432 });
433
434 let (mut terminal, area) = setup_test_terminal(32, 9);
435 let props = RenderProps {
436 area,
437 is_focused: true,
438 };
439
440 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
441
442 let buffer = terminal
443 .draw(|frame| view.render(frame, props))
444 .unwrap()
445 .buffer
446 .clone();
447 let expected = Buffer::with_lines([
448 "┌Search────────────────────────┐",
449 "│ │",
450 "└──────────────────────────────┘",
451 "┌Results───────────────────────┐",
452 "│▶ Songs (1): │",
453 "│▶ Albums (1): │",
454 "│▶ Artists (1): │",
455 "│/: Search | ␣ : Check─────────│",
456 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
457 ]);
458 assert_buffer_eq(&buffer, &expected);
459 }
460
461 #[test]
462 fn smoke_navigation() {
463 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
464 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
465 active_view: ActiveView::Search,
466 ..state_with_everything()
467 });
468
469 view.handle_key_event(KeyEvent::from(KeyCode::Up));
470 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
471 view.handle_key_event(KeyEvent::from(KeyCode::Down));
472 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
473 view.handle_key_event(KeyEvent::from(KeyCode::Left));
474 view.handle_key_event(KeyEvent::from(KeyCode::Right));
475 }
476
477 #[test]
478 #[allow(clippy::too_many_lines)]
479 fn test_keys() {
480 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
481 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
482 active_view: ActiveView::Search,
483 ..state_with_everything()
484 });
485
486 let (mut terminal, area) = setup_test_terminal(32, 10);
487 let props = RenderProps {
488 area,
489 is_focused: true,
490 };
491
492 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
493 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
494 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
495
496 let buffer = terminal
497 .draw(|frame| view.render(frame, props))
498 .unwrap()
499 .buffer
500 .clone();
501 let expected = Buffer::with_lines([
502 "┌Search────────────────────────┐",
503 "│qrp │",
504 "└──────────────────────────────┘",
505 "┌Results───────────────────────┐",
506 "│▶ Songs (1): │",
507 "│▶ Albums (1): │",
508 "│▶ Artists (1): │",
509 "│ │",
510 "│ │",
511 "└ ⏎ : Search───────────────────┘",
512 ]);
513 assert_buffer_eq(&buffer, &expected);
514
515 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
516 let action = rx.blocking_recv().unwrap();
517 assert_eq!(action, Action::Search("qrp".to_string()));
518
519 let buffer = terminal
520 .draw(|frame| view.render(frame, props))
521 .unwrap()
522 .buffer
523 .clone();
524 let expected = Buffer::with_lines([
525 "┌Search────────────────────────┐",
526 "│ │",
527 "└──────────────────────────────┘",
528 "┌Results───────────────────────┐",
529 "│▶ Songs (1): │",
530 "│▶ Albums (1): │",
531 "│▶ Artists (1): │",
532 "│ │",
533 "│/: Search | ␣ : Check─────────│",
534 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
535 ]);
536 assert_buffer_eq(&buffer, &expected);
537
538 view.handle_key_event(KeyEvent::from(KeyCode::Char('/')));
539 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
540
541 let buffer = terminal
542 .draw(|frame| view.render(frame, props))
543 .unwrap()
544 .buffer
545 .clone();
546 assert_buffer_eq(&buffer, &expected);
547
548 view.handle_key_event(KeyEvent::from(KeyCode::Down));
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 let expected = Buffer::with_lines([
557 "┌Search────────────────────────┐",
558 "│ │",
559 "└──────────────────────────────┘",
560 "┌Results───────────────────────┐",
561 "│▼ Songs (1): │",
562 "│ ☐ Test Song Test Artist │",
563 "│▶ Albums (1): │",
564 "│▶ Artists (1): │",
565 "│/: Search | ␣ : Check─────────│",
566 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
567 ]);
568 assert_buffer_eq(&buffer, &expected);
569
570 view.handle_key_event(KeyEvent::from(KeyCode::Down));
571 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
572
573 let buffer = terminal
574 .draw(|frame| view.render(frame, props))
575 .unwrap()
576 .buffer
577 .clone();
578 let expected = Buffer::with_lines([
579 "┌Search────────────────────────┐",
580 "│ │",
581 "└──────────────────────────────┘",
582 "┌Results───────────────────────┐",
583 "│q: add to queue | r: start rad│",
584 "│▼ Songs (1): ▲│",
585 "│ ☑ Test Song Test Artist █│",
586 "│▶ Albums (1): ▼│",
587 "│/: Search | ␣ : Check─────────│",
588 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
589 ]);
590 assert_buffer_eq(&buffer, &expected);
591
592 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
593 let action = rx.blocking_recv().unwrap();
594 assert_eq!(
595 action,
596 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![RecordId::new(
597 "song",
598 item_id()
599 )])))
600 );
601
602 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
603 let action = rx.blocking_recv().unwrap();
604 assert_eq!(
605 action,
606 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![RecordId::new(
607 "song",
608 item_id()
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![RecordId::new(
617 "song",
618 item_id()
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::CONTROL,
826 },
827 area,
828 );
829 assert_eq!(
830 rx.blocking_recv().unwrap(),
831 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id().into())))
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}