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/new_scrollable/scrollable_test.rs
StratoSDK / crates / strato-ui-core / src / elements / new_scrollable / scrollable_test.rs
1use std::{
2 cell::RefCell,
3 collections::{HashMap, HashSet},
4 rc::Rc,
5};
6 
7use pathfinder_color::ColorU;
8use pathfinder_geometry::vector::{vec2f, Vector2F};
9 
10use crate::{
11 elements::{
12 Axis, ClippedScrollStateHandle, ConstrainedBox, DispatchEventResult, EventHandler, Fill,
13 ParentElement, Point, Rect, SavePosition, ScrollData, ScrollStateHandle, ScrollTarget,
14 ScrollToPositionMode, ScrollbarWidth, SelectableElement, SelectionFragment, Stack, ZIndex,
15 },
16 event::DispatchedEvent,
17 platform::{TerminationMode, WindowStyle},
18 text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
19 units::Pixels,
20 AfterLayoutContext, App, AppContext, Element, Entity, EntityId, Event, EventContext,
21 LayoutContext, PaintContext, Presenter, SizeConstraint, TypedActionView, View, ViewContext,
22 WindowInvalidation,
23};
24 
25use super::{
26 AxisConfiguration, ClippedAxisConfiguration, DualAxisConfig, NewScrollable,
27 NewScrollableElement, ScrollableAppearance, ScrollableAxis, SingleAxisConfig,
28};
29 
30const TOTAL_SCROLLABLE_SIZE: f32 = 500.;
31const CHILD_EVENT_HANDLER_DIMENSION: f32 = 50.;
32const CHILD_EVENT_HANDLER_COUNT: usize = 10;
33const SCROLLABLE_VIEWPORT_SIZE: f32 = 250.;
34 
35fn select_entire_probe_text(
36 _content: &str,
37 _click_offset: crate::text_offsets::ByteOffset,
38) -> Option<std::ops::Range<crate::text_offsets::ByteOffset>> {
39 Some(crate::text_offsets::ByteOffset::zero()..crate::text_offsets::ByteOffset::from(1))
40}
41 
42#[derive(Clone, Default)]
43struct SelectableProbeState {
44 get_selection_args: Rc<RefCell<Vec<(Vector2F, Vector2F, IsRect)>>>,
45 expand_selection_args: Rc<RefCell<Vec<(Vector2F, SelectionDirection, SelectionType)>>>,
46 semantic_order_args: Rc<RefCell<Vec<(Vector2F, Vector2F)>>>,
47 smart_select_args: Rc<RefCell<Vec<Vector2F>>>,
48 clickable_bounds_args: Rc<RefCell<Vec<Option<crate::elements::Selection>>>>,
49}
50 
51struct SelectableProbeElement {
52 state: SelectableProbeState,
53 size: Vector2F,
54}
55 
56impl SelectableProbeElement {
57 fn new(state: SelectableProbeState) -> Self {
58 Self {
59 state,
60 size: vec2f(400.0, 120.0),
61 }
62 }
63}
64 
65impl Element for SelectableProbeElement {
66 fn layout(
67 &mut self,
68 _constraint: SizeConstraint,
69 _ctx: &mut LayoutContext,
70 _app: &AppContext,
71 ) -> Vector2F {
72 self.size
73 }
74 
75 fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
76 
77 fn paint(&mut self, _origin: Vector2F, _ctx: &mut PaintContext, _app: &AppContext) {}
78 
79 fn size(&self) -> Option<Vector2F> {
80 Some(self.size)
81 }
82 
83 fn origin(&self) -> Option<Point> {
84 Some(Point::new(0.0, 0.0, ZIndex::new(0)))
85 }
86 
87 fn dispatch_event(
88 &mut self,
89 _event: &DispatchedEvent,
90 _ctx: &mut EventContext,
91 _app: &AppContext,
92 ) -> bool {
93 false
94 }
95 
96 fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
97 Some(self)
98 }
99}
100 
101impl SelectableElement for SelectableProbeElement {
102 fn get_selection(
103 &self,
104 selection_start: Vector2F,
105 selection_end: Vector2F,
106 is_rect: IsRect,
107 ) -> Option<Vec<SelectionFragment>> {
108 self.state
109 .get_selection_args
110 .borrow_mut()
111 .push((selection_start, selection_end, is_rect));
112 Some(vec![SelectionFragment {
113 text: "probe".to_string(),
114 origin: Point::new(0.0, 0.0, ZIndex::new(0)),
115 }])
116 }
117 
118 fn expand_selection(
119 &self,
120 absolute_point: Vector2F,
121 direction: SelectionDirection,
122 unit: SelectionType,
123 _word_boundaries_policy: &WordBoundariesPolicy,
124 ) -> Option<Vector2F> {
125 self.state
126 .expand_selection_args
127 .borrow_mut()
128 .push((absolute_point, direction, unit));
129 Some(absolute_point + vec2f(5.0, 0.0))
130 }
131 
132 fn is_point_semantically_before(
133 &self,
134 absolute_point: Vector2F,
135 absolute_point_other: Vector2F,
136 ) -> Option<bool> {
137 self.state
138 .semantic_order_args
139 .borrow_mut()
140 .push((absolute_point, absolute_point_other));
141 Some(absolute_point.x() < absolute_point_other.x())
142 }
143 
144 fn smart_select(
145 &self,
146 absolute_point: Vector2F,
147 _smart_select_fn: crate::elements::SmartSelectFn,
148 ) -> Option<(Vector2F, Vector2F)> {
149 self.state
150 .smart_select_args
151 .borrow_mut()
152 .push(absolute_point);
153 Some((absolute_point, absolute_point + vec2f(12.0, 0.0)))
154 }
155 
156 fn calculate_clickable_bounds(
157 &self,
158 current_selection: Option<crate::elements::Selection>,
159 ) -> Vec<crate::geometry::rect::RectF> {
160 self.state
161 .clickable_bounds_args
162 .borrow_mut()
163 .push(current_selection);
164 Vec::new()
165 }
166}
167 
168fn test_clipped_horizontal_scrollable_with_probe(
169 state: SelectableProbeState,
170 scroll_left: f32,
171) -> NewScrollable {
172 let handle = ClippedScrollStateHandle::default();
173 handle.scroll_to(Pixels::new(scroll_left));
174 test_clipped_horizontal_scrollable_with_probe_handle(state, handle)
175}
176 
177fn test_clipped_horizontal_scrollable_with_probe_handle(
178 state: SelectableProbeState,
179 handle: ClippedScrollStateHandle,
180) -> NewScrollable {
181 NewScrollable::horizontal(
182 SingleAxisConfig::Clipped {
183 handle,
184 child: Box::new(SelectableProbeElement::new(state)),
185 },
186 Fill::None,
187 Fill::None,
188 Fill::None,
189 )
190}
191 
192struct ScrollableElement {
193 size: Option<Vector2F>,
194 origin: Option<Point>,
195 scroll_top: f32,
196 scroll_left: f32,
197 elements: Vec<Vec<Box<dyn Element>>>,
198}
199 
200impl ScrollableElement {
201 fn new(scroll_top: f32, scroll_left: f32, elements: Vec<Vec<Box<dyn Element>>>) -> Self {
202 Self {
203 scroll_left,
204 scroll_top,
205 size: None,
206 origin: None,
207 elements,
208 }
209 }
210}
211 
212impl Element for ScrollableElement {
213 fn layout(
214 &mut self,
215 constraint: SizeConstraint,
216 ctx: &mut LayoutContext,
217 app: &AppContext,
218 ) -> Vector2F {
219 // The child element size should all be hard-coded. We don't need to worry about the
220 // size constraint here.
221 for element in self.elements.iter_mut().flatten() {
222 element.layout(constraint, ctx, app);
223 }
224 let size = vec2f(
225 constraint
226 .max_along(Axis::Horizontal)
227 .min(TOTAL_SCROLLABLE_SIZE),
228 constraint
229 .max_along(Axis::Vertical)
230 .min(TOTAL_SCROLLABLE_SIZE),
231 );
232 self.size = Some(size);
233 size
234 }
235 
236 fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {}
237 
238 fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
239 self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
240 let adjusted_origin = origin - vec2f(self.scroll_left, self.scroll_top);
241 
242 for i in 0..CHILD_EVENT_HANDLER_COUNT {
243 for j in 0..CHILD_EVENT_HANDLER_COUNT {
244 let cell_origin = adjusted_origin
245 + vec2f(
246 i as f32 * CHILD_EVENT_HANDLER_DIMENSION,
247 j as f32 * CHILD_EVENT_HANDLER_DIMENSION,
248 );
249 self.elements[i][j].as_mut().paint(cell_origin, ctx, app);
250 }
251 }
252 }
253 
254 fn size(&self) -> Option<Vector2F> {
255 self.size
256 }
257 
258 fn origin(&self) -> Option<Point> {
259 self.origin
260 }
261 
262 fn dispatch_event(
263 &mut self,
264 event: &DispatchedEvent,
265 ctx: &mut EventContext,
266 app: &AppContext,
267 ) -> bool {
268 self.elements
269 .iter_mut()
270 .flatten()
271 .any(|element| element.dispatch_event(event, ctx, app))
272 }
273}
274 
275impl NewScrollableElement for ScrollableElement {
276 fn axis(&self) -> ScrollableAxis {
277 ScrollableAxis::Both
278 }
279 
280 fn scroll_data(&self, axis: Axis, _app: &AppContext) -> Option<ScrollData> {
281 match axis {
282 Axis::Horizontal => Some(ScrollData {
283 scroll_start: Pixels::new(self.scroll_left),
284 visible_px: Pixels::new(self.size.unwrap().x()),
285 total_size: Pixels::new(TOTAL_SCROLLABLE_SIZE),
286 }),
287 Axis::Vertical => Some(ScrollData {
288 scroll_start: Pixels::new(self.scroll_top),
289 visible_px: Pixels::new(self.size.unwrap().y()),
290 total_size: Pixels::new(TOTAL_SCROLLABLE_SIZE),
291 }),
292 }
293 }
294 
295 fn scroll(&mut self, delta: Pixels, axis: Axis, ctx: &mut EventContext) {
296 match axis {
297 Axis::Horizontal => ctx.dispatch_action("test_view:scroll_horizontal", delta.as_f32()),
298 Axis::Vertical => ctx.dispatch_action("test_view:scroll_vertical", delta.as_f32()),
299 }
300 }
301}
302 
303#[derive(Clone)]
304enum ScrollBehavior {
305 Manual(ScrollStateHandle),
306 Clipped(ClippedScrollStateHandle),
307}
308 
309struct BasicScrollableView {
310 horizontal_axis: Option<ScrollBehavior>,
311 vertical_axis: Option<ScrollBehavior>,
312 // maps view id to number of mouse downs
313 mouse_downs: HashMap<(usize, usize), u32>,
314 scroll_top: f32,
315 scroll_left: f32,
316}
317 
318pub fn init(app: &mut AppContext) {
319 app.add_action("test_view:mouse_down", BasicScrollableView::mouse_down);
320 app.add_action(
321 "test_view:scroll_horizontal",
322 BasicScrollableView::scroll_horizontal,
323 );
324 app.add_action(
325 "test_view:scroll_vertical",
326 BasicScrollableView::scroll_vertical,
327 );
328}
329 
330impl BasicScrollableView {
331 fn new(horizontal_axis: Option<ScrollBehavior>, vertical_axis: Option<ScrollBehavior>) -> Self {
332 Self {
333 horizontal_axis,
334 vertical_axis,
335 scroll_left: 0.,
336 scroll_top: 0.,
337 mouse_downs: Default::default(),
338 }
339 }
340 
341 fn mouse_down(&mut self, element_id: &(usize, usize), _ctx: &mut ViewContext<Self>) -> bool {
342 log::info!("Recording mouse_down on element_id {element_id:?}");
343 let entry = self.mouse_downs.entry(*element_id).or_insert(0);
344 *entry += 1;
345 true
346 }
347 
348 fn scroll_horizontal(&mut self, delta: &f32, ctx: &mut ViewContext<Self>) -> bool {
349 log::info!("Received scroll horizontal event {}", *delta);
350 self.scroll_left = (self.scroll_left - *delta).clamp(0., 257.);
351 ctx.notify();
352 true
353 }
354 
355 fn scroll_vertical(&mut self, delta: &f32, ctx: &mut ViewContext<Self>) -> bool {
356 log::info!("Received scroll vertical event {}", *delta);
357 self.scroll_top = (self.scroll_top - *delta).clamp(0., 257.);
358 ctx.notify();
359 true
360 }
361}
362 
363impl Entity for BasicScrollableView {
364 type Event = String;
365}
366 
367impl View for BasicScrollableView {
368 fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
369 let mut elements = Vec::new();
370 for i in 0..CHILD_EVENT_HANDLER_COUNT {
371 let mut row = Vec::new();
372 for j in 0..CHILD_EVENT_HANDLER_COUNT {
373 row.push(
374 EventHandler::new(
375 SavePosition::new(
376 ConstrainedBox::new(Rect::new().finish())
377 .with_height(CHILD_EVENT_HANDLER_DIMENSION)
378 .with_width(CHILD_EVENT_HANDLER_DIMENSION)
379 .finish(),
380 &format!("child-{i}-{j}"),
381 )
382 .finish(),
383 )
384 .on_left_mouse_down(move |evt_ctx, _ctx, _position| {
385 evt_ctx.dispatch_action("test_view:mouse_down", (i, j));
386 DispatchEventResult::StopPropagation
387 })
388 .finish(),
389 );
390 }
391 elements.push(row);
392 }
393 
394 let element = match (self.horizontal_axis.clone(), self.vertical_axis.clone()) {
395 (
396 Some(ScrollBehavior::Clipped(horizontal_state)),
397 Some(ScrollBehavior::Clipped(vertical_state)),
398 ) => {
399 let axis_config = DualAxisConfig::Clipped {
400 horizontal: ClippedAxisConfiguration {
401 handle: horizontal_state,
402 max_size: None,
403 stretch_child: false,
404 },
405 vertical: ClippedAxisConfiguration {
406 handle: vertical_state,
407 max_size: None,
408 stretch_child: false,
409 },
410 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
411 .finish(),
412 };
413 
414 NewScrollable::horizontal_and_vertical(
415 axis_config,
416 ColorU::white().into(),
417 ColorU::white().into(),
418 ColorU::new(100, 100, 100, 255).into(),
419 )
420 .with_horizontal_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
421 .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
422 }
423 (Some(ScrollBehavior::Manual(horizontal)), Some(ScrollBehavior::Clipped(vertical))) => {
424 let axis_config = DualAxisConfig::Manual {
425 horizontal: AxisConfiguration::Manual(horizontal),
426 vertical: AxisConfiguration::Clipped(ClippedAxisConfiguration {
427 handle: vertical,
428 max_size: None,
429 stretch_child: false,
430 }),
431 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
432 .finish_scrollable(),
433 };
434 
435 NewScrollable::horizontal_and_vertical(
436 axis_config,
437 ColorU::white().into(),
438 ColorU::white().into(),
439 ColorU::new(100, 100, 100, 255).into(),
440 )
441 .with_horizontal_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
442 .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
443 }
444 (Some(ScrollBehavior::Clipped(horizontal)), Some(ScrollBehavior::Manual(vertical))) => {
445 let axis_config = DualAxisConfig::Manual {
446 horizontal: AxisConfiguration::Clipped(ClippedAxisConfiguration {
447 handle: horizontal,
448 max_size: None,
449 stretch_child: false,
450 }),
451 vertical: AxisConfiguration::Manual(vertical),
452 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
453 .finish_scrollable(),
454 };
455 
456 NewScrollable::horizontal_and_vertical(
457 axis_config,
458 ColorU::white().into(),
459 ColorU::white().into(),
460 ColorU::new(100, 100, 100, 255).into(),
461 )
462 .with_horizontal_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
463 .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
464 }
465 (Some(ScrollBehavior::Manual(horizontal)), Some(ScrollBehavior::Manual(vertical))) => {
466 let axis_config = DualAxisConfig::Manual {
467 horizontal: AxisConfiguration::Manual(horizontal),
468 vertical: AxisConfiguration::Manual(vertical),
469 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
470 .finish_scrollable(),
471 };
472 
473 NewScrollable::horizontal_and_vertical(
474 axis_config,
475 ColorU::white().into(),
476 ColorU::white().into(),
477 ColorU::new(100, 100, 100, 255).into(),
478 )
479 .with_horizontal_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
480 .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
481 }
482 (Some(ScrollBehavior::Clipped(horizontal)), None) => {
483 let axis_config = SingleAxisConfig::Clipped {
484 handle: horizontal,
485 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
486 .finish(),
487 };
488 
489 NewScrollable::horizontal(
490 axis_config,
491 ColorU::white().into(),
492 ColorU::white().into(),
493 ColorU::new(100, 100, 100, 255).into(),
494 )
495 .with_horizontal_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
496 }
497 (Some(ScrollBehavior::Manual(horizontal)), None) => {
498 let axis_config = SingleAxisConfig::Manual {
499 handle: horizontal,
500 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
501 .finish_scrollable(),
502 };
503 
504 NewScrollable::horizontal(
505 axis_config,
506 ColorU::white().into(),
507 ColorU::white().into(),
508 ColorU::new(100, 100, 100, 255).into(),
509 )
510 .with_horizontal_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
511 }
512 (None, Some(ScrollBehavior::Manual(vertical))) => {
513 let axis_config = SingleAxisConfig::Manual {
514 handle: vertical,
515 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
516 .finish_scrollable(),
517 };
518 
519 NewScrollable::vertical(
520 axis_config,
521 ColorU::white().into(),
522 ColorU::white().into(),
523 ColorU::new(100, 100, 100, 255).into(),
524 )
525 .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
526 }
527 (None, Some(ScrollBehavior::Clipped(vertical))) => {
528 let axis_config = SingleAxisConfig::Clipped {
529 handle: vertical,
530 child: ScrollableElement::new(self.scroll_top, self.scroll_left, elements)
531 .finish(),
532 };
533 
534 NewScrollable::vertical(
535 axis_config,
536 ColorU::white().into(),
537 ColorU::white().into(),
538 ColorU::new(100, 100, 100, 255).into(),
539 )
540 .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false))
541 }
542 (None, None) => panic!("Invalid test configuration"),
543 };
544 
545 let constrained = ConstrainedBox::new(element.finish())
546 .with_height(SCROLLABLE_VIEWPORT_SIZE)
547 .with_width(SCROLLABLE_VIEWPORT_SIZE);
548 
549 Stack::new()
550 .with_child(Rect::new().with_background_color(ColorU::black()).finish())
551 .with_child(constrained.finish())
552 .finish()
553 }
554 
555 fn ui_name() -> &'static str {
556 "View"
557 }
558}
559 
560impl TypedActionView for BasicScrollableView {
561 type Action = ();
562}
563 
564fn render(presenter: &mut Presenter, view_id: EntityId, ctx: &mut AppContext) {
565 let mut updated = HashSet::new();
566 updated.insert(view_id);
567 let invalidation = WindowInvalidation {
568 updated,
569 ..Default::default()
570 };
571 
572 presenter.invalidate(invalidation, ctx);
573 presenter.build_scene(vec2f(1000., 1000.), 1., None, ctx);
574}
575 
576#[test]
577fn clipped_scrollable_selection_apis_use_viewport_coordinates() {
578 let probe = SelectableProbeState::default();
579 let scrollable = test_clipped_horizontal_scrollable_with_probe(probe.clone(), 64.0);
580 let start = vec2f(180.0, 24.0);
581 let end = vec2f(220.0, 24.0);
582 
583 let fragments = scrollable
584 .get_selection(start, end, IsRect::False)
585 .expect("probe selection should succeed");
586 assert_eq!(fragments[0].text, "probe");
587 assert_eq!(
588 probe.get_selection_args.borrow().as_slice(),
589 &[(start, end, IsRect::False)]
590 );
591 
592 let expanded = scrollable
593 .expand_selection(
594 start,
595 SelectionDirection::Forward,
596 SelectionType::Semantic,
597 &WordBoundariesPolicy::Default,
598 )
599 .expect("probe expansion should succeed");
600 assert_eq!(expanded, start + vec2f(5.0, 0.0));
601 let expand_args = probe.expand_selection_args.borrow();
602 assert_eq!(expand_args.len(), 1);
603 assert_eq!(expand_args[0].0, start);
604 assert!(matches!(expand_args[0].1, SelectionDirection::Forward));
605 assert!(matches!(expand_args[0].2, SelectionType::Semantic));
606 
607 let is_before = scrollable
608 .is_point_semantically_before(start, end)
609 .expect("probe semantic comparison should succeed");
610 assert!(is_before);
611 assert_eq!(
612 probe.semantic_order_args.borrow().as_slice(),
613 &[(start, end)]
614 );
615 
616 let smart_selection = scrollable
617 .smart_select(start, select_entire_probe_text)
618 .expect("probe smart select should succeed");
619 assert_eq!(smart_selection, (start, start + vec2f(12.0, 0.0)));
620 assert_eq!(probe.smart_select_args.borrow().as_slice(), &[start]);
621}
622 
623#[test]
624fn clipped_scrollable_reanchors_existing_selection_after_horizontal_scroll() {
625 let probe = SelectableProbeState::default();
626 let handle = ClippedScrollStateHandle::default();
627 handle.scroll_to(Pixels::new(64.0));
628 let selection = crate::elements::Selection {
629 start: vec2f(180.0, 24.0),
630 end: vec2f(220.0, 24.0),
631 is_rect: IsRect::False,
632 };
633 
634 let scrollable =
635 test_clipped_horizontal_scrollable_with_probe_handle(probe.clone(), handle.clone());
636 scrollable
637 .get_selection(selection.start, selection.end, selection.is_rect)
638 .expect("initial probe selection should succeed");
639 assert_eq!(
640 probe.get_selection_args.borrow().last().copied(),
641 Some((selection.start, selection.end, selection.is_rect))
642 );
643 
644 handle.scroll_to(Pixels::new(96.0));
645 let scrollable = test_clipped_horizontal_scrollable_with_probe_handle(probe.clone(), handle);
646 scrollable
647 .get_selection(selection.start, selection.end, selection.is_rect)
648 .expect("reanchored probe selection should succeed");
649 assert_eq!(
650 probe.get_selection_args.borrow().last().copied(),
651 Some((vec2f(148.0, 24.0), vec2f(188.0, 24.0), IsRect::False))
652 );
653 
654 scrollable.calculate_clickable_bounds(Some(selection));
655 let clickable_bounds_args = probe.clickable_bounds_args.borrow();
656 let latest_selection = clickable_bounds_args
657 .last()
658 .copied()
659 .flatten()
660 .expect("scrollable should forward adjusted clickable-bounds selection");
661 assert_eq!(latest_selection.start, vec2f(148.0, 24.0));
662 assert_eq!(latest_selection.end, vec2f(188.0, 24.0));
663 assert_eq!(latest_selection.is_rect, IsRect::False);
664}
665 
666#[test]
667fn clearing_scroll_anchor_treats_same_viewport_selection_as_new_content() {
668 let probe = SelectableProbeState::default();
669 let handle = ClippedScrollStateHandle::default();
670 handle.scroll_to(Pixels::new(64.0));
671 let selection = crate::elements::Selection {
672 start: vec2f(180.0, 24.0),
673 end: vec2f(220.0, 24.0),
674 is_rect: IsRect::False,
675 };
676 
677 let scrollable =
678 test_clipped_horizontal_scrollable_with_probe_handle(probe.clone(), handle.clone());
679 scrollable
680 .get_selection(selection.start, selection.end, selection.is_rect)
681 .expect("initial probe selection should succeed");
682 
683 handle.scroll_to(Pixels::new(96.0));
684 let scrollable = test_clipped_horizontal_scrollable_with_probe_handle(probe.clone(), handle);
685 scrollable.clear_selection_scroll_anchor();
686 scrollable
687 .get_selection(selection.start, selection.end, selection.is_rect)
688 .expect("selection after anchor clear should use current viewport coordinates");
689 
690 assert_eq!(
691 probe.get_selection_args.borrow().last().copied(),
692 Some((selection.start, selection.end, selection.is_rect))
693 );
694}
695 
696#[test]
697fn test_click_to_scroll_dual() {
698 App::test((), |mut app| async move {
699 let app = &mut app;
700 app.update(init);
701 
702 let dual_configurations = [
703 (
704 ScrollBehavior::Clipped(Default::default()),
705 ScrollBehavior::Clipped(Default::default()),
706 ),
707 (
708 ScrollBehavior::Manual(Default::default()),
709 ScrollBehavior::Clipped(Default::default()),
710 ),
711 (
712 ScrollBehavior::Clipped(Default::default()),
713 ScrollBehavior::Manual(Default::default()),
714 ),
715 (
716 ScrollBehavior::Manual(Default::default()),
717 ScrollBehavior::Manual(Default::default()),
718 ),
719 ];
720 
721 for (x_config, y_config) in dual_configurations {
722 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
723 BasicScrollableView::new(Some(x_config), Some(y_config))
724 });
725 
726 let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
727 let view_id = app.root_view_id(window_id).unwrap();
728 
729 app.update(move |ctx| {
730 render(&mut presenter.borrow_mut(), view_id, ctx);
731 
732 // Fire event on child (0, 0)
733 ctx.simulate_window_event(
734 Event::LeftMouseDown {
735 position: vec2f(
736 CHILD_EVENT_HANDLER_DIMENSION * 0.5,
737 CHILD_EVENT_HANDLER_DIMENSION * 0.5,
738 ),
739 modifiers: Default::default(),
740 click_count: 1,
741 is_first_mouse: false,
742 },
743 window_id,
744 presenter.clone(),
745 );
746 
747 // Fire event on child (2, 1)
748 ctx.simulate_window_event(
749 Event::LeftMouseDown {
750 position: vec2f(
751 CHILD_EVENT_HANDLER_DIMENSION * 2.5,
752 CHILD_EVENT_HANDLER_DIMENSION * 1.5,
753 ),
754 modifiers: Default::default(),
755 click_count: 1,
756 is_first_mouse: false,
757 },
758 window_id,
759 presenter.clone(),
760 );
761 
762 // Click on the vertical scrollbar track. This should scroll the view down.
763 ctx.simulate_window_event(
764 Event::LeftMouseDown {
765 position: vec2f(
766 CHILD_EVENT_HANDLER_DIMENSION * 5.0 - ScrollbarWidth::Auto.as_f32(),
767 CHILD_EVENT_HANDLER_DIMENSION * 4.5,
768 ),
769 modifiers: Default::default(),
770 click_count: 1,
771 is_first_mouse: false,
772 },
773 window_id,
774 presenter.clone(),
775 );
776 
777 // Click on the horizontal scrollbar track. This should scroll the view right.
778 ctx.simulate_window_event(
779 Event::LeftMouseDown {
780 position: vec2f(
781 CHILD_EVENT_HANDLER_DIMENSION * 4.5,
782 CHILD_EVENT_HANDLER_DIMENSION * 5.0 - ScrollbarWidth::Auto.as_f32(),
783 ),
784 modifiers: Default::default(),
785 click_count: 1,
786 is_first_mouse: false,
787 },
788 window_id,
789 presenter.clone(),
790 );
791 });
792 
793 view.read(app, |view, _ctx| {
794 for (coord, count) in view.mouse_downs.iter() {
795 match coord {
796 (0, 0) | (2, 1) => assert_eq!(1, *count),
797 _ => assert_eq!(0, *count),
798 }
799 }
800 
801 match view.vertical_axis.clone().unwrap() {
802 ScrollBehavior::Clipped(handle) => {
803 assert!(handle.scroll_start().as_f32() > 0.)
804 }
805 ScrollBehavior::Manual(_) => assert!(view.scroll_top > 0.),
806 };
807 
808 match view.horizontal_axis.clone().unwrap() {
809 ScrollBehavior::Clipped(handle) => {
810 assert!(handle.scroll_start().as_f32() > 0.)
811 }
812 ScrollBehavior::Manual(_) => assert!(view.scroll_left > 0.),
813 };
814 });
815 
816 app.update(|ctx| {
817 ctx.windows()
818 .close_window(window_id, TerminationMode::ForceTerminate)
819 });
820 }
821 })
822}
823 
824#[test]
825fn test_click_to_scroll_horizontal() {
826 App::test((), |mut app| async move {
827 let app = &mut app;
828 app.update(init);
829 
830 let configurations = [
831 ScrollBehavior::Manual(Default::default()),
832 ScrollBehavior::Clipped(Default::default()),
833 ];
834 
835 for config in configurations {
836 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
837 BasicScrollableView::new(Some(config), None)
838 });
839 
840 let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
841 let view_id = app.root_view_id(window_id).unwrap();
842 
843 app.update(move |ctx| {
844 render(&mut presenter.borrow_mut(), view_id, ctx);
845 
846 // Fire event on child (0, 0)
847 ctx.simulate_window_event(
848 Event::LeftMouseDown {
849 position: vec2f(
850 CHILD_EVENT_HANDLER_DIMENSION * 0.5,
851 CHILD_EVENT_HANDLER_DIMENSION * 0.5,
852 ),
853 modifiers: Default::default(),
854 click_count: 1,
855 is_first_mouse: false,
856 },
857 window_id,
858 presenter.clone(),
859 );
860 
861 // Fire event on child (2, 1)
862 ctx.simulate_window_event(
863 Event::LeftMouseDown {
864 position: vec2f(
865 CHILD_EVENT_HANDLER_DIMENSION * 2.5,
866 CHILD_EVENT_HANDLER_DIMENSION * 1.5,
867 ),
868 modifiers: Default::default(),
869 click_count: 1,
870 is_first_mouse: false,
871 },
872 window_id,
873 presenter.clone(),
874 );
875 
876 // Click on the vertical scrollbar track. This should NOT scroll the view down.
877 ctx.simulate_window_event(
878 Event::LeftMouseDown {
879 position: vec2f(
880 CHILD_EVENT_HANDLER_DIMENSION * 5.0 - ScrollbarWidth::Auto.as_f32(),
881 CHILD_EVENT_HANDLER_DIMENSION * 4.5,
882 ),
883 modifiers: Default::default(),
884 click_count: 1,
885 is_first_mouse: false,
886 },
887 window_id,
888 presenter.clone(),
889 );
890 
891 // Click on the horizontal scrollbar track. This should scroll the view right.
892 ctx.simulate_window_event(
893 Event::LeftMouseDown {
894 position: vec2f(
895 CHILD_EVENT_HANDLER_DIMENSION * 4.5,
896 CHILD_EVENT_HANDLER_DIMENSION * 5.0 - ScrollbarWidth::Auto.as_f32(),
897 ),
898 modifiers: Default::default(),
899 click_count: 1,
900 is_first_mouse: false,
901 },
902 window_id,
903 presenter.clone(),
904 );
905 });
906 
907 view.read(app, |view, _ctx| {
908 for (coord, count) in view.mouse_downs.iter() {
909 match coord {
910 (0, 0) | (2, 1) | (4, 4) => assert_eq!(1, *count),
911 _ => assert_eq!(0, *count),
912 }
913 }
914 
915 match view.horizontal_axis.clone().unwrap() {
916 ScrollBehavior::Clipped(handle) => {
917 assert!(handle.scroll_start().as_f32() > 0.)
918 }
919 ScrollBehavior::Manual(_) => assert!(view.scroll_left > 0.),
920 };
921 });
922 
923 app.update(|ctx| {
924 ctx.windows()
925 .close_window(window_id, TerminationMode::ForceTerminate)
926 });
927 }
928 })
929}
930 
931#[test]
932fn test_click_to_scroll_vertical() {
933 App::test((), |mut app| async move {
934 let app = &mut app;
935 app.update(init);
936 
937 let configurations = [
938 ScrollBehavior::Manual(Default::default()),
939 ScrollBehavior::Clipped(Default::default()),
940 ];
941 
942 for config in configurations {
943 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
944 BasicScrollableView::new(None, Some(config))
945 });
946 
947 let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
948 let view_id = app.root_view_id(window_id).unwrap();
949 
950 app.update(move |ctx| {
951 render(&mut presenter.borrow_mut(), view_id, ctx);
952 
953 // Fire event on child (0, 0)
954 ctx.simulate_window_event(
955 Event::LeftMouseDown {
956 position: vec2f(
957 CHILD_EVENT_HANDLER_DIMENSION * 0.5,
958 CHILD_EVENT_HANDLER_DIMENSION * 0.5,
959 ),
960 modifiers: Default::default(),
961 click_count: 1,
962 is_first_mouse: false,
963 },
964 window_id,
965 presenter.clone(),
966 );
967 
968 // Fire event on child (2, 1)
969 ctx.simulate_window_event(
970 Event::LeftMouseDown {
971 position: vec2f(
972 CHILD_EVENT_HANDLER_DIMENSION * 2.5,
973 CHILD_EVENT_HANDLER_DIMENSION * 1.5,
974 ),
975 modifiers: Default::default(),
976 click_count: 1,
977 is_first_mouse: false,
978 },
979 window_id,
980 presenter.clone(),
981 );
982 
983 // Click on the vertical scrollbar track. This should scroll the view down.
984 ctx.simulate_window_event(
985 Event::LeftMouseDown {
986 position: vec2f(
987 CHILD_EVENT_HANDLER_DIMENSION * 5.0 - ScrollbarWidth::Auto.as_f32(),
988 CHILD_EVENT_HANDLER_DIMENSION * 4.5,
989 ),
990 modifiers: Default::default(),
991 click_count: 1,
992 is_first_mouse: false,
993 },
994 window_id,
995 presenter.clone(),
996 );
997 
998 // Click on the horizontal scrollbar track. This should NOT scroll the view right.
999 ctx.simulate_window_event(
1000 Event::LeftMouseDown {
1001 position: vec2f(
1002 CHILD_EVENT_HANDLER_DIMENSION * 4.5,
1003 CHILD_EVENT_HANDLER_DIMENSION * 5.0 - ScrollbarWidth::Auto.as_f32(),
1004 ),
1005 modifiers: Default::default(),
1006 click_count: 1,
1007 is_first_mouse: false,
1008 },
1009 window_id,
1010 presenter.clone(),
1011 );
1012 });
1013 
1014 view.read(app, |view, _ctx| {
1015 for (coord, count) in view.mouse_downs.iter() {
1016 match coord {
1017 (0, 0) | (2, 1) | (4, 4) => assert_eq!(1, *count),
1018 _ => assert_eq!(0, *count),
1019 }
1020 }
1021 
1022 match view.vertical_axis.clone().unwrap() {
1023 ScrollBehavior::Clipped(handle) => {
1024 assert!(handle.scroll_start().as_f32() > 0.)
1025 }
1026 ScrollBehavior::Manual(_) => assert!(view.scroll_top > 0.),
1027 };
1028 });
1029 
1030 app.update(|ctx| {
1031 ctx.windows()
1032 .close_window(window_id, TerminationMode::ForceTerminate)
1033 });
1034 }
1035 })
1036}
1037 
1038#[test]
1039fn test_scroll_to_position_dual() {
1040 App::test((), |mut app| async move {
1041 let app = &mut app;
1042 app.update(init);
1043 
1044 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
1045 BasicScrollableView::new(
1046 Some(ScrollBehavior::Clipped(Default::default())),
1047 Some(ScrollBehavior::Clipped(Default::default())),
1048 )
1049 });
1050 
1051 let mut presenter = Presenter::new(window_id);
1052 let view_id = app.root_view_id(window_id).unwrap();
1053 
1054 app.update(|ctx| {
1055 render(&mut presenter, view_id, ctx);
1056 });
1057 
1058 view.read(app, |view, _| {
1059 let (horizontal, vertical) = get_scroll_handles(view);
1060 assert_eq!(horizontal.scroll_start().as_f32(), 0.);
1061 assert_eq!(vertical.scroll_start().as_f32(), 0.);
1062 vertical.scroll_to_position(ScrollTarget {
1063 position_id: "child-4-8".to_owned(),
1064 mode: ScrollToPositionMode::FullyIntoView,
1065 });
1066 });
1067 
1068 app.update(|ctx| {
1069 render(&mut presenter, view_id, ctx);
1070 });
1071 
1072 view.read(app, |view, _| {
1073 let (horizontal, vertical) = get_scroll_handles(view);
1074 assert_eq!(horizontal.scroll_start().as_f32(), 0.);
1075 assert_eq!(
1076 vertical.scroll_start().as_f32(),
1077 position_for_child(8, Boundary::End)
1078 );
1079 vertical.scroll_to_position(ScrollTarget {
1080 position_id: "child-8-2".to_owned(),
1081 mode: ScrollToPositionMode::FullyIntoView,
1082 });
1083 });
1084 
1085 app.update(|ctx| {
1086 render(&mut presenter, view_id, ctx);
1087 });
1088 
1089 view.read(app, |view, _| {
1090 let (horizontal, vertical) = get_scroll_handles(view);
1091 assert_eq!(horizontal.scroll_start().as_f32(), 0.);
1092 assert_eq!(
1093 vertical.scroll_start().as_f32(),
1094 position_for_child(2, Boundary::Start)
1095 );
1096 horizontal.scroll_to_position(ScrollTarget {
1097 position_id: "child-6-3".to_owned(),
1098 mode: ScrollToPositionMode::FullyIntoView,
1099 });
1100 vertical.scroll_to_position(ScrollTarget {
1101 position_id: "child-6-3".to_owned(),
1102 mode: ScrollToPositionMode::FullyIntoView,
1103 });
1104 });
1105 
1106 app.update(|ctx| {
1107 render(&mut presenter, view_id, ctx);
1108 });
1109 
1110 view.read(app, |view, _| {
1111 let (horizontal, vertical) = get_scroll_handles(view);
1112 assert_eq!(
1113 horizontal.scroll_start().as_f32(),
1114 position_for_child(6, Boundary::End)
1115 );
1116 assert_eq!(
1117 vertical.scroll_start().as_f32(),
1118 position_for_child(2, Boundary::Start)
1119 );
1120 });
1121 })
1122}
1123 
1124fn get_scroll_handles(
1125 view: &BasicScrollableView,
1126) -> (&ClippedScrollStateHandle, &ClippedScrollStateHandle) {
1127 let Some((ScrollBehavior::Clipped(horizontal), ScrollBehavior::Clipped(vertical))) = view
1128 .horizontal_axis
1129 .as_ref()
1130 .zip(view.vertical_axis.as_ref())
1131 else {
1132 panic!("invalid test config");
1133 };
1134 (horizontal, vertical)
1135}
1136 
1137#[test]
1138fn test_scroll_to_position_horizontal() {
1139 App::test((), |mut app| async move {
1140 let app = &mut app;
1141 app.update(init);
1142 
1143 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
1144 BasicScrollableView::new(Some(ScrollBehavior::Clipped(Default::default())), None)
1145 });
1146 
1147 let mut presenter = Presenter::new(window_id);
1148 let view_id = app.root_view_id(window_id).unwrap();
1149 
1150 app.update(|ctx| {
1151 render(&mut presenter, view_id, ctx);
1152 });
1153 
1154 view.read(app, |view, _| {
1155 let Some(ScrollBehavior::Clipped(handle)) = view.horizontal_axis.as_ref() else {
1156 panic!("invalid test config");
1157 };
1158 assert_eq!(handle.scroll_start().as_f32(), 0.);
1159 handle.scroll_to_position(ScrollTarget {
1160 position_id: "child-4-2".to_owned(),
1161 mode: ScrollToPositionMode::FullyIntoView,
1162 });
1163 });
1164 
1165 app.update(|ctx| {
1166 render(&mut presenter, view_id, ctx);
1167 });
1168 
1169 view.read(app, |view, _| {
1170 let Some(ScrollBehavior::Clipped(handle)) = view.horizontal_axis.as_ref() else {
1171 panic!("invalid test config");
1172 };
1173 assert_eq!(handle.scroll_start().as_f32(), 0.);
1174 handle.scroll_to_position(ScrollTarget {
1175 position_id: "child-5-2".to_owned(),
1176 mode: ScrollToPositionMode::FullyIntoView,
1177 });
1178 });
1179 
1180 app.update(|ctx| {
1181 render(&mut presenter, view_id, ctx);
1182 });
1183 
1184 view.read(app, |view, _| {
1185 let Some(ScrollBehavior::Clipped(handle)) = view.horizontal_axis.as_ref() else {
1186 panic!("invalid test config");
1187 };
1188 assert_eq!(
1189 handle.scroll_start().as_f32(),
1190 position_for_child(5, Boundary::End)
1191 );
1192 handle.scroll_to_position(ScrollTarget {
1193 position_id: "child-0-0".to_owned(),
1194 mode: ScrollToPositionMode::FullyIntoView,
1195 });
1196 });
1197 
1198 app.update(|ctx| {
1199 render(&mut presenter, view_id, ctx);
1200 });
1201 
1202 view.read(app, |view, _| {
1203 let Some(ScrollBehavior::Clipped(handle)) = view.horizontal_axis.as_ref() else {
1204 panic!("invalid test config");
1205 };
1206 assert_eq!(handle.scroll_start().as_f32(), 0.);
1207 });
1208 })
1209}
1210 
1211#[test]
1212fn test_scroll_to_position_vertical() {
1213 App::test((), |mut app| async move {
1214 let app = &mut app;
1215 app.update(init);
1216 
1217 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
1218 BasicScrollableView::new(None, Some(ScrollBehavior::Clipped(Default::default())))
1219 });
1220 
1221 let mut presenter = Presenter::new(window_id);
1222 let view_id = app.root_view_id(window_id).unwrap();
1223 
1224 app.update(|ctx| {
1225 render(&mut presenter, view_id, ctx);
1226 });
1227 
1228 view.read(app, |view, _| {
1229 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1230 panic!("invalid test config");
1231 };
1232 assert_eq!(handle.scroll_start().as_f32(), 0.);
1233 handle.scroll_to_position(ScrollTarget {
1234 position_id: "child-1-9".to_owned(),
1235 mode: ScrollToPositionMode::FullyIntoView,
1236 });
1237 });
1238 
1239 app.update(|ctx| {
1240 render(&mut presenter, view_id, ctx);
1241 });
1242 
1243 view.read(app, |view, _| {
1244 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1245 panic!("invalid test config");
1246 };
1247 assert_eq!(
1248 handle.scroll_start().as_f32(),
1249 position_for_child(9, Boundary::End)
1250 );
1251 handle.scroll_to_position(ScrollTarget {
1252 position_id: "child-3-6".to_owned(),
1253 mode: ScrollToPositionMode::FullyIntoView,
1254 });
1255 });
1256 
1257 app.update(|ctx| {
1258 render(&mut presenter, view_id, ctx);
1259 });
1260 
1261 view.read(app, |view, _| {
1262 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1263 panic!("invalid test config");
1264 };
1265 assert_eq!(
1266 handle.scroll_start().as_f32(),
1267 position_for_child(9, Boundary::End)
1268 );
1269 // This example is subtly different from the rest b/c child (4, 3) is partially
1270 // clipped on the right by the scrollbar gutter. That clipping shouldn't affect
1271 // vertical scrolling.
1272 handle.scroll_to_position(ScrollTarget {
1273 position_id: "child-4-3".to_owned(),
1274 mode: ScrollToPositionMode::FullyIntoView,
1275 });
1276 });
1277 
1278 app.update(|ctx| {
1279 render(&mut presenter, view_id, ctx);
1280 });
1281 
1282 view.read(app, |view, _| {
1283 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1284 panic!("invalid test config");
1285 };
1286 assert_eq!(
1287 handle.scroll_start().as_f32(),
1288 position_for_child(3, Boundary::Start)
1289 );
1290 });
1291 })
1292}
1293 
1294enum Boundary {
1295 Start,
1296 End,
1297}
1298 
1299/// Returns what the scroll_start value should be to have the child square at the edge of the
1300/// viewport (either the start or the end).
1301///
1302/// For example, if we want to scroll the x-axis to child (6, 1) at the end, we need to set
1303/// scroll_start to 100px:
1304/// ```
1305/// assert_eq!(position_for_child(6, Boundary::End), 100.);
1306/// ```
1307/// Viewport
1308/// 100px┌──────┴───────┐
1309/// ┌──┴──┐
1310///
1311/// 0 1 2 3 4 5 6 7 8 9
1312/// ┌──┬──┲━━┯━━┯━━┯━━┯━━┱──┬──┬──┐ ┐
1313/// 0│ │ ┃ │ │ │ │ ┃ │ │ │ │
1314/// ├──┼──╂──┼──┼──┼──┼──╂──┼──┼──┤ │
1315/// 1│ │ ┃ │ │ │ │**┃ │ │ │ │
1316/// ├──┼──╂──┼──┼──┼──┼──╂──┼──┼──┤ │
1317/// 2│ │ ┃ │ │ │ │ ┃ │ │ │ ├─Viewport
1318/// ├──┼──╂──┼──┼──┼──┼──╂──┼──┼──┤ │
1319/// 3│ │ ┃ │ │ │ │ ┃ │ │ │ │
1320/// ├──┼──╂──┼──┼──┼──┼──╂──┼──┼──┤ │
1321/// 4│ │ ┃ │ │ │ │ ┃ │ │ │ │
1322/// ├──┼──╄━━┿━━┿━━┿━━┿━━╃──┼──┼──┤ ┘
1323/// 5│ │ │ │ │ │ │ │ │ │ │
1324/// ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤
1325/// 6│ │ │ │ │ │ │ │ │ │ │
1326/// ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤
1327/// 7│ │ │ │ │ │ │ │ │ │ │
1328/// ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤
1329/// 8│ │ │ │ │ │ │ │ │ │ │
1330/// ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤
1331/// 9│ │ │ │ │ │ │ │ │ │ │
1332/// └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
1333fn position_for_child(i: usize, boundary: Boundary) -> f32 {
1334 let mut pos = CHILD_EVENT_HANDLER_DIMENSION * i as f32;
1335 if let Boundary::End = boundary {
1336 pos -= SCROLLABLE_VIEWPORT_SIZE - CHILD_EVENT_HANDLER_DIMENSION;
1337 }
1338 pos.clamp(0., SCROLLABLE_VIEWPORT_SIZE)
1339}
1340 
1341/// Validates that `scroll_position_top_into_view` stabilizes after one scroll:
1342/// scrolling to a child whose full bounds extend past the viewport should bring
1343/// the child's top edge into view and not oscillate on repeated calls.
1344#[test]
1345fn test_scroll_position_top_into_view_does_not_alternate() {
1346 App::test((), |mut app| async move {
1347 let app = &mut app;
1348 app.update(init);
1349 
1350 let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
1351 BasicScrollableView::new(None, Some(ScrollBehavior::Clipped(Default::default())))
1352 });
1353 
1354 let mut presenter = Presenter::new(window_id);
1355 let view_id = app.root_view_id(window_id).unwrap();
1356 
1357 app.update(|ctx| {
1358 render(&mut presenter, view_id, ctx);
1359 });
1360 
1361 // Scroll to child (1,9). Its top is at y=450 (row 9 * 50px).
1362 // Viewport is 250px. The raw delta is 450, but the next layout
1363 // clamps scroll_start to max_scroll = 500 - 250 = 250.
1364 // We need a second render pass to let layout clamping settle.
1365 view.read(app, |view, _| {
1366 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1367 panic!("invalid test config");
1368 };
1369 assert_eq!(handle.scroll_start().as_f32(), 0.);
1370 handle.scroll_to_position(ScrollTarget {
1371 position_id: "child-1-9".to_owned(),
1372 mode: ScrollToPositionMode::TopIntoView,
1373 });
1374 });
1375 
1376 // First render: paint applies the scroll. Layout on the next render
1377 // will clamp to max_scroll.
1378 app.update(|ctx| {
1379 render(&mut presenter, view_id, ctx);
1380 });
1381 
1382 // Second render: layout clamps scroll_start from 450 to 250.
1383 app.update(|ctx| {
1384 render(&mut presenter, view_id, ctx);
1385 });
1386 
1387 let scroll_after_settled = view.read(app, |view, _| {
1388 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1389 panic!("invalid test config");
1390 };
1391 let pos = handle.scroll_start().as_f32();
1392 assert!(pos > 0., "should have scrolled down");
1393 pos
1394 });
1395 
1396 // Call scroll_to_position with TopIntoView again for the same element.
1397 // After clamping, the top of child (1,9) is at y = 450 - 250 = 200,
1398 // which is within the viewport [0, 250]. No scroll should happen.
1399 view.read(app, |view, _| {
1400 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1401 panic!("invalid test config");
1402 };
1403 handle.scroll_to_position(ScrollTarget {
1404 position_id: "child-1-9".to_owned(),
1405 mode: ScrollToPositionMode::TopIntoView,
1406 });
1407 });
1408 
1409 app.update(|ctx| {
1410 render(&mut presenter, view_id, ctx);
1411 });
1412 
1413 view.read(app, |view, _| {
1414 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1415 panic!("invalid test config");
1416 };
1417 assert_eq!(
1418 handle.scroll_start().as_f32(),
1419 scroll_after_settled,
1420 "scroll position should not change on repeated calls"
1421 );
1422 });
1423 
1424 // A third call should also be stable.
1425 view.read(app, |view, _| {
1426 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1427 panic!("invalid test config");
1428 };
1429 handle.scroll_to_position(ScrollTarget {
1430 position_id: "child-1-9".to_owned(),
1431 mode: ScrollToPositionMode::TopIntoView,
1432 });
1433 });
1434 
1435 app.update(|ctx| {
1436 render(&mut presenter, view_id, ctx);
1437 });
1438 
1439 view.read(app, |view, _| {
1440 let Some(ScrollBehavior::Clipped(handle)) = view.vertical_axis.as_ref() else {
1441 panic!("invalid test config");
1442 };
1443 assert_eq!(
1444 handle.scroll_start().as_f32(),
1445 scroll_after_settled,
1446 "scroll position should remain stable on third call"
1447 );
1448 });
1449 })
1450}
1451