1use std::cell::Cell;
31use std::rc::Rc;
32use std::time::Duration;
33
34use teksilo_canvas::{Point, Rect, Size, SizeProposal};
35use teksilo_core::accessibility::AccessNodeBuilder;
36use teksilo_core::binding::BindingLevel;
37use teksilo_core::build_context::BuildContext;
38use teksilo_core::color_prop::ColorProp;
39use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
40use teksilo_core::signal::{Prop, Signal};
41use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::Easing;
45
46use crate::common::scroll::OverscrollBehavior;
47use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum ScrollBarMode {
52 #[default]
56 Overlay,
57 Permanent,
61 Thin,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum ScrollBarPolicy {
71 #[default]
73 AsNeeded,
74 AlwaysOn,
76 AlwaysOff,
78}
79
80pub struct ScrollArea {
86 content_child: Option<Box<dyn Widget>>,
87 content_child_id: Option<WidgetId>,
88 scroll_bar_style: ScrollBarMode,
89 vertical_policy: ScrollBarPolicy,
91 horizontal_policy: ScrollBarPolicy,
92 line_height: f32,
94 scroll_bar_thickness: f32,
96 scroll_bar_thumb_color: Option<ColorProp>,
100 widget_resizable: bool,
102 smooth_scrolling: bool,
104 smooth_scroll_duration: Duration,
106 preferred_size: Option<Size>,
109 preferred_height: Option<f32>,
111 overscroll_behavior: OverscrollBehavior,
115 scroll_past_end: Prop<f32>,
118
119 scroll_y: Signal<f32>,
122 scroll_x: Signal<f32>,
124 max_scroll_y: Signal<f32>,
126 max_scroll_x: Signal<f32>,
128 viewport_ratio_y: Signal<f32>,
130 viewport_ratio_x: Signal<f32>,
132
133 child_ids: Vec<WidgetId>,
136
137 content_size: Cell<Size>,
139 viewport_size: Rc<Cell<Size>>,
144 viewport_origin: Rc<Cell<Point>>,
149
150 pending_restore_y: Rc<Cell<Option<f32>>>,
156 restore_wrote_y: Rc<Cell<Option<f32>>>,
171}
172
173impl Default for ScrollArea {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179impl std::fmt::Debug for ScrollArea {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 f.debug_struct("ScrollArea")
182 .field("scroll_y", &self.scroll_y.get())
183 .field("scroll_x", &self.scroll_x.get())
184 .field("style", &self.scroll_bar_style)
185 .field("v_policy", &self.vertical_policy)
186 .field("h_policy", &self.horizontal_policy)
187 .field("widget_resizable", &self.widget_resizable)
188 .field("content_size", &self.content_size.get())
189 .field("viewport_size", &self.viewport_size.get())
190 .finish()
191 }
192}
193
194impl ScrollArea {
195 pub fn new() -> Self {
197 Self {
198 content_child: None,
199 content_child_id: None,
200 scroll_bar_style: ScrollBarMode::default(),
201 vertical_policy: ScrollBarPolicy::default(),
202 horizontal_policy: ScrollBarPolicy::default(),
203 line_height: 20.0,
204 scroll_bar_thickness: 12.0,
205 scroll_bar_thumb_color: None,
206 widget_resizable: false,
207 smooth_scrolling: true,
208 smooth_scroll_duration: Duration::from_millis(150),
209 preferred_size: None,
210 preferred_height: None,
211 overscroll_behavior: OverscrollBehavior::default(),
212 scroll_past_end: Prop::Static(0.0),
213 scroll_y: Signal::new_animated(0.0),
214 scroll_x: Signal::new_animated(0.0),
215 max_scroll_y: Signal::new(0.0),
216 max_scroll_x: Signal::new(0.0),
217 viewport_ratio_y: Signal::new(1.0),
218 viewport_ratio_x: Signal::new(1.0),
219 child_ids: Vec::new(),
220 content_size: Cell::new(Size::ZERO),
221 viewport_size: Rc::new(Cell::new(Size::ZERO)),
222 viewport_origin: Rc::new(Cell::new(Point::ZERO)),
223 pending_restore_y: Rc::new(Cell::new(None)),
224 restore_wrote_y: Rc::new(Cell::new(None)),
225 }
226 }
227
228 pub fn child(mut self, child: impl Widget + 'static) -> Self {
230 self.content_child = Some(Box::new(child));
231 self.content_child_id = None;
232 self
233 }
234
235 pub fn from_id(child: WidgetId) -> Self {
237 let mut sa = Self::new();
238 sa.content_child_id = Some(child);
239 sa
240 }
241
242 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
244 self.scroll_bar_style = style;
245 self
246 }
247
248 pub fn scroll_bar_thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
257 self.scroll_bar_thumb_color = Some(color.into());
258 self
259 }
260
261 pub fn vertical_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
263 self.vertical_policy = policy;
264 self
265 }
266
267 pub fn horizontal_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
269 self.horizontal_policy = policy;
270 self
271 }
272
273 pub fn line_height(mut self, lh: f32) -> Self {
275 self.line_height = lh;
276 self
277 }
278
279 pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self {
281 self.scroll_bar_thickness = thickness;
282 self
283 }
284
285 pub fn widget_resizable(mut self, resizable: bool) -> Self {
288 self.widget_resizable = resizable;
289 self
290 }
291
292 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
299 self.smooth_scrolling = enabled;
300 self
301 }
302
303 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
305 self.smooth_scroll_duration = duration;
306 self
307 }
308
309 pub fn scroll_past_end(mut self, fraction: impl Into<Prop<f32>>) -> Self {
331 self.scroll_past_end = fraction.into();
332 self
333 }
334
335 pub fn preferred_size(mut self, width: f32, height: f32) -> Self {
346 self.preferred_size = Some(Size::new(width, height));
347 self
348 }
349
350 fn natural_content_width(&self, ctx: &LayoutContext) -> f32 {
363 if let Some(&child) = self.child_ids.first()
366 && let Some(size) = ctx.child_size(
367 child,
368 SizeProposal {
369 width: None,
370 height: None,
371 },
372 )
373 && size.width > 0.0
374 {
375 return size.width;
376 }
377 let cached = self.content_size.get().width;
378 if cached > 0.0 { cached } else { 300.0 }
379 }
380
381 pub fn preferred_height(mut self, height: f32) -> Self {
391 self.preferred_height = Some(height);
392 self
393 }
394
395 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
399 self.overscroll_behavior = behavior;
400 self
401 }
402
403 pub fn restore_scroll_y(self, offset: f32) -> Self {
426 self.pending_restore_y.set((offset > 0.0).then_some(offset));
430 self.restore_wrote_y.set(None);
435 self
436 }
437
438 pub fn scroll_y_signal(&self) -> &Signal<f32> {
440 &self.scroll_y
441 }
442
443 pub fn scroll_x_signal(&self) -> &Signal<f32> {
445 &self.scroll_x
446 }
447
448 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
454 &self.max_scroll_y
455 }
456
457 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
462 &self.viewport_ratio_y
463 }
464
465 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
469 &self.max_scroll_x
470 }
471
472 pub(crate) fn viewport_size_cell(&self) -> Rc<Cell<Size>> {
482 self.viewport_size.clone()
483 }
484
485 fn clamp_and_set_scroll(&self) {
486 let max_y = self.max_scroll_y.get();
487 let max_x = self.max_scroll_x.get();
488 let cur_y = self.scroll_y.get();
489 let cur_x = self.scroll_x.get();
490 let clamped_y = cur_y.clamp(0.0, max_y);
491 let clamped_x = cur_x.clamp(0.0, max_x);
492 if (clamped_y - cur_y).abs() > f32::EPSILON {
493 self.scroll_y.set(clamped_y);
494 }
495 if (clamped_x - cur_x).abs() > f32::EPSILON {
496 self.scroll_x.set(clamped_x);
497 }
498 }
499}
500
501impl Widget for ScrollArea {
502 fn as_any(&self) -> Option<&dyn std::any::Any> {
507 Some(self)
508 }
509
510 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
511 let mut ids = Vec::new();
512
513 let content_id = if let Some(child) = self.content_child.take() {
515 ctx.add_boxed(child)
516 } else if let Some(id) = self.content_child_id.take() {
517 id
518 } else if !self.child_ids.is_empty() {
519 return self.child_ids.clone();
521 } else {
522 self.child_ids.clear();
528 return Vec::new();
529 };
530 ids.push(content_id);
531
532 let visual = match self.scroll_bar_style {
534 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
535 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
536 ScrollBarMode::Thin => ScrollBarVisual::Thin,
537 };
538 let thickness = self.scroll_bar_thickness; let mut v_scrollbar = ScrollBar::new(
542 ScrollBarOrientation::Vertical,
543 self.scroll_y.clone(),
544 self.max_scroll_y.clone(),
545 self.viewport_ratio_y.clone(),
546 )
547 .thickness(thickness)
548 .visual(visual);
549 if let Some(tint) = &self.scroll_bar_thumb_color {
550 v_scrollbar = v_scrollbar.thumb_color(tint.clone());
551 }
552 let v_id = ctx.add(v_scrollbar);
553 ids.push(v_id);
554
555 let mut h_scrollbar = ScrollBar::new(
557 ScrollBarOrientation::Horizontal,
558 self.scroll_x.clone(),
559 self.max_scroll_x.clone(),
560 self.viewport_ratio_x.clone(),
561 )
562 .thickness(thickness)
563 .visual(visual);
564 if let Some(tint) = &self.scroll_bar_thumb_color {
565 h_scrollbar = h_scrollbar.thumb_color(tint.clone());
566 }
567 let h_id = ctx.add(h_scrollbar);
568 ids.push(h_id);
569
570 ctx.register_animated_signal(&self.scroll_y);
572 ctx.register_animated_signal(&self.scroll_x);
573
574 let self_id = ctx.self_id();
576 let registry = ctx.binding_registry();
577 self.scroll_y
578 .bind_to(self_id, registry, BindingLevel::Relayout);
579 self.scroll_x
580 .bind_to(self_id, registry, BindingLevel::Relayout);
581 self.scroll_past_end
585 .register_if_bound(self_id, registry, BindingLevel::Relayout);
586
587 self.child_ids = ids.clone();
588
589 let scroll_y = self.scroll_y.clone();
591 let scroll_x = self.scroll_x.clone();
592 let max_scroll_y = self.max_scroll_y.clone();
593 let max_scroll_x = self.max_scroll_x.clone();
594 let viewport_size = self.viewport_size.clone();
595 let viewport_origin = self.viewport_origin.clone();
596 let line_height = self.line_height;
597 let smooth_scrolling = self.smooth_scrolling;
598 let smooth_scroll_duration = self.smooth_scroll_duration;
599 let overscroll_behavior = self.overscroll_behavior;
600
601 let clamp_and_set = {
602 let scroll_y = scroll_y.clone();
603 let scroll_x = scroll_x.clone();
604 let max_scroll_y = max_scroll_y.clone();
605 let max_scroll_x = max_scroll_x.clone();
606 move || {
607 let max_y = max_scroll_y.get();
608 let max_x = max_scroll_x.get();
609 let cur_y = scroll_y.get();
610 let cur_x = scroll_x.get();
611 let clamped_y = cur_y.clamp(0.0, max_y);
612 let clamped_x = cur_x.clamp(0.0, max_x);
613 if (clamped_y - cur_y).abs() > f32::EPSILON {
614 scroll_y.set(clamped_y);
615 }
616 if (clamped_x - cur_x).abs() > f32::EPSILON {
617 scroll_x.set(clamped_x);
618 }
619 }
620 };
621
622 let mut handlers = HandlerSet::new().clips_children(true);
623
624 {
634 let scroll_y = scroll_y.clone();
635 let scroll_x = scroll_x.clone();
636 let max_scroll_y = max_scroll_y.clone();
637 let max_scroll_x = max_scroll_x.clone();
638 let viewport_size = viewport_size.clone();
639 let viewport_origin = viewport_origin.clone();
640 let pending_restore_y = self.pending_restore_y.clone();
646 let restore_wrote_y = self.restore_wrote_y.clone();
647 handlers = handlers.on_scroll(move |event, _ctx| match event {
648 WidgetEvent::Scroll { delta, .. } => {
649 pending_restore_y.set(None);
650 restore_wrote_y.set(None);
651 let max_y = max_scroll_y.get();
652 let max_x = max_scroll_x.get();
653 let cur_y = scroll_y.get();
654 let cur_x = scroll_x.get();
655 let base_y = scroll_y.animation_target().unwrap_or(cur_y);
658 let base_x = scroll_x.animation_target().unwrap_or(cur_x);
659
660 let (dx, dy) = match delta {
661 ScrollDelta::Lines { x, y } => (x * line_height, y * line_height),
662 ScrollDelta::Pixels { x, y } => (*x, *y),
663 };
664 let (target_x, moved_x) =
665 crate::common::scroll::scroll_clamp_axis(base_x, dx, max_x);
666 let (target_y, moved_y) =
667 crate::common::scroll::scroll_clamp_axis(base_y, dy, max_y);
668
669 if moved_x || moved_y {
670 if smooth_scrolling {
671 scroll_y.animate_to(target_y, smooth_scroll_duration, Easing::EaseOut);
672 scroll_x.animate_to(target_x, smooth_scroll_duration, Easing::EaseOut);
673 } else {
674 scroll_y.set(target_y);
675 scroll_x.set(target_x);
676 }
677 }
678 crate::common::scroll::scroll_response(
681 moved_x || moved_y,
682 overscroll_behavior == OverscrollBehavior::Contain,
683 )
684 }
685 WidgetEvent::ScrollIntoView {
686 target_bounds,
687 margin,
688 align,
689 motion,
690 applied_scroll,
691 } => {
692 pending_restore_y.set(None);
693 restore_wrote_y.set(None);
694 let vp = viewport_size.get();
701 let vo = viewport_origin.get();
702 let sy = scroll_y.get();
703 let sx = scroll_x.get();
704
705 let viewport_top = sy;
713 let viewport_bottom = viewport_top + vp.height;
714 let target_top = target_bounds.y - vo.y + sy - margin;
715 let target_bottom = target_top + target_bounds.height + margin * 2.0;
716
717 let mut new_y = sy;
718 match align {
719 teksilo_core::event::ScrollAlign::Fraction(f) => {
725 let target_top = target_bounds.y - vo.y + sy;
726 new_y = target_top - (vp.height - target_bounds.height) * f;
727 }
728 teksilo_core::event::ScrollAlign::Minimal => {
729 if !(target_top <= viewport_top && target_bottom >= viewport_bottom) {
730 if target_top < viewport_top {
731 new_y = target_top;
732 } else if target_bottom > viewport_bottom {
733 new_y = target_bottom - vp.height;
734 }
735 }
736 }
737 }
738
739 let viewport_left = sx;
740 let viewport_right = viewport_left + vp.width;
741 let target_left = target_bounds.x - vo.x + sx - margin;
742 let target_right = target_left + target_bounds.width + margin * 2.0;
743
744 let mut new_x = sx;
745 if !(target_left <= viewport_left && target_right >= viewport_right) {
746 if target_left < viewport_left {
747 new_x = target_left;
748 } else if target_right > viewport_right {
749 new_x = target_right - vp.width;
750 }
751 }
752
753 let new_y = new_y.clamp(0.0, max_scroll_y.get());
758 let new_x = new_x.clamp(0.0, max_scroll_x.get());
759
760 match motion {
761 teksilo_core::event::ScrollMotion::Smooth if smooth_scrolling => {
762 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
763 scroll_x.animate_to(new_x, smooth_scroll_duration, Easing::EaseOut);
764 }
765 _ => {
766 scroll_y.set(new_y);
767 scroll_x.set(new_x);
768 }
769 }
770 if let Some(cell) = applied_scroll
776 && let Ok(mut d) = cell.lock()
777 {
778 *d = teksilo_canvas::Point::new(new_x - sx, new_y - sy);
779 }
780 EventResponse::Handled
781 }
782 _ => EventResponse::Ignored,
783 });
784 }
785
786 {
788 let scroll_y = scroll_y.clone();
789 let scroll_x = scroll_x.clone();
790 let viewport_size = viewport_size.clone();
791 let clamp_and_set = clamp_and_set.clone();
792 handlers = handlers.on_access_action(move |action, _ctx| match action {
793 teksilo_core::accesskit::Action::ScrollDown => {
794 let step = viewport_size.get().height * 0.9;
795 scroll_y.set(scroll_y.get() + step);
796 clamp_and_set();
797 EventResponse::Handled
798 }
799 teksilo_core::accesskit::Action::ScrollUp => {
800 let step = viewport_size.get().height * 0.9;
801 scroll_y.set(scroll_y.get() - step);
802 clamp_and_set();
803 EventResponse::Handled
804 }
805 teksilo_core::accesskit::Action::ScrollRight => {
806 let step = viewport_size.get().width * 0.9;
807 scroll_x.set(scroll_x.get() + step);
808 clamp_and_set();
809 EventResponse::Handled
810 }
811 teksilo_core::accesskit::Action::ScrollLeft => {
812 let step = viewport_size.get().width * 0.9;
813 scroll_x.set(scroll_x.get() - step);
814 clamp_and_set();
815 EventResponse::Handled
816 }
817 _ => EventResponse::Ignored,
818 });
819 }
820
821 ctx.apply_self_handlers(handlers);
822
823 ids
824 }
825
826 fn layout_response(
827 &self,
828 proposal: SizeProposal,
829 ctx: &LayoutContext,
830 ) -> teksilo_core::widget::LayoutResponse {
831 let (default_w, default_h) = if let Some(pref) = self.preferred_size {
837 (pref.width, pref.height)
838 } else {
839 let h = self.preferred_height.unwrap_or(200.0);
840 let w = if proposal.width.is_none() {
851 self.natural_content_width(ctx)
852 } else {
853 0.0
854 };
855 (w, h)
856 };
857 proposal.resolve(default_w, default_h).into()
858 }
859
860 fn place_children(
861 &self,
862 bounds: Rect,
863 _proposal: SizeProposal,
864 children: &mut [WidgetPlacement],
865 ctx: &LayoutContext,
866 ) {
867 if children.is_empty() {
868 return;
869 }
870
871 let has_v = children.len() > 1;
876 let has_h = children.len() > 2;
877 let v_off = self.vertical_policy == ScrollBarPolicy::AlwaysOff;
878 let _h_off = self.horizontal_policy == ScrollBarPolicy::AlwaysOff;
879
880 let sb_thickness = self.scroll_bar_thickness;
882
883 let resolve_show = |policy: ScrollBarPolicy, has_bar: bool, overflows: bool| -> bool {
887 has_bar
888 && match policy {
889 ScrollBarPolicy::AlwaysOn => true,
890 ScrollBarPolicy::AlwaysOff => false,
891 ScrollBarPolicy::AsNeeded => overflows,
892 }
893 };
894
895 let v_reserved_1 = match self.scroll_bar_style {
897 ScrollBarMode::Permanent if has_v && !v_off => sb_thickness,
898 _ => 0.0,
899 };
900 let vp_w1 = (bounds.width - v_reserved_1).max(0.0);
901 let content_size_1 = ctx
902 .child_size(
903 children[0].id,
904 SizeProposal {
905 width: Some(vp_w1),
906 height: None,
907 },
908 )
909 .unwrap_or(Size::new(vp_w1, bounds.height));
910
911 let show_v_1 = resolve_show(
912 self.vertical_policy,
913 has_v,
914 content_size_1.height > bounds.height + 0.5,
915 );
916 let show_h_1 = resolve_show(
917 self.horizontal_policy,
918 has_h,
919 content_size_1.width > vp_w1 + 0.5,
920 );
921
922 let v_res = match self.scroll_bar_style {
924 ScrollBarMode::Permanent if show_v_1 => sb_thickness,
925 _ => 0.0,
926 };
927 let h_res = match self.scroll_bar_style {
928 ScrollBarMode::Permanent if show_h_1 => sb_thickness,
929 _ => 0.0,
930 };
931
932 let vp_h_after_h = (bounds.height - h_res).max(0.0);
934 let new_needs_v = content_size_1.height > vp_h_after_h + 0.5;
935 let show_v = resolve_show(self.vertical_policy, has_v, new_needs_v);
936 let new_v_res = match self.scroll_bar_style {
937 ScrollBarMode::Permanent if show_v => sb_thickness,
938 _ => 0.0,
939 };
940
941 let (viewport_width, content_size, show_h) = if (new_v_res - v_res).abs() > 0.01 {
942 let vp_w2 = (bounds.width - new_v_res).max(0.0);
944 let cs2 = ctx
945 .child_size(
946 children[0].id,
947 SizeProposal {
948 width: Some(vp_w2),
949 height: None,
950 },
951 )
952 .unwrap_or(Size::new(vp_w2, bounds.height));
953 let sh2 = resolve_show(self.horizontal_policy, has_h, cs2.width > vp_w2 + 0.5);
954 (vp_w2, cs2, sh2)
955 } else {
956 (
957 (bounds.width - new_v_res).max(0.0),
958 content_size_1,
959 show_h_1,
960 )
961 };
962
963 let v_reserved = new_v_res;
964 let h_reserved = match self.scroll_bar_style {
965 ScrollBarMode::Permanent if show_h => sb_thickness,
966 _ => 0.0,
967 };
968 let viewport_height = (bounds.height - h_reserved).max(0.0);
969
970 let placed_content_size = if self.widget_resizable {
972 Size::new(
973 content_size.width.max(viewport_width),
974 content_size.height.max(viewport_height),
975 )
976 } else {
977 content_size
978 };
979
980 self.content_size.set(placed_content_size);
993 self.viewport_size
994 .set(Size::new(viewport_width, viewport_height));
995 self.viewport_origin.set(bounds.origin());
996
997 let set_if_changed = |sig: &Signal<f32>, v: f32| {
1004 if (sig.get() - v).abs() > f32::EPSILON {
1005 sig.set(v);
1006 }
1007 };
1008
1009 let past_end = (self.scroll_past_end.get().max(0.0)) * viewport_height;
1014 let scrollable_height = placed_content_size.height + past_end;
1015
1016 let max_y = (scrollable_height - viewport_height).max(0.0);
1017 let max_x = (placed_content_size.width - viewport_width).max(0.0);
1018 set_if_changed(&self.max_scroll_y, max_y);
1019 set_if_changed(&self.max_scroll_x, max_x);
1020
1021 let ratio_y = if scrollable_height > 0.0 {
1022 (viewport_height / scrollable_height).clamp(0.0, 1.0)
1023 } else {
1024 1.0
1025 };
1026 let ratio_x = if placed_content_size.width > 0.0 {
1027 (viewport_width / placed_content_size.width).clamp(0.0, 1.0)
1028 } else {
1029 1.0
1030 };
1031 set_if_changed(&self.viewport_ratio_y, ratio_y);
1032 set_if_changed(&self.viewport_ratio_x, ratio_x);
1033
1034 if let Some(ours) = self.restore_wrote_y.get()
1063 && (self.scroll_y.get() - ours).abs() > f32::EPSILON
1064 {
1065 self.pending_restore_y.set(None);
1066 self.restore_wrote_y.set(None);
1067 }
1068 if let Some(pending) = self.pending_restore_y.get()
1069 && max_y > 0.0
1070 {
1071 let landed = pending.min(max_y);
1072 if (landed - self.scroll_y.get()).abs() > f32::EPSILON {
1073 self.scroll_y.set(landed);
1074 }
1075 if max_y >= pending {
1076 self.pending_restore_y.set(None);
1077 self.restore_wrote_y.set(None);
1078 } else {
1079 self.restore_wrote_y.set(Some(landed));
1082 }
1083 }
1084
1085 self.clamp_and_set_scroll();
1086 let scroll_y = self.scroll_y.get();
1087 let scroll_x = self.scroll_x.get();
1088
1089 let content_x = if ctx.is_rtl() {
1098 bounds.right() - placed_content_size.width + scroll_x
1099 } else {
1100 bounds.x - scroll_x
1101 };
1102 children[0].origin = Point::new(content_x, bounds.y - scroll_y);
1103 children[0].size = placed_content_size;
1104
1105 if has_v {
1107 if show_v {
1108 let sb_x = if ctx.is_rtl() {
1109 bounds.x
1110 } else {
1111 bounds.right() - sb_thickness
1112 };
1113 let sb_h = if h_reserved > 0.0
1114 || (matches!(
1115 self.scroll_bar_style,
1116 ScrollBarMode::Overlay | ScrollBarMode::Thin
1117 ) && show_h)
1118 {
1119 bounds.height - sb_thickness
1120 } else {
1121 bounds.height
1122 };
1123 children[1].origin = Point::new(sb_x, bounds.y);
1124 children[1].size = Size::new(sb_thickness, sb_h);
1125 } else {
1126 children[1].origin = Point::new(bounds.x, bounds.y);
1128 children[1].size = Size::ZERO;
1129 }
1130 }
1131
1132 if has_h {
1134 if show_h {
1135 let sb_y = bounds.bottom() - sb_thickness;
1136 let sb_x = if ctx.is_rtl() && v_reserved > 0.0 {
1137 bounds.x + sb_thickness
1138 } else {
1139 bounds.x
1140 };
1141 let sb_w = if v_reserved > 0.0
1142 || (matches!(
1143 self.scroll_bar_style,
1144 ScrollBarMode::Overlay | ScrollBarMode::Thin
1145 ) && show_v)
1146 {
1147 bounds.width - sb_thickness
1148 } else {
1149 bounds.width
1150 };
1151 children[2].origin = Point::new(sb_x, sb_y);
1152 children[2].size = Size::new(sb_w, sb_thickness);
1153 } else {
1154 children[2].origin = Point::new(bounds.x, bounds.y);
1155 children[2].size = Size::ZERO;
1156 }
1157 }
1158 }
1159
1160 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
1161 }
1163
1164 fn children(&self) -> Vec<WidgetId> {
1165 self.child_ids.clone()
1166 }
1167
1168 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1169 builder.set_role(teksilo_core::accesskit::Role::ScrollView);
1170 builder.inner_mut().set_clips_children();
1171
1172 let scroll_y = self.scroll_y.get();
1173 let scroll_x = self.scroll_x.get();
1174 let max_y = self.max_scroll_y.get();
1175 let max_x = self.max_scroll_x.get();
1176
1177 builder.inner_mut().set_scroll_y(scroll_y as f64);
1178 builder.inner_mut().set_scroll_y_min(0.0);
1179 builder.inner_mut().set_scroll_y_max(max_y as f64);
1180 builder.inner_mut().set_scroll_x(scroll_x as f64);
1181 builder.inner_mut().set_scroll_x_min(0.0);
1182 builder.inner_mut().set_scroll_x_max(max_x as f64);
1183
1184 if max_y > 0.0 {
1187 if scroll_y < max_y {
1188 builder.add_action(teksilo_core::accesskit::Action::ScrollDown);
1189 }
1190 if scroll_y > 0.0 {
1191 builder.add_action(teksilo_core::accesskit::Action::ScrollUp);
1192 }
1193 }
1194 if max_x > 0.0 {
1195 if scroll_x < max_x {
1196 builder.add_action(teksilo_core::accesskit::Action::ScrollRight);
1197 }
1198 if scroll_x > 0.0 {
1199 builder.add_action(teksilo_core::accesskit::Action::ScrollLeft);
1200 }
1201 }
1202 }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208 use teksilo_canvas::SizeProposal;
1209 use teksilo_core::widget::LayoutContext;
1210 use teksilo_core::widget_tree::WidgetTree;
1211
1212 use teksilo_core::widget_builder::WidgetBuilder;
1213
1214 use crate::primitives::VStack;
1215
1216 #[derive(Debug)]
1218 struct TallLeaf {
1219 width: f32,
1220 height: f32,
1221 }
1222
1223 impl TallLeaf {
1224 fn new(w: f32, h: f32) -> Self {
1225 Self {
1226 width: w,
1227 height: h,
1228 }
1229 }
1230 }
1231
1232 impl Widget for TallLeaf {
1233 fn layout_response(
1234 &self,
1235 proposal: SizeProposal,
1236 _ctx: &LayoutContext,
1237 ) -> teksilo_core::widget::LayoutResponse {
1238 Size::new(
1239 proposal.width.unwrap_or(self.width),
1240 proposal.height.unwrap_or(self.height),
1241 )
1242 .into()
1243 }
1244 }
1245
1246 #[derive(Debug)]
1251 struct GrowingLeaf {
1252 width: f32,
1253 height: Rc<Cell<f32>>,
1254 }
1255
1256 impl GrowingLeaf {
1257 fn new(w: f32, height: Rc<Cell<f32>>) -> Self {
1258 Self { width: w, height }
1259 }
1260 }
1261
1262 impl Widget for GrowingLeaf {
1263 fn layout_response(
1264 &self,
1265 proposal: SizeProposal,
1266 _ctx: &LayoutContext,
1267 ) -> teksilo_core::widget::LayoutResponse {
1268 Size::new(
1269 proposal.width.unwrap_or(self.width),
1270 proposal.height.unwrap_or(self.height.get()),
1271 )
1272 .into()
1273 }
1274 }
1275
1276 #[test]
1277 fn scroll_area_clips_hit_test() {
1278 let mut tree = WidgetTree::new();
1279
1280 let a = tree.add(TallLeaf::new(200.0, 100.0));
1282 let b = tree.add(TallLeaf::new(200.0, 100.0));
1283 let c = tree.add(TallLeaf::new(200.0, 100.0));
1284 let content = tree.add(VStack::new().add_child(a).add_child(b).add_child(c));
1285
1286 let scroll = tree.add(ScrollArea::from_id(content));
1287
1288 tree.layout(SizeProposal::exact(200.0, 80.0));
1290
1291 let hit = tree.hit_test(Point::new(50.0, 40.0));
1293 assert!(hit.is_some());
1294
1295 let hit_outside = tree.hit_test(Point::new(50.0, 100.0));
1297 assert!(hit_outside.is_none() || hit_outside == Some(scroll));
1299 }
1300
1301 #[test]
1302 fn scroll_changes_visible_content() {
1303 let mut tree = WidgetTree::new();
1304
1305 let a = tree.add(TallLeaf::new(200.0, 100.0));
1306 let b = tree.add(TallLeaf::new(200.0, 100.0));
1307 let content = tree.add(VStack::new().add_child(a).add_child(b));
1308
1309 let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1310
1311 tree.layout(SizeProposal::exact(200.0, 80.0));
1312
1313 assert!(tree.bounds(a).y >= 0.0);
1315
1316 tree.pointer_move(Point::new(50.0, 40.0));
1318
1319 tree.dispatch_event(WidgetEvent::Scroll {
1321 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
1322 modifiers: Default::default(),
1323 });
1324 tree.layout(SizeProposal::exact(200.0, 80.0));
1325
1326 assert!(tree.bounds(a).y < 0.0);
1328 assert!(tree.bounds(b).y < 80.0);
1330 }
1331
1332 #[test]
1333 fn scroll_accessibility_reports_position() {
1334 let mut tree = WidgetTree::new();
1335 let content = tree.add(TallLeaf::new(200.0, 1000.0));
1336 let scroll = tree.add(ScrollArea::from_id(content));
1337
1338 tree.layout(SizeProposal::exact(200.0, 80.0));
1339
1340 let info = tree.accessibility_node(scroll);
1341 assert_eq!(info.role(), teksilo_core::accesskit::Role::ScrollView);
1342 }
1343
1344 #[test]
1345 fn scroll_offset_is_clamped() {
1346 let mut tree = WidgetTree::new();
1347 let content = tree.add(TallLeaf::new(200.0, 200.0));
1348 let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1349
1350 tree.layout(SizeProposal::exact(200.0, 100.0));
1351
1352 tree.pointer_move(Point::new(50.0, 50.0));
1354
1355 tree.dispatch_event(WidgetEvent::Scroll {
1357 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
1358 modifiers: Default::default(),
1359 });
1360 tree.layout(SizeProposal::exact(200.0, 100.0));
1361
1362 let content_y = tree.bounds(content).y;
1364 assert!(content_y >= -100.0 - 0.01);
1365 }
1366
1367 #[test]
1368 fn permanent_scrollbar_reduces_viewport() {
1369 let mut tree = WidgetTree::new();
1370
1371 let content = TallLeaf::new(200.0, 500.0);
1372 let scroll = tree.add(
1373 ScrollArea::new()
1374 .child(content)
1375 .scroll_bar_style(ScrollBarMode::Permanent)
1376 .scroll_bar_thickness(12.0),
1377 );
1378
1379 tree.layout(SizeProposal::exact(200.0, 100.0));
1380
1381 let scroll_bounds = tree.bounds(scroll);
1383 assert!((scroll_bounds.width - 200.0).abs() < 0.01);
1384 assert!((scroll_bounds.height - 100.0).abs() < 0.01);
1385 }
1386
1387 #[test]
1388 fn permanent_scrollbar_scroll_event_updates_content() {
1389 let mut tree = WidgetTree::new();
1390
1391 let leaf = TallLeaf::new(180.0, 500.0);
1392 let scroll = tree.add(
1393 ScrollArea::new()
1394 .child(leaf)
1395 .scroll_bar_style(ScrollBarMode::Permanent)
1396 .smooth_scrolling(false),
1397 );
1398
1399 tree.layout(SizeProposal::exact(200.0, 100.0));
1400
1401 tree.pointer_move(Point::new(50.0, 50.0));
1403 tree.dispatch_event(WidgetEvent::Scroll {
1404 delta: ScrollDelta::Pixels { x: 0.0, y: 50.0 },
1405 modifiers: Default::default(),
1406 });
1407 tree.layout(SizeProposal::exact(200.0, 100.0));
1408
1409 let children = tree.children(scroll);
1411 assert!(!children.is_empty());
1412 let content_y = tree.bounds(children[0]).y;
1413 assert!(
1414 content_y < 0.0,
1415 "Expected negative y after scroll, got {}",
1416 content_y
1417 );
1418 }
1419
1420 #[test]
1421 fn overlay_mode_has_scrollbar_children() {
1422 let mut tree = WidgetTree::new();
1423 let content = tree.add(TallLeaf::new(200.0, 500.0));
1424 let scroll = tree.add(ScrollArea::from_id(content));
1425
1426 tree.layout(SizeProposal::exact(200.0, 100.0));
1427
1428 let children = tree.children(scroll);
1430 assert_eq!(children.len(), 3, "Overlay mode should have 3 children");
1431
1432 let content_bounds = tree.bounds(children[0]);
1434 assert!(
1435 (content_bounds.width - 200.0).abs() < 0.01,
1436 "Overlay mode should not shrink viewport"
1437 );
1438 }
1439
1440 #[test]
1441 fn scroll_area_new_accepts_inline_widget() {
1442 let mut tree = WidgetTree::new();
1443 let scroll = tree.add(ScrollArea::new().child(TallLeaf::new(200.0, 500.0)));
1445
1446 tree.layout(SizeProposal::exact(200.0, 100.0));
1447
1448 let bounds = tree.bounds(scroll);
1449 assert!((bounds.width - 200.0).abs() < 0.01);
1450 }
1451
1452 #[derive(Debug)]
1454 struct WideLeaf {
1455 width: f32,
1456 height: f32,
1457 }
1458 impl WideLeaf {
1459 fn new(w: f32, h: f32) -> Self {
1460 Self {
1461 width: w,
1462 height: h,
1463 }
1464 }
1465 }
1466 impl Widget for WideLeaf {
1467 fn layout_response(
1468 &self,
1469 _proposal: SizeProposal,
1470 _ctx: &LayoutContext,
1471 ) -> teksilo_core::widget::LayoutResponse {
1472 Size::new(self.width, self.height).into()
1473 }
1474 }
1475
1476 #[test]
1477 fn permanent_horizontal_scrollbar_present() {
1478 let mut tree = WidgetTree::new();
1479 let scroll = tree.add(
1481 ScrollArea::new()
1482 .child(WideLeaf::new(400.0, 500.0))
1483 .scroll_bar_style(ScrollBarMode::Permanent)
1484 .scroll_bar_thickness(12.0),
1485 );
1486
1487 tree.layout(SizeProposal::exact(200.0, 100.0));
1488
1489 let children = tree.children(scroll);
1490 assert_eq!(
1491 children.len(),
1492 3,
1493 "Permanent mode should have content + v_sb + h_sb"
1494 );
1495
1496 let v_sb = tree.bounds(children[1]);
1498 assert!((v_sb.width - 12.0).abs() < 0.01, "v_sb width should be 12");
1499 assert!((v_sb.x - (200.0 - 12.0)).abs() < 0.01, "v_sb at right edge");
1500 assert!(
1501 (v_sb.height - (100.0 - 12.0)).abs() < 0.01,
1502 "v_sb height reduced by h_sb thickness, got {}",
1503 v_sb.height
1504 );
1505
1506 let h_sb = tree.bounds(children[2]);
1508 assert!(
1509 (h_sb.height - 12.0).abs() < 0.01,
1510 "h_sb height should be 12"
1511 );
1512 assert!(
1513 (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1514 "h_sb at bottom edge"
1515 );
1516 assert!(
1517 (h_sb.width - (200.0 - 12.0)).abs() < 0.01,
1518 "h_sb width = bounds.width - v_sb, got {}",
1519 h_sb.width
1520 );
1521 }
1522
1523 #[test]
1524 fn permanent_no_horizontal_when_content_fits() {
1525 let mut tree = WidgetTree::new();
1526 let scroll = tree.add(
1528 ScrollArea::new()
1529 .child(TallLeaf::new(180.0, 500.0))
1530 .scroll_bar_style(ScrollBarMode::Permanent)
1531 .scroll_bar_thickness(12.0),
1532 );
1533
1534 tree.layout(SizeProposal::exact(200.0, 100.0));
1535
1536 let children = tree.children(scroll);
1537 assert_eq!(children.len(), 3);
1538
1539 let v_sb = tree.bounds(children[1]);
1542 assert!(
1543 (v_sb.height - 100.0).abs() < 0.01,
1544 "v_sb should use full height when no h-scroll needed, got {}",
1545 v_sb.height
1546 );
1547 }
1548
1549 #[test]
1550 fn overlay_scrollbar_does_not_reduce_viewport() {
1551 let mut tree = WidgetTree::new();
1552 let scroll = tree.add(
1553 ScrollArea::new()
1554 .child(WideLeaf::new(400.0, 500.0))
1555 .scroll_bar_style(ScrollBarMode::Overlay),
1556 );
1557
1558 tree.layout(SizeProposal::exact(200.0, 100.0));
1559
1560 let children = tree.children(scroll);
1561 assert_eq!(children.len(), 3);
1562
1563 let content = tree.bounds(children[0]);
1565 assert!(
1566 content.width >= 400.0,
1567 "Content should report its full intrinsic width, got {}",
1568 content.width
1569 );
1570
1571 let v_sb = tree.bounds(children[1]);
1573 assert!(
1574 (v_sb.width - 12.0).abs() < 0.01,
1575 "Overlay v_sb should have full thickness for hover expansion, got {}",
1576 v_sb.width
1577 );
1578 assert!(
1579 (v_sb.x - (200.0 - 12.0)).abs() < 0.01,
1580 "Overlay v_sb at right edge"
1581 );
1582
1583 let h_sb = tree.bounds(children[2]);
1585 assert!(
1586 (h_sb.height - 12.0).abs() < 0.01,
1587 "Overlay h_sb should have full thickness for hover expansion, got {}",
1588 h_sb.height
1589 );
1590 assert!(
1591 (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1592 "Overlay h_sb at bottom edge"
1593 );
1594 }
1595
1596 #[test]
1597 fn horizontal_scroll_via_wheel() {
1598 let mut tree = WidgetTree::new();
1599 let scroll = tree.add(
1600 ScrollArea::new()
1601 .child(WideLeaf::new(400.0, 100.0))
1602 .scroll_bar_style(ScrollBarMode::Permanent)
1603 .scroll_bar_thickness(12.0)
1604 .smooth_scrolling(false),
1605 );
1606
1607 tree.layout(SizeProposal::exact(200.0, 100.0));
1608
1609 tree.pointer_move(Point::new(50.0, 50.0));
1610
1611 tree.dispatch_event(WidgetEvent::Scroll {
1613 delta: ScrollDelta::Pixels { x: 80.0, y: 0.0 },
1614 modifiers: Default::default(),
1615 });
1616 tree.layout(SizeProposal::exact(200.0, 100.0));
1617
1618 let children = tree.children(scroll);
1620 let content_x = tree.bounds(children[0]).x;
1621 assert!(
1622 content_x < 0.0,
1623 "Expected negative x after h-scroll, got {}",
1624 content_x
1625 );
1626 }
1627
1628 #[test]
1631 fn vertical_scrollbar_always_off_hides_scrollbar() {
1632 let mut tree = WidgetTree::new();
1633 let scroll = tree.add(
1634 ScrollArea::new()
1635 .child(TallLeaf::new(200.0, 500.0))
1636 .scroll_bar_style(ScrollBarMode::Permanent)
1637 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1638 .scroll_bar_thickness(12.0),
1639 );
1640
1641 tree.layout(SizeProposal::exact(200.0, 100.0));
1642
1643 let children = tree.children(scroll);
1644 let v_sb = tree.bounds(children[1]);
1646 assert!(
1647 (v_sb.width).abs() < 0.01,
1648 "v_sb should be zero-width, got {}",
1649 v_sb.width
1650 );
1651 assert!(
1652 (v_sb.height).abs() < 0.01,
1653 "v_sb should be zero-height, got {}",
1654 v_sb.height
1655 );
1656
1657 let content = tree.bounds(children[0]);
1659 assert!(
1660 (content.width - 200.0).abs() < 0.01,
1661 "Content should use full width when v_sb is off, got {}",
1662 content.width
1663 );
1664 }
1665
1666 #[test]
1667 fn horizontal_scrollbar_always_off_hides_scrollbar() {
1668 let mut tree = WidgetTree::new();
1669 let scroll = tree.add(
1670 ScrollArea::new()
1671 .child(WideLeaf::new(400.0, 500.0))
1672 .scroll_bar_style(ScrollBarMode::Permanent)
1673 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1674 .scroll_bar_thickness(12.0),
1675 );
1676
1677 tree.layout(SizeProposal::exact(200.0, 100.0));
1678
1679 let children = tree.children(scroll);
1680 let h_sb = tree.bounds(children[2]);
1682 assert!(
1683 (h_sb.width).abs() < 0.01,
1684 "h_sb should be zero-width, got {}",
1685 h_sb.width
1686 );
1687
1688 let v_sb = tree.bounds(children[1]);
1690 assert!(
1691 (v_sb.height - 100.0).abs() < 0.01,
1692 "v_sb should use full height when h_sb off, got {}",
1693 v_sb.height
1694 );
1695 }
1696
1697 #[test]
1698 fn scrollbar_always_on_shows_even_when_content_fits() {
1699 let mut tree = WidgetTree::new();
1700 let scroll = tree.add(
1702 ScrollArea::new()
1703 .child(TallLeaf::new(100.0, 50.0))
1704 .scroll_bar_style(ScrollBarMode::Permanent)
1705 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOn)
1706 .scroll_bar_thickness(12.0),
1707 );
1708
1709 tree.layout(SizeProposal::exact(200.0, 100.0));
1710
1711 let children = tree.children(scroll);
1712 let v_sb = tree.bounds(children[1]);
1713 assert!(
1715 (v_sb.width - 12.0).abs() < 0.01,
1716 "v_sb should be visible (12px) even when content fits, got {}",
1717 v_sb.width
1718 );
1719 }
1720
1721 #[test]
1724 fn widget_resizable_stretches_small_content() {
1725 let mut tree = WidgetTree::new();
1726 let scroll = tree.add(
1728 ScrollArea::new()
1729 .child(TallLeaf::new(100.0, 50.0))
1730 .widget_resizable(true),
1731 );
1732
1733 tree.layout(SizeProposal::exact(200.0, 100.0));
1734
1735 let children = tree.children(scroll);
1736 let content = tree.bounds(children[0]);
1737 assert!(
1739 content.width >= 200.0 - 0.01,
1740 "Resizable content width should fill viewport, got {}",
1741 content.width
1742 );
1743 assert!(
1744 content.height >= 100.0 - 0.01,
1745 "Resizable content height should fill viewport, got {}",
1746 content.height
1747 );
1748 }
1749
1750 #[test]
1751 fn widget_resizable_does_not_shrink_large_content() {
1752 let mut tree = WidgetTree::new();
1753 let scroll = tree.add(
1755 ScrollArea::new()
1756 .child(WideLeaf::new(400.0, 500.0))
1757 .widget_resizable(true),
1758 );
1759
1760 tree.layout(SizeProposal::exact(200.0, 100.0));
1761
1762 let children = tree.children(scroll);
1763 let content = tree.bounds(children[0]);
1764 assert!(
1765 content.width >= 400.0 - 0.01,
1766 "Large content should not be shrunk, got {}",
1767 content.width
1768 );
1769 assert!(
1770 content.height >= 500.0 - 0.01,
1771 "Large content should not be shrunk, got {}",
1772 content.height
1773 );
1774 }
1775
1776 #[test]
1779 fn smooth_scrolling_line_events_use_animation() {
1780 let mut tree = WidgetTree::new();
1781 let scroll = tree.add(
1782 ScrollArea::new()
1783 .child(TallLeaf::new(200.0, 1000.0))
1784 .smooth_scrolling(true),
1785 );
1786
1787 tree.layout(SizeProposal::exact(200.0, 100.0));
1788
1789 tree.pointer_move(Point::new(50.0, 50.0));
1790
1791 tree.dispatch_event(WidgetEvent::Scroll {
1793 delta: ScrollDelta::Lines { x: 0.0, y: 5.0 },
1794 modifiers: Default::default(),
1795 });
1796
1797 tree.layout(SizeProposal::exact(200.0, 100.0));
1801
1802 tree.tick_animations(Duration::from_millis(75));
1804 tree.layout(SizeProposal::exact(200.0, 100.0));
1805
1806 let children = tree.children(scroll);
1807 let content_y = tree.bounds(children[0]).y;
1808 assert!(
1810 content_y < 0.0,
1811 "Expected partial scroll, got y={}",
1812 content_y
1813 );
1814 assert!(
1815 content_y > -100.0,
1816 "Should not have reached target yet, got y={}",
1817 content_y
1818 );
1819 }
1820
1821 #[test]
1822 fn smooth_scrolling_disabled_jumps_immediately() {
1823 let mut tree = WidgetTree::new();
1824 let scroll = tree.add(
1825 ScrollArea::new()
1826 .child(TallLeaf::new(200.0, 1000.0))
1827 .smooth_scrolling(false),
1828 );
1829
1830 tree.layout(SizeProposal::exact(200.0, 100.0));
1831
1832 tree.pointer_move(Point::new(50.0, 50.0));
1833
1834 tree.dispatch_event(WidgetEvent::Scroll {
1835 delta: ScrollDelta::Lines { x: 0.0, y: 5.0 },
1836 modifiers: Default::default(),
1837 });
1838 tree.layout(SizeProposal::exact(200.0, 100.0));
1839
1840 let children = tree.children(scroll);
1841 let content_y = tree.bounds(children[0]).y;
1842 assert!(
1844 (content_y - (-100.0)).abs() < 0.01,
1845 "Should jump immediately, got y={}",
1846 content_y
1847 );
1848 }
1849
1850 #[test]
1853 fn preferred_size_overrides_default() {
1854 let mut tree = WidgetTree::new();
1855 let scroll = tree.add(
1856 ScrollArea::new()
1857 .child(TallLeaf::new(200.0, 500.0))
1858 .preferred_size(500.0, 400.0),
1859 );
1860 tree.layout(SizeProposal {
1862 width: None,
1863 height: None,
1864 });
1865 let bounds = tree.bounds(scroll);
1866 assert!(
1867 (bounds.width - 500.0).abs() < 0.01,
1868 "Should use preferred width, got {}",
1869 bounds.width
1870 );
1871 assert!(
1872 (bounds.height - 400.0).abs() < 0.01,
1873 "Should use preferred height, got {}",
1874 bounds.height
1875 );
1876 }
1877
1878 #[test]
1879 fn constrained_proposal_overrides_preferred_size() {
1880 let mut tree = WidgetTree::new();
1881 let scroll = tree.add(
1882 ScrollArea::new()
1883 .child(TallLeaf::new(200.0, 500.0))
1884 .preferred_size(500.0, 400.0),
1885 );
1886 tree.layout(SizeProposal::exact(200.0, 100.0));
1888 let bounds = tree.bounds(scroll);
1889 assert!((bounds.width - 200.0).abs() < 0.01);
1890 assert!((bounds.height - 100.0).abs() < 0.01);
1891 }
1892
1893 #[test]
1896 fn scroll_survives_theme_switch_at_root() {
1897 let mut tree = WidgetTree::new();
1898 let scroll = tree.add(
1899 ScrollArea::new()
1900 .child(TallLeaf::new(200.0, 500.0))
1901 .smooth_scrolling(false),
1902 );
1903 tree.layout(SizeProposal::exact(200.0, 100.0));
1904
1905 tree.pointer_move(Point::new(50.0, 50.0));
1907 tree.dispatch_event(WidgetEvent::Scroll {
1908 delta: ScrollDelta::Pixels { x: 0.0, y: 150.0 },
1909 modifiers: Default::default(),
1910 });
1911 tree.layout(SizeProposal::exact(200.0, 100.0));
1912
1913 let content = tree.children(scroll)[0];
1914 let content_y_before = tree.bounds(content).y;
1915 assert!(
1916 content_y_before < -100.0,
1917 "Content should have scrolled; got y={}",
1918 content_y_before
1919 );
1920
1921 tree.set_theme(teksilo_core::presets::intui::dark());
1923 tree.layout(SizeProposal::exact(200.0, 100.0));
1924
1925 let content = tree.children(scroll)[0];
1926 let content_y_after = tree.bounds(content).y;
1927 assert!(
1928 (content_y_after - content_y_before).abs() < 0.01,
1929 "Scroll offset should survive theme switch: before={}, after={}",
1930 content_y_before,
1931 content_y_after
1932 );
1933 }
1934
1935 #[derive(Debug)]
1939 struct ScrollParent {
1940 scroll_id: Option<WidgetId>,
1941 }
1942 impl ScrollParent {
1943 fn new() -> Self {
1944 Self { scroll_id: None }
1945 }
1946 }
1947 impl Widget for ScrollParent {
1948 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1949 let id = ctx.add(
1950 ScrollArea::new()
1951 .child(TallLeaf::new(200.0, 500.0))
1952 .smooth_scrolling(false),
1953 );
1954 self.scroll_id = Some(id);
1955 vec![id]
1956 }
1957 fn layout_response(
1958 &self,
1959 proposal: SizeProposal,
1960 ctx: &LayoutContext,
1961 ) -> teksilo_core::widget::LayoutResponse {
1962 self.scroll_id
1963 .and_then(|id| ctx.child_size(id, proposal))
1964 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1965 .into()
1966 }
1967 fn place_children(
1968 &self,
1969 bounds: Rect,
1970 _proposal: SizeProposal,
1971 children: &mut [WidgetPlacement],
1972 _ctx: &LayoutContext,
1973 ) {
1974 if let Some(child) = children.first_mut() {
1975 child.origin = bounds.origin();
1976 child.size = bounds.size();
1977 }
1978 }
1979 }
1980
1981 #[test]
1982 fn scroll_survives_theme_switch_inside_composite() {
1983 let mut tree = WidgetTree::new();
1984 let parent = tree.add(ScrollParent::new());
1985 tree.layout(SizeProposal::exact(200.0, 100.0));
1986
1987 tree.pointer_move(Point::new(50.0, 50.0));
1988 tree.dispatch_event(WidgetEvent::Scroll {
1989 delta: ScrollDelta::Pixels { x: 0.0, y: 150.0 },
1990 modifiers: Default::default(),
1991 });
1992 tree.layout(SizeProposal::exact(200.0, 100.0));
1993
1994 let scroll_before = tree.children(parent)[0];
1995 let content_before = tree.children(scroll_before)[0];
1996 let y_before = tree.bounds(content_before).y;
1997 assert!(
1998 y_before < -100.0,
1999 "Content should have scrolled; got y={}",
2000 y_before
2001 );
2002
2003 tree.set_theme(teksilo_core::presets::intui::dark());
2004 tree.layout(SizeProposal::exact(200.0, 100.0));
2005
2006 let scroll_after = tree.children(parent)[0];
2007 let content_after = tree.children(scroll_after)[0];
2008 let y_after = tree.bounds(content_after).y;
2009 assert!(
2010 (y_after - y_before).abs() < 0.01,
2011 "Scroll offset should survive theme switch inside composite: before={}, after={}",
2012 y_before,
2013 y_after
2014 );
2015 }
2016
2017 #[test]
2030 fn scroll_into_view_brings_widget_above_viewport_into_view() {
2031 let mut tree = WidgetTree::new();
2032
2033 let header = tree.add(TallLeaf::new(200.0, 50.0));
2037 let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2039 let after = tree.add(TallLeaf::new(200.0, 470.0));
2040 let content = tree.add(VStack::new().add_child(target).add_child(after));
2041 let scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
2042 let _root = tree.add(VStack::new().add_child(header).add_child(scroll));
2043
2044 tree.layout(SizeProposal::exact(200.0, 250.0));
2045
2046 let scroll_bounds = tree.bounds(scroll);
2047 assert!(
2048 (scroll_bounds.y - 50.0).abs() < 0.01,
2049 "ScrollArea should sit below the header at y=50, got {}",
2050 scroll_bounds.y
2051 );
2052
2053 tree.pointer_move(Point::new(100.0, 100.0));
2055 tree.dispatch_event(WidgetEvent::Scroll {
2056 delta: ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2057 modifiers: Default::default(),
2058 });
2059 tree.layout(SizeProposal::exact(200.0, 250.0));
2060
2061 let target_before = tree.bounds(target);
2062 assert!(
2063 target_before.bottom() < scroll_bounds.y,
2064 "Target should be above viewport before focus, got y={} (viewport top={})",
2065 target_before.y,
2066 scroll_bounds.y
2067 );
2068
2069 tree.focus(target);
2072 tree.layout(SizeProposal::exact(200.0, 250.0));
2073
2074 let target_after = tree.bounds(target);
2075 let viewport_top = scroll_bounds.y;
2076 let viewport_bottom = scroll_bounds.bottom();
2077 assert!(
2078 target_after.y >= viewport_top - 0.5 && target_after.bottom() <= viewport_bottom + 0.5,
2079 "Target should be inside viewport after focus, got y={}..{} (viewport={}..{})",
2080 target_after.y,
2081 target_after.bottom(),
2082 viewport_top,
2083 viewport_bottom
2084 );
2085 }
2086
2087 struct PinFixture {
2098 tree: WidgetTree,
2099 bounds: Rect,
2100 viewport_h: f32,
2101 scroll_y: Signal<f32>,
2102 max_scroll_y: Signal<f32>,
2103 ratio_y: Signal<f32>,
2104 request: Rc<Cell<(Rect, f32)>>,
2107 }
2108
2109 fn pin_fixture(content_h: f32, viewport_h: f32, past_end: f32) -> PinFixture {
2110 let request = Rc::new(Cell::new((Rect::new(0.0, 0.0, 0.0, 0.0), 0.5)));
2111 let mut tree = WidgetTree::new();
2112
2113 let req = request.clone();
2114 let actor = tree.add(TallLeaf::new(200.0, content_h).focusable(true).on_key(
2115 move |_ev, ctx| {
2116 let (rect, fraction) = req.get();
2117 ctx.ensure_visible_aligned(
2118 rect,
2119 fraction,
2120 teksilo_core::event::ScrollMotion::Instant,
2121 );
2122 EventResponse::Handled
2123 },
2124 ));
2125 let content = tree.add(VStack::new().add_child(actor));
2126 let sa = ScrollArea::from_id(content)
2127 .smooth_scrolling(false)
2128 .scroll_past_end(past_end);
2129 let scroll_y = sa.scroll_y_signal().clone();
2130 let max_scroll_y = sa.max_scroll_y_signal().clone();
2131 let ratio_y = sa.viewport_ratio_y_signal().clone();
2132 let scroll = tree.add(sa);
2133 tree.layout(SizeProposal::exact(200.0, viewport_h));
2134 tree.focus(actor);
2135 tree.layout(SizeProposal::exact(200.0, viewport_h));
2138 scroll_y.set(0.0);
2139
2140 let bounds = tree.bounds(scroll);
2141 PinFixture {
2142 tree,
2143 bounds,
2144 viewport_h,
2145 scroll_y,
2146 max_scroll_y,
2147 ratio_y,
2148 request,
2149 }
2150 }
2151
2152 impl PinFixture {
2153 fn pin(&mut self, content_y: f32, height: f32, fraction: f32) {
2156 let window_y = self.bounds.y + content_y - self.scroll_y.get();
2157 self.request
2158 .set((Rect::new(0.0, window_y, 200.0, height), fraction));
2159 self.tree.dispatch_event(WidgetEvent::KeyDown {
2160 key: teksilo_core::event::Key::ArrowDown,
2161 modifiers: Default::default(),
2162 text: None,
2163 });
2164 self.tree
2165 .layout(SizeProposal::exact(200.0, self.viewport_h));
2166 }
2167 }
2168
2169 #[test]
2170 fn scroll_past_end_extends_the_range_without_changing_the_content() {
2171 let plain = pin_fixture(300.0, 100.0, 0.0);
2173 assert_eq!(plain.max_scroll_y.get(), 200.0);
2174
2175 let padded = pin_fixture(300.0, 100.0, 0.5);
2177 assert_eq!(
2178 padded.max_scroll_y.get(),
2179 250.0,
2180 "scroll_past_end(0.5) must add half a viewport of range"
2181 );
2182 }
2183
2184 #[test]
2185 fn scroll_past_end_keeps_the_thumb_proportional() {
2186 let f = pin_fixture(300.0, 100.0, 0.5);
2189 let expected = 100.0 / 350.0;
2191 assert!(
2192 (f.ratio_y.get() - expected).abs() < 1e-4,
2193 "thumb ratio must use the extended range, got {}",
2194 f.ratio_y.get()
2195 );
2196 }
2197
2198 #[test]
2199 fn scroll_past_end_lets_the_last_line_reach_a_centre_pin() {
2200 let mut f = pin_fixture(300.0, 100.0, 0.5);
2204 f.pin(280.0, 20.0, 0.5);
2205
2206 assert_eq!(
2210 f.scroll_y.get(),
2211 240.0,
2212 "the last line must be able to sit at the pin"
2213 );
2214 }
2215
2216 #[test]
2217 fn without_scroll_past_end_the_last_line_cannot_reach_the_pin() {
2218 let mut f = pin_fixture(300.0, 100.0, 0.0);
2221 f.pin(280.0, 20.0, 0.5);
2222 assert_eq!(
2223 f.scroll_y.get(),
2224 200.0,
2225 "clamped to the un-extended maximum"
2226 );
2227 }
2228
2229 #[test]
2230 fn a_pin_near_the_document_start_clamps_instead_of_scrolling_negative() {
2231 let mut f = pin_fixture(300.0, 100.0, 0.5);
2234 f.pin(0.0, 20.0, 0.5);
2235 assert_eq!(
2236 f.scroll_y.get(),
2237 0.0,
2238 "the first line must clamp at the top, never scroll past it"
2239 );
2240 }
2241
2242 #[test]
2243 fn a_fraction_pin_places_the_target_at_that_height() {
2244 let mut f = pin_fixture(600.0, 100.0, 0.0);
2246 f.pin(300.0, 20.0, 0.25);
2247 assert_eq!(f.scroll_y.get(), 280.0);
2249 }
2250
2251 #[test]
2252 fn a_pin_re_asserts_on_an_already_visible_target() {
2253 let mut f = pin_fixture(600.0, 100.0, 0.0);
2257 f.scroll_y.set(250.0);
2258 f.tree.layout(SizeProposal::exact(200.0, 100.0));
2259
2260 f.pin(300.0, 20.0, 0.5);
2262
2263 assert_eq!(
2264 f.scroll_y.get(),
2265 260.0,
2266 "a pin must move an already-visible target onto the mark"
2267 );
2268 }
2269
2270 #[test]
2271 fn scroll_into_view_reveals_target_through_two_nested_scroll_areas() {
2272 use crate::primitives::FixedSize;
2279
2280 let mut tree = WidgetTree::new();
2281 let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2283 let inner_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2284 let inner_tail = tree.add(TallLeaf::new(200.0, 100.0));
2285 let inner_content = tree.add(
2286 VStack::new()
2287 .add_child(inner_spacer)
2288 .add_child(target)
2289 .add_child(inner_tail),
2290 );
2291 let inner_sa = tree.add(ScrollArea::from_id(inner_content).smooth_scrolling(false));
2292 let inner_box = tree.add(
2294 FixedSize::new()
2295 .width(200.0)
2296 .height(80.0)
2297 .child_id(inner_sa),
2298 );
2299 let outer_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2301 let outer_tail = tree.add(TallLeaf::new(200.0, 200.0));
2302 let outer_content = tree.add(
2303 VStack::new()
2304 .add_child(outer_spacer)
2305 .add_child(inner_box)
2306 .add_child(outer_tail),
2307 );
2308 let outer_sa = tree.add(ScrollArea::from_id(outer_content).smooth_scrolling(false));
2309
2310 let sz = SizeProposal::exact(200.0, 100.0);
2312 tree.layout(sz);
2313
2314 tree.focus(target);
2316 tree.layout(sz);
2317
2318 let outer_bounds = tree.bounds(outer_sa);
2319 let t = tree.bounds(target);
2320 assert!(
2321 t.y >= outer_bounds.y - 1.0 && t.bottom() <= outer_bounds.bottom() + 1.0,
2322 "target must be visible in the outer window after both scroll: target y={}..{}, \
2323 outer viewport {}..{}",
2324 t.y,
2325 t.bottom(),
2326 outer_bounds.y,
2327 outer_bounds.bottom()
2328 );
2329 }
2330
2331 #[derive(Debug)]
2336 struct FixedLeaf(f32, f32);
2337 impl Widget for FixedLeaf {
2338 fn layout_response(
2339 &self,
2340 _proposal: SizeProposal,
2341 _ctx: &LayoutContext,
2342 ) -> teksilo_core::widget::LayoutResponse {
2343 Size::new(self.0, self.1).into()
2344 }
2345 }
2346
2347 #[test]
2348 fn rtl_anchors_narrow_content_to_trailing_edge() {
2349 let mut tree = WidgetTree::new();
2353 let content = tree.add(FixedLeaf(120.0, 80.0));
2354 let _scroll = tree.add(ScrollArea::from_id(content));
2355
2356 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
2357 tree.layout(SizeProposal::exact(400.0, 200.0));
2358
2359 let cb = tree.bounds(content);
2360 assert!(
2361 (cb.x - (400.0 - 120.0)).abs() < 0.01,
2362 "RTL content should be flush-right at x=280, got {}",
2363 cb.x
2364 );
2365 }
2366
2367 #[test]
2368 fn ltr_anchors_narrow_content_to_leading_edge() {
2369 let mut tree = WidgetTree::new();
2370 let content = tree.add(FixedLeaf(120.0, 80.0));
2371 let _scroll = tree.add(ScrollArea::from_id(content));
2372
2373 tree.layout(SizeProposal::exact(400.0, 200.0));
2374
2375 let cb = tree.bounds(content);
2376 assert!(
2377 cb.x.abs() < 0.01,
2378 "LTR content should be flush-left at x=0, got {}",
2379 cb.x
2380 );
2381 }
2382
2383 fn nested_scroll_fixture(
2387 inner_overscroll: OverscrollBehavior,
2388 ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
2389 let mut tree = WidgetTree::new();
2390
2391 let inner_content = tree.add(TallLeaf::new(200.0, 300.0));
2392 let inner_sa = ScrollArea::from_id(inner_content)
2393 .smooth_scrolling(false)
2394 .preferred_size(200.0, 100.0)
2395 .overscroll_behavior(inner_overscroll);
2396 let inner_y = inner_sa.scroll_y_signal().clone();
2397 let inner = tree.add(inner_sa);
2398
2399 let filler = tree.add(TallLeaf::new(200.0, 200.0));
2400 let outer_content = tree.add(VStack::new().add_child(inner).add_child(filler));
2401 let outer_sa = ScrollArea::from_id(outer_content).smooth_scrolling(false);
2402 let outer_y = outer_sa.scroll_y_signal().clone();
2403 let _outer = tree.add(outer_sa);
2404
2405 tree.layout(SizeProposal::exact(200.0, 150.0));
2406 (tree, inner_y, outer_y)
2407 }
2408
2409 #[test]
2410 fn nested_scroll_chains_to_outer_at_boundary() {
2411 let (mut tree, inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Chain);
2412
2413 tree.pointer_move(Point::new(50.0, 40.0));
2415 tree.dispatch_event(WidgetEvent::Scroll {
2416 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2417 modifiers: Default::default(),
2418 });
2419 tree.layout(SizeProposal::exact(200.0, 150.0));
2420
2421 let inner_bottom = inner_y.get();
2422 assert!(inner_bottom > 0.0, "inner should have scrolled down");
2423 assert!(
2424 outer_y.get() < 0.01,
2425 "outer must not move while the inner still absorbs the scroll"
2426 );
2427
2428 tree.pointer_move(Point::new(50.0, 40.0));
2430 tree.dispatch_event(WidgetEvent::Scroll {
2431 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2432 modifiers: Default::default(),
2433 });
2434 tree.layout(SizeProposal::exact(200.0, 150.0));
2435
2436 assert!(
2437 (inner_y.get() - inner_bottom).abs() < 0.01,
2438 "inner stays clamped at its bottom"
2439 );
2440 assert!(
2441 outer_y.get() > 0.01,
2442 "outer scrolled because the inner chained the boundary scroll"
2443 );
2444 }
2445
2446 #[test]
2447 fn contain_blocks_scroll_chaining() {
2448 let (mut tree, _inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Contain);
2449
2450 tree.pointer_move(Point::new(50.0, 40.0));
2451 tree.dispatch_event(WidgetEvent::Scroll {
2452 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2453 modifiers: Default::default(),
2454 });
2455 tree.layout(SizeProposal::exact(200.0, 150.0));
2456
2457 tree.pointer_move(Point::new(50.0, 40.0));
2459 tree.dispatch_event(WidgetEvent::Scroll {
2460 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2461 modifiers: Default::default(),
2462 });
2463 tree.layout(SizeProposal::exact(200.0, 150.0));
2464
2465 assert!(
2466 outer_y.get() < 0.01,
2467 "Contain must prevent chaining: outer stays put"
2468 );
2469 }
2470
2471 #[derive(Debug)]
2478 struct RecordingLeaf {
2479 width: f32,
2480 height: f32,
2481 log: Rc<std::cell::RefCell<Vec<SizeProposal>>>,
2482 }
2483
2484 impl Widget for RecordingLeaf {
2485 fn layout_response(
2486 &self,
2487 proposal: SizeProposal,
2488 _ctx: &LayoutContext,
2489 ) -> teksilo_core::widget::LayoutResponse {
2490 self.log.borrow_mut().push(proposal);
2491 Size::new(
2492 proposal.width.unwrap_or(self.width),
2493 proposal.height.unwrap_or(self.height),
2494 )
2495 .into()
2496 }
2497 }
2498
2499 #[test]
2500 fn preferred_height_reports_natural_width_when_parent_proposes_unbounded() {
2501 let mut tree = WidgetTree::new();
2504 let content = tree.add(TallLeaf::new(392.0, 500.0));
2505 let scroll = tree.add(ScrollArea::from_id(content).preferred_height(150.0));
2506
2507 tree.layout(SizeProposal {
2509 width: None,
2510 height: None,
2511 });
2512
2513 let bounds = tree.bounds(scroll);
2514 assert!(
2515 (bounds.width - 392.0).abs() < 0.01,
2516 "should report the content's real natural width, got {}",
2517 bounds.width
2518 );
2519 assert!(
2520 (bounds.height - 150.0).abs() < 0.01,
2521 "should still cap the height at preferred_height, got {}",
2522 bounds.height
2523 );
2524 }
2525
2526 #[test]
2527 fn bounded_proposal_never_triggers_an_unbounded_content_query() {
2528 let log: Rc<std::cell::RefCell<Vec<SizeProposal>>> =
2530 Rc::new(std::cell::RefCell::new(Vec::new()));
2531 let mut tree = WidgetTree::new();
2532 let content = tree.add(RecordingLeaf {
2533 width: 900.0,
2534 height: 500.0,
2535 log: log.clone(),
2536 });
2537 tree.add(ScrollArea::from_id(content));
2538
2539 tree.layout(SizeProposal::exact(300.0, 100.0));
2541
2542 let recorded = log.borrow();
2543 assert!(!recorded.is_empty(), "content widget was never laid out");
2544 for proposal in recorded.iter() {
2545 assert!(
2546 proposal.width.is_some(),
2547 "content queried with an unbounded width ({:?}) even though the \
2548 incoming proposal was already bounded — the unbounded natural-width \
2549 measure must only run when `proposal.width` is `None`",
2550 proposal
2551 );
2552 }
2553 }
2554
2555 #[test]
2556 fn exact_proposal_still_wins_over_natural_width() {
2557 let mut tree = WidgetTree::new();
2560 let content = tree.add(TallLeaf::new(900.0, 500.0));
2561 let scroll = tree.add(ScrollArea::from_id(content));
2562
2563 tree.layout(SizeProposal::exact(300.0, 100.0));
2564
2565 let bounds = tree.bounds(scroll);
2566 assert!(
2567 (bounds.width - 300.0).abs() < 0.01,
2568 "exact proposal must win over the content's natural width, got {}",
2569 bounds.width
2570 );
2571 assert!(
2572 (bounds.height - 100.0).abs() < 0.01,
2573 "exact proposal must win over the content's natural height, got {}",
2574 bounds.height
2575 );
2576 }
2577
2578 #[test]
2594 fn cross_axis_overflow_through_a_vstack_is_scrollable() {
2595 use crate::primitives::{HStack, Padding};
2596
2597 let mut tree = WidgetTree::new();
2598 let cells: Vec<_> = (0..4)
2600 .map(|_| tree.add(TallLeaf::new(200.0, 40.0)))
2601 .collect();
2602 let mut row = HStack::new();
2603 for &c in &cells {
2604 row = row.add_child(c);
2605 }
2606 let row = tree.add(row);
2607 let col = tree.add(VStack::new().add_child(row));
2608 let padded = tree.add(Padding::uniform(20.0).child_id(col));
2609 let _scroll = tree.add(ScrollArea::from_id(padded).smooth_scrolling(false));
2610
2611 tree.layout(SizeProposal::exact(600.0, 400.0));
2612
2613 let last = *cells.last().unwrap();
2614 assert!(
2615 tree.bounds(last).x > 600.0,
2616 "precondition: the 4th cell should start beyond the viewport, got x={}",
2617 tree.bounds(last).x
2618 );
2619
2620 tree.pointer_move(Point::new(300.0, 40.0));
2622 tree.dispatch_event(WidgetEvent::Scroll {
2623 delta: ScrollDelta::Pixels { x: 300.0, y: 0.0 },
2624 modifiers: Default::default(),
2625 });
2626 tree.layout(SizeProposal::exact(600.0, 400.0));
2627
2628 let b = tree.bounds(last);
2629 assert!(
2630 b.x >= 0.0 && b.x + b.width <= 600.5,
2631 "the 4th cell must be reachable by horizontal scrolling; got x={} w={}",
2632 b.x,
2633 b.width
2634 );
2635 }
2636
2637 #[test]
2641 fn restore_scroll_y_lands_on_the_first_measured_layout() {
2642 let mut tree = WidgetTree::new();
2644 let sa = ScrollArea::new()
2645 .child(TallLeaf::new(200.0, 500.0))
2646 .smooth_scrolling(false)
2647 .restore_scroll_y(150.0);
2648 let scroll_y = sa.scroll_y_signal().clone();
2649 let max_scroll_y = sa.max_scroll_y_signal().clone();
2650 let _scroll = tree.add(sa);
2651
2652 tree.layout(SizeProposal::exact(200.0, 100.0));
2656
2657 assert_eq!(max_scroll_y.get(), 400.0);
2658 assert_eq!(
2659 scroll_y.get(),
2660 150.0,
2661 "the restored offset must land on the first laid-out frame"
2662 );
2663 }
2664
2665 #[test]
2666 fn restore_scroll_y_is_not_re_applied_after_a_later_reflow() {
2667 let mut tree = WidgetTree::new();
2668 let sa = ScrollArea::new()
2669 .child(TallLeaf::new(200.0, 500.0))
2670 .smooth_scrolling(false)
2671 .restore_scroll_y(150.0);
2672 let scroll_y = sa.scroll_y_signal().clone();
2673 let _scroll = tree.add(sa);
2674
2675 tree.layout(SizeProposal::exact(200.0, 100.0));
2676 assert_eq!(scroll_y.get(), 150.0, "precondition: restore landed once");
2677
2678 scroll_y.set(70.0);
2681 tree.layout(SizeProposal::exact(200.0, 120.0));
2682
2683 assert_eq!(
2684 scroll_y.get(),
2685 70.0,
2686 "a one-shot restore must not re-arm itself on a later reflow"
2687 );
2688 }
2689
2690 #[test]
2691 fn a_restore_the_content_can_never_hold_does_not_pin_the_reader() {
2692 let mut tree = WidgetTree::new();
2702 let sa = ScrollArea::new()
2703 .child(TallLeaf::new(200.0, 150.0))
2704 .smooth_scrolling(false)
2705 .restore_scroll_y(200.0);
2706 let scroll_y = sa.scroll_y_signal().clone();
2707 let _scroll = tree.add(sa);
2708
2709 tree.layout(SizeProposal::exact(200.0, 100.0));
2710 assert_eq!(
2711 scroll_y.get(),
2712 50.0,
2713 "precondition: the offset lands clamped to the range that exists"
2714 );
2715
2716 scroll_y.set(0.0);
2718 tree.layout(SizeProposal::exact(200.0, 100.0));
2719
2720 assert_eq!(
2721 scroll_y.get(),
2722 0.0,
2723 "a drag away from the clamped landing must stand the restore down, \
2724 not be undone by the next layout pass"
2725 );
2726 }
2727
2728 #[test]
2729 fn a_restore_still_waits_out_content_that_is_only_slow_to_measure() {
2730 let mut tree = WidgetTree::new();
2739 let height = Rc::new(Cell::new(150.0));
2740 let sa = ScrollArea::new()
2741 .child(GrowingLeaf::new(200.0, height.clone()))
2742 .smooth_scrolling(false)
2743 .restore_scroll_y(200.0);
2744 let scroll_y = sa.scroll_y_signal().clone();
2745 let _scroll = tree.add(sa);
2746
2747 tree.layout(SizeProposal::exact(200.0, 100.0));
2748 assert_eq!(scroll_y.get(), 50.0, "clamped to the range measured so far");
2749
2750 height.set(400.0);
2751 tree.layout(SizeProposal::exact(200.0, 100.0));
2752 assert_eq!(
2753 scroll_y.get(),
2754 200.0,
2755 "the range grew past the offset, so the offset lands in full"
2756 );
2757
2758 scroll_y.set(10.0);
2760 height.set(900.0);
2761 tree.layout(SizeProposal::exact(200.0, 100.0));
2762 assert_eq!(scroll_y.get(), 10.0, "a one-shot does not re-arm");
2763 }
2764
2765 #[test]
2766 fn restore_scroll_y_past_the_range_never_lets_an_observer_see_the_overshoot() {
2767 let mut tree = WidgetTree::new();
2775 let sa = ScrollArea::new()
2776 .child(TallLeaf::new(200.0, 500.0))
2777 .smooth_scrolling(false)
2778 .restore_scroll_y(9999.0);
2779 let scroll_y = sa.scroll_y_signal().clone();
2780 let max_scroll_y = sa.max_scroll_y_signal().clone();
2781
2782 let seen: Rc<std::cell::RefCell<Vec<f32>>> = Rc::new(std::cell::RefCell::new(Vec::new()));
2783 let recorder = seen.clone();
2784 let _observer = scroll_y.observe(move |v: &f32| recorder.borrow_mut().push(*v));
2785
2786 let _scroll = tree.add(sa);
2787 tree.layout(SizeProposal::exact(200.0, 100.0));
2788
2789 assert_eq!(
2790 scroll_y.get(),
2791 400.0,
2792 "it must settle at the end of the range"
2793 );
2794 assert_eq!(scroll_y.get(), max_scroll_y.get());
2795 let overshoot: Vec<f32> = seen
2796 .borrow()
2797 .iter()
2798 .copied()
2799 .filter(|v| *v > max_scroll_y.get())
2800 .collect();
2801 assert!(
2802 overshoot.is_empty(),
2803 "an observer saw an offset past the end of the content: {overshoot:?}"
2804 );
2805 }
2806
2807 #[test]
2808 fn restore_scroll_y_waits_for_a_range_long_enough_to_hold_it() {
2809 let height = Rc::new(Cell::new(500.0_f32));
2816 let mut tree = WidgetTree::new();
2817 let sa = ScrollArea::new()
2818 .child(GrowingLeaf::new(200.0, height.clone()))
2819 .smooth_scrolling(false)
2820 .restore_scroll_y(11560.0);
2821 let scroll_y = sa.scroll_y_signal().clone();
2822 let max_scroll_y = sa.max_scroll_y_signal().clone();
2823 let _scroll = tree.add(sa);
2824
2825 tree.layout(SizeProposal::exact(200.0, 100.0));
2826 assert_eq!(
2827 max_scroll_y.get(),
2828 400.0,
2829 "precondition: a short first pass"
2830 );
2831 assert_eq!(
2832 scroll_y.get(),
2833 400.0,
2834 "as far down as the content so far allows, so the page is never at the top"
2835 );
2836
2837 height.set(12000.0);
2838 tree.layout(SizeProposal::exact(200.0, 100.0));
2839 assert_eq!(
2840 scroll_y.get(),
2841 11560.0,
2842 "once the content is long enough, the offset must land in full"
2843 );
2844
2845 scroll_y.set(60.0);
2847 height.set(20000.0);
2848 tree.layout(SizeProposal::exact(200.0, 100.0));
2849 assert_eq!(
2850 scroll_y.get(),
2851 60.0,
2852 "a restore already honoured must not re-assert itself on a later reflow"
2853 );
2854 }
2855
2856 #[test]
2857 fn a_reader_scrolling_stands_down_a_restore_that_has_not_landed() {
2858 let height = Rc::new(Cell::new(500.0_f32));
2863 let mut tree = WidgetTree::new();
2864 let sa = ScrollArea::new()
2865 .child(GrowingLeaf::new(200.0, height.clone()))
2866 .smooth_scrolling(false)
2867 .restore_scroll_y(11560.0);
2868 let scroll_y = sa.scroll_y_signal().clone();
2869 let _scroll = tree.add(sa);
2870
2871 tree.layout(SizeProposal::exact(200.0, 100.0));
2872 assert_eq!(scroll_y.get(), 400.0, "precondition: still pending");
2873
2874 tree.pointer_move(Point::new(50.0, 40.0));
2875 tree.dispatch_event(WidgetEvent::Scroll {
2876 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2877 modifiers: Default::default(),
2878 });
2879 let after_reader = scroll_y.get();
2880
2881 height.set(12000.0);
2882 tree.layout(SizeProposal::exact(200.0, 100.0));
2883 assert_eq!(
2884 scroll_y.get(),
2885 after_reader,
2886 "the content growing must not yank a reader who has already scrolled"
2887 );
2888 }
2889
2890 #[test]
2891 fn without_restore_scroll_y_behaviour_is_unchanged() {
2892 let mut tree = WidgetTree::new();
2895 let sa = ScrollArea::new()
2896 .child(TallLeaf::new(200.0, 500.0))
2897 .smooth_scrolling(false);
2898 let scroll_y = sa.scroll_y_signal().clone();
2899 let _scroll = tree.add(sa);
2900
2901 tree.layout(SizeProposal::exact(200.0, 100.0));
2902 assert_eq!(scroll_y.get(), 0.0);
2903
2904 tree.layout(SizeProposal::exact(200.0, 120.0));
2906 assert_eq!(scroll_y.get(), 0.0);
2907 }
2908
2909 #[test]
2910 fn restore_scroll_y_of_zero_arms_nothing_and_leaves_a_host_write_alone() {
2911 let mut tree = WidgetTree::new();
2917 let sa = ScrollArea::new()
2918 .child(TallLeaf::new(200.0, 500.0))
2919 .smooth_scrolling(false)
2920 .restore_scroll_y(0.0);
2921 let scroll_y = sa.scroll_y_signal().clone();
2922 let _scroll = tree.add(sa);
2923
2924 scroll_y.set(120.0);
2925 tree.layout(SizeProposal::exact(200.0, 100.0));
2926
2927 assert_eq!(
2928 scroll_y.get(),
2929 120.0,
2930 "restore_scroll_y(0.0) armed a restore and overwrote the host's own offset"
2931 );
2932 }
2933
2934 #[test]
2935 fn restore_scroll_y_of_zero_disarms_a_previously_armed_offset() {
2936 let mut tree = WidgetTree::new();
2942 let sa = ScrollArea::new()
2943 .child(TallLeaf::new(200.0, 500.0))
2944 .smooth_scrolling(false)
2945 .restore_scroll_y(150.0)
2946 .restore_scroll_y(0.0);
2947 let scroll_y = sa.scroll_y_signal().clone();
2948 let _scroll = tree.add(sa);
2949
2950 scroll_y.set(120.0);
2951 tree.layout(SizeProposal::exact(200.0, 100.0));
2952
2953 assert_eq!(
2954 scroll_y.get(),
2955 120.0,
2956 "a later restore_scroll_y(0.0) must disarm the earlier pending offset"
2957 );
2958 }
2959}