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, Margin, 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 colors::{border_color, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL},
19 components::{content_view::ActiveView, Component, ComponentRender, RenderProps},
20 widgets::{
21 input_box::{self, InputBox},
22 popups::PopupType,
23 tree::{state::CheckTreeState, CheckTree},
24 },
25 AppState,
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 let content_area = content_area.inner(Margin::new(1, 1));
199
200 if self.search_bar_focused {
201 if search_bar_area.contains(mouse_position) {
202 self.search_bar.handle_mouse_event(mouse, search_bar_area);
203 } else if content_area.contains(mouse_position)
204 && kind == MouseEventKind::Down(MouseButton::Left)
205 {
206 self.search_bar_focused = false;
207 }
208 } else if kind == MouseEventKind::Down(MouseButton::Left)
209 && search_bar_area.contains(mouse_position)
210 {
211 self.search_bar_focused = true;
212 } else {
213 let content_area = Rect {
214 height: content_area.height.saturating_sub(1),
215 ..content_area
216 };
217
218 let result = self
219 .tree_state
220 .lock()
221 .unwrap()
222 .handle_mouse_event(mouse, content_area);
223 if let Some(action) = result {
224 self.action_tx.send(action).unwrap();
225 }
226 }
227 }
228}
229
230fn split_area(area: Rect) -> [Rect; 2] {
231 let [search_bar_area, content_area] = *Layout::default()
232 .direction(Direction::Vertical)
233 .constraints([Constraint::Length(3), Constraint::Min(4)].as_ref())
234 .split(area)
235 else {
236 panic!("Failed to split search view area");
237 };
238 [search_bar_area, content_area]
239}
240
241impl ComponentRender<RenderProps> for SearchView {
242 fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
243 let border_style =
244 Style::default().fg(border_color(props.is_focused && !self.search_bar_focused).into());
245
246 let [search_bar_area, content_area] = split_area(props.area);
248
249 self.search_bar.render(
251 frame,
252 input_box::RenderProps {
253 area: search_bar_area,
254 text_color: if self.search_bar_focused {
255 TEXT_HIGHLIGHT_ALT.into()
256 } else {
257 TEXT_NORMAL.into()
258 },
259 border: Block::bordered().title("Search").border_style(
260 Style::default()
261 .fg(border_color(self.search_bar_focused && props.is_focused).into()),
262 ),
263 show_cursor: self.search_bar_focused,
264 },
265 );
266
267 let area = if self.search_bar_focused {
269 let border = Block::bordered()
270 .title_top("Results")
271 .title_bottom(" \u{23CE} : Search")
272 .border_style(border_style);
273 frame.render_widget(&border, content_area);
274 border.inner(content_area)
275 } else {
276 let border = Block::bordered()
277 .title_top("Results")
278 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate")
279 .border_style(border_style);
280 frame.render_widget(&border, content_area);
281 let content_area = border.inner(content_area);
282
283 let border = Block::default()
284 .borders(Borders::BOTTOM)
285 .title_bottom("/: Search | \u{2423} : Check")
286 .border_style(border_style);
287 frame.render_widget(&border, content_area);
288 border.inner(content_area)
289 };
290
291 let area = if self
293 .tree_state
294 .lock()
295 .unwrap()
296 .get_checked_things()
297 .is_empty()
298 {
299 area
300 } else {
301 let border = Block::default()
302 .borders(Borders::TOP)
303 .title_top("q: add to queue | r: start radio | p: add to playlist")
304 .border_style(border_style);
305 frame.render_widget(&border, area);
306 border.inner(area)
307 };
308
309 RenderProps { area, ..props }
310 }
311
312 fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
313 if self.props.search_results.is_empty() {
315 frame.render_widget(
316 Line::from("No results found")
317 .style(Style::default().fg(TEXT_NORMAL.into()))
318 .alignment(Alignment::Center),
319 props.area,
320 );
321 return;
322 }
323
324 let song_tree = create_song_tree_item(&self.props.search_results.songs).unwrap();
326 let album_tree = create_album_tree_item(&self.props.search_results.albums).unwrap();
327 let artist_tree = create_artist_tree_item(&self.props.search_results.artists).unwrap();
328 let items = &[song_tree, album_tree, artist_tree];
329
330 frame.render_stateful_widget(
332 CheckTree::new(items)
333 .unwrap()
334 .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
335 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
336 props.area,
337 &mut self.tree_state.lock().unwrap(),
338 );
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::{
346 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
347 ui::components::content_view::ActiveView,
348 };
349 use anyhow::Result;
350 use crossterm::event::KeyEvent;
351 use crossterm::event::KeyModifiers;
352 use pretty_assertions::assert_eq;
353 use ratatui::buffer::Buffer;
354
355 #[test]
356 fn test_render_search_focused() -> Result<()> {
357 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
358 let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
359 active_view: ActiveView::Search,
360 ..state_with_everything()
361 });
362
363 let (mut terminal, area) = setup_test_terminal(24, 8);
364 let props = RenderProps {
365 area,
366 is_focused: true,
367 };
368 let buffer = terminal
369 .draw(|frame| view.render(frame, props))
370 .unwrap()
371 .buffer
372 .clone();
373 let expected = Buffer::with_lines([
374 "┌Search────────────────┐",
375 "│ │",
376 "└──────────────────────┘",
377 "┌Results───────────────┐",
378 "│▶ Songs (1): │",
379 "│▶ Albums (1): │",
380 "│▶ Artists (1): │",
381 "└ ⏎ : Search───────────┘",
382 ]);
383
384 assert_buffer_eq(&buffer, &expected);
385
386 Ok(())
387 }
388
389 #[test]
390 fn test_render_empty() -> Result<()> {
391 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
392 let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
393 active_view: ActiveView::Search,
394 search: SearchResult::default(),
395 ..state_with_everything()
396 });
397
398 let (mut terminal, area) = setup_test_terminal(24, 8);
399 let props = RenderProps {
400 area,
401 is_focused: true,
402 };
403 let buffer = terminal
404 .draw(|frame| view.render(frame, props))
405 .unwrap()
406 .buffer
407 .clone();
408 let expected = Buffer::with_lines([
409 "┌Search────────────────┐",
410 "│ │",
411 "└──────────────────────┘",
412 "┌Results───────────────┐",
413 "│ No results found │",
414 "│ │",
415 "│ │",
416 "└ ⏎ : Search───────────┘",
417 ]);
418
419 assert_buffer_eq(&buffer, &expected);
420
421 Ok(())
422 }
423
424 #[test]
425 fn test_render_search_unfocused() -> Result<()> {
426 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
427 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
428 active_view: ActiveView::Search,
429 ..state_with_everything()
430 });
431
432 let (mut terminal, area) = setup_test_terminal(32, 9);
433 let props = RenderProps {
434 area,
435 is_focused: true,
436 };
437
438 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
439
440 let buffer = terminal
441 .draw(|frame| view.render(frame, props))
442 .unwrap()
443 .buffer
444 .clone();
445 let expected = Buffer::with_lines([
446 "┌Search────────────────────────┐",
447 "│ │",
448 "└──────────────────────────────┘",
449 "┌Results───────────────────────┐",
450 "│▶ Songs (1): │",
451 "│▶ Albums (1): │",
452 "│▶ Artists (1): │",
453 "│/: Search | ␣ : Check─────────│",
454 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
455 ]);
456 assert_buffer_eq(&buffer, &expected);
457
458 Ok(())
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 fn test_keys() -> Result<()> {
479 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
480 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
481 active_view: ActiveView::Search,
482 ..state_with_everything()
483 });
484
485 let (mut terminal, area) = setup_test_terminal(32, 10);
486 let props = RenderProps {
487 area,
488 is_focused: true,
489 };
490
491 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
492 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
493 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
494
495 let buffer = terminal
496 .draw(|frame| view.render(frame, props))
497 .unwrap()
498 .buffer
499 .clone();
500 let expected = Buffer::with_lines([
501 "┌Search────────────────────────┐",
502 "│qrp │",
503 "└──────────────────────────────┘",
504 "┌Results───────────────────────┐",
505 "│▶ Songs (1): │",
506 "│▶ Albums (1): │",
507 "│▶ Artists (1): │",
508 "│ │",
509 "│ │",
510 "└ ⏎ : Search───────────────────┘",
511 ]);
512 assert_buffer_eq(&buffer, &expected);
513
514 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
515 let action = rx.blocking_recv().unwrap();
516 assert_eq!(action, Action::Search("qrp".to_string()));
517
518 let buffer = terminal
519 .draw(|frame| view.render(frame, props))
520 .unwrap()
521 .buffer
522 .clone();
523 let expected = Buffer::with_lines([
524 "┌Search────────────────────────┐",
525 "│ │",
526 "└──────────────────────────────┘",
527 "┌Results───────────────────────┐",
528 "│▶ Songs (1): │",
529 "│▶ Albums (1): │",
530 "│▶ Artists (1): │",
531 "│ │",
532 "│/: Search | ␣ : Check─────────│",
533 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
534 ]);
535 assert_buffer_eq(&buffer, &expected);
536
537 view.handle_key_event(KeyEvent::from(KeyCode::Char('/')));
538 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
539
540 let buffer = terminal
541 .draw(|frame| view.render(frame, props))
542 .unwrap()
543 .buffer
544 .clone();
545 assert_buffer_eq(&buffer, &expected);
546
547 view.handle_key_event(KeyEvent::from(KeyCode::Down));
548 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
549
550 let buffer = terminal
551 .draw(|frame| view.render(frame, props))
552 .unwrap()
553 .buffer
554 .clone();
555 let expected = Buffer::with_lines([
556 "┌Search────────────────────────┐",
557 "│ │",
558 "└──────────────────────────────┘",
559 "┌Results───────────────────────┐",
560 "│▼ Songs (1): │",
561 "│ ☐ Test Song Test Artist │",
562 "│▶ Albums (1): │",
563 "│▶ Artists (1): │",
564 "│/: Search | ␣ : Check─────────│",
565 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
566 ]);
567 assert_buffer_eq(&buffer, &expected);
568
569 view.handle_key_event(KeyEvent::from(KeyCode::Down));
570 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
571
572 let buffer = terminal
573 .draw(|frame| view.render(frame, props))
574 .unwrap()
575 .buffer
576 .clone();
577 let expected = Buffer::with_lines([
578 "┌Search────────────────────────┐",
579 "│ │",
580 "└──────────────────────────────┘",
581 "┌Results───────────────────────┐",
582 "│q: add to queue | r: start rad│",
583 "│▼ Songs (1): ▲│",
584 "│ ☑ Test Song Test Artist █│",
585 "│▶ Albums (1): ▼│",
586 "│/: Search | ␣ : Check─────────│",
587 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
588 ]);
589 assert_buffer_eq(&buffer, &expected);
590
591 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
592 let action = rx.blocking_recv().unwrap();
593 assert_eq!(
594 action,
595 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
596 "song",
597 item_id()
598 )
599 .into()])))
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![(
607 "song",
608 item_id()
609 )
610 .into()],)))
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",
619 item_id()
620 )
621 .into()])))
622 );
623
624 Ok(())
625 }
626
627 #[test]
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::empty(),
829 },
830 area,
831 );
832 assert_eq!(
833 rx.blocking_recv().unwrap(),
834 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
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}