Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/elements/scrollable_test.rs
StratoSDK / crates / strato-ui-core / src / elements / scrollable_test.rs
1use std::{
2 cell::RefCell,
3 collections::{HashMap, HashSet},
4 rc::Rc,
5};
6 
7use itertools::Itertools;
8 
9use super::*;
10use crate::{
11 elements::{ClippedScrollStateHandle, ClippedScrollable, DispatchEventResult, Flex},
12 platform::WindowStyle,
13 TypedActionView,
14};
15use crate::{
16 elements::{ConstrainedBox, EventHandler, ParentElement, Rect, Stack},
17 presenter::DispatchedActionKind,
18 App, AppContext, Entity, Event, Presenter, ViewContext, WindowInvalidation,
19};
20 
21/// Since we support scrolling in both vertical and horizontal directions,
22/// this macro makes it easier to define tests for both directions. Simply
23/// define an axis-agnostic "test function" that is _essentially_ a test
24/// but has two main differences:
25/// - it isn't decorated with the #[test] macro, and
26/// - it takes a `axis: Axis` argument.
27///
28/// Then, you can use this macro to turn that test function into two real tests
29/// (one for each scrollable direction).
30macro_rules! define_axis_agnostic_tests {
31 ($test_function:ident) => {
32 concat_idents::concat_idents!(test_name = $test_function, _, vertical {
33 #[test]
34 fn test_name() {
35 $test_function(Axis::Vertical);
36 }
37 });
38 
39 concat_idents::concat_idents!(test_name = $test_function, _, horizontal {
40 #[test]
41 fn test_name() {
42 $test_function(Axis::Horizontal);
43 }
44 });
45 };
46}
47 
48fn create_presenter_and_render<F, T>(
49 app: &mut App,
50 build_root_view: F,
51 window_size: Vector2F,
52) -> Rc<RefCell<Presenter>>
53where
54 T: crate::View + TypedActionView,
55 F: FnOnce(&mut ViewContext<T>) -> T,
56{
57 let (window_id, _view) = app.add_window(WindowStyle::NotStealFocus, build_root_view);
58 
59 let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
60 
61 let mut updated = HashSet::new();
62 updated.insert(app.root_view_id(window_id).unwrap());
63 let invalidation = WindowInvalidation {
64 updated,
65 ..Default::default()
66 };
67 
68 app.update(move |ctx| {
69 presenter.borrow_mut().invalidate(invalidation, ctx);
70 let _ = presenter
71 .borrow_mut()
72 .build_scene(window_size, 1., None, ctx);
73 presenter
74 })
75}
76 
77struct BasicScrollableView {
78 /// [`Axis::Horizontal`] will create a horizontally scrollable view.
79 /// [`Axis::Vertical`] will create a verticall scrollable view.
80 axis: Axis,
81 // maps view id to number of mouse downs
82 mouse_downs: HashMap<usize, u32>,
83 clipped_scroll_state: ClippedScrollStateHandle,
84 scroll_area_size: f32,
85 num_elements: usize,
86}
87 
88pub fn init(app: &mut AppContext) {
89 app.add_action("test_view:mouse_down", BasicScrollableView::mouse_down);
90}
91 
92impl BasicScrollableView {
93 const ITEM_SIZE: f32 = 50.;
94 const SCROLLBAR_SIZE: ScrollbarWidth = ScrollbarWidth::Auto;
95 
96 fn new(axis: Axis, scroll_area_size: f32, num_elements: usize) -> Self {
97 Self {
98 axis,
99 scroll_area_size,
100 num_elements,
101 clipped_scroll_state: Default::default(),
102 mouse_downs: Default::default(),
103 }
104 }
105 
106 fn mouse_down(&mut self, view_id: &usize, _ctx: &mut ViewContext<Self>) -> bool {
107 log::info!("Recording mouse_down on view_id {view_id}");
108 let entry = self.mouse_downs.entry(*view_id).or_insert(0);
109 *entry += 1;
110 true
111 }
112}
113 
114impl Entity for BasicScrollableView {
115 type Event = String;
116}
117 
118impl crate::core::View for BasicScrollableView {
119 fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
120 let mut flex = Flex::new(self.axis);
121 for i in 0..self.num_elements {
122 let id = i + 1;
123 flex.add_child(
124 EventHandler::new(
125 ConstrainedBox::new(Rect::new().finish())
126 .with_height(50.)
127 .with_width(50.)
128 .finish(),
129 )
130 .on_left_mouse_down(move |evt_ctx, _ctx, _position| {
131 evt_ctx.dispatch_action("test_view:mouse_down", id);
132 DispatchEventResult::StopPropagation
133 })
134 .finish(),
135 );
136 }
137 
138 if matches!(self.axis, Axis::Vertical) {
139 ConstrainedBox::new(
140 ClippedScrollable::vertical(
141 self.clipped_scroll_state.clone(),
142 ConstrainedBox::new(flex.finish())
143 .with_height(self.num_elements as f32 * 50.)
144 .finish(),
145 ScrollbarWidth::Auto,
146 Fill::None,
147 Fill::None,
148 Fill::None,
149 )
150 .finish(),
151 )
152 .with_height(self.scroll_area_size)
153 .finish()
154 } else {
155 ConstrainedBox::new(
156 ClippedScrollable::horizontal(
157 self.clipped_scroll_state.clone(),
158 ConstrainedBox::new(flex.finish())
159 .with_width(self.num_elements as f32 * 50.)
160 .finish(),
161 ScrollbarWidth::Auto,
162 Fill::None,
163 Fill::None,
164 Fill::None,
165 )
166 .finish(),
167 )
168 .with_width(self.scroll_area_size)
169 .finish()
170 }
171 }
172 
173 fn ui_name() -> &'static str {
174 "View"
175 }
176}
177 
178impl TypedActionView for BasicScrollableView {
179 type Action = ();
180}
181 
182const STACKED_VIEW_LENGTH: f32 = 100.;
183 
184/// Similar to [`BasicScrollableView`] except the scrollable
185/// view is an element on a [`Stack`].
186struct StackedScrollableView {
187 axis: Axis,
188 clipped_scroll_state: ClippedScrollStateHandle,
189}
190 
191impl StackedScrollableView {
192 fn new(axis: Axis) -> Self {
193 Self {
194 axis,
195 clipped_scroll_state: Default::default(),
196 }
197 }
198}
199 
200impl Entity for StackedScrollableView {
201 type Event = String;
202}
203 
204impl crate::core::View for StackedScrollableView {
205 fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
206 let mut inner_stack = Stack::new();
207 inner_stack.add_child(
208 ConstrainedBox::new(Rect::new().finish())
209 .with_height(STACKED_VIEW_LENGTH)
210 .with_width(STACKED_VIEW_LENGTH)
211 .finish(),
212 );
213 
214 if matches!(self.axis, Axis::Vertical) {
215 ConstrainedBox::new(
216 ClippedScrollable::vertical(
217 self.clipped_scroll_state.clone(),
218 inner_stack.finish(),
219 ScrollbarWidth::Auto,
220 Fill::None,
221 Fill::None,
222 Fill::None,
223 )
224 .finish(),
225 )
226 // Make the scrollable element half as large as the child so that
227 // there is something to scroll.
228 .with_height(STACKED_VIEW_LENGTH / 2.)
229 .finish()
230 } else {
231 ConstrainedBox::new(
232 ClippedScrollable::horizontal(
233 self.clipped_scroll_state.clone(),
234 inner_stack.finish(),
235 ScrollbarWidth::Auto,
236 Fill::None,
237 Fill::None,
238 Fill::None,
239 )
240 .finish(),
241 )
242 // Make the scrollable element half as large as the child so that
243 // there is something to scroll.
244 .with_width(STACKED_VIEW_LENGTH / 2.)
245 .finish()
246 }
247 }
248 
249 fn ui_name() -> &'static str {
250 "StackedView"
251 }
252}
253 
254impl TypedActionView for StackedScrollableView {
255 type Action = ();
256}
257 
258/// Tests if clipped scrolling works along `axis`.
259fn test_clipped_scrolling(axis: Axis) {
260 App::test((), |mut app| async move {
261 let app = &mut app;
262 app.update(init);
263 
264 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
265 BasicScrollableView::new(axis, 200., 10)
266 });
267 
268 let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
269 
270 let mut updated = HashSet::new();
271 updated.insert(app.root_view_id(window_id).unwrap());
272 let invalidation = WindowInvalidation {
273 updated,
274 ..Default::default()
275 };
276 
277 let presenter_clone = presenter.clone();
278 app.update(move |ctx| {
279 presenter_clone.borrow_mut().invalidate(invalidation, ctx);
280 let _ = presenter_clone
281 .borrow_mut()
282 .build_scene(vec2f(1000., 1000.), 1., None, ctx);
283 
284 // Fire event on first child
285 ctx.simulate_window_event(
286 Event::LeftMouseDown {
287 position: vec2f(15., 15.),
288 modifiers: Default::default(),
289 click_count: 1,
290 is_first_mouse: false,
291 },
292 window_id,
293 presenter_clone.clone(),
294 );
295 
296 // Trigger a scroll to make the second child be at the start
297 // of the visible area
298 ctx.simulate_window_event(
299 Event::ScrollWheel {
300 position: vec2f(15., 15.),
301 delta: -(50_f32.along(axis)),
302 precise: true,
303 modifiers: Default::default(),
304 },
305 window_id,
306 presenter_clone.clone(),
307 );
308 });
309 
310 view.read(app, |view, _ctx| {
311 assert_eq!(1, *view.mouse_downs.get(&1).unwrap());
312 assert_eq!(None, view.mouse_downs.get(&2));
313 assert!(view.clipped_scroll_state.scroll_start() > Pixels::zero());
314 });
315 
316 let mut updated = HashSet::new();
317 updated.insert(app.root_view_id(window_id).unwrap());
318 let invalidation = WindowInvalidation {
319 updated,
320 ..Default::default()
321 };
322 
323 let presenter_clone = presenter.clone();
324 app.update(move |ctx| {
325 presenter_clone.borrow_mut().invalidate(invalidation, ctx);
326 let _ = presenter_clone
327 .borrow_mut()
328 .build_scene(vec2f(1000., 1000.), 1., None, ctx);
329 
330 // Fire event on second child
331 ctx.simulate_window_event(
332 Event::LeftMouseDown {
333 position: vec2f(15., 15.),
334 modifiers: Default::default(),
335 click_count: 1,
336 is_first_mouse: false,
337 },
338 window_id,
339 presenter_clone.clone(),
340 );
341 });
342 
343 view.read(app, |view, _ctx| {
344 assert_eq!(1, *view.mouse_downs.get(&1).unwrap());
345 assert_eq!(1, *view.mouse_downs.get(&2).unwrap());
346 });
347 
348 let mut updated = HashSet::new();
349 updated.insert(app.root_view_id(window_id).unwrap());
350 let invalidation = WindowInvalidation {
351 updated,
352 ..Default::default()
353 };
354 
355 let presenter_clone = presenter;
356 app.update(move |ctx| {
357 presenter_clone.borrow_mut().invalidate(invalidation, ctx);
358 let _ = presenter_clone
359 .borrow_mut()
360 .build_scene(vec2f(1000., 1000.), 1., None, ctx);
361 
362 // Trigger a scroll back to the start
363 ctx.simulate_window_event(
364 Event::ScrollWheel {
365 position: vec2f(15., 15.),
366 delta: 50_f32.along(axis),
367 precise: true,
368 modifiers: Default::default(),
369 },
370 window_id,
371 presenter_clone.clone(),
372 );
373 });
374 
375 view.read(app, |view, _ctx| {
376 // Make sure scroll start is reset to zero
377 assert!(view.clipped_scroll_state.scroll_start().as_f32().abs() < f32::EPSILON);
378 });
379 })
380}
381 
382fn test_clipped_scrolling_no_scrollbars(axis: Axis) {
383 App::test((), |mut app| async move {
384 let app = &mut app;
385 app.update(init);
386 
387 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
388 BasicScrollableView::new(axis, 500., 10)
389 });
390 
391 let mut presenter = Presenter::new(window_id);
392 
393 let mut updated = HashSet::new();
394 updated.insert(app.root_view_id(window_id).unwrap());
395 let invalidation = WindowInvalidation {
396 updated,
397 ..Default::default()
398 };
399 
400 app.update(move |ctx| {
401 presenter.invalidate(invalidation, ctx);
402 let _ = presenter.build_scene(vec2f(1000., 1000.), 1., None, ctx);
403 let presenter = Rc::new(RefCell::new(presenter));
404 
405 // Fire event on first child
406 ctx.simulate_window_event(
407 Event::LeftMouseDown {
408 position: vec2f(15., 15.),
409 modifiers: Default::default(),
410 click_count: 1,
411 is_first_mouse: false,
412 },
413 window_id,
414 presenter.clone(),
415 );
416 
417 // Try to trigger a scroll (but there shouldn't actually be one)
418 ctx.simulate_window_event(
419 Event::ScrollWheel {
420 position: vec2f(15., 15.),
421 delta: -(25_f32.along(axis)),
422 precise: true,
423 modifiers: Default::default(),
424 },
425 window_id,
426 presenter,
427 );
428 });
429 
430 view.read(app, |view, _ctx| {
431 assert_eq!(1, *view.mouse_downs.get(&1).unwrap());
432 assert!(view.clipped_scroll_state.scroll_start().as_f32().abs() < f32::EPSILON);
433 });
434 
435 presenter = Presenter::new(window_id);
436 
437 let mut updated = HashSet::new();
438 updated.insert(app.root_view_id(window_id).unwrap());
439 let invalidation = WindowInvalidation {
440 updated,
441 ..Default::default()
442 };
443 
444 app.update(move |ctx| {
445 presenter.invalidate(invalidation, ctx);
446 let _ = presenter.build_scene(vec2f(1000., 1000.), 1., None, ctx);
447 let presenter = Rc::new(RefCell::new(presenter));
448 
449 // Fire another event on the first child
450 ctx.simulate_window_event(
451 Event::LeftMouseDown {
452 position: vec2f(15., 15.),
453 modifiers: Default::default(),
454 click_count: 1,
455 is_first_mouse: false,
456 },
457 window_id,
458 presenter,
459 );
460 });
461 
462 view.read(app, |view, _ctx| {
463 assert_eq!(2, *view.mouse_downs.get(&1).unwrap());
464 assert_eq!(None, view.mouse_downs.get(&2));
465 assert!(view.clipped_scroll_state.scroll_start().as_f32().abs() < f32::EPSILON);
466 });
467 })
468}
469 
470fn test_stacked_view_scroll_handling(axis: Axis) {
471 App::test((), |mut app| async move {
472 let app = &mut app;
473 app.update(init);
474 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
475 StackedScrollableView::new(axis)
476 });
477 
478 let mut presenter = Presenter::new(window_id);
479 
480 let mut updated = HashSet::new();
481 updated.insert(app.root_view_id(window_id).unwrap());
482 let invalidation = WindowInvalidation {
483 updated,
484 ..Default::default()
485 };
486 
487 app.update(move |ctx| {
488 presenter.invalidate(invalidation, ctx);
489 let scene = presenter.build_scene(
490 vec2f(STACKED_VIEW_LENGTH, STACKED_VIEW_LENGTH),
491 1.,
492 None,
493 ctx,
494 );
495 let presenter = Rc::new(RefCell::new(presenter));
496 
497 assert_eq!(scene.z_index(), ZIndex::new(0));
498 assert_eq!(scene.layer_count(), 3);
499 
500 // Try to scroll the stacked element
501 ctx.simulate_window_event(
502 Event::ScrollWheel {
503 position: vec2f(25., 25.),
504 delta: -(25_f32.along(axis)),
505 precise: true,
506 modifiers: Default::default(),
507 },
508 window_id,
509 presenter,
510 );
511 });
512 
513 view.read(app, |view, _ctx| {
514 assert!(view.clipped_scroll_state.scroll_start() > Pixels::zero());
515 });
516 })
517}
518 
519fn test_clicks_in_scrollbar_gutter_change_scroll_position(axis: Axis) {
520 App::test((), |mut app| async move {
521 let app = &mut app;
522 app.update(init);
523 
524 let scroll_area_size = 200.;
525 // This should make the scrollbar thumb half the size of the scrollbar.
526 let num_elements =
527 (scroll_area_size / BasicScrollableView::ITEM_SIZE * 2.).round() as usize;
528 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
529 BasicScrollableView::new(axis, scroll_area_size, num_elements)
530 });
531 
532 let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
533 
534 let mut updated = HashSet::new();
535 updated.insert(app.root_view_id(window_id).unwrap());
536 let invalidation = WindowInvalidation {
537 updated,
538 ..Default::default()
539 };
540 
541 app.update(move |ctx| {
542 presenter.borrow_mut().invalidate(invalidation, ctx);
543 let _ = presenter
544 .borrow_mut()
545 .build_scene(vec2f(1000., 1000.), 1., None, ctx);
546 
547 // Assert that the scrollable view is scrolled to the start.
548 view.read(ctx, |view, _ctx| {
549 assert_eq!(view.clipped_scroll_state.scroll_start(), Pixels::zero());
550 });
551 
552 // Click on the scrollbar gutter (somewhere before the scrollbar thumb).
553 let click_position = axis.to_point(
554 scroll_area_size - 5.,
555 1000. - (BasicScrollableView::SCROLLBAR_SIZE.as_f32() / 2.),
556 );
557 ctx.simulate_window_event(
558 Event::LeftMouseDown {
559 position: click_position,
560 modifiers: Default::default(),
561 click_count: 1,
562 is_first_mouse: false,
563 },
564 window_id,
565 presenter.clone(),
566 );
567 
568 // Assert that the scrollable view is no longer scrolled to the
569 // top.
570 view.read(ctx, |view, _ctx| {
571 assert_ne!(view.clipped_scroll_state.scroll_start(), Pixels::zero());
572 });
573 });
574 })
575}
576 
577fn test_ignores_clicks_outside_scrollbar_bounds(axis: Axis) {
578 App::test((), |mut app| async move {
579 let app = &mut app;
580 app.update(init);
581 
582 let scroll_area_size = 100.;
583 // This should make the scrollbar thumb half the size of the scrollbar.
584 let num_elements =
585 (scroll_area_size / BasicScrollableView::ITEM_SIZE * 2.).round() as usize;
586 
587 let window_length = 1000.;
588 let window_size = vec2f(window_length, window_length);
589 let presenter = create_presenter_and_render(
590 app,
591 |_| BasicScrollableView::new(axis, scroll_area_size, num_elements),
592 window_size,
593 );
594 
595 app.update(move |ctx| {
596 // Define a macro to help us determine which actions would be
597 // produced if we dispatched the given event.
598 macro_rules! actions_for_dispatched_event {
599 ($event:expr) => {{
600 let result = presenter
601 .borrow_mut()
602 .dispatch_event($event, ctx);
603 result.actions.iter().flat_map(|action| {
604 match action.kind {
605 DispatchedActionKind::Legacy { name, .. } => Some(name),
606 _ => None
607 }
608 }).collect_vec()
609 }};
610 }
611 
612 let click_point_along_inverse_axis = window_length - (BasicScrollableView::SCROLLBAR_SIZE.as_f32() / 2.);
613 
614 // Ensure that a click on the scrollbar thumb is handled.
615 let click_position = axis.to_point(5., click_point_along_inverse_axis);
616 let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
617 position: click_position,
618 modifiers: Default::default(),
619 click_count: 1,
620 is_first_mouse: false,
621 });
622 assert_eq!(actions, vec!["scrollable_click::on_thumb"], "Should handle clicks on the scrollbar thumb");
623 
624 // Ensure that a click on the scrollbar gutter (i.e. outside of the thumb) is handled.
625 let click_position = axis.to_point(scroll_area_size - 5., click_point_along_inverse_axis);
626 let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
627 position: click_position,
628 modifiers: Default::default(),
629 click_count: 1,
630 is_first_mouse: false,
631 });
632 assert_eq!(actions, vec!["scrollable_click::on_gutter"], "Should handle clicks on the scrollbar gutter");
633 
634 // Ensure that a click event below the scrollbar isn't handled.
635 let click_position = axis.to_point(scroll_area_size + 5., click_point_along_inverse_axis);
636 let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
637 position: click_position,
638 modifiers: Default::default(),
639 click_count: 1,
640 is_first_mouse: false,
641 });
642 assert!(actions.is_empty(), "Should not handle click events that are outside the vertical bounds of the scrollbar");
643 
644 // Ensure that a click event to the left of the scrollbar isn't handled.
645 let click_position = axis.to_point(scroll_area_size - 5., 100.);
646 let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
647 position: click_position,
648 modifiers: Default::default(),
649 click_count: 1,
650 is_first_mouse: false,
651 });
652 assert!(actions.is_empty(), "Should not handle click events that are outside the horizontal bounds of the scrollbar");
653 });
654 })
655}
656 
657define_axis_agnostic_tests!(test_clipped_scrolling);
658define_axis_agnostic_tests!(test_clipped_scrolling_no_scrollbars);
659define_axis_agnostic_tests!(test_stacked_view_scroll_handling);
660define_axis_agnostic_tests!(test_clicks_in_scrollbar_gutter_change_scroll_position);
661define_axis_agnostic_tests!(test_ignores_clicks_outside_scrollbar_bounds);
662