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 crossterm::event::KeyEvent;
350 use crossterm::event::KeyModifiers;
351 use pretty_assertions::assert_eq;
352 use ratatui::buffer::Buffer;
353
354 #[test]
355 fn test_render_search_focused() {
356 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
357 let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
358 active_view: ActiveView::Search,
359 ..state_with_everything()
360 });
361
362 let (mut terminal, area) = setup_test_terminal(24, 8);
363 let props = RenderProps {
364 area,
365 is_focused: true,
366 };
367 let buffer = terminal
368 .draw(|frame| view.render(frame, props))
369 .unwrap()
370 .buffer
371 .clone();
372 let expected = Buffer::with_lines([
373 "┌Search────────────────┐",
374 "│ │",
375 "└──────────────────────┘",
376 "┌Results───────────────┐",
377 "│▶ Songs (1): │",
378 "│▶ Albums (1): │",
379 "│▶ Artists (1): │",
380 "└ ⏎ : Search───────────┘",
381 ]);
382
383 assert_buffer_eq(&buffer, &expected);
384 }
385
386 #[test]
387 fn test_render_empty() {
388 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
389 let view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
390 active_view: ActiveView::Search,
391 search: SearchResult::default(),
392 ..state_with_everything()
393 });
394
395 let (mut terminal, area) = setup_test_terminal(24, 8);
396 let props = RenderProps {
397 area,
398 is_focused: true,
399 };
400 let buffer = terminal
401 .draw(|frame| view.render(frame, props))
402 .unwrap()
403 .buffer
404 .clone();
405 let expected = Buffer::with_lines([
406 "┌Search────────────────┐",
407 "│ │",
408 "└──────────────────────┘",
409 "┌Results───────────────┐",
410 "│ No results found │",
411 "│ │",
412 "│ │",
413 "└ ⏎ : Search───────────┘",
414 ]);
415
416 assert_buffer_eq(&buffer, &expected);
417 }
418
419 #[test]
420 fn test_render_search_unfocused() {
421 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
422 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
423 active_view: ActiveView::Search,
424 ..state_with_everything()
425 });
426
427 let (mut terminal, area) = setup_test_terminal(32, 9);
428 let props = RenderProps {
429 area,
430 is_focused: true,
431 };
432
433 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
434
435 let buffer = terminal
436 .draw(|frame| view.render(frame, props))
437 .unwrap()
438 .buffer
439 .clone();
440 let expected = Buffer::with_lines([
441 "┌Search────────────────────────┐",
442 "│ │",
443 "└──────────────────────────────┘",
444 "┌Results───────────────────────┐",
445 "│▶ Songs (1): │",
446 "│▶ Albums (1): │",
447 "│▶ Artists (1): │",
448 "│/: Search | ␣ : Check─────────│",
449 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
450 ]);
451 assert_buffer_eq(&buffer, &expected);
452 }
453
454 #[test]
455 fn smoke_navigation() {
456 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
457 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
458 active_view: ActiveView::Search,
459 ..state_with_everything()
460 });
461
462 view.handle_key_event(KeyEvent::from(KeyCode::Up));
463 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
464 view.handle_key_event(KeyEvent::from(KeyCode::Down));
465 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
466 view.handle_key_event(KeyEvent::from(KeyCode::Left));
467 view.handle_key_event(KeyEvent::from(KeyCode::Right));
468 }
469
470 #[test]
471 #[allow(clippy::too_many_lines)]
472 fn test_keys() {
473 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
474 let mut view = SearchView::new(&AppState::default(), tx).move_with_state(&AppState {
475 active_view: ActiveView::Search,
476 ..state_with_everything()
477 });
478
479 let (mut terminal, area) = setup_test_terminal(32, 10);
480 let props = RenderProps {
481 area,
482 is_focused: true,
483 };
484
485 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
486 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
487 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
488
489 let buffer = terminal
490 .draw(|frame| view.render(frame, props))
491 .unwrap()
492 .buffer
493 .clone();
494 let expected = Buffer::with_lines([
495 "┌Search────────────────────────┐",
496 "│qrp │",
497 "└──────────────────────────────┘",
498 "┌Results───────────────────────┐",
499 "│▶ Songs (1): │",
500 "│▶ Albums (1): │",
501 "│▶ Artists (1): │",
502 "│ │",
503 "│ │",
504 "└ ⏎ : Search───────────────────┘",
505 ]);
506 assert_buffer_eq(&buffer, &expected);
507
508 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
509 let action = rx.blocking_recv().unwrap();
510 assert_eq!(action, Action::Search("qrp".to_string()));
511
512 let buffer = terminal
513 .draw(|frame| view.render(frame, props))
514 .unwrap()
515 .buffer
516 .clone();
517 let expected = Buffer::with_lines([
518 "┌Search────────────────────────┐",
519 "│ │",
520 "└──────────────────────────────┘",
521 "┌Results───────────────────────┐",
522 "│▶ Songs (1): │",
523 "│▶ Albums (1): │",
524 "│▶ Artists (1): │",
525 "│ │",
526 "│/: Search | ␣ : Check─────────│",
527 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
528 ]);
529 assert_buffer_eq(&buffer, &expected);
530
531 view.handle_key_event(KeyEvent::from(KeyCode::Char('/')));
532 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
533
534 let buffer = terminal
535 .draw(|frame| view.render(frame, props))
536 .unwrap()
537 .buffer
538 .clone();
539 assert_buffer_eq(&buffer, &expected);
540
541 view.handle_key_event(KeyEvent::from(KeyCode::Down));
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 let expected = Buffer::with_lines([
550 "┌Search────────────────────────┐",
551 "│ │",
552 "└──────────────────────────────┘",
553 "┌Results───────────────────────┐",
554 "│▼ Songs (1): │",
555 "│ ☐ Test Song Test Artist │",
556 "│▶ Albums (1): │",
557 "│▶ Artists (1): │",
558 "│/: Search | ␣ : Check─────────│",
559 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
560 ]);
561 assert_buffer_eq(&buffer, &expected);
562
563 view.handle_key_event(KeyEvent::from(KeyCode::Down));
564 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
565
566 let buffer = terminal
567 .draw(|frame| view.render(frame, props))
568 .unwrap()
569 .buffer
570 .clone();
571 let expected = Buffer::with_lines([
572 "┌Search────────────────────────┐",
573 "│ │",
574 "└──────────────────────────────┘",
575 "┌Results───────────────────────┐",
576 "│q: add to queue | r: start rad│",
577 "│▼ Songs (1): ▲│",
578 "│ ☑ Test Song Test Artist █│",
579 "│▶ Albums (1): ▼│",
580 "│/: Search | ␣ : Check─────────│",
581 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
582 ]);
583 assert_buffer_eq(&buffer, &expected);
584
585 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
586 let action = rx.blocking_recv().unwrap();
587 assert_eq!(
588 action,
589 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
590 "song",
591 item_id()
592 )
593 .into()])))
594 );
595
596 view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
597 let action = rx.blocking_recv().unwrap();
598 assert_eq!(
599 action,
600 Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![(
601 "song",
602 item_id()
603 )
604 .into()],)))
605 );
606
607 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
608 let action = rx.blocking_recv().unwrap();
609 assert_eq!(
610 action,
611 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
612 "song",
613 item_id()
614 )
615 .into()])))
616 );
617 }
618
619 #[test]
620 #[allow(clippy::too_many_lines)]
621 fn test_mouse() {
622 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
623 let mut view = SearchView::new(&state_with_everything(), tx);
624
625 let (mut terminal, area) = setup_test_terminal(32, 10);
626 let props = RenderProps {
627 area,
628 is_focused: true,
629 };
630 let buffer = terminal
631 .draw(|frame| view.render(frame, props))
632 .unwrap()
633 .buffer
634 .clone();
635 let expected = Buffer::with_lines([
636 "┌Search────────────────────────┐",
637 "│ │",
638 "└──────────────────────────────┘",
639 "┌Results───────────────────────┐",
640 "│▶ Songs (1): │",
641 "│▶ Albums (1): │",
642 "│▶ Artists (1): │",
643 "│ │",
644 "│ │",
645 "└ ⏎ : Search───────────────────┘",
646 ]);
647 assert_buffer_eq(&buffer, &expected);
648
649 view.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
651 view.handle_key_event(KeyEvent::from(KeyCode::Char('b')));
652 view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
653
654 let buffer = terminal
655 .draw(|frame| view.render(frame, props))
656 .unwrap()
657 .buffer
658 .clone();
659 let expected = Buffer::with_lines([
660 "┌Search────────────────────────┐",
661 "│abc │",
662 "└──────────────────────────────┘",
663 "┌Results───────────────────────┐",
664 "│▶ Songs (1): │",
665 "│▶ Albums (1): │",
666 "│▶ Artists (1): │",
667 "│ │",
668 "│ │",
669 "└ ⏎ : Search───────────────────┘",
670 ]);
671 assert_buffer_eq(&buffer, &expected);
672
673 view.handle_mouse_event(
675 MouseEvent {
676 kind: MouseEventKind::Down(MouseButton::Left),
677 column: 2,
678 row: 1,
679 modifiers: KeyModifiers::empty(),
680 },
681 area,
682 );
683 view.handle_key_event(KeyEvent::from(KeyCode::Char('c')));
684 let buffer = terminal
685 .draw(|frame| view.render(frame, props))
686 .unwrap()
687 .buffer
688 .clone();
689 let expected = Buffer::with_lines([
690 "┌Search────────────────────────┐",
691 "│acbc │",
692 "└──────────────────────────────┘",
693 "┌Results───────────────────────┐",
694 "│▶ Songs (1): │",
695 "│▶ Albums (1): │",
696 "│▶ Artists (1): │",
697 "│ │",
698 "│ │",
699 "└ ⏎ : Search───────────────────┘",
700 ]);
701
702 assert_buffer_eq(&buffer, &expected);
703
704 view.handle_mouse_event(
706 MouseEvent {
707 kind: MouseEventKind::Down(MouseButton::Left),
708 column: 2,
709 row: 5,
710 modifiers: KeyModifiers::empty(),
711 },
712 area,
713 );
714
715 view.handle_mouse_event(
717 MouseEvent {
718 kind: MouseEventKind::ScrollDown,
719 column: 2,
720 row: 4,
721 modifiers: KeyModifiers::empty(),
722 },
723 area,
724 );
725 view.handle_mouse_event(
726 MouseEvent {
727 kind: MouseEventKind::ScrollDown,
728 column: 2,
729 row: 4,
730 modifiers: KeyModifiers::empty(),
731 },
732 area,
733 );
734
735 view.handle_mouse_event(
737 MouseEvent {
738 kind: MouseEventKind::Down(MouseButton::Left),
739 column: 2,
740 row: 5,
741 modifiers: KeyModifiers::empty(),
742 },
743 area,
744 );
745 let expected = Buffer::with_lines([
746 "┌Search────────────────────────┐",
747 "│acbc │",
748 "└──────────────────────────────┘",
749 "┌Results───────────────────────┐",
750 "│▶ Songs (1): │",
751 "│▼ Albums (1): │",
752 "│ ☐ Test Album Test Artist │",
753 "│▶ Artists (1): │",
754 "│/: Search | ␣ : Check─────────│",
755 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
756 ]);
757 let buffer = terminal
758 .draw(|frame| view.render(frame, props))
759 .unwrap()
760 .buffer
761 .clone();
762 assert_buffer_eq(&buffer, &expected);
763
764 view.handle_mouse_event(
766 MouseEvent {
767 kind: MouseEventKind::ScrollUp,
768 column: 2,
769 row: 4,
770 modifiers: KeyModifiers::empty(),
771 },
772 area,
773 );
774
775 view.handle_mouse_event(
777 MouseEvent {
778 kind: MouseEventKind::Down(MouseButton::Left),
779 column: 2,
780 row: 4,
781 modifiers: KeyModifiers::empty(),
782 },
783 area,
784 );
785 let expected = Buffer::with_lines([
786 "┌Search────────────────────────┐",
787 "│acbc │",
788 "└──────────────────────────────┘",
789 "┌Results───────────────────────┐",
790 "│▼ Songs (1): ▲│",
791 "│ ☐ Test Song Test Artist █│",
792 "│▼ Albums (1): █│",
793 "│ ☐ Test Album Test Artist ▼│",
794 "│/: Search | ␣ : Check─────────│",
795 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
796 ]);
797 let buffer = terminal
798 .draw(|frame| view.render(frame, props))
799 .unwrap()
800 .buffer
801 .clone();
802 assert_buffer_eq(&buffer, &expected);
803
804 view.handle_mouse_event(
806 MouseEvent {
807 kind: MouseEventKind::ScrollDown,
808 column: 2,
809 row: 4,
810 modifiers: KeyModifiers::empty(),
811 },
812 area,
813 );
814
815 view.handle_mouse_event(
817 MouseEvent {
818 kind: MouseEventKind::Down(MouseButton::Left),
819 column: 2,
820 row: 5,
821 modifiers: KeyModifiers::empty(),
822 },
823 area,
824 );
825 assert_eq!(
826 rx.blocking_recv().unwrap(),
827 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
828 );
829 let expected = Buffer::with_lines([
830 "┌Search────────────────────────┐",
831 "│acbc │",
832 "└──────────────────────────────┘",
833 "┌Results───────────────────────┐",
834 "│q: add to queue | r: start rad│",
835 "│▼ Songs (1): ▲│",
836 "│ ☑ Test Song Test Artist █│",
837 "│▼ Albums (1): ▼│",
838 "│/: Search | ␣ : Check─────────│",
839 "└ ⏎ : Open | ←/↑/↓/→: Navigate─┘",
840 ]);
841 let buffer = terminal
842 .draw(|frame| view.render(frame, props))
843 .unwrap()
844 .buffer
845 .clone();
846 assert_buffer_eq(&buffer, &expected);
847 }
848}