1use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
5use mecomp_prost::{CollectionBrief, SongBrief};
6use ratatui::{
7 layout::{Margin, Rect},
8 style::Style,
9 text::{Line, Span},
10 widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
11};
12use tokio::sync::mpsc::UnboundedSender;
13
14use crate::{
15 state::action::{Action, ViewAction},
16 ui::{
17 AppState,
18 colors::{TEXT_HIGHLIGHT, border_color},
19 components::{
20 Component, ComponentRender, RenderProps,
21 content_view::{ActiveView, views::generic::SortableItemView},
22 },
23 widgets::tree::{CheckTree, state::CheckTreeState},
24 },
25};
26
27use super::{
28 CollectionViewProps,
29 checktree_utils::create_collection_tree_leaf,
30 sort_mode::{NameSort, SongSort},
31 traits::SortMode,
32};
33
34#[allow(clippy::module_name_repetitions)]
35pub type CollectionView = SortableItemView<CollectionViewProps, SongSort, SongBrief>;
36
37pub struct LibraryCollectionsView {
38 pub action_tx: UnboundedSender<Action>,
40 props: Props,
42 tree_state: CheckTreeState<String>,
44}
45
46struct Props {
47 collections: Vec<CollectionBrief>,
48 sort_mode: NameSort<CollectionBrief>,
49}
50impl Props {
51 fn new(state: &AppState, sort_mode: NameSort<CollectionBrief>) -> Self {
52 let mut collections = state.library.collections.clone();
53 sort_mode.sort_items(&mut collections);
54 Self {
55 collections,
56 sort_mode,
57 }
58 }
59}
60
61impl Component for LibraryCollectionsView {
62 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
63 where
64 Self: Sized,
65 {
66 let sort_mode = NameSort::default();
67 Self {
68 action_tx,
69 props: Props::new(state, sort_mode),
70 tree_state: CheckTreeState::default(),
71 }
72 }
73
74 fn move_with_state(self, state: &AppState) -> Self
75 where
76 Self: Sized,
77 {
78 let tree_state = if state.active_view == ActiveView::Collections {
79 self.tree_state
80 } else {
81 CheckTreeState::default()
82 };
83
84 Self {
85 props: Props::new(state, self.props.sort_mode),
86 tree_state,
87 ..self
88 }
89 }
90
91 fn name(&self) -> &'static str {
92 "Library Collections View"
93 }
94
95 fn handle_key_event(&mut self, key: KeyEvent) {
96 match key.code {
97 KeyCode::PageUp => {
99 self.tree_state.select_relative(|current| {
100 let first = self.props.collections.len().saturating_sub(1);
101 current.map_or(first, |c| c.saturating_sub(10))
102 });
103 }
104 KeyCode::Up => {
105 self.tree_state.key_up();
106 }
107 KeyCode::PageDown => {
108 self.tree_state
109 .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
110 }
111 KeyCode::Down => {
112 self.tree_state.key_down();
113 }
114 KeyCode::Left => {
115 self.tree_state.key_left();
116 }
117 KeyCode::Right => {
118 self.tree_state.key_right();
119 }
120 KeyCode::Enter => {
122 if self.tree_state.toggle_selected() {
123 let things = self.tree_state.get_selected_thing();
124
125 if let Some(thing) = things {
126 self.action_tx
127 .send(Action::ActiveView(ViewAction::Set(thing.into())))
128 .unwrap();
129 }
130 }
131 }
132 KeyCode::Char('s') => {
134 self.props.sort_mode = self.props.sort_mode.next();
135 self.props.sort_mode.sort_items(&mut self.props.collections);
136 self.tree_state.scroll_selected_into_view();
137 }
138 KeyCode::Char('S') => {
139 self.props.sort_mode = self.props.sort_mode.prev();
140 self.props.sort_mode.sort_items(&mut self.props.collections);
141 self.tree_state.scroll_selected_into_view();
142 }
143 _ => {}
144 }
145 }
146
147 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
148 let area = area.inner(Margin::new(1, 2));
150
151 let result = self.tree_state.handle_mouse_event(mouse, area, true);
152 if let Some(action) = result {
153 self.action_tx.send(action).unwrap();
154 }
155 }
156}
157
158impl ComponentRender<RenderProps> for LibraryCollectionsView {
159 fn render_border(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
160 let border_style = Style::default().fg(border_color(props.is_focused).into());
161
162 let border = Block::bordered()
164 .title_top(Line::from(vec![
165 Span::styled("Library Collections".to_string(), Style::default().bold()),
166 Span::raw(" sorted by: "),
167 Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
168 ]))
169 .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | s/S: change sort")
170 .border_style(border_style);
171 let content_area = border.inner(props.area);
172 frame.render_widget(border, props.area);
173
174 let border = Block::new()
176 .borders(Borders::TOP)
177 .border_style(border_style);
178 frame.render_widget(&border, content_area);
179 let content_area = border.inner(content_area);
180
181 RenderProps {
183 area: content_area,
184 is_focused: props.is_focused,
185 }
186 }
187
188 fn render_content(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
189 let items = self
191 .props
192 .collections
193 .iter()
194 .map(create_collection_tree_leaf)
195 .collect::<Vec<_>>();
196
197 frame.render_stateful_widget(
199 CheckTree::new(&items)
200 .unwrap()
201 .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
202 .node_unchecked_symbol("▪ ")
204 .node_checked_symbol("▪ ")
205 .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
206 props.area,
207 &mut self.tree_state,
208 );
209 }
210}
211
212#[cfg(test)]
213mod sort_mode_tests {
214 use super::*;
215 use mecomp_prost::RecordId;
216 use pretty_assertions::assert_eq;
217 use rstest::rstest;
218
219 #[rstest]
220 #[case(NameSort::default(), NameSort::default())]
221 fn test_sort_mode_next_prev(
222 #[case] mode: NameSort<CollectionBrief>,
223 #[case] expected: NameSort<CollectionBrief>,
224 ) {
225 assert_eq!(mode.next(), expected);
226 assert_eq!(mode.next().prev(), mode);
227 }
228
229 #[rstest]
230 #[case(NameSort::default(), "Name")]
231 fn test_sort_mode_display(#[case] mode: NameSort<CollectionBrief>, #[case] expected: &str) {
232 assert_eq!(mode.to_string(), expected);
233 }
234
235 #[rstest]
236 fn test_sort_collectionss() {
237 let mut collections = vec![
238 CollectionBrief {
239 id: RecordId::new("collection", "3"),
240 name: "C".into(),
241 },
242 CollectionBrief {
243 id: RecordId::new("collection", "1"),
244 name: "A".into(),
245 },
246 CollectionBrief {
247 id: RecordId::new("collection", "2"),
248 name: "B".into(),
249 },
250 ];
251
252 NameSort::default().sort_items(&mut collections);
253 assert_eq!(collections[0].name, "A");
254 assert_eq!(collections[1].name, "B");
255 assert_eq!(collections[2].name, "C");
256 }
257}
258
259#[cfg(test)]
260mod item_view_tests {
261 use super::*;
262 use crate::{
263 state::action::{AudioAction, PopupAction, QueueAction},
264 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
265 ui::{components::content_view::ActiveView, widgets::popups::PopupType},
266 };
267 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
268 use pretty_assertions::assert_eq;
269 use ratatui::buffer::Buffer;
270
271 #[test]
272 fn test_new() {
273 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
274 let state = state_with_everything();
275 let view = CollectionView::new(&state, tx).item_view;
276
277 assert_eq!(view.name(), "Collection View");
278 assert_eq!(
279 view.props,
280 Some(state.additional_view_data.collection.unwrap())
281 );
282 }
283
284 #[test]
285 fn test_move_with_state() {
286 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
287 let state = AppState::default();
288 let new_state = state_with_everything();
289 let view = CollectionView::new(&state, tx)
290 .move_with_state(&new_state)
291 .item_view;
292
293 assert_eq!(
294 view.props,
295 Some(new_state.additional_view_data.collection.unwrap())
296 );
297 }
298 #[test]
299 fn test_render_no_collection() {
300 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
301 let mut view = CollectionView::new(&AppState::default(), tx);
302
303 let (mut terminal, area) = setup_test_terminal(22, 3);
304 let props = RenderProps {
305 area,
306 is_focused: true,
307 };
308 let buffer = terminal
309 .draw(|frame| view.render(frame, props))
310 .unwrap()
311 .buffer
312 .clone();
313 #[rustfmt::skip]
314 let expected = Buffer::with_lines([
315 "┌Collection View─────┐",
316 "│No active collection│",
317 "└────────────────────┘",
318 ]);
319
320 assert_buffer_eq(&buffer, &expected);
321 }
322
323 #[test]
324 fn test_render() {
325 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
326 let mut view = CollectionView::new(&state_with_everything(), tx);
327
328 let (mut terminal, area) = setup_test_terminal(60, 9);
329 let props = RenderProps {
330 area,
331 is_focused: true,
332 };
333 let buffer = terminal
334 .draw(|frame| view.render(frame, props))
335 .unwrap()
336 .buffer
337 .clone();
338 let expected = Buffer::with_lines([
339 "┌Collection View sorted by: Artist─────────────────────────┐",
340 "│ Collection 0 │",
341 "│ Songs: 1 Duration: 00:03:00.00 │",
342 "│ │",
343 "│q: add to queue | r: start radio | p: add to playlist─────│",
344 "│Performing operations on entire collection────────────────│",
345 "│☐ Test Song Test Artist │",
346 "│s/S: change sort──────────────────────────────────────────│",
347 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
348 ]);
349
350 assert_buffer_eq(&buffer, &expected);
351 }
352
353 #[test]
354 fn test_render_with_checked() {
355 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
356 let mut view = CollectionView::new(&state_with_everything(), tx);
357 let (mut terminal, area) = setup_test_terminal(60, 9);
358 let props = RenderProps {
359 area,
360 is_focused: true,
361 };
362 let buffer = terminal
363 .draw(|frame| view.render(frame, props))
364 .unwrap()
365 .buffer
366 .clone();
367 let expected = Buffer::with_lines([
368 "┌Collection View sorted by: Artist─────────────────────────┐",
369 "│ Collection 0 │",
370 "│ Songs: 1 Duration: 00:03:00.00 │",
371 "│ │",
372 "│q: add to queue | r: start radio | p: add to playlist─────│",
373 "│Performing operations on entire collection────────────────│",
374 "│☐ Test Song Test Artist │",
375 "│s/S: change sort──────────────────────────────────────────│",
376 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
377 ]);
378 assert_buffer_eq(&buffer, &expected);
379
380 view.handle_key_event(KeyEvent::from(KeyCode::Down));
382 view.handle_key_event(KeyEvent::from(KeyCode::Down));
383 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
384
385 let buffer = terminal
386 .draw(|frame| view.render(frame, props))
387 .unwrap()
388 .buffer
389 .clone();
390 let expected = Buffer::with_lines([
391 "┌Collection View sorted by: Artist─────────────────────────┐",
392 "│ Collection 0 │",
393 "│ Songs: 1 Duration: 00:03:00.00 │",
394 "│ │",
395 "│q: add to queue | r: start radio | p: add to playlist─────│",
396 "│Performing operations on checked items────────────────────│",
397 "│☑ Test Song Test Artist │",
398 "│s/S: change sort──────────────────────────────────────────│",
399 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
400 ]);
401
402 assert_buffer_eq(&buffer, &expected);
403 }
404
405 #[test]
406 fn smoke_navigation() {
407 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
408 let mut view = CollectionView::new(&state_with_everything(), tx);
409
410 view.handle_key_event(KeyEvent::from(KeyCode::Up));
411 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
412 view.handle_key_event(KeyEvent::from(KeyCode::Down));
413 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
414 view.handle_key_event(KeyEvent::from(KeyCode::Left));
415 view.handle_key_event(KeyEvent::from(KeyCode::Right));
416 }
417
418 #[test]
419 fn test_actions() {
420 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
421 let mut view = CollectionView::new(&state_with_everything(), tx);
422
423 let (mut terminal, area) = setup_test_terminal(60, 9);
425 let props = RenderProps {
426 area,
427 is_focused: true,
428 };
429 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
430
431 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
434 assert_eq!(
435 rx.blocking_recv().unwrap(),
436 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
437 ("collection", item_id()).into()
438 ])))
439 );
440 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
441 assert_eq!(
442 rx.blocking_recv().unwrap(),
443 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
444 ("collection", item_id()).into()
445 ])))
446 );
447 view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
448
449 view.handle_key_event(KeyEvent::from(KeyCode::Up));
452 let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
453
454 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
456 assert_eq!(
457 rx.blocking_recv().unwrap(),
458 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
459 );
460
461 view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
463
464 view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
466 assert_eq!(
467 rx.blocking_recv().unwrap(),
468 Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
469 ("song", item_id()).into()
470 ])))
471 );
472
473 view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
475 assert_eq!(
476 rx.blocking_recv().unwrap(),
477 Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
478 ("song", item_id()).into()
479 ])))
480 );
481 }
482
483 #[test]
484 #[allow(clippy::too_many_lines)]
485 fn test_mouse_event() {
486 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
487 let mut view = CollectionView::new(&state_with_everything(), tx);
488
489 let (mut terminal, area) = setup_test_terminal(60, 9);
491 let props = RenderProps {
492 area,
493 is_focused: true,
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 "┌Collection View sorted by: Artist─────────────────────────┐",
502 "│ Collection 0 │",
503 "│ Songs: 1 Duration: 00:03:00.00 │",
504 "│ │",
505 "│q: add to queue | r: start radio | p: add to playlist─────│",
506 "│Performing operations on entire collection────────────────│",
507 "│☐ Test Song Test Artist │",
508 "│s/S: change sort──────────────────────────────────────────│",
509 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
510 ]);
511 assert_buffer_eq(&buffer, &expected);
512
513 view.handle_mouse_event(
515 MouseEvent {
516 kind: MouseEventKind::Down(MouseButton::Left),
517 column: 2,
518 row: 6,
519 modifiers: KeyModifiers::empty(),
520 },
521 area,
522 );
523 let buffer = terminal
524 .draw(|frame| view.render(frame, props))
525 .unwrap()
526 .buffer
527 .clone();
528 let expected = Buffer::with_lines([
529 "┌Collection View sorted by: Artist─────────────────────────┐",
530 "│ Collection 0 │",
531 "│ Songs: 1 Duration: 00:03:00.00 │",
532 "│ │",
533 "│q: add to queue | r: start radio | p: add to playlist─────│",
534 "│Performing operations on checked items────────────────────│",
535 "│☑ Test Song Test Artist │",
536 "│s/S: change sort──────────────────────────────────────────│",
537 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
538 ]);
539 assert_buffer_eq(&buffer, &expected);
540
541 view.handle_mouse_event(
543 MouseEvent {
544 kind: MouseEventKind::Down(MouseButton::Left),
545 column: 2,
546 row: 6,
547 modifiers: KeyModifiers::empty(),
548 },
549 area,
550 );
551 let expected = Buffer::with_lines([
552 "┌Collection View sorted by: Artist─────────────────────────┐",
553 "│ Collection 0 │",
554 "│ Songs: 1 Duration: 00:03:00.00 │",
555 "│ │",
556 "│q: add to queue | r: start radio | p: add to playlist─────│",
557 "│Performing operations on entire collection────────────────│",
558 "│☐ Test Song Test Artist │",
559 "│s/S: change sort──────────────────────────────────────────│",
560 "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
561 ]);
562 let buffer = terminal
563 .draw(|frame| view.render(frame, props))
564 .unwrap()
565 .buffer
566 .clone();
567 assert_buffer_eq(&buffer, &expected);
568 for _ in 0..2 {
570 view.handle_mouse_event(
571 MouseEvent {
572 kind: MouseEventKind::Down(MouseButton::Left),
573 column: 2,
574 row: 6,
575 modifiers: KeyModifiers::CONTROL,
576 },
577 area,
578 );
579 assert_eq!(
580 rx.blocking_recv().unwrap(),
581 Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
582 );
583 }
584
585 view.handle_mouse_event(
587 MouseEvent {
588 kind: MouseEventKind::ScrollDown,
589 column: 2,
590 row: 6,
591 modifiers: KeyModifiers::empty(),
592 },
593 area,
594 );
595 let buffer = terminal
596 .draw(|frame| view.render(frame, props))
597 .unwrap()
598 .buffer
599 .clone();
600 assert_buffer_eq(&buffer, &expected);
601 view.handle_mouse_event(
603 MouseEvent {
604 kind: MouseEventKind::ScrollUp,
605 column: 2,
606 row: 6,
607 modifiers: KeyModifiers::empty(),
608 },
609 area,
610 );
611 let buffer = terminal
612 .draw(|frame| view.render(frame, props))
613 .unwrap()
614 .buffer
615 .clone();
616 assert_buffer_eq(&buffer, &expected);
617 }
618}
619
620#[cfg(test)]
621mod library_view_tests {
622 use super::*;
623 use crate::{
624 test_utils::{assert_buffer_eq, item_id, setup_test_terminal, state_with_everything},
625 ui::components::content_view::ActiveView,
626 };
627 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
628 use pretty_assertions::assert_eq;
629 use ratatui::buffer::Buffer;
630
631 #[test]
632 fn test_new() {
633 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
634 let state = state_with_everything();
635 let view = LibraryCollectionsView::new(&state, tx);
636
637 assert_eq!(view.name(), "Library Collections View");
638 assert_eq!(view.props.collections, state.library.collections);
639 }
640
641 #[test]
642 fn test_move_with_state() {
643 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
644 let state = AppState::default();
645 let new_state = state_with_everything();
646 let view = LibraryCollectionsView::new(&state, tx).move_with_state(&new_state);
647
648 assert_eq!(view.props.collections, new_state.library.collections);
649 }
650
651 #[test]
652 fn test_render() {
653 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
654 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
655
656 let (mut terminal, area) = setup_test_terminal(60, 6);
657 let props = RenderProps {
658 area,
659 is_focused: true,
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 "┌Library Collections sorted by: Name───────────────────────┐",
668 "│──────────────────────────────────────────────────────────│",
669 "│▪ Collection 0 │",
670 "│ │",
671 "│ │",
672 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
673 ]);
674
675 assert_buffer_eq(&buffer, &expected);
676 }
677
678 #[test]
679 fn test_sort_keys() {
680 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
681 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
682
683 assert_eq!(view.props.sort_mode, NameSort::default());
684 view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
685 assert_eq!(view.props.sort_mode, NameSort::default());
686 view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
687 assert_eq!(view.props.sort_mode, NameSort::default());
688 }
689
690 #[test]
691 fn smoke_navigation() {
692 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
693 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
694
695 view.handle_key_event(KeyEvent::from(KeyCode::Up));
696 view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
697 view.handle_key_event(KeyEvent::from(KeyCode::Down));
698 view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
699 view.handle_key_event(KeyEvent::from(KeyCode::Left));
700 view.handle_key_event(KeyEvent::from(KeyCode::Right));
701 }
702
703 #[test]
704 fn test_actions() {
705 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
706 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
707
708 let (mut terminal, area) = setup_test_terminal(60, 9);
710 let props = RenderProps {
711 area,
712 is_focused: true,
713 };
714 terminal.draw(|frame| view.render(frame, props)).unwrap();
715
716 view.handle_key_event(KeyEvent::from(KeyCode::Down));
718
719 view.handle_key_event(KeyEvent::from(KeyCode::Enter));
721 assert_eq!(
722 rx.blocking_recv().unwrap(),
723 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
724 );
725 }
726
727 #[test]
728 fn test_mouse_event() {
729 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
730 let mut view = LibraryCollectionsView::new(&state_with_everything(), tx);
731
732 let (mut terminal, area) = setup_test_terminal(60, 9);
734 let props = RenderProps {
735 area,
736 is_focused: true,
737 };
738 let buffer = terminal
739 .draw(|frame| view.render(frame, props))
740 .unwrap()
741 .buffer
742 .clone();
743 let expected = Buffer::with_lines([
744 "┌Library Collections sorted by: Name───────────────────────┐",
745 "│──────────────────────────────────────────────────────────│",
746 "│▪ Collection 0 │",
747 "│ │",
748 "│ │",
749 "│ │",
750 "│ │",
751 "│ │",
752 "└ ⏎ : Open | ←/↑/↓/→: Navigate | s/S: change sort──────────┘",
753 ]);
754 assert_buffer_eq(&buffer, &expected);
755
756 view.handle_mouse_event(
758 MouseEvent {
759 kind: MouseEventKind::Down(MouseButton::Left),
760 column: 2,
761 row: 2,
762 modifiers: KeyModifiers::empty(),
763 },
764 area,
765 );
766 assert_eq!(
767 rx.blocking_recv().unwrap(),
768 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
769 );
770 let buffer = terminal
771 .draw(|frame| view.render(frame, props))
772 .unwrap()
773 .buffer
774 .clone();
775 assert_buffer_eq(&buffer, &expected);
776
777 view.handle_mouse_event(
779 MouseEvent {
780 kind: MouseEventKind::ScrollDown,
781 column: 2,
782 row: 2,
783 modifiers: KeyModifiers::empty(),
784 },
785 area,
786 );
787
788 view.handle_mouse_event(
790 MouseEvent {
791 kind: MouseEventKind::Down(MouseButton::Left),
792 column: 2,
793 row: 2,
794 modifiers: KeyModifiers::empty(),
795 },
796 area,
797 );
798 assert_eq!(
799 rx.blocking_recv().unwrap(),
800 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
801 );
802 let buffer = terminal
803 .draw(|frame| view.render(frame, props))
804 .unwrap()
805 .buffer
806 .clone();
807 assert_buffer_eq(&buffer, &expected);
808
809 view.handle_mouse_event(
811 MouseEvent {
812 kind: MouseEventKind::ScrollUp,
813 column: 2,
814 row: 2,
815 modifiers: KeyModifiers::empty(),
816 },
817 area,
818 );
819
820 view.handle_mouse_event(
822 MouseEvent {
823 kind: MouseEventKind::Down(MouseButton::Left),
824 column: 2,
825 row: 2,
826 modifiers: KeyModifiers::empty(),
827 },
828 area,
829 );
830 assert_eq!(
831 rx.blocking_recv().unwrap(),
832 Action::ActiveView(ViewAction::Set(ActiveView::Collection(item_id())))
833 );
834
835 let mouse = MouseEvent {
837 kind: MouseEventKind::Down(MouseButton::Left),
838 column: 2,
839 row: 3,
840 modifiers: KeyModifiers::empty(),
841 };
842 view.handle_mouse_event(mouse, area);
843 assert_eq!(view.tree_state.get_selected_thing(), None);
844 view.handle_mouse_event(mouse, area);
845 assert_eq!(
846 rx.try_recv(),
847 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
848 );
849 }
850}