1use std::cell::RefCell;
2use std::rc::Rc;
3
4use geometry_core::Rect;
5use layout_core::{AvailableSpace, LayoutError, LayoutStyle, NodeId};
6use platform_core::{Event, ScrollDelta};
7use reactive_core::{Effect, Memo, ReadSignal, RwSignal, effect, memo, 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 last_pointer: Option<(f32, f32)>,
97) -> EventResult {
98 if let Event::Scrolled { delta } = event {
99 if content.borrow_mut().on_event(event) == EventResult::Handled {
102 return EventResult::Handled;
103 }
104 if last_pointer.is_some_and(|(px, py)| !viewport.contains(px, py)) {
107 return EventResult::Ignored;
108 }
109 let (delta_x, delta_y) = match delta {
110 ScrollDelta::Lines { x, y } => (*x * 20.0, *y * 20.0),
111 ScrollDelta::Pixels { x, y } => (*x, *y),
112 };
113 let content_rect = content_rect_signal.get();
114 let max_scroll_x = (content_rect.width - viewport.width).max(0.0);
115 let max_scroll_y = (content_rect.height - viewport.height).max(0.0);
116 scroll_x.set((scroll_x.get() - delta_x).clamp(0.0, max_scroll_x));
117 scroll_y.set((scroll_y.get() - delta_y).clamp(0.0, max_scroll_y));
118 return EventResult::Handled;
119 }
120
121 let Some(event) = clip_pointer_event(event, viewport) else {
122 return EventResult::Ignored;
123 };
124
125 let scroll_offset_x = scroll_x.get() as f64;
126 let scroll_offset_y = scroll_y.get() as f64;
127 let adjusted = offset_pointer(
128 event,
129 viewport.x as f64 - scroll_offset_x,
130 viewport.y as f64 - scroll_offset_y,
131 );
132 let effective = adjusted.as_ref().unwrap_or(event);
133 content.borrow_mut().on_event(effective)
134}
135
136pub(crate) struct ScrollCore {
137 content_rect_signal: RwSignal<Rect>,
138 scroll_x: RwSignal<f32>,
139 scroll_y: RwSignal<f32>,
140 content: Rc<RefCell<Box<dyn LayoutItem>>>,
142 content_segment: Rc<Segment>,
143 scrollbar_style: ScrollbarStyle,
144 press_active: bool,
149 gesture_scroll: f32,
150 tap_cancelled: bool,
151 last_pointer: Option<(f32, f32)>,
154}
155
156const SCROLL_TAP_SLOP: f32 = 8.0;
159
160impl ScrollCore {
161 fn with_offsets(
164 content_rect_signal: RwSignal<Rect>,
165 content: Box<dyn LayoutItem>,
166 scroll_x: RwSignal<f32>,
167 scroll_y: RwSignal<f32>,
168 ) -> Self {
169 let content = Rc::new(RefCell::new(content));
170 let content_segment = mount_item_segment(Rc::clone(&content));
171 Self {
172 content_rect_signal,
173 scroll_x,
174 scroll_y,
175 content,
176 content_segment,
177 scrollbar_style: ScrollbarStyle::default(),
178 press_active: false,
179 gesture_scroll: 0.0,
180 tap_cancelled: false,
181 last_pointer: None,
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::PointerMoved { x, y, .. } => self.last_pointer = Some((*x as f32, *y as f32)),
252 Event::Scrolled { delta } if self.press_active && !self.tap_cancelled => {
256 let (dx, dy) = match delta {
257 ScrollDelta::Lines { x, y } => (*x * 20.0, *y * 20.0),
258 ScrollDelta::Pixels { x, y } => (*x, *y),
259 };
260 self.gesture_scroll += (dx * dx + dy * dy).sqrt();
261 if self.gesture_scroll > SCROLL_TAP_SLOP {
262 self.tap_cancelled = true;
263 self.content.borrow_mut().on_event(&Event::CursorLeft);
264 }
265 }
266 _ => {}
267 }
268 handle_scroll_event(
269 event,
270 viewport,
271 self.scroll_x.clone(),
272 self.scroll_y.clone(),
273 self.content_rect_signal.clone(),
274 &self.content,
275 self.last_pointer,
276 )
277 }
278}
279
280#[cfg(test)]
282struct ScrollArea {
283 viewport: Box<dyn Fn() -> Rect>,
284 core: ScrollCore,
285}
286
287#[cfg(test)]
288impl ScrollArea {
289 fn new(viewport: impl Fn() -> Rect + 'static, content: Box<dyn LayoutItem>) -> Self {
290 let content_rect_signal =
291 track_layout(content.layout_node()).expect("content node not registered in ctx");
292 Self {
293 viewport: Box::new(viewport),
294 core: ScrollCore::with_offsets(content_rect_signal, content, signal(0.0), signal(0.0)),
295 }
296 }
297}
298
299#[cfg(test)]
300impl Component for ScrollArea {
301 fn view(&self) -> RenderNode {
302 self.core.view((self.viewport)())
303 }
304
305 fn on_event(&mut self, event: &Event) -> EventResult {
306 self.core.on_event(event, (self.viewport)())
307 }
308}
309
310#[derive(Clone)]
315pub struct ScrollViewport {
316 offset_x: ReadSignal<f32>,
317 offset_y: ReadSignal<f32>,
318 rect: ReadSignal<Rect>,
319 set_x: RwSignal<f32>,
322 set_y: RwSignal<f32>,
323}
324
325impl ScrollViewport {
326 pub fn offset(&self) -> (ReadSignal<f32>, ReadSignal<f32>) {
328 (self.offset_x.clone(), self.offset_y.clone())
329 }
330
331 pub fn reveal(&self, item: NodeId, margin: f32) {
339 let Some(item_rect) = track_layout(item) else {
340 return;
341 };
342 let item = item_rect.get();
343 let viewport = self.rect.get();
344
345 let reveal_axis = |offset: f32, span: f32, start: f32, size: f32| -> f32 {
346 if start - margin < offset {
347 (start - margin).max(0.0)
349 } else if start + size + margin > offset + span {
350 (start + size + margin - span).max(0.0)
352 } else {
353 offset
354 }
355 };
356
357 let y = reveal_axis(self.set_y.peek(), viewport.height, item.y, item.height);
363 if y != self.set_y.peek() {
364 self.set_y.set(y);
365 }
366 let x = reveal_axis(self.set_x.peek(), viewport.width, item.x, item.width);
367 if x != self.set_x.peek() {
368 self.set_x.set(x);
369 }
370 }
371
372 pub fn rect(&self) -> ReadSignal<Rect> {
374 self.rect.clone()
375 }
376
377 pub fn scroll_to_top(&self) {
388 if self.set_x.peek() != 0.0 {
389 self.set_x.set(0.0);
390 }
391 if self.set_y.peek() != 0.0 {
392 self.set_y.set(0.0);
393 }
394 }
395
396 pub fn visible(&self, item: NodeId, margin: f32) -> Memo<bool> {
401 let item_rect = track_layout(item).expect("item node not registered in ctx");
402 let offset_x = self.offset_x.clone();
403 let offset_y = self.offset_y.clone();
404 let rect = self.rect.clone();
405 memo(move || {
406 let vp = rect.get();
407 let window = Rect::new(
408 offset_x.get() - margin,
409 offset_y.get() - margin,
410 vp.width + 2.0 * margin,
411 vp.height + 2.0 * margin,
412 );
413 item_rect.get().overlaps(window)
414 })
415 }
416}
417
418pub struct LayoutScrollArea {
420 leaf: LayoutLeaf,
421 core: ScrollCore,
422 scroll_region: ScrollRegionId,
424 _layout_effect: Effect,
428 _clamp_effect: Effect,
430}
431
432impl LayoutScrollArea {
433 pub fn new(
434 layout_style: LayoutStyle,
435 content: Box<dyn LayoutItem>,
436 ) -> Result<Self, LayoutError> {
437 Self::new_with(layout_style, move |_| Ok(content))
438 }
439
440 pub fn new_with<F>(layout_style: LayoutStyle, build: F) -> Result<Self, LayoutError>
446 where
447 F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
448 {
449 Self::new_keeping(layout_style, (signal(0.0), signal(0.0)), build)
450 }
451
452 pub fn new_kept<F>(
459 key: &'static str,
460 layout_style: LayoutStyle,
461 build: F,
462 ) -> Result<Self, LayoutError>
463 where
464 F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
465 {
466 let offset = kept(key, || (signal(0.0f32), signal(0.0f32)));
467 Self::new_keeping(layout_style, offset, build)
468 }
469
470 pub fn new_keeping<F>(
478 layout_style: LayoutStyle,
479 offset: (RwSignal<f32>, RwSignal<f32>),
480 build: F,
481 ) -> Result<Self, LayoutError>
482 where
483 F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
484 {
485 let leaf = LayoutLeaf::register(layout_style)?;
486 let (scroll_x, scroll_y) = offset;
487 let content = build(ScrollViewport {
488 offset_x: scroll_x.read_only(),
489 offset_y: scroll_y.read_only(),
490 rect: leaf.rect.read_only(),
491 set_x: scroll_x.clone(),
492 set_y: scroll_y.clone(),
493 })?;
494 let content_node = content.layout_node();
495 let content_rect_signal =
496 track_layout(content_node).expect("content node not registered in ctx");
497
498 let viewport = leaf.rect.clone();
503 let layout_effect = effect(move || {
504 let vp = viewport.get();
505 if vp.width > 0.0 {
506 let _ = crate::context::compute_layout_root(
507 content_node,
508 AvailableSpace::Definite(vp.width),
509 AvailableSpace::MaxContent,
510 );
511 }
512 });
513
514 let clamp_effect = {
521 let viewport = leaf.rect.clone();
522 let content_rect = content_rect_signal.clone();
523 let (scroll_x, scroll_y) = (scroll_x.clone(), scroll_y.clone());
524 effect(move || {
525 let vp = viewport.get();
526 let content = content_rect.get();
527 if vp.height <= 0.0 || content.height <= 0.0 {
530 return;
531 }
532 let max_x = (content.width - vp.width).max(0.0);
533 let max_y = (content.height - vp.height).max(0.0);
534 if scroll_x.peek() > max_x {
537 scroll_x.set(max_x);
538 }
539 if scroll_y.peek() > max_y {
540 scroll_y.set(max_y);
541 }
542 })
543 };
544
545 let scroll_region =
547 register_scroll_region(content_node, scroll_x.clone(), scroll_y.clone());
548
549 Ok(Self {
550 leaf,
551 core: ScrollCore::with_offsets(content_rect_signal, content, scroll_x, scroll_y),
552 scroll_region,
553 _layout_effect: layout_effect,
554 _clamp_effect: clamp_effect,
555 })
556 }
557
558 pub fn scroll_offset(&self) -> (ReadSignal<f32>, ReadSignal<f32>) {
561 (
562 self.core.scroll_x.read_only(),
563 self.core.scroll_y.read_only(),
564 )
565 }
566
567 pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self {
568 self.core.scrollbar_style = style;
569 self
570 }
571
572 pub fn clamp_scroll(&mut self) {
573 self.core.clamp_scroll(self.leaf.rect.get());
574 }
575
576 pub fn scroll_to_top(&mut self) {
578 self.core.scroll_to_top();
579 }
580
581 pub fn viewport_rect(&self) -> Rect {
582 self.leaf.rect.get()
583 }
584}
585
586impl Drop for LayoutScrollArea {
587 fn drop(&mut self) {
588 unregister_scroll_region(self.scroll_region);
589 }
590}
591
592impl_leaf_widget!(LayoutScrollArea);
593
594impl Component for LayoutScrollArea {
595 fn view(&self) -> RenderNode {
596 self.core.view(self.leaf.rect.get())
597 }
598
599 fn on_event(&mut self, event: &Event) -> EventResult {
600 self.core.on_event(event, self.leaf.rect.get())
601 }
602
603 fn debug_name(&self) -> &'static str {
604 "ScrollArea"
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use crate::context::reset_layout_runtime;
611 use geometry_core::Rect;
612 use layout_core::{AvailableSpace, LayoutStyle, NodeId, SizeDimension};
613 use platform_core::{Event, PointerSource, ScrollDelta};
614 use renderer_core::DrawCommand;
615 use ui_tree::{Component, EventResult, RenderNode};
616
617 use super::*;
618 use crate::canvas::Canvas;
619 use crate::context::{compute_layout, new_container, track_layout};
620 use crate::layout_item::LayoutItem;
621 use crate::layout_leaf::LayoutLeaf;
622
623 #[test]
626 fn scroll_area_lays_out_its_detached_content() {
627 reset_layout_runtime();
628 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
629 RenderNode::Empty
630 })
631 .unwrap();
632 let content_node = content.layout_node();
633 let scroll = LayoutScrollArea::new(
634 LayoutStyle::new().width(300.0).height(160.0),
635 Box::new(content),
636 )
637 .unwrap();
638 let scroll_node = scroll.layout_node();
639 compute_layout(
640 scroll_node,
641 AvailableSpace::Definite(300.0),
642 AvailableSpace::Definite(160.0),
643 )
644 .unwrap();
645 let content_rect = track_layout(content_node).unwrap().get();
646 assert!(
647 content_rect.height > 0.0,
648 "scroll must lay out its content, got {content_rect:?}"
649 );
650 }
651
652 #[test]
659 fn content_that_gets_shorter_pulls_the_view_back_into_it() {
660 reset_layout_runtime();
661 let tall = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
662 RenderNode::Empty
663 })
664 .unwrap();
665 let short = Canvas::new(LayoutStyle::new().width(300.0).height(120.0), |_| {
666 RenderNode::Empty
667 })
668 .unwrap();
669 let short_node = short.layout_node();
670 let page = crate::container::Container::new(
671 LayoutStyle::new().flex_column(),
672 vec![Box::new(tall) as Box<dyn LayoutItem>],
673 )
674 .unwrap();
675 let page_node = page.layout_node();
676 let scroll = LayoutScrollArea::new(
677 LayoutStyle::new().width(300.0).height(200.0),
678 Box::new(page),
679 )
680 .unwrap();
681 compute_layout(
682 scroll.layout_node(),
683 AvailableSpace::Definite(300.0),
684 AvailableSpace::Definite(200.0),
685 )
686 .unwrap();
687
688 scroll.core.scroll_y.set(600.0);
689 assert_eq!(scroll.core.scroll_y.get(), 600.0, "600 of 700 scrollable");
690
691 crate::context::set_children(page_node, &[short_node]).unwrap();
693 crate::context::mark_dirty(page_node).unwrap();
694 crate::context::relayout_if_dirty();
695
696 assert_eq!(
697 scroll.core.scroll_y.get(),
698 0.0,
699 "a page shorter than the viewport has nothing to scroll, so the view is at its top — not \
700 600px past the end of it, drawing nothing"
701 );
702 }
703
704 #[test]
712 fn a_viewport_command_run_from_an_effect_does_not_undo_the_users_own_scrolling() {
713 reset_layout_runtime();
714 let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
715 RenderNode::Empty
716 })
717 .unwrap();
718 let row = content.layout_node();
719 let captured: Rc<RefCell<Option<ScrollViewport>>> = Rc::new(RefCell::new(None));
720 let sink = Rc::clone(&captured);
721 let scroll = LayoutScrollArea::new_with(
722 LayoutStyle::new().width(300.0).height(200.0),
723 move |viewport| {
724 *sink.borrow_mut() = Some(viewport);
725 Ok(Box::new(content) as Box<dyn LayoutItem>)
726 },
727 )
728 .unwrap();
729 compute_layout(
730 scroll.layout_node(),
731 AvailableSpace::Definite(300.0),
732 AvailableSpace::Definite(200.0),
733 )
734 .unwrap();
735 let viewport = captured.borrow().clone().expect("the builder ran");
736
737 let page = signal(0u32);
739 let watched = page.read_only();
740 let commanded = viewport.clone();
741 let followed = watched.clone();
742 let _follow = effect(move || {
743 followed.get();
744 commanded.scroll_to_top();
745 });
746
747 scroll.core.scroll_y.set(150.0);
748 reactive_core::batch(|| {});
749 assert_eq!(
750 scroll.core.scroll_y.get(),
751 150.0,
752 "scrolling is not a page change, so the effect must not have run and pulled the view back"
753 );
754
755 page.set(1);
756 reactive_core::batch(|| {});
757 assert_eq!(
758 scroll.core.scroll_y.get(),
759 0.0,
760 "and the command still does its job when the thing it follows actually changes"
761 );
762
763 let revealing = viewport.clone();
765 let _follow_row = effect(move || {
766 watched.get();
767 revealing.reveal(row, 4.0);
768 });
769 scroll.core.scroll_y.set(80.0);
770 reactive_core::batch(|| {});
771 assert_eq!(
772 scroll.core.scroll_y.get(),
773 80.0,
774 "a row already in view is left alone, and scrolling must not re-ask about it"
775 );
776 }
777
778 #[test]
780 fn a_taller_viewport_pulls_the_view_back_into_the_content() {
781 reset_layout_runtime();
782 let content = Canvas::new(LayoutStyle::new().width(300.0).height(500.0), |_| {
783 RenderNode::Empty
784 })
785 .unwrap();
786 let scroll = LayoutScrollArea::new(
789 LayoutStyle::new()
790 .width(SizeDimension::Percent(1.0))
791 .flex_grow(1.0)
792 .min_height(0.0),
793 Box::new(content),
794 )
795 .unwrap();
796 let scroll_node = scroll.layout_node();
797 let surface = new_container(
798 LayoutStyle::new()
799 .flex_column()
800 .width(SizeDimension::Percent(1.0))
801 .height(SizeDimension::Percent(1.0)),
802 &[scroll_node],
803 )
804 .unwrap();
805 compute_layout(
806 surface,
807 AvailableSpace::Definite(300.0),
808 AvailableSpace::Definite(100.0),
809 )
810 .unwrap();
811 scroll.core.scroll_y.set(400.0);
812
813 crate::context::mark_dirty(surface).unwrap();
815 compute_layout(
816 surface,
817 AvailableSpace::Definite(300.0),
818 AvailableSpace::Definite(450.0),
819 )
820 .unwrap();
821
822 assert_eq!(
823 scroll.core.scroll_y.get(),
824 50.0,
825 "500 of content in 450 of window leaves 50 to scroll, and that is where the view lands"
826 );
827 }
828
829 #[test]
831 fn a_scroll_built_on_kept_offsets_opens_where_the_last_one_left_off() {
832 reset_layout_runtime();
833 let offset = (signal(0.0f32), signal(0.0f32));
834 let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
835 RenderNode::Empty
836 })
837 .unwrap();
838 let first = LayoutScrollArea::new_keeping(
839 LayoutStyle::new().width(300.0).height(200.0),
840 offset.clone(),
841 |_| Ok(Box::new(content) as Box<dyn LayoutItem>),
842 )
843 .unwrap();
844 compute_layout(
845 first.layout_node(),
846 AvailableSpace::Definite(300.0),
847 AvailableSpace::Definite(200.0),
848 )
849 .unwrap();
850 first.core.scroll_y.set(320.0);
851 drop(first);
852
853 let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
854 RenderNode::Empty
855 })
856 .unwrap();
857 let rebuilt = LayoutScrollArea::new_keeping(
858 LayoutStyle::new().width(300.0).height(200.0),
859 offset,
860 |_| Ok(Box::new(content) as Box<dyn LayoutItem>),
861 )
862 .unwrap();
863 assert_eq!(
864 rebuilt.core.scroll_y.get(),
865 320.0,
866 "the tree was replaced, the reader's place in it was not"
867 );
868 compute_layout(
869 rebuilt.layout_node(),
870 AvailableSpace::Definite(300.0),
871 AvailableSpace::Definite(200.0),
872 )
873 .unwrap();
874 assert_eq!(
875 rebuilt.core.scroll_y.get(),
876 320.0,
877 "and laying the rebuilt content out does not throw it away — a 0×0 rect is 'not measured yet', \
878 not 'nothing to show'"
879 );
880 }
881
882 #[test]
884 fn a_trigger_scrolled_inside_a_scroll_area_anchors_where_it_is_drawn() {
885 reset_layout_runtime();
886 let spacer = Canvas::new(LayoutStyle::new().width(400.0).height(600.0), |_| {
887 RenderNode::Empty
888 })
889 .unwrap();
890 let trigger = Canvas::new(LayoutStyle::new().width(80.0).height(24.0), |_| {
891 RenderNode::Empty
892 })
893 .unwrap();
894 let trigger_node = trigger.layout_node();
895 let content = crate::container::Container::new(
896 LayoutStyle::new().flex_column(),
897 vec![Box::new(spacer) as Box<dyn LayoutItem>, Box::new(trigger)],
898 )
899 .unwrap();
900 let scroll = LayoutScrollArea::new(
901 LayoutStyle::new().width(300.0).height(200.0),
902 Box::new(content),
903 )
904 .unwrap();
905 compute_layout(
906 scroll.layout_node(),
907 AvailableSpace::Definite(300.0),
908 AvailableSpace::Definite(200.0),
909 )
910 .unwrap();
911
912 let laid_out = crate::context::absolute_rect(trigger_node).unwrap();
913 assert!(laid_out.y > 200.0, "the trigger starts below the fold");
914 assert_eq!(
915 crate::scroll_region::visible_rect(trigger_node),
916 Some(laid_out),
917 "unscrolled, drawn position and laid-out position agree"
918 );
919
920 scroll.core.scroll_y.set(150.0);
921 let drawn = crate::scroll_region::visible_rect(trigger_node).unwrap();
922 assert_eq!(
923 drawn.y,
924 laid_out.y - 150.0,
925 "scrolling down draws the trigger higher, and that is where a panel must anchor"
926 );
927
928 drop(scroll);
930 assert_eq!(
931 crate::scroll_region::visible_rect(trigger_node),
932 Some(laid_out)
933 );
934 }
935
936 fn make_scroll_area() -> ScrollArea {
937 reset_layout_runtime();
938 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
939 RenderNode::Empty
940 })
941 .unwrap();
942 let node = content.layout_node();
943 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
944 compute_layout(
945 node,
946 AvailableSpace::Definite(400.0),
947 AvailableSpace::MaxContent,
948 )
949 .unwrap();
950 sa
951 }
952
953 fn make_scroll_area_small() -> ScrollArea {
954 reset_layout_runtime();
955 let content = Canvas::new(LayoutStyle::new().width(400.0).height(200.0), |_| {
956 RenderNode::Empty
957 })
958 .unwrap();
959 let node = content.layout_node();
960 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
961 compute_layout(
962 node,
963 AvailableSpace::Definite(400.0),
964 AvailableSpace::MaxContent,
965 )
966 .unwrap();
967 sa
968 }
969
970 #[test]
971 fn scroll_content_click_force_tick_no_panic() {
972 use crate::container::Container;
973 use crate::context::track_layout;
974 use crate::styled_container::StyledContainer;
975 use platform_core::PointerButton;
976 use reactive_core::{begin_batch, end_batch, signal};
977
978 reset_layout_runtime();
979 let s = signal(0i32);
980 let s_cb = s.clone();
981 let btn = StyledContainer::new(
983 LayoutStyle::new().width(50.0).height(30.0),
984 |_r| RectStyle::default(),
985 vec![],
986 )
987 .unwrap()
988 .on_press(move || s_cb.update(|n| *n += 1));
989 let btn_node = btn.layout_node();
990 let s_txt = s.clone();
991 let txt = crate::text::Text::new(
992 move || format!("{}", s_txt.get()),
993 LayoutStyle::new().width(50.0).height(20.0),
994 || renderer_core::TextStyle::new(14.0, renderer_core::Color::BLACK),
995 )
996 .unwrap();
997 let content = Container::new(
998 LayoutStyle::new().flex_column().width(400.0).height(1000.0),
999 vec![Box::new(btn), Box::new(txt)],
1000 )
1001 .unwrap();
1002 let content_node = content.layout_node();
1003 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1004 compute_layout(
1005 content_node,
1006 AvailableSpace::Definite(400.0),
1007 AvailableSpace::MaxContent,
1008 )
1009 .unwrap();
1010 let br = track_layout(btn_node).unwrap().get();
1011
1012 let mut tree = crate::ComponentList::new(sa);
1013 let _ = tree.commands();
1014
1015 let cx = (br.x + br.width / 2.0) as f64;
1017 let cy = (br.y + br.height / 2.0) as f64;
1018 for phase in [true, false] {
1019 begin_batch();
1020 let ev = if phase {
1021 Event::PointerPressed {
1022 x: cx,
1023 y: cy,
1024 button: PointerButton::Primary,
1025 source: PointerSource::Mouse,
1026 }
1027 } else {
1028 Event::PointerReleased {
1029 x: cx,
1030 y: cy,
1031 button: PointerButton::Primary,
1032 source: PointerSource::Mouse,
1033 }
1034 };
1035 if tree.on_event(&ev) == EventResult::Handled {
1036 tree.bump_force_ticks();
1037 end_batch();
1038 begin_batch();
1039 }
1040 let _ = tree.commands();
1041 end_batch();
1042 }
1043 assert_eq!(
1044 s.get(),
1045 1,
1046 "scroll-content click should increment the signal"
1047 );
1048 }
1049
1050 #[test]
1053 fn scroll_gesture_over_button_does_not_click() {
1054 use crate::container::Container;
1055 use crate::context::track_layout;
1056 use crate::styled_container::StyledContainer;
1057 use platform_core::PointerButton;
1058 use reactive_core::signal;
1059
1060 reset_layout_runtime();
1061 let s = signal(0i32);
1062 let s_cb = s.clone();
1063 let btn = StyledContainer::new(
1065 LayoutStyle::new().width(50.0).height(30.0),
1066 |_r| RectStyle::default(),
1067 vec![],
1068 )
1069 .unwrap()
1070 .on_press(move || s_cb.update(|n| *n += 1));
1071 let btn_node = btn.layout_node();
1072 let content = Container::new(
1073 LayoutStyle::new().flex_column().width(400.0).height(1000.0),
1074 vec![Box::new(btn)],
1075 )
1076 .unwrap();
1077 let content_node = content.layout_node();
1078 let mut sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1079 compute_layout(
1080 content_node,
1081 AvailableSpace::Definite(400.0),
1082 AvailableSpace::MaxContent,
1083 )
1084 .unwrap();
1085 let br = track_layout(btn_node).unwrap().get();
1086 let (cx, cy) = (
1087 (br.x + br.width / 2.0) as f64,
1088 (br.y + br.height / 2.0) as f64,
1089 );
1090
1091 sa.on_event(&Event::PointerPressed {
1093 x: cx,
1094 y: cy,
1095 button: PointerButton::Primary,
1096 source: PointerSource::Touch { id: 1 },
1097 });
1098 for _ in 0..5 {
1099 sa.on_event(&Event::Scrolled {
1100 delta: ScrollDelta::Pixels { x: 0.0, y: -20.0 },
1101 });
1102 sa.on_event(&Event::PointerMoved {
1103 x: cx,
1104 y: cy,
1105 source: PointerSource::Touch { id: 1 },
1106 });
1107 }
1108 sa.on_event(&Event::PointerReleased {
1109 x: cx,
1110 y: cy,
1111 button: PointerButton::Primary,
1112 source: PointerSource::Touch { id: 1 },
1113 });
1114
1115 assert_eq!(
1116 s.get(),
1117 0,
1118 "a scroll gesture over a button must not click it"
1119 );
1120 assert!(
1121 sa.core.scroll_y.get() > 0.0,
1122 "the gesture should have scrolled the content"
1123 );
1124 }
1125
1126 #[test]
1127 fn as_layout_item_uses_leaf_rect_as_viewport() {
1128 reset_layout_runtime();
1129 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
1130 RenderNode::Empty
1131 })
1132 .unwrap();
1133 let content_node = content.layout_node();
1134 let sa = LayoutScrollArea::new(
1135 LayoutStyle::new().width(400.0).height(300.0),
1136 Box::new(content),
1137 )
1138 .unwrap();
1139 let root = new_container(
1140 LayoutStyle::new().flex_column().width(400.0).height(300.0),
1141 &[sa.layout_node()],
1142 )
1143 .unwrap();
1144 compute_layout(
1145 root,
1146 AvailableSpace::Definite(400.0),
1147 AvailableSpace::Definite(300.0),
1148 )
1149 .unwrap();
1150 compute_layout(
1151 content_node,
1152 AvailableSpace::Definite(400.0),
1153 AvailableSpace::MaxContent,
1154 )
1155 .unwrap();
1156 let vp = sa.viewport_rect();
1157 assert_eq!(vp.width, 400.0);
1158 assert_eq!(vp.height, 300.0);
1159 }
1160
1161 #[test]
1162 fn as_layout_item_emits_clip_and_vbar_on_overflow() {
1163 reset_layout_runtime();
1164 let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
1165 RenderNode::Empty
1166 })
1167 .unwrap();
1168 let content_node = content.layout_node();
1169 let sa = LayoutScrollArea::new(
1170 LayoutStyle::new().width(400.0).height(300.0),
1171 Box::new(content),
1172 )
1173 .unwrap();
1174 let root = new_container(
1175 LayoutStyle::new().flex_column().width(400.0).height(300.0),
1176 &[sa.layout_node()],
1177 )
1178 .unwrap();
1179 compute_layout(
1180 root,
1181 AvailableSpace::Definite(400.0),
1182 AvailableSpace::Definite(300.0),
1183 )
1184 .unwrap();
1185 compute_layout(
1186 content_node,
1187 AvailableSpace::Definite(400.0),
1188 AvailableSpace::MaxContent,
1189 )
1190 .unwrap();
1191 if let RenderNode::Group { children, .. } = sa.view() {
1192 assert_eq!(children.len(), 3);
1193 assert!(matches!(&children[0], RenderNode::Clip { .. }));
1194 assert!(matches!(
1195 &children[1],
1196 RenderNode::Primitive(DrawCommand::Rect { .. })
1197 ));
1198 } else {
1199 panic!("expected Group");
1200 }
1201 }
1202
1203 #[test]
1204 fn scroll_lines_updates_offset() {
1205 let mut sa = make_scroll_area();
1206 sa.on_event(&Event::Scrolled {
1207 delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
1208 });
1209 assert_eq!(sa.core.scroll_y.get(), 60.0);
1210 }
1211
1212 #[test]
1215 fn wheel_outside_viewport_does_not_scroll() {
1216 let mut sa = make_scroll_area(); sa.on_event(&Event::PointerMoved {
1218 x: 500.0,
1219 y: 500.0,
1220 source: PointerSource::Mouse,
1221 });
1222 let result = sa.on_event(&Event::Scrolled {
1223 delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
1224 });
1225 assert_eq!(result, EventResult::Ignored);
1226 assert_eq!(
1227 sa.core.scroll_y.get(),
1228 0.0,
1229 "must not scroll when the pointer is elsewhere"
1230 );
1231 }
1232
1233 #[test]
1234 fn wheel_inside_viewport_scrolls() {
1235 let mut sa = make_scroll_area();
1236 sa.on_event(&Event::PointerMoved {
1237 x: 100.0,
1238 y: 100.0,
1239 source: PointerSource::Mouse,
1240 });
1241 sa.on_event(&Event::Scrolled {
1242 delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
1243 });
1244 assert!(
1245 sa.core.scroll_y.get() > 0.0,
1246 "wheel over the viewport scrolls it"
1247 );
1248 }
1249
1250 #[test]
1251 fn scroll_pixels_updates_offset() {
1252 let mut sa = make_scroll_area();
1253 sa.on_event(&Event::Scrolled {
1254 delta: ScrollDelta::Pixels { x: 0.0, y: -80.0 },
1255 });
1256 assert_eq!(sa.core.scroll_y.get(), 80.0);
1257 }
1258
1259 #[test]
1260 fn scroll_clamps_to_max() {
1261 let mut sa = make_scroll_area();
1262 sa.on_event(&Event::Scrolled {
1263 delta: ScrollDelta::Pixels { x: 0.0, y: -9999.0 },
1264 });
1265 assert_eq!(sa.core.scroll_y.get(), 700.0);
1266 }
1267
1268 #[test]
1269 fn scroll_clamps_to_zero() {
1270 let mut sa = make_scroll_area();
1271 sa.on_event(&Event::Scrolled {
1272 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
1273 });
1274 assert_eq!(sa.core.scroll_y.get(), 0.0);
1275 }
1276
1277 #[test]
1278 fn pointer_outside_viewport_is_ignored() {
1279 let mut sa = make_scroll_area();
1280 let result = sa.on_event(&Event::PointerMoved {
1281 x: 500.0,
1282 y: 100.0,
1283 source: PointerSource::Mouse,
1284 });
1285 assert!(matches!(result, EventResult::Ignored));
1286 }
1287
1288 #[test]
1289 fn view_emits_clip_and_scrollbar_when_content_overflows() {
1290 let sa = make_scroll_area();
1291 let view = sa.view();
1292 if let RenderNode::Group { children, .. } = view {
1293 assert_eq!(children.len(), 3);
1294 assert!(matches!(&children[0], RenderNode::Clip { .. }));
1295 assert!(matches!(
1296 &children[1],
1297 RenderNode::Primitive(DrawCommand::Rect { .. })
1298 ));
1299 } else {
1300 panic!("expected Group");
1301 }
1302 }
1303
1304 #[test]
1305 fn view_no_scrollbar_when_content_fits() {
1306 let sa = make_scroll_area_small();
1307 let view = sa.view();
1308 if let RenderNode::Group { children, .. } = view {
1309 assert!(matches!(&children[1], RenderNode::Empty));
1310 } else {
1311 panic!("expected Group");
1312 }
1313 }
1314
1315 #[test]
1316 fn child_receives_offset_pointer_event() {
1317 use std::cell::Cell;
1318 use std::rc::Rc;
1319
1320 let captured_y: Rc<Cell<f64>> = Rc::new(Cell::new(-1.0));
1321 let captured_y_clone = captured_y.clone();
1322
1323 struct CapturingItem {
1324 leaf: LayoutLeaf,
1325 out: Rc<Cell<f64>>,
1326 }
1327 impl Component for CapturingItem {
1328 fn view(&self) -> RenderNode {
1329 RenderNode::Empty
1330 }
1331 fn on_event(&mut self, event: &Event) -> EventResult {
1332 if let Event::PointerMoved { y, .. } = event {
1333 self.out.set(*y);
1334 EventResult::Handled
1335 } else {
1336 EventResult::Ignored
1337 }
1338 }
1339 }
1340 impl LayoutItem for CapturingItem {
1341 fn layout_node(&self) -> NodeId {
1342 self.leaf.node
1343 }
1344 }
1345
1346 reset_layout_runtime();
1347 let leaf = LayoutLeaf::register(LayoutStyle::new().width(400.0).height(1000.0)).unwrap();
1348 let node = leaf.node;
1349 let content = CapturingItem {
1350 leaf,
1351 out: captured_y_clone,
1352 };
1353 let mut sa = ScrollArea::new(|| Rect::new(100.0, 50.0, 400.0, 300.0), Box::new(content));
1354 compute_layout(
1355 node,
1356 AvailableSpace::Definite(400.0),
1357 AvailableSpace::MaxContent,
1358 )
1359 .unwrap();
1360
1361 sa.on_event(&Event::Scrolled {
1362 delta: ScrollDelta::Pixels { x: 0.0, y: -100.0 },
1363 });
1364
1365 sa.on_event(&Event::PointerMoved {
1366 x: 150.0,
1367 y: 200.0,
1368 source: PointerSource::Mouse,
1369 });
1370
1371 assert!((captured_y.get() - 250.0).abs() < 0.001);
1372 }
1373
1374 fn make_scroll_area_wide() -> ScrollArea {
1375 reset_layout_runtime();
1376 let content = Canvas::new(LayoutStyle::new().width(1000.0).height(300.0), |_| {
1377 RenderNode::Empty
1378 })
1379 .unwrap();
1380 let node = content.layout_node();
1381 let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1382 compute_layout(
1383 node,
1384 AvailableSpace::Definite(1000.0),
1385 AvailableSpace::MaxContent,
1386 )
1387 .unwrap();
1388 sa
1389 }
1390
1391 #[test]
1392 fn scroll_x_lines_updates_offset() {
1393 let mut sa = make_scroll_area_wide();
1394 sa.on_event(&Event::Scrolled {
1395 delta: ScrollDelta::Lines { x: -3.0, y: 0.0 },
1396 });
1397 assert_eq!(sa.core.scroll_x.get(), 60.0);
1398 }
1399
1400 #[test]
1401 fn scroll_x_pixels_updates_offset() {
1402 let mut sa = make_scroll_area_wide();
1403 sa.on_event(&Event::Scrolled {
1404 delta: ScrollDelta::Pixels { x: -80.0, y: 0.0 },
1405 });
1406 assert_eq!(sa.core.scroll_x.get(), 80.0);
1407 }
1408
1409 #[test]
1410 fn scroll_x_clamps_to_max() {
1411 let mut sa = make_scroll_area_wide();
1412 sa.on_event(&Event::Scrolled {
1413 delta: ScrollDelta::Pixels { x: -9999.0, y: 0.0 },
1414 });
1415 assert_eq!(sa.core.scroll_x.get(), 600.0);
1416 }
1417
1418 #[test]
1419 fn scroll_x_clamps_to_zero() {
1420 let mut sa = make_scroll_area_wide();
1421 sa.on_event(&Event::Scrolled {
1422 delta: ScrollDelta::Pixels { x: 9999.0, y: 0.0 },
1423 });
1424 assert_eq!(sa.core.scroll_x.get(), 0.0);
1425 }
1426
1427 #[test]
1428 fn view_emits_hbar_when_content_overflows_x() {
1429 let sa = make_scroll_area_wide();
1430 let view = sa.view();
1431 if let RenderNode::Group { children, .. } = view {
1432 assert_eq!(children.len(), 3);
1433 assert!(matches!(&children[0], RenderNode::Clip { .. }));
1434 assert!(matches!(&children[1], RenderNode::Empty));
1435 assert!(matches!(
1436 &children[2],
1437 RenderNode::Primitive(DrawCommand::Rect { .. })
1438 ));
1439 } else {
1440 panic!("expected Group");
1441 }
1442 }
1443
1444 #[test]
1445 fn view_no_hbar_when_content_fits_x() {
1446 let sa = make_scroll_area();
1447 let view = sa.view();
1448 if let RenderNode::Group { children, .. } = view {
1449 assert_eq!(children.len(), 3);
1450 assert!(matches!(&children[2], RenderNode::Empty));
1451 } else {
1452 panic!("expected Group");
1453 }
1454 }
1455}