1use std::cell::RefCell;
2use std::rc::Rc;
3
4use geometry_core::Rect;
5use layout_core::{AvailableSpace, LayoutError, LayoutStyle, NodeId};
6use platform_core::Event;
7use reactive_core::{Effect, ReadSignal, RwSignal, effect, signal};
8use renderer_core::{BorderRadius, Color, RectStyle, ShapeStyle};
9use theme_core::use_theme_tokens;
10use ui_tree::{Component, EventResult, RenderNode, Segment};
11
12use ui_tree::NodeVec;
13
14use crate::context::track_layout;
15use crate::impl_leaf_widget;
16use crate::kept::kept;
17use crate::layout_item::{LayoutItem, mount_item_segment};
18use crate::layout_leaf::LayoutLeaf;
19use crate::pointer::{clip_pointer_event, offset_pointer};
20use crate::scroll_region::{ScrollRegionId, register_scroll_region, unregister_scroll_region};
21
22pub struct ScrollbarStyle {
23 pub color: Color,
24 pub width: f32,
25 pub corner_radius: f32,
26}
27
28impl Default for ScrollbarStyle {
29 fn default() -> Self {
30 let color = use_theme_tokens()
31 .map(|t| t.scrollbar())
32 .unwrap_or(Color::rgba(0.5, 0.5, 0.6, 0.6));
33 Self {
34 color,
35 width: 8.0,
36 corner_radius: 3.0,
37 }
38 }
39}
40
41fn draw_scrollbars(
42 viewport: Rect,
43 scroll_x: f32,
44 scroll_y: f32,
45 content_rect: Rect,
46 scrollbar_style: &ScrollbarStyle,
47) -> (RenderNode, RenderNode) {
48 let vbar = if content_rect.height > viewport.height {
49 let bar_h = (viewport.height / content_rect.height * viewport.height).max(24.0);
50 let max_scroll = (content_rect.height - viewport.height).max(1.0);
51 let bar_y = viewport.y + (scroll_y / max_scroll) * (viewport.height - bar_h);
52 RenderNode::rect(
53 Rect::new(
54 viewport.x + viewport.width - scrollbar_style.width,
55 bar_y,
56 scrollbar_style.width - 2.0,
57 bar_h,
58 ),
59 RectStyle::default()
60 .with_fill(scrollbar_style.color)
61 .with_radius(BorderRadius::all(scrollbar_style.corner_radius)),
62 )
63 } else {
64 RenderNode::Empty
65 };
66
67 let hbar = if content_rect.width > viewport.width {
68 let bar_w = (viewport.width / content_rect.width * viewport.width).max(24.0);
69 let max_scroll_x = (content_rect.width - viewport.width).max(1.0);
70 let bar_x = viewport.x + (scroll_x / max_scroll_x) * (viewport.width - bar_w);
71 RenderNode::rect(
72 Rect::new(
73 bar_x,
74 viewport.y + viewport.height - scrollbar_style.width,
75 bar_w,
76 scrollbar_style.width - 2.0,
77 ),
78 RectStyle::default()
79 .with_fill(scrollbar_style.color)
80 .with_radius(BorderRadius::all(scrollbar_style.corner_radius)),
81 )
82 } else {
83 RenderNode::Empty
84 };
85
86 (vbar, hbar)
87}
88
89fn handle_scroll_event(
90 event: &Event,
91 viewport: Rect,
92 scroll_x: RwSignal<f32>,
93 scroll_y: RwSignal<f32>,
94 content_rect_signal: RwSignal<Rect>,
95 content: &Rc<RefCell<Box<dyn LayoutItem>>>,
96) -> EventResult {
97 if let Event::Scrolled { delta, x, y } = event {
98 if !viewport.contains(*x as f32, *y as f32) {
100 return EventResult::Ignored;
101 }
102 let inner = offset_pointer(
105 event,
106 viewport.x as f64 - scroll_x.get() as f64,
107 viewport.y as f64 - scroll_y.get() as f64,
108 );
109 if content
110 .borrow_mut()
111 .on_event(inner.as_ref().unwrap_or(event))
112 == EventResult::Handled
113 {
114 return EventResult::Handled;
115 }
116 let (delta_x, delta_y) = delta.pixels();
117 let content_rect = content_rect_signal.get();
118 let max_scroll_x = (content_rect.width - viewport.width).max(0.0);
119 let max_scroll_y = (content_rect.height - viewport.height).max(0.0);
120 scroll_x.set((scroll_x.get() - delta_x).clamp(0.0, max_scroll_x));
121 scroll_y.set((scroll_y.get() - delta_y).clamp(0.0, max_scroll_y));
122 return EventResult::Handled;
123 }
124
125 let Some(event) = clip_pointer_event(event, viewport) else {
126 return EventResult::Ignored;
127 };
128
129 let scroll_offset_x = scroll_x.get() as f64;
130 let scroll_offset_y = scroll_y.get() as f64;
131 let adjusted = offset_pointer(
132 event,
133 viewport.x as f64 - scroll_offset_x,
134 viewport.y as f64 - scroll_offset_y,
135 );
136 let effective = adjusted.as_ref().unwrap_or(event);
137 content.borrow_mut().on_event(effective)
138}
139
140pub(crate) struct ScrollCore {
141 content_rect_signal: RwSignal<Rect>,
142 scroll_x: RwSignal<f32>,
143 scroll_y: RwSignal<f32>,
144 content: Rc<RefCell<Box<dyn LayoutItem>>>,
146 content_segment: Rc<Segment>,
147 scrollbar_style: ScrollbarStyle,
148 press_active: bool,
153 gesture_scroll: f32,
154 tap_cancelled: bool,
155}
156
157const SCROLL_TAP_SLOP: f32 = 8.0;
160
161impl ScrollCore {
162 fn with_offsets(
165 content_rect_signal: RwSignal<Rect>,
166 content: Box<dyn LayoutItem>,
167 scroll_x: RwSignal<f32>,
168 scroll_y: RwSignal<f32>,
169 ) -> Self {
170 let content = Rc::new(RefCell::new(content));
171 let content_segment = mount_item_segment(Rc::clone(&content));
172 Self {
173 content_rect_signal,
174 scroll_x,
175 scroll_y,
176 content,
177 content_segment,
178 scrollbar_style: ScrollbarStyle::default(),
179 press_active: false,
180 gesture_scroll: 0.0,
181 tap_cancelled: false,
182 }
183 }
184
185 fn scroll_to_top(&mut self) {
189 if self.scroll_x.peek() != 0.0 {
190 self.scroll_x.set(0.0);
191 }
192 if self.scroll_y.peek() != 0.0 {
193 self.scroll_y.set(0.0);
194 }
195 }
196
197 fn clamp_scroll(&mut self, viewport: Rect) {
198 let content_rect = self.content_rect_signal.peek();
199 let max_x = (content_rect.width - viewport.width).max(0.0);
200 let max_y = (content_rect.height - viewport.height).max(0.0);
201 let clamped_x = self.scroll_x.peek().clamp(0.0, max_x);
202 let clamped_y = self.scroll_y.peek().clamp(0.0, max_y);
203 if self.scroll_x.peek() != clamped_x {
204 self.scroll_x.set(clamped_x);
205 }
206 if self.scroll_y.peek() != clamped_y {
207 self.scroll_y.set(clamped_y);
208 }
209 }
210
211 fn view(&self, viewport: Rect) -> RenderNode {
212 let scroll_x = self.scroll_x.get();
213 let scroll_y = self.scroll_y.get();
214 let content_rect = self.content_rect_signal.get();
215 let scrollable = RenderNode::Clip {
216 rect: viewport,
217 radius: BorderRadius::zero(),
218 children: NodeVec::collect([RenderNode::Transform {
219 matrix: [
220 1.0,
221 0.0,
222 0.0,
223 1.0,
224 viewport.x - scroll_x,
225 viewport.y - scroll_y,
226 ],
227 children: NodeVec::collect([self.content_segment.boundary()]),
228 }]),
229 };
230 let (vbar, hbar) = draw_scrollbars(
231 viewport,
232 scroll_x,
233 scroll_y,
234 content_rect,
235 &self.scrollbar_style,
236 );
237 RenderNode::group([scrollable, vbar, hbar])
238 }
239
240 fn on_event(&mut self, event: &Event, viewport: Rect) -> EventResult {
241 match event {
242 Event::PointerPressed { .. } => {
244 self.press_active = true;
245 self.gesture_scroll = 0.0;
246 self.tap_cancelled = false;
247 }
248 Event::PointerReleased { .. } => self.press_active = false,
249 Event::Scrolled { delta, .. } if self.press_active && !self.tap_cancelled => {
253 let (dx, dy) = delta.pixels();
254 self.gesture_scroll += (dx * dx + dy * dy).sqrt();
255 if self.gesture_scroll > SCROLL_TAP_SLOP {
256 self.tap_cancelled = true;
257 self.content.borrow_mut().on_event(&Event::CursorLeft);
258 }
259 }
260 _ => {}
261 }
262 handle_scroll_event(
263 event,
264 viewport,
265 self.scroll_x.clone(),
266 self.scroll_y.clone(),
267 self.content_rect_signal.clone(),
268 &self.content,
269 )
270 }
271}
272
273#[cfg(test)]
275struct ScrollArea {
276 viewport: Box<dyn Fn() -> Rect>,
277 core: ScrollCore,
278}
279
280#[cfg(test)]
281impl ScrollArea {
282 fn new(viewport: impl Fn() -> Rect + 'static, content: Box<dyn LayoutItem>) -> Self {
283 let content_rect_signal =
284 track_layout(content.layout_node()).expect("content node not registered in ctx");
285 Self {
286 viewport: Box::new(viewport),
287 core: ScrollCore::with_offsets(content_rect_signal, content, signal(0.0), signal(0.0)),
288 }
289 }
290}
291
292#[cfg(test)]
293impl Component for ScrollArea {
294 fn view(&self) -> RenderNode {
295 self.core.view((self.viewport)())
296 }
297
298 fn on_event(&mut self, event: &Event) -> EventResult {
299 self.core.on_event(event, (self.viewport)())
300 }
301}
302
303#[derive(Clone)]
308pub struct ScrollViewport {
309 offset_x: ReadSignal<f32>,
310 offset_y: ReadSignal<f32>,
311 rect: ReadSignal<Rect>,
312 set_x: RwSignal<f32>,
315 set_y: RwSignal<f32>,
316}
317
318impl ScrollViewport {
319 pub fn offset(&self) -> (ReadSignal<f32>, ReadSignal<f32>) {
321 (self.offset_x.clone(), self.offset_y.clone())
322 }
323
324 pub fn reveal(&self, item: NodeId, margin: f32) {
332 let Some(item_rect) = track_layout(item) else {
333 return;
334 };
335 let item = item_rect.get();
336 let viewport = self.rect.get();
337
338 let reveal_axis = |offset: f32, span: f32, start: f32, size: f32| -> f32 {
339 if start - margin < offset {
340 (start - margin).max(0.0)
342 } else if start + size + margin > offset + span {
343 (start + size + margin - span).max(0.0)
345 } else {
346 offset
347 }
348 };
349
350 let y = reveal_axis(self.set_y.peek(), viewport.height, item.y, item.height);
356 if y != self.set_y.peek() {
357 self.set_y.set(y);
358 }
359 let x = reveal_axis(self.set_x.peek(), viewport.width, item.x, item.width);
360 if x != self.set_x.peek() {
361 self.set_x.set(x);
362 }
363 }
364
365 pub fn rect(&self) -> ReadSignal<Rect> {
367 self.rect.clone()
368 }
369
370 pub fn scroll_to_top(&self) {
381 if self.set_x.peek() != 0.0 {
382 self.set_x.set(0.0);
383 }
384 if self.set_y.peek() != 0.0 {
385 self.set_y.set(0.0);
386 }
387 }
388}
389
390pub struct LayoutScrollArea {
392 leaf: LayoutLeaf,
393 core: ScrollCore,
394 scroll_region: ScrollRegionId,
396 _layout_effect: Effect,
400 _clamp_effect: Effect,
402}
403
404impl LayoutScrollArea {
405 pub fn new(
406 layout_style: LayoutStyle,
407 content: Box<dyn LayoutItem>,
408 ) -> Result<Self, LayoutError> {
409 Self::new_with(layout_style, move |_| Ok(content))
410 }
411
412 pub fn new_with<F>(layout_style: LayoutStyle, build: F) -> Result<Self, LayoutError>
418 where
419 F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
420 {
421 Self::new_keeping(layout_style, (signal(0.0), signal(0.0)), build)
422 }
423
424 pub fn new_kept<F>(
431 key: &'static str,
432 layout_style: LayoutStyle,
433 build: F,
434 ) -> Result<Self, LayoutError>
435 where
436 F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
437 {
438 let offset = kept(key, || (signal(0.0f32), signal(0.0f32)));
439 Self::new_keeping(layout_style, offset, build)
440 }
441
442 pub fn new_keeping<F>(
450 layout_style: LayoutStyle,
451 offset: (RwSignal<f32>, RwSignal<f32>),
452 build: F,
453 ) -> Result<Self, LayoutError>
454 where
455 F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
456 {
457 let leaf = LayoutLeaf::register(layout_style)?;
458 let (scroll_x, scroll_y) = offset;
459 let content = build(ScrollViewport {
460 offset_x: scroll_x.read_only(),
461 offset_y: scroll_y.read_only(),
462 rect: leaf.rect.read_only(),
463 set_x: scroll_x.clone(),
464 set_y: scroll_y.clone(),
465 })?;
466 let content_node = content.layout_node();
467 let content_rect_signal =
468 track_layout(content_node).expect("content node not registered in ctx");
469
470 let viewport = leaf.rect.clone();
475 let layout_effect = effect(move || {
476 let vp = viewport.get();
477 if vp.width > 0.0 {
478 let _ = crate::context::compute_layout(
479 content_node,
480 AvailableSpace::Definite(vp.width),
481 AvailableSpace::MaxContent,
482 );
483 }
484 });
485
486 let clamp_effect = {
493 let viewport = leaf.rect.clone();
494 let content_rect = content_rect_signal.clone();
495 let (scroll_x, scroll_y) = (scroll_x.clone(), scroll_y.clone());
496 effect(move || {
497 let vp = viewport.get();
498 let content = content_rect.get();
499 if vp.height <= 0.0 || content.height <= 0.0 {
502 return;
503 }
504 let max_x = (content.width - vp.width).max(0.0);
505 let max_y = (content.height - vp.height).max(0.0);
506 if scroll_x.peek() > max_x {
509 scroll_x.set(max_x);
510 }
511 if scroll_y.peek() > max_y {
512 scroll_y.set(max_y);
513 }
514 })
515 };
516
517 let scroll_region =
519 register_scroll_region(content_node, scroll_x.clone(), scroll_y.clone());
520
521 Ok(Self {
522 leaf,
523 core: ScrollCore::with_offsets(content_rect_signal, content, scroll_x, scroll_y),
524 scroll_region,
525 _layout_effect: layout_effect,
526 _clamp_effect: clamp_effect,
527 })
528 }
529
530 pub fn clamp_scroll(&mut self) {
531 self.core.clamp_scroll(self.leaf.rect.get());
532 }
533
534 pub fn scroll_to_top(&mut self) {
536 self.core.scroll_to_top();
537 }
538
539 pub fn viewport_rect(&self) -> Rect {
540 self.leaf.rect.get()
541 }
542}
543
544impl Drop for LayoutScrollArea {
545 fn drop(&mut self) {
546 unregister_scroll_region(self.scroll_region);
547 }
548}
549
550impl_leaf_widget!(LayoutScrollArea);
551
552impl Component for LayoutScrollArea {
553 fn view(&self) -> RenderNode {
554 self.core.view(self.leaf.rect.get())
555 }
556
557 fn on_event(&mut self, event: &Event) -> EventResult {
558 self.core.on_event(event, self.leaf.rect.get())
559 }
560
561 fn debug_name(&self) -> &'static str {
562 "ScrollArea"
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use crate::context::reset_layout_runtime;
569 use geometry_core::Rect;
570 use layout_core::{AvailableSpace, LayoutStyle, NodeId, SizeDimension};
571 use platform_core::{Event, PointerSource, ScrollDelta};
572 use renderer_core::DrawCommand;
573 use ui_tree::{Component, EventResult, RenderNode};
574
575 use super::*;
576 use crate::canvas::Canvas;
577 use crate::context::{compute_layout, new_container, track_layout};
578 use crate::layout_item::LayoutItem;
579 use crate::layout_leaf::LayoutLeaf;
580
581 #[test]
584 fn scroll_area_lays_out_its_detached_content() {
585 reset_layout_runtime();
586 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
587 RenderNode::Empty
588 })
589 .unwrap();
590 let content_node = content.layout_node();
591 let scroll = LayoutScrollArea::new(
592 LayoutStyle::new().width(300.0).height(160.0),
593 Box::new(content),
594 )
595 .unwrap();
596 let scroll_node = scroll.layout_node();
597 compute_layout(
598 scroll_node,
599 AvailableSpace::Definite(300.0),
600 AvailableSpace::Definite(160.0),
601 )
602 .unwrap();
603 let content_rect = track_layout(content_node).unwrap().get();
604 assert!(
605 content_rect.height > 0.0,
606 "scroll must lay out its content, got {content_rect:?}"
607 );
608 }
609
610 #[test]
617 fn content_that_gets_shorter_pulls_the_view_back_into_it() {
618 reset_layout_runtime();
619 let tall = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
620 RenderNode::Empty
621 })
622 .unwrap();
623 let short = Canvas::new(LayoutStyle::new().width(300.0).height(120.0), |_| {
624 RenderNode::Empty
625 })
626 .unwrap();
627 let short_node = short.layout_node();
628 let page = crate::container::Container::new(
629 LayoutStyle::new().flex_column(),
630 vec![Box::new(tall) as Box<dyn LayoutItem>],
631 )
632 .unwrap();
633 let page_node = page.layout_node();
634 let scroll = LayoutScrollArea::new(
635 LayoutStyle::new().width(300.0).height(200.0),
636 Box::new(page),
637 )
638 .unwrap();
639 compute_layout(
640 scroll.layout_node(),
641 AvailableSpace::Definite(300.0),
642 AvailableSpace::Definite(200.0),
643 )
644 .unwrap();
645
646 scroll.core.scroll_y.set(600.0);
647 assert_eq!(scroll.core.scroll_y.get(), 600.0, "600 of 700 scrollable");
648
649 crate::context::set_children(page_node, &[short_node]).unwrap();
651 crate::context::mark_dirty(page_node).unwrap();
652 crate::context::relayout_if_dirty();
653
654 assert_eq!(
655 scroll.core.scroll_y.get(),
656 0.0,
657 "a page shorter than the viewport has nothing to scroll, so the view is at its top — not \
658 600px past the end of it, drawing nothing"
659 );
660 }
661
662 #[test]
670 fn a_viewport_command_run_from_an_effect_does_not_undo_the_users_own_scrolling() {
671 reset_layout_runtime();
672 let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
673 RenderNode::Empty
674 })
675 .unwrap();
676 let row = content.layout_node();
677 let captured: Rc<RefCell<Option<ScrollViewport>>> = Rc::new(RefCell::new(None));
678 let sink = Rc::clone(&captured);
679 let scroll = LayoutScrollArea::new_with(
680 LayoutStyle::new().width(300.0).height(200.0),
681 move |viewport| {
682 *sink.borrow_mut() = Some(viewport);
683 Ok(Box::new(content) as Box<dyn LayoutItem>)
684 },
685 )
686 .unwrap();
687 compute_layout(
688 scroll.layout_node(),
689 AvailableSpace::Definite(300.0),
690 AvailableSpace::Definite(200.0),
691 )
692 .unwrap();
693 let viewport = captured.borrow().clone().expect("the builder ran");
694
695 let page = signal(0u32);
697 let watched = page.read_only();
698 let commanded = viewport.clone();
699 let followed = watched.clone();
700 let _follow = effect(move || {
701 followed.get();
702 commanded.scroll_to_top();
703 });
704
705 scroll.core.scroll_y.set(150.0);
706 reactive_core::batch(|| {});
707 assert_eq!(
708 scroll.core.scroll_y.get(),
709 150.0,
710 "scrolling is not a page change, so the effect must not have run and pulled the view back"
711 );
712
713 page.set(1);
714 reactive_core::batch(|| {});
715 assert_eq!(
716 scroll.core.scroll_y.get(),
717 0.0,
718 "and the command still does its job when the thing it follows actually changes"
719 );
720
721 let revealing = viewport.clone();
723 let _follow_row = effect(move || {
724 watched.get();
725 revealing.reveal(row, 4.0);
726 });
727 scroll.core.scroll_y.set(80.0);
728 reactive_core::batch(|| {});
729 assert_eq!(
730 scroll.core.scroll_y.get(),
731 80.0,
732 "a row already in view is left alone, and scrolling must not re-ask about it"
733 );
734 }
735
736 #[test]
738 fn a_taller_viewport_pulls_the_view_back_into_the_content() {
739 reset_layout_runtime();
740 let content = Canvas::new(LayoutStyle::new().width(300.0).height(500.0), |_| {
741 RenderNode::Empty
742 })
743 .unwrap();
744 let scroll = LayoutScrollArea::new(
747 LayoutStyle::new()
748 .width(SizeDimension::Percent(1.0))
749 .flex_grow(1.0)
750 .min_height(0.0),
751 Box::new(content),
752 )
753 .unwrap();
754 let scroll_node = scroll.layout_node();
755 let surface = new_container(
756 LayoutStyle::new()
757 .flex_column()
758 .width(SizeDimension::Percent(1.0))
759 .height(SizeDimension::Percent(1.0)),
760 &[scroll_node],
761 )
762 .unwrap();
763 compute_layout(
764 surface,
765 AvailableSpace::Definite(300.0),
766 AvailableSpace::Definite(100.0),
767 )
768 .unwrap();
769 scroll.core.scroll_y.set(400.0);
770
771 crate::context::mark_dirty(surface).unwrap();
773 compute_layout(
774 surface,
775 AvailableSpace::Definite(300.0),
776 AvailableSpace::Definite(450.0),
777 )
778 .unwrap();
779
780 assert_eq!(
781 scroll.core.scroll_y.get(),
782 50.0,
783 "500 of content in 450 of window leaves 50 to scroll, and that is where the view lands"
784 );
785 }
786
787 #[test]
789 fn a_scroll_built_on_kept_offsets_opens_where_the_last_one_left_off() {
790 reset_layout_runtime();
791 let offset = (signal(0.0f32), signal(0.0f32));
792 let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
793 RenderNode::Empty
794 })
795 .unwrap();
796 let first = LayoutScrollArea::new_keeping(
797 LayoutStyle::new().width(300.0).height(200.0),
798 offset.clone(),
799 |_| Ok(Box::new(content) as Box<dyn LayoutItem>),
800 )
801 .unwrap();
802 compute_layout(
803 first.layout_node(),
804 AvailableSpace::Definite(300.0),
805 AvailableSpace::Definite(200.0),
806 )
807 .unwrap();
808 first.core.scroll_y.set(320.0);
809 drop(first);
810
811 let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
812 RenderNode::Empty
813 })
814 .unwrap();
815 let rebuilt = LayoutScrollArea::new_keeping(
816 LayoutStyle::new().width(300.0).height(200.0),
817 offset,
818 |_| Ok(Box::new(content) as Box<dyn LayoutItem>),
819 )
820 .unwrap();
821 assert_eq!(
822 rebuilt.core.scroll_y.get(),
823 320.0,
824 "the tree was replaced, the reader's place in it was not"
825 );
826 compute_layout(
827 rebuilt.layout_node(),
828 AvailableSpace::Definite(300.0),
829 AvailableSpace::Definite(200.0),
830 )
831 .unwrap();
832 assert_eq!(
833 rebuilt.core.scroll_y.get(),
834 320.0,
835 "and laying the rebuilt content out does not throw it away — a 0×0 rect is 'not measured yet', \
836 not 'nothing to show'"
837 );
838 }
839
840 #[test]
842 fn a_trigger_scrolled_inside_a_scroll_area_anchors_where_it_is_drawn() {
843 reset_layout_runtime();
844 let spacer = Canvas::new(LayoutStyle::new().width(400.0).height(600.0), |_| {
845 RenderNode::Empty
846 })
847 .unwrap();
848 let trigger = Canvas::new(LayoutStyle::new().width(80.0).height(24.0), |_| {
849 RenderNode::Empty
850 })
851 .unwrap();
852 let trigger_node = trigger.layout_node();
853 let content = crate::container::Container::new(
854 LayoutStyle::new().flex_column(),
855 vec![Box::new(spacer) as Box<dyn LayoutItem>, Box::new(trigger)],
856 )
857 .unwrap();
858 let scroll = LayoutScrollArea::new(
859 LayoutStyle::new().width(300.0).height(200.0),
860 Box::new(content),
861 )
862 .unwrap();
863 compute_layout(
864 scroll.layout_node(),
865 AvailableSpace::Definite(300.0),
866 AvailableSpace::Definite(200.0),
867 )
868 .unwrap();
869
870 let laid_out = crate::context::absolute_rect(trigger_node).unwrap();
871 assert!(laid_out.y > 200.0, "the trigger starts below the fold");
872 assert_eq!(
873 crate::scroll_region::visible_rect(trigger_node),
874 Some(laid_out),
875 "unscrolled, drawn position and laid-out position agree"
876 );
877
878 scroll.core.scroll_y.set(150.0);
879 let drawn = crate::scroll_region::visible_rect(trigger_node).unwrap();
880 assert_eq!(
881 drawn.y,
882 laid_out.y - 150.0,
883 "scrolling down draws the trigger higher, and that is where a panel must anchor"
884 );
885
886 drop(scroll);
888 assert_eq!(
889 crate::scroll_region::visible_rect(trigger_node),
890 Some(laid_out)
891 );
892 }
893
894 fn make_scroll_area() -> ScrollArea {
895 reset_layout_runtime();
896 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
897 RenderNode::Empty
898 })
899 .unwrap();
900 let node = content.layout_node();
901 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
902 compute_layout(
903 node,
904 AvailableSpace::Definite(400.0),
905 AvailableSpace::MaxContent,
906 )
907 .unwrap();
908 sa
909 }
910
911 fn wheel_over(delta: ScrollDelta) -> Event {
913 Event::Scrolled {
914 delta,
915 x: 100.0,
916 y: 100.0,
917 }
918 }
919
920 fn make_scroll_area_small() -> ScrollArea {
921 reset_layout_runtime();
922 let content = Canvas::new(LayoutStyle::new().width(400.0).height(200.0), |_| {
923 RenderNode::Empty
924 })
925 .unwrap();
926 let node = content.layout_node();
927 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
928 compute_layout(
929 node,
930 AvailableSpace::Definite(400.0),
931 AvailableSpace::MaxContent,
932 )
933 .unwrap();
934 sa
935 }
936
937 #[test]
938 fn scroll_content_click_force_tick_no_panic() {
939 use crate::container::Container;
940 use crate::context::track_layout;
941 use crate::styled_container::StyledContainer;
942 use platform_core::PointerButton;
943 use reactive_core::{begin_batch, end_batch, signal};
944
945 reset_layout_runtime();
946 let s = signal(0i32);
947 let s_cb = s.clone();
948 let btn = StyledContainer::new(
950 LayoutStyle::new().width(50.0).height(30.0),
951 |_r| RectStyle::default(),
952 vec![],
953 )
954 .unwrap()
955 .on_press(move || s_cb.update(|n| *n += 1));
956 let btn_node = btn.layout_node();
957 let s_txt = s.clone();
958 let txt = crate::text::Text::new(
959 move || format!("{}", s_txt.get()),
960 LayoutStyle::new().width(50.0).height(20.0),
961 || renderer_core::TextStyle::new(14.0, renderer_core::Color::BLACK),
962 )
963 .unwrap();
964 let content = Container::new(
965 LayoutStyle::new().flex_column().width(400.0).height(1000.0),
966 vec![Box::new(btn), Box::new(txt)],
967 )
968 .unwrap();
969 let content_node = content.layout_node();
970 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
971 compute_layout(
972 content_node,
973 AvailableSpace::Definite(400.0),
974 AvailableSpace::MaxContent,
975 )
976 .unwrap();
977 let br = track_layout(btn_node).unwrap().get();
978
979 let mut tree = crate::ComponentList::new(sa);
980 let _ = tree.commands();
981
982 let cx = (br.x + br.width / 2.0) as f64;
984 let cy = (br.y + br.height / 2.0) as f64;
985 for phase in [true, false] {
986 begin_batch();
987 let ev = if phase {
988 Event::PointerPressed {
989 x: cx,
990 y: cy,
991 button: PointerButton::Primary,
992 source: PointerSource::Mouse,
993 }
994 } else {
995 Event::PointerReleased {
996 x: cx,
997 y: cy,
998 button: PointerButton::Primary,
999 source: PointerSource::Mouse,
1000 }
1001 };
1002 if tree.on_event(&ev) == EventResult::Handled {
1003 tree.bump_force_ticks();
1004 end_batch();
1005 begin_batch();
1006 }
1007 let _ = tree.commands();
1008 end_batch();
1009 }
1010 assert_eq!(
1011 s.get(),
1012 1,
1013 "scroll-content click should increment the signal"
1014 );
1015 }
1016
1017 #[test]
1020 fn scroll_gesture_over_button_does_not_click() {
1021 use crate::container::Container;
1022 use crate::context::track_layout;
1023 use crate::styled_container::StyledContainer;
1024 use platform_core::PointerButton;
1025 use reactive_core::signal;
1026
1027 reset_layout_runtime();
1028 let s = signal(0i32);
1029 let s_cb = s.clone();
1030 let btn = StyledContainer::new(
1032 LayoutStyle::new().width(50.0).height(30.0),
1033 |_r| RectStyle::default(),
1034 vec![],
1035 )
1036 .unwrap()
1037 .on_press(move || s_cb.update(|n| *n += 1));
1038 let btn_node = btn.layout_node();
1039 let content = Container::new(
1040 LayoutStyle::new().flex_column().width(400.0).height(1000.0),
1041 vec![Box::new(btn)],
1042 )
1043 .unwrap();
1044 let content_node = content.layout_node();
1045 let mut sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1046 compute_layout(
1047 content_node,
1048 AvailableSpace::Definite(400.0),
1049 AvailableSpace::MaxContent,
1050 )
1051 .unwrap();
1052 let br = track_layout(btn_node).unwrap().get();
1053 let (cx, cy) = (
1054 (br.x + br.width / 2.0) as f64,
1055 (br.y + br.height / 2.0) as f64,
1056 );
1057
1058 sa.on_event(&Event::PointerPressed {
1060 x: cx,
1061 y: cy,
1062 button: PointerButton::Primary,
1063 source: PointerSource::Touch { id: 1 },
1064 });
1065 for _ in 0..5 {
1066 sa.on_event(&Event::Scrolled {
1067 delta: ScrollDelta::Pixels { x: 0.0, y: -20.0 },
1068 x: cx,
1069 y: cy,
1070 });
1071 sa.on_event(&Event::PointerMoved {
1072 x: cx,
1073 y: cy,
1074 source: PointerSource::Touch { id: 1 },
1075 });
1076 }
1077 sa.on_event(&Event::PointerReleased {
1078 x: cx,
1079 y: cy,
1080 button: PointerButton::Primary,
1081 source: PointerSource::Touch { id: 1 },
1082 });
1083
1084 assert_eq!(
1085 s.get(),
1086 0,
1087 "a scroll gesture over a button must not click it"
1088 );
1089 assert!(
1090 sa.core.scroll_y.get() > 0.0,
1091 "the gesture should have scrolled the content"
1092 );
1093 }
1094
1095 #[test]
1096 fn as_layout_item_uses_leaf_rect_as_viewport() {
1097 reset_layout_runtime();
1098 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
1099 RenderNode::Empty
1100 })
1101 .unwrap();
1102 let content_node = content.layout_node();
1103 let sa = LayoutScrollArea::new(
1104 LayoutStyle::new().width(400.0).height(300.0),
1105 Box::new(content),
1106 )
1107 .unwrap();
1108 let root = new_container(
1109 LayoutStyle::new().flex_column().width(400.0).height(300.0),
1110 &[sa.layout_node()],
1111 )
1112 .unwrap();
1113 compute_layout(
1114 root,
1115 AvailableSpace::Definite(400.0),
1116 AvailableSpace::Definite(300.0),
1117 )
1118 .unwrap();
1119 compute_layout(
1120 content_node,
1121 AvailableSpace::Definite(400.0),
1122 AvailableSpace::MaxContent,
1123 )
1124 .unwrap();
1125 let vp = sa.viewport_rect();
1126 assert_eq!(vp.width, 400.0);
1127 assert_eq!(vp.height, 300.0);
1128 }
1129
1130 #[test]
1131 fn as_layout_item_emits_clip_and_vbar_on_overflow() {
1132 reset_layout_runtime();
1133 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
1134 RenderNode::Empty
1135 })
1136 .unwrap();
1137 let content_node = content.layout_node();
1138 let sa = LayoutScrollArea::new(
1139 LayoutStyle::new().width(400.0).height(300.0),
1140 Box::new(content),
1141 )
1142 .unwrap();
1143 let root = new_container(
1144 LayoutStyle::new().flex_column().width(400.0).height(300.0),
1145 &[sa.layout_node()],
1146 )
1147 .unwrap();
1148 compute_layout(
1149 root,
1150 AvailableSpace::Definite(400.0),
1151 AvailableSpace::Definite(300.0),
1152 )
1153 .unwrap();
1154 compute_layout(
1155 content_node,
1156 AvailableSpace::Definite(400.0),
1157 AvailableSpace::MaxContent,
1158 )
1159 .unwrap();
1160 if let RenderNode::Group { children, .. } = sa.view() {
1161 assert_eq!(children.len(), 3);
1162 assert!(matches!(&children[0], RenderNode::Clip { .. }));
1163 assert!(matches!(
1164 &children[1],
1165 RenderNode::Primitive(DrawCommand::Rect { .. })
1166 ));
1167 } else {
1168 panic!("expected Group");
1169 }
1170 }
1171
1172 #[test]
1173 fn scroll_lines_updates_offset() {
1174 let mut sa = make_scroll_area();
1175 sa.on_event(&wheel_over(ScrollDelta::Lines { x: 0.0, y: -3.0 }));
1176 assert_eq!(sa.core.scroll_y.get(), 60.0);
1177 }
1178
1179 #[test]
1182 fn wheel_outside_viewport_does_not_scroll() {
1183 let mut sa = make_scroll_area(); let result = sa.on_event(&Event::Scrolled {
1185 delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
1186 x: 500.0,
1187 y: 500.0,
1188 });
1189 assert_eq!(result, EventResult::Ignored);
1190 assert_eq!(
1191 sa.core.scroll_y.get(),
1192 0.0,
1193 "must not scroll when the wheel turned elsewhere"
1194 );
1195 }
1196
1197 #[test]
1200 fn wheel_inside_viewport_scrolls_without_a_prior_move() {
1201 let mut sa = make_scroll_area();
1202 sa.on_event(&wheel_over(ScrollDelta::Lines { x: 0.0, y: -3.0 }));
1203 assert!(
1204 sa.core.scroll_y.get() > 0.0,
1205 "wheel over the viewport scrolls it"
1206 );
1207 }
1208
1209 #[test]
1210 fn scroll_pixels_updates_offset() {
1211 let mut sa = make_scroll_area();
1212 sa.on_event(&wheel_over(ScrollDelta::Pixels { x: 0.0, y: -80.0 }));
1213 assert_eq!(sa.core.scroll_y.get(), 80.0);
1214 }
1215
1216 #[test]
1217 fn scroll_clamps_to_max() {
1218 let mut sa = make_scroll_area();
1219 sa.on_event(&wheel_over(ScrollDelta::Pixels { x: 0.0, y: -9999.0 }));
1220 assert_eq!(sa.core.scroll_y.get(), 700.0);
1221 }
1222
1223 #[test]
1224 fn scroll_clamps_to_zero() {
1225 let mut sa = make_scroll_area();
1226 sa.on_event(&wheel_over(ScrollDelta::Pixels { x: 0.0, y: 9999.0 }));
1227 assert_eq!(sa.core.scroll_y.get(), 0.0);
1228 }
1229
1230 #[test]
1231 fn pointer_outside_viewport_is_ignored() {
1232 let mut sa = make_scroll_area();
1233 let result = sa.on_event(&Event::PointerMoved {
1234 x: 500.0,
1235 y: 100.0,
1236 source: PointerSource::Mouse,
1237 });
1238 assert!(matches!(result, EventResult::Ignored));
1239 }
1240
1241 #[test]
1242 fn view_emits_clip_and_scrollbar_when_content_overflows() {
1243 let sa = make_scroll_area();
1244 let view = sa.view();
1245 if let RenderNode::Group { children, .. } = view {
1246 assert_eq!(children.len(), 3);
1247 assert!(matches!(&children[0], RenderNode::Clip { .. }));
1248 assert!(matches!(
1249 &children[1],
1250 RenderNode::Primitive(DrawCommand::Rect { .. })
1251 ));
1252 } else {
1253 panic!("expected Group");
1254 }
1255 }
1256
1257 #[test]
1258 fn view_no_scrollbar_when_content_fits() {
1259 let sa = make_scroll_area_small();
1260 let view = sa.view();
1261 if let RenderNode::Group { children, .. } = view {
1262 assert!(matches!(&children[1], RenderNode::Empty));
1263 } else {
1264 panic!("expected Group");
1265 }
1266 }
1267
1268 #[test]
1269 fn child_receives_offset_pointer_event() {
1270 use std::cell::Cell;
1271 use std::rc::Rc;
1272
1273 let captured_y: Rc<Cell<f64>> = Rc::new(Cell::new(-1.0));
1274 let captured_y_clone = captured_y.clone();
1275
1276 struct CapturingItem {
1277 leaf: LayoutLeaf,
1278 out: Rc<Cell<f64>>,
1279 }
1280 impl Component for CapturingItem {
1281 fn view(&self) -> RenderNode {
1282 RenderNode::Empty
1283 }
1284 fn on_event(&mut self, event: &Event) -> EventResult {
1285 if let Event::PointerMoved { y, .. } = event {
1286 self.out.set(*y);
1287 EventResult::Handled
1288 } else {
1289 EventResult::Ignored
1290 }
1291 }
1292 }
1293 impl LayoutItem for CapturingItem {
1294 fn layout_node(&self) -> NodeId {
1295 self.leaf.node
1296 }
1297 }
1298
1299 reset_layout_runtime();
1300 let leaf = LayoutLeaf::register(LayoutStyle::new().width(400.0).height(1000.0)).unwrap();
1301 let node = leaf.node;
1302 let content = CapturingItem {
1303 leaf,
1304 out: captured_y_clone,
1305 };
1306 let mut sa = ScrollArea::new(|| Rect::new(100.0, 50.0, 400.0, 300.0), Box::new(content));
1307 compute_layout(
1308 node,
1309 AvailableSpace::Definite(400.0),
1310 AvailableSpace::MaxContent,
1311 )
1312 .unwrap();
1313
1314 sa.on_event(&Event::Scrolled {
1315 delta: ScrollDelta::Pixels { x: 0.0, y: -100.0 },
1316 x: 150.0,
1317 y: 200.0,
1318 });
1319
1320 sa.on_event(&Event::PointerMoved {
1321 x: 150.0,
1322 y: 200.0,
1323 source: PointerSource::Mouse,
1324 });
1325
1326 assert!((captured_y.get() - 250.0).abs() < 0.001);
1327 }
1328
1329 fn make_scroll_area_wide() -> ScrollArea {
1330 reset_layout_runtime();
1331 let content = Canvas::new(LayoutStyle::new().width(1000.0).height(300.0), |_| {
1332 RenderNode::Empty
1333 })
1334 .unwrap();
1335 let node = content.layout_node();
1336 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1337 compute_layout(
1338 node,
1339 AvailableSpace::Definite(1000.0),
1340 AvailableSpace::MaxContent,
1341 )
1342 .unwrap();
1343 sa
1344 }
1345
1346 #[test]
1347 fn scroll_x_lines_updates_offset() {
1348 let mut sa = make_scroll_area_wide();
1349 sa.on_event(&wheel_over(ScrollDelta::Lines { x: -3.0, y: 0.0 }));
1350 assert_eq!(sa.core.scroll_x.get(), 60.0);
1351 }
1352
1353 #[test]
1354 fn scroll_x_pixels_updates_offset() {
1355 let mut sa = make_scroll_area_wide();
1356 sa.on_event(&wheel_over(ScrollDelta::Pixels { x: -80.0, y: 0.0 }));
1357 assert_eq!(sa.core.scroll_x.get(), 80.0);
1358 }
1359
1360 #[test]
1361 fn scroll_x_clamps_to_max() {
1362 let mut sa = make_scroll_area_wide();
1363 sa.on_event(&wheel_over(ScrollDelta::Pixels { x: -9999.0, y: 0.0 }));
1364 assert_eq!(sa.core.scroll_x.get(), 600.0);
1365 }
1366
1367 #[test]
1368 fn scroll_x_clamps_to_zero() {
1369 let mut sa = make_scroll_area_wide();
1370 sa.on_event(&wheel_over(ScrollDelta::Pixels { x: 9999.0, y: 0.0 }));
1371 assert_eq!(sa.core.scroll_x.get(), 0.0);
1372 }
1373
1374 #[test]
1375 fn view_emits_hbar_when_content_overflows_x() {
1376 let sa = make_scroll_area_wide();
1377 let view = sa.view();
1378 if let RenderNode::Group { children, .. } = view {
1379 assert_eq!(children.len(), 3);
1380 assert!(matches!(&children[0], RenderNode::Clip { .. }));
1381 assert!(matches!(&children[1], RenderNode::Empty));
1382 assert!(matches!(
1383 &children[2],
1384 RenderNode::Primitive(DrawCommand::Rect { .. })
1385 ));
1386 } else {
1387 panic!("expected Group");
1388 }
1389 }
1390
1391 #[test]
1392 fn view_no_hbar_when_content_fits_x() {
1393 let sa = make_scroll_area();
1394 let view = sa.view();
1395 if let RenderNode::Group { children, .. } = view {
1396 assert_eq!(children.len(), 3);
1397 assert!(matches!(&children[2], RenderNode::Empty));
1398 } else {
1399 panic!("expected Group");
1400 }
1401 }
1402}