1use std::cell::{Cell, RefCell};
46use std::rc::Rc;
47use std::time::Duration;
48
49use teksilo_canvas::{Point, Rect, Size, SizeProposal, Vec2};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::binding::BindingLevel;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::color_prop::ColorProp;
54use teksilo_core::event::{EventResponse, WidgetEvent};
55use teksilo_core::kinetic::KineticScroller;
56use teksilo_core::pointer::touch_action::PanAxes;
57use teksilo_core::pointer::{ScrollPhase, ScrollSource};
58use teksilo_core::signal::{Prop, Signal};
59use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
60use teksilo_core::widget_builder::HandlerSet;
61use teksilo_core::widget_id::WidgetId;
62use teksilo_tokens::{Easing, OverscrollStyle, RevealPolicy};
63
64use crate::common::scroll::OverscrollBehavior;
65use crate::common::scrollable::{ScrollableAxes, ScrollableBehavior};
66use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum ScrollBarMode {
71 #[default]
75 Overlay,
76 Permanent,
80 Thin,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum ScrollBarPolicy {
95 #[default]
97 AsNeeded,
98 AlwaysOn,
100 AlwaysOff,
110}
111
112pub struct ScrollArea {
118 content_child: Option<Box<dyn Widget>>,
119 content_child_id: Option<WidgetId>,
120 scroll_bar_style: ScrollBarMode,
121 vertical_policy: ScrollBarPolicy,
123 horizontal_policy: ScrollBarPolicy,
124 line_height: f32,
126 scroll_bar_thickness: f32,
128 scroll_bar_thumb_color: Option<ColorProp>,
132 widget_resizable: bool,
134 smooth_scrolling: bool,
136 smooth_scroll_duration: Duration,
138 preferred_size: Option<Size>,
141 preferred_height: Option<f32>,
143 overscroll_behavior: OverscrollBehavior,
147 scroll_past_end: Prop<f32>,
150
151 scroll_y: Signal<f32>,
154 scroll_x: Signal<f32>,
156 max_scroll_y: Signal<f32>,
158 max_scroll_x: Signal<f32>,
160 viewport_ratio_y: Signal<f32>,
162 viewport_ratio_x: Signal<f32>,
164
165 child_ids: Vec<WidgetId>,
168
169 content_size: Cell<Size>,
171 viewport_size: Rc<Cell<Size>>,
176 viewport_origin: Rc<Cell<Point>>,
181
182 pending_restore_y: Rc<Cell<Option<f32>>>,
188 restore_wrote_y: Rc<Cell<Option<f32>>>,
203
204 scroller: Rc<RefCell<KineticScroller>>,
210 overscroll: Signal<Vec2>,
213 rubber_band: bool,
216 scrollbar_reveal: Signal<bool>,
219}
220
221impl Default for ScrollArea {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227impl std::fmt::Debug for ScrollArea {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 f.debug_struct("ScrollArea")
230 .field("scroll_y", &self.scroll_y.get())
231 .field("scroll_x", &self.scroll_x.get())
232 .field("style", &self.scroll_bar_style)
233 .field("v_policy", &self.vertical_policy)
234 .field("h_policy", &self.horizontal_policy)
235 .field("widget_resizable", &self.widget_resizable)
236 .field("content_size", &self.content_size.get())
237 .field("viewport_size", &self.viewport_size.get())
238 .finish()
239 }
240}
241
242impl ScrollArea {
243 pub fn new() -> Self {
245 Self {
246 content_child: None,
247 content_child_id: None,
248 scroll_bar_style: ScrollBarMode::default(),
249 vertical_policy: ScrollBarPolicy::default(),
250 horizontal_policy: ScrollBarPolicy::default(),
251 line_height: 20.0,
252 scroll_bar_thickness: 12.0,
253 scroll_bar_thumb_color: None,
254 widget_resizable: false,
255 smooth_scrolling: true,
256 smooth_scroll_duration: Duration::from_millis(150),
257 preferred_size: None,
258 preferred_height: None,
259 overscroll_behavior: OverscrollBehavior::default(),
260 scroll_past_end: Prop::Static(0.0),
261 scroll_y: Signal::new_animated(0.0),
262 scroll_x: Signal::new_animated(0.0),
263 max_scroll_y: Signal::new(0.0),
264 max_scroll_x: Signal::new(0.0),
265 viewport_ratio_y: Signal::new(1.0),
266 viewport_ratio_x: Signal::new(1.0),
267 child_ids: Vec::new(),
268 content_size: Cell::new(Size::ZERO),
269 viewport_size: Rc::new(Cell::new(Size::ZERO)),
270 viewport_origin: Rc::new(Cell::new(Point::ZERO)),
271 pending_restore_y: Rc::new(Cell::new(None)),
272 restore_wrote_y: Rc::new(Cell::new(None)),
273 scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
274 overscroll: Signal::new(Vec2::ZERO),
275 rubber_band: false,
276 scrollbar_reveal: Signal::new(false),
277 }
278 }
279
280 pub fn rubber_band(mut self, enabled: bool) -> Self {
290 self.rubber_band = enabled;
291 self
292 }
293
294 pub fn overscroll_signal(&self) -> Signal<Vec2> {
300 self.overscroll.clone()
301 }
302
303 pub fn child(mut self, child: impl teksilo_core::IntoTeksiChild) -> Self {
305 match teksilo_core::IntoTeksiChild::into_pending(child) {
306 teksilo_core::PendingChild::Id(id) => {
307 self.content_child_id = Some(id);
308 self.content_child = None;
309 self
310 }
311 teksilo_core::PendingChild::Deferred(w) => {
312 self.content_child = Some(w);
313 self.content_child_id = None;
314 self
315 }
316 }
317 }
318 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
325 match widget {
326 Some(w) => self.child(w),
327 None => self,
328 }
329 }
330
331 pub fn from_id(child: WidgetId) -> Self {
333 let mut sa = Self::new();
334 sa.content_child_id = Some(child);
335 sa
336 }
337
338 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
340 self.scroll_bar_style = style;
341 self
342 }
343
344 pub fn scroll_bar_thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
353 self.scroll_bar_thumb_color = Some(color.into());
354 self
355 }
356
357 pub fn vertical_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
359 self.vertical_policy = policy;
360 self
361 }
362
363 pub fn horizontal_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
365 self.horizontal_policy = policy;
366 self
367 }
368
369 pub fn line_height(mut self, lh: f32) -> Self {
371 self.line_height = lh;
372 self
373 }
374
375 pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self {
377 self.scroll_bar_thickness = thickness;
378 self
379 }
380
381 pub fn widget_resizable(mut self, resizable: bool) -> Self {
384 self.widget_resizable = resizable;
385 self
386 }
387
388 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
395 self.smooth_scrolling = enabled;
396 self
397 }
398
399 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
401 self.smooth_scroll_duration = duration;
402 self
403 }
404
405 pub fn scroll_past_end(mut self, fraction: impl Into<Prop<f32>>) -> Self {
427 self.scroll_past_end = fraction.into();
428 self
429 }
430
431 pub fn preferred_size(mut self, width: f32, height: f32) -> Self {
442 self.preferred_size = Some(Size::new(width, height));
443 self
444 }
445
446 fn natural_content_width(&self, ctx: &LayoutContext) -> f32 {
459 if let Some(&child) = self.child_ids.first()
462 && let Some(size) = ctx.child_size(
463 child,
464 SizeProposal {
465 width: None,
466 height: None,
467 },
468 )
469 && size.width > 0.0
470 {
471 return size.width;
472 }
473 let cached = self.content_size.get().width;
474 if cached > 0.0 { cached } else { 300.0 }
475 }
476
477 pub fn preferred_height(mut self, height: f32) -> Self {
487 self.preferred_height = Some(height);
488 self
489 }
490
491 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
495 self.overscroll_behavior = behavior;
496 self
497 }
498
499 pub fn restore_scroll_y(self, offset: f32) -> Self {
522 self.pending_restore_y.set((offset > 0.0).then_some(offset));
526 self.restore_wrote_y.set(None);
531 self
532 }
533
534 pub fn scroll_y_signal(&self) -> &Signal<f32> {
536 &self.scroll_y
537 }
538
539 pub fn scroll_x_signal(&self) -> &Signal<f32> {
541 &self.scroll_x
542 }
543
544 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
550 &self.max_scroll_y
551 }
552
553 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
558 &self.viewport_ratio_y
559 }
560
561 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
565 &self.max_scroll_x
566 }
567
568 pub(crate) fn viewport_size_cell(&self) -> Rc<Cell<Size>> {
578 self.viewport_size.clone()
579 }
580
581 fn clamp_and_set_scroll(&self) {
582 let max_y = self.max_scroll_y.get();
583 let max_x = self.max_scroll_x.get();
584 let cur_y = self.scroll_y.get();
585 let cur_x = self.scroll_x.get();
586 let clamped_y = cur_y.clamp(0.0, max_y);
587 let clamped_x = cur_x.clamp(0.0, max_x);
588 if (clamped_y - cur_y).abs() > f32::EPSILON {
589 self.scroll_y.set(clamped_y);
590 }
591 if (clamped_x - cur_x).abs() > f32::EPSILON {
592 self.scroll_x.set(clamped_x);
593 }
594 }
595}
596
597impl Widget for ScrollArea {
598 fn as_any(&self) -> Option<&dyn std::any::Any> {
603 Some(self)
604 }
605
606 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
607 let mut ids = Vec::new();
608
609 let content_id = if let Some(child) = self.content_child.take() {
611 ctx.add_boxed(child)
612 } else if let Some(id) = self.content_child_id.take() {
613 id
614 } else if !self.child_ids.is_empty() {
615 return self.child_ids.clone();
617 } else {
618 self.child_ids.clear();
624 return Vec::new();
625 };
626 ids.push(content_id);
627
628 let visual = match self.scroll_bar_style {
630 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
631 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
632 ScrollBarMode::Thin => ScrollBarVisual::Thin,
633 };
634 let thickness = self.scroll_bar_thickness; let mut v_scrollbar = ScrollBar::new(
638 ScrollBarOrientation::Vertical,
639 self.scroll_y.clone(),
640 self.max_scroll_y.clone(),
641 self.viewport_ratio_y.clone(),
642 )
643 .thickness(thickness)
644 .visual(visual)
645 .reveal(self.scrollbar_reveal.clone());
649 if let Some(tint) = &self.scroll_bar_thumb_color {
650 v_scrollbar = v_scrollbar.thumb_color(tint.clone());
651 }
652 let v_id = ctx.add(v_scrollbar);
653 ids.push(v_id);
654
655 let mut h_scrollbar = ScrollBar::new(
657 ScrollBarOrientation::Horizontal,
658 self.scroll_x.clone(),
659 self.max_scroll_x.clone(),
660 self.viewport_ratio_x.clone(),
661 )
662 .thickness(thickness)
663 .visual(visual)
664 .reveal(self.scrollbar_reveal.clone());
665 if let Some(tint) = &self.scroll_bar_thumb_color {
666 h_scrollbar = h_scrollbar.thumb_color(tint.clone());
667 }
668 let h_id = ctx.add(h_scrollbar);
669 ids.push(h_id);
670
671 ctx.register_animated_signal(&self.scroll_y);
673 ctx.register_animated_signal(&self.scroll_x);
674
675 let self_id = ctx.self_id();
677 let registry = ctx.binding_registry();
678 self.scroll_y
679 .bind_to(self_id, registry, BindingLevel::Relayout);
680 self.scroll_x
681 .bind_to(self_id, registry, BindingLevel::Relayout);
682 self.scroll_past_end
686 .register_if_bound(self_id, registry, BindingLevel::Relayout);
687
688 self.child_ids = ids.clone();
689
690 let scroll_y = self.scroll_y.clone();
692 let scroll_x = self.scroll_x.clone();
693 let max_scroll_y = self.max_scroll_y.clone();
694 let max_scroll_x = self.max_scroll_x.clone();
695 let viewport_size = self.viewport_size.clone();
696 let viewport_origin = self.viewport_origin.clone();
697 let line_height = self.line_height;
698 let smooth_scrolling = self.smooth_scrolling;
699 let smooth_scroll_duration = self.smooth_scroll_duration;
700 let overscroll_behavior = self.overscroll_behavior;
701
702 let clamp_and_set = {
703 let scroll_y = scroll_y.clone();
704 let scroll_x = scroll_x.clone();
705 let max_scroll_y = max_scroll_y.clone();
706 let max_scroll_x = max_scroll_x.clone();
707 move || {
708 let max_y = max_scroll_y.get();
709 let max_x = max_scroll_x.get();
710 let cur_y = scroll_y.get();
711 let cur_x = scroll_x.get();
712 let clamped_y = cur_y.clamp(0.0, max_y);
713 let clamped_x = cur_x.clamp(0.0, max_x);
714 if (clamped_y - cur_y).abs() > f32::EPSILON {
715 scroll_y.set(clamped_y);
716 }
717 if (clamped_x - cur_x).abs() > f32::EPSILON {
718 scroll_x.set(clamped_x);
719 }
720 }
721 };
722
723 let mut handlers = HandlerSet::new().clips_children(true);
724
725 {
736 let scroll_y = scroll_y.clone();
743 let scroll_x = scroll_x.clone();
744 let max_scroll_y = max_scroll_y.clone();
745 let max_scroll_x = max_scroll_x.clone();
746 let viewport_size = viewport_size.clone();
747 let viewport_origin = viewport_origin.clone();
748 let pending_restore_y = self.pending_restore_y.clone();
754 let restore_wrote_y = self.restore_wrote_y.clone();
755 let reveal = self.scrollbar_reveal.clone();
760 let reveal_at_rest = ctx.theme().input.reveal == RevealPolicy::Always;
761
762 let own_arm = move |event: &WidgetEvent,
763 ctx: &mut teksilo_core::widget::EventContext|
764 -> Option<EventResponse> {
765 match event {
766 WidgetEvent::Scroll { phase, .. } => {
767 pending_restore_y.set(None);
768 restore_wrote_y.set(None);
769 if ctx.scroll_source() == ScrollSource::TouchPan {
770 let in_flight = !matches!(
771 phase,
772 ScrollPhase::Ended
773 | ScrollPhase::MomentumEnded
774 | ScrollPhase::Cancelled
775 );
776 let want = in_flight || reveal_at_rest;
777 if reveal.get() != want {
778 reveal.set(want);
779 }
780 }
781 None
786 }
787 WidgetEvent::ScrollIntoView {
788 target_bounds,
789 margin,
790 align,
791 motion,
792 applied_scroll,
793 } => {
794 pending_restore_y.set(None);
795 restore_wrote_y.set(None);
796 let vp = viewport_size.get();
803 let vo = viewport_origin.get();
804 let sy = scroll_y.get();
805 let sx = scroll_x.get();
806
807 let viewport_top = sy;
815 let viewport_bottom = viewport_top + vp.height;
816 let target_top = target_bounds.y - vo.y + sy - margin;
817 let target_bottom = target_top + target_bounds.height + margin * 2.0;
818
819 let mut new_y = sy;
820 match align {
821 teksilo_core::event::ScrollAlign::Fraction(f) => {
827 let target_top = target_bounds.y - vo.y + sy;
828 new_y = target_top - (vp.height - target_bounds.height) * f;
829 }
830 teksilo_core::event::ScrollAlign::Minimal => {
831 if !(target_top <= viewport_top && target_bottom >= viewport_bottom)
832 {
833 if target_top < viewport_top {
834 new_y = target_top;
835 } else if target_bottom > viewport_bottom {
836 new_y = target_bottom - vp.height;
837 }
838 }
839 }
840 }
841
842 let viewport_left = sx;
843 let viewport_right = viewport_left + vp.width;
844 let target_left = target_bounds.x - vo.x + sx - margin;
845 let target_right = target_left + target_bounds.width + margin * 2.0;
846
847 let mut new_x = sx;
848 if !(target_left <= viewport_left && target_right >= viewport_right) {
849 if target_left < viewport_left {
850 new_x = target_left;
851 } else if target_right > viewport_right {
852 new_x = target_right - vp.width;
853 }
854 }
855
856 let new_y = new_y.clamp(0.0, max_scroll_y.get());
861 let new_x = new_x.clamp(0.0, max_scroll_x.get());
862
863 match motion {
864 teksilo_core::event::ScrollMotion::Smooth if smooth_scrolling => {
865 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
866 scroll_x.animate_to(new_x, smooth_scroll_duration, Easing::EaseOut);
867 }
868 _ => {
869 scroll_y.set(new_y);
870 scroll_x.set(new_x);
871 }
872 }
873 if let Some(cell) = applied_scroll
879 && let Ok(mut d) = cell.lock()
880 {
881 *d = teksilo_canvas::Point::new(new_x - sx, new_y - sy);
882 }
883 Some(EventResponse::Handled)
884 }
885 _ => None,
886 }
887 };
888
889 let axes = ScrollableAxes {
890 x: self.scroll_x.clone(),
891 y: self.scroll_y.clone(),
892 max_x: self.max_scroll_x.clone(),
893 max_y: self.max_scroll_y.clone(),
894 overscroll: self.overscroll.clone(),
895 };
896 let behavior = ScrollableBehavior::new(axes)
897 .with_scroller(self.scroller.clone())
898 .axes(PanAxes::BOTH)
902 .overscroll(overscroll_behavior)
903 .rubber_band(self.rubber_band)
904 .overscroll_style(OverscrollStyle::RubberBand)
905 .smooth(smooth_scrolling)
906 .smooth_duration(smooth_scroll_duration)
907 .line_height(line_height)
908 .reduced_motion(ctx.prefers_reduced_motion())
909 .physics(ctx.theme().input.scroll_physics)
910 .before(own_arm);
911 handlers = behavior.install(handlers);
912 }
913
914 {
916 let scroll_y = scroll_y.clone();
917 let scroll_x = scroll_x.clone();
918 let viewport_size = viewport_size.clone();
919 let clamp_and_set = clamp_and_set.clone();
920 handlers = handlers.on_access_action(move |action, _ctx| match action {
921 teksilo_core::accesskit::Action::ScrollDown => {
922 let step = viewport_size.get().height * 0.9;
923 scroll_y.set(scroll_y.get() + step);
924 clamp_and_set();
925 EventResponse::Handled
926 }
927 teksilo_core::accesskit::Action::ScrollUp => {
928 let step = viewport_size.get().height * 0.9;
929 scroll_y.set(scroll_y.get() - step);
930 clamp_and_set();
931 EventResponse::Handled
932 }
933 teksilo_core::accesskit::Action::ScrollRight => {
934 let step = viewport_size.get().width * 0.9;
935 scroll_x.set(scroll_x.get() + step);
936 clamp_and_set();
937 EventResponse::Handled
938 }
939 teksilo_core::accesskit::Action::ScrollLeft => {
940 let step = viewport_size.get().width * 0.9;
941 scroll_x.set(scroll_x.get() - step);
942 clamp_and_set();
943 EventResponse::Handled
944 }
945 _ => EventResponse::Ignored,
946 });
947 }
948
949 ctx.apply_self_handlers(handlers);
950
951 ids
952 }
953
954 fn layout_response(
955 &self,
956 proposal: SizeProposal,
957 ctx: &LayoutContext,
958 ) -> teksilo_core::widget::LayoutResponse {
959 let (default_w, default_h) = if let Some(pref) = self.preferred_size {
965 (pref.width, pref.height)
966 } else {
967 let h = self.preferred_height.unwrap_or(200.0);
968 let w = if proposal.width.is_none() {
979 self.natural_content_width(ctx)
980 } else {
981 0.0
982 };
983 (w, h)
984 };
985 proposal.resolve(default_w, default_h).into()
986 }
987
988 fn place_children(
989 &self,
990 bounds: Rect,
991 _proposal: SizeProposal,
992 children: &mut [WidgetPlacement],
993 ctx: &LayoutContext,
994 ) {
995 if children.is_empty() {
996 return;
997 }
998
999 let has_v = children.len() > 1;
1004 let has_h = children.len() > 2;
1005 let v_off = self.vertical_policy == ScrollBarPolicy::AlwaysOff;
1006 let _h_off = self.horizontal_policy == ScrollBarPolicy::AlwaysOff;
1007
1008 let sb_thickness = self.scroll_bar_thickness;
1010
1011 let resolve_show = |policy: ScrollBarPolicy, has_bar: bool, overflows: bool| -> bool {
1015 has_bar
1016 && match policy {
1017 ScrollBarPolicy::AlwaysOn => true,
1018 ScrollBarPolicy::AlwaysOff => false,
1019 ScrollBarPolicy::AsNeeded => overflows,
1020 }
1021 };
1022
1023 let v_reserved_1 = match self.scroll_bar_style {
1025 ScrollBarMode::Permanent if has_v && !v_off => sb_thickness,
1026 _ => 0.0,
1027 };
1028 let vp_w1 = (bounds.width - v_reserved_1).max(0.0);
1029 let content_size_1 = ctx
1030 .child_size(
1031 children[0].id,
1032 SizeProposal {
1033 width: Some(vp_w1),
1034 height: None,
1035 },
1036 )
1037 .unwrap_or(Size::new(vp_w1, bounds.height));
1038
1039 let show_v_1 = resolve_show(
1040 self.vertical_policy,
1041 has_v,
1042 content_size_1.height > bounds.height + 0.5,
1043 );
1044 let show_h_1 = resolve_show(
1045 self.horizontal_policy,
1046 has_h,
1047 content_size_1.width > vp_w1 + 0.5,
1048 );
1049
1050 let v_res = match self.scroll_bar_style {
1052 ScrollBarMode::Permanent if show_v_1 => sb_thickness,
1053 _ => 0.0,
1054 };
1055 let h_res = match self.scroll_bar_style {
1056 ScrollBarMode::Permanent if show_h_1 => sb_thickness,
1057 _ => 0.0,
1058 };
1059
1060 let vp_h_after_h = (bounds.height - h_res).max(0.0);
1062 let new_needs_v = content_size_1.height > vp_h_after_h + 0.5;
1063 let show_v = resolve_show(self.vertical_policy, has_v, new_needs_v);
1064 let new_v_res = match self.scroll_bar_style {
1065 ScrollBarMode::Permanent if show_v => sb_thickness,
1066 _ => 0.0,
1067 };
1068
1069 let (viewport_width, content_size, show_h) = if (new_v_res - v_res).abs() > 0.01 {
1070 let vp_w2 = (bounds.width - new_v_res).max(0.0);
1072 let cs2 = ctx
1073 .child_size(
1074 children[0].id,
1075 SizeProposal {
1076 width: Some(vp_w2),
1077 height: None,
1078 },
1079 )
1080 .unwrap_or(Size::new(vp_w2, bounds.height));
1081 let sh2 = resolve_show(self.horizontal_policy, has_h, cs2.width > vp_w2 + 0.5);
1082 (vp_w2, cs2, sh2)
1083 } else {
1084 (
1085 (bounds.width - new_v_res).max(0.0),
1086 content_size_1,
1087 show_h_1,
1088 )
1089 };
1090
1091 let v_reserved = new_v_res;
1092 let h_reserved = match self.scroll_bar_style {
1093 ScrollBarMode::Permanent if show_h => sb_thickness,
1094 _ => 0.0,
1095 };
1096 let viewport_height = (bounds.height - h_reserved).max(0.0);
1097
1098 let placed_content_size = if self.widget_resizable {
1100 Size::new(
1101 content_size.width.max(viewport_width),
1102 content_size.height.max(viewport_height),
1103 )
1104 } else {
1105 content_size
1106 };
1107
1108 self.content_size.set(placed_content_size);
1121 self.viewport_size
1122 .set(Size::new(viewport_width, viewport_height));
1123 self.viewport_origin.set(bounds.origin());
1124 self.scroller
1129 .borrow_mut()
1130 .set_viewport(Vec2::new(viewport_width, viewport_height));
1131
1132 let set_if_changed = |sig: &Signal<f32>, v: f32| {
1139 if (sig.get() - v).abs() > f32::EPSILON {
1140 sig.set(v);
1141 }
1142 };
1143
1144 let past_end = (self.scroll_past_end.get().max(0.0)) * viewport_height;
1149 let scrollable_height = placed_content_size.height + past_end;
1150
1151 let max_y = (scrollable_height - viewport_height).max(0.0);
1152 let max_x = (placed_content_size.width - viewport_width).max(0.0);
1153 set_if_changed(&self.max_scroll_y, max_y);
1154 set_if_changed(&self.max_scroll_x, max_x);
1155
1156 let ratio_y = if scrollable_height > 0.0 {
1157 (viewport_height / scrollable_height).clamp(0.0, 1.0)
1158 } else {
1159 1.0
1160 };
1161 let ratio_x = if placed_content_size.width > 0.0 {
1162 (viewport_width / placed_content_size.width).clamp(0.0, 1.0)
1163 } else {
1164 1.0
1165 };
1166 set_if_changed(&self.viewport_ratio_y, ratio_y);
1167 set_if_changed(&self.viewport_ratio_x, ratio_x);
1168
1169 if let Some(ours) = self.restore_wrote_y.get()
1198 && (self.scroll_y.get() - ours).abs() > f32::EPSILON
1199 {
1200 self.pending_restore_y.set(None);
1201 self.restore_wrote_y.set(None);
1202 }
1203 if let Some(pending) = self.pending_restore_y.get()
1204 && max_y > 0.0
1205 {
1206 let landed = pending.min(max_y);
1207 if (landed - self.scroll_y.get()).abs() > f32::EPSILON {
1208 self.scroll_y.set(landed);
1209 }
1210 if max_y >= pending {
1211 self.pending_restore_y.set(None);
1212 self.restore_wrote_y.set(None);
1213 } else {
1214 self.restore_wrote_y.set(Some(landed));
1217 }
1218 }
1219
1220 self.clamp_and_set_scroll();
1221 let scroll_y = self.scroll_y.get();
1222 let scroll_x = self.scroll_x.get();
1223
1224 let content_x = if ctx.is_rtl() {
1233 bounds.right() - placed_content_size.width + scroll_x
1234 } else {
1235 bounds.x - scroll_x
1236 };
1237 children[0].origin = Point::new(content_x, bounds.y - scroll_y);
1238 children[0].size = placed_content_size;
1239
1240 if has_v {
1242 if show_v {
1243 let sb_x = if ctx.is_rtl() {
1244 bounds.x
1245 } else {
1246 bounds.right() - sb_thickness
1247 };
1248 let sb_h = if h_reserved > 0.0
1249 || (matches!(
1250 self.scroll_bar_style,
1251 ScrollBarMode::Overlay | ScrollBarMode::Thin
1252 ) && show_h)
1253 {
1254 bounds.height - sb_thickness
1255 } else {
1256 bounds.height
1257 };
1258 children[1].origin = Point::new(sb_x, bounds.y);
1259 children[1].size = Size::new(sb_thickness, sb_h);
1260 } else {
1261 children[1].origin = Point::new(bounds.x, bounds.y);
1263 children[1].size = Size::ZERO;
1264 }
1265 }
1266
1267 if has_h {
1269 if show_h {
1270 let sb_y = bounds.bottom() - sb_thickness;
1271 let sb_x = if ctx.is_rtl() && v_reserved > 0.0 {
1272 bounds.x + sb_thickness
1273 } else {
1274 bounds.x
1275 };
1276 let sb_w = if v_reserved > 0.0
1277 || (matches!(
1278 self.scroll_bar_style,
1279 ScrollBarMode::Overlay | ScrollBarMode::Thin
1280 ) && show_v)
1281 {
1282 bounds.width - sb_thickness
1283 } else {
1284 bounds.width
1285 };
1286 children[2].origin = Point::new(sb_x, sb_y);
1287 children[2].size = Size::new(sb_w, sb_thickness);
1288 } else {
1289 children[2].origin = Point::new(bounds.x, bounds.y);
1290 children[2].size = Size::ZERO;
1291 }
1292 }
1293 }
1294
1295 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
1296 }
1298
1299 fn children(&self) -> Vec<WidgetId> {
1300 self.child_ids.clone()
1301 }
1302
1303 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1304 builder.set_role(teksilo_core::accesskit::Role::ScrollView);
1305 builder.inner_mut().set_clips_children();
1306
1307 let scroll_y = self.scroll_y.get();
1308 let scroll_x = self.scroll_x.get();
1309 let max_y = self.max_scroll_y.get();
1310 let max_x = self.max_scroll_x.get();
1311
1312 builder.inner_mut().set_scroll_y(scroll_y as f64);
1313 builder.inner_mut().set_scroll_y_min(0.0);
1314 builder.inner_mut().set_scroll_y_max(max_y as f64);
1315 builder.inner_mut().set_scroll_x(scroll_x as f64);
1316 builder.inner_mut().set_scroll_x_min(0.0);
1317 builder.inner_mut().set_scroll_x_max(max_x as f64);
1318
1319 if max_y > 0.0 {
1322 if scroll_y < max_y {
1323 builder.add_action(teksilo_core::accesskit::Action::ScrollDown);
1324 }
1325 if scroll_y > 0.0 {
1326 builder.add_action(teksilo_core::accesskit::Action::ScrollUp);
1327 }
1328 }
1329 if max_x > 0.0 {
1330 if scroll_x < max_x {
1331 builder.add_action(teksilo_core::accesskit::Action::ScrollRight);
1332 }
1333 if scroll_x > 0.0 {
1334 builder.add_action(teksilo_core::accesskit::Action::ScrollLeft);
1335 }
1336 }
1337 }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343 use teksilo_canvas::SizeProposal;
1344 use teksilo_core::event::ScrollDelta;
1345 use teksilo_core::widget::LayoutContext;
1346 use teksilo_core::widget_tree::WidgetTree;
1347
1348 use teksilo_core::widget_builder::WidgetBuilder;
1349
1350 use crate::primitives::VStack;
1351
1352 #[derive(Debug)]
1354 struct TallLeaf {
1355 width: f32,
1356 height: f32,
1357 }
1358
1359 impl TallLeaf {
1360 fn new(w: f32, h: f32) -> Self {
1361 Self {
1362 width: w,
1363 height: h,
1364 }
1365 }
1366 }
1367
1368 impl Widget for TallLeaf {
1369 fn layout_response(
1370 &self,
1371 proposal: SizeProposal,
1372 _ctx: &LayoutContext,
1373 ) -> teksilo_core::widget::LayoutResponse {
1374 Size::new(
1375 proposal.width.unwrap_or(self.width),
1376 proposal.height.unwrap_or(self.height),
1377 )
1378 .into()
1379 }
1380 }
1381
1382 #[derive(Debug)]
1387 struct GrowingLeaf {
1388 width: f32,
1389 height: Rc<Cell<f32>>,
1390 }
1391
1392 impl GrowingLeaf {
1393 fn new(w: f32, height: Rc<Cell<f32>>) -> Self {
1394 Self { width: w, height }
1395 }
1396 }
1397
1398 impl Widget for GrowingLeaf {
1399 fn layout_response(
1400 &self,
1401 proposal: SizeProposal,
1402 _ctx: &LayoutContext,
1403 ) -> teksilo_core::widget::LayoutResponse {
1404 Size::new(
1405 proposal.width.unwrap_or(self.width),
1406 proposal.height.unwrap_or(self.height.get()),
1407 )
1408 .into()
1409 }
1410 }
1411
1412 #[test]
1413 fn scroll_area_clips_hit_test() {
1414 let mut tree = WidgetTree::new();
1415
1416 let a = tree.add(TallLeaf::new(200.0, 100.0));
1418 let b = tree.add(TallLeaf::new(200.0, 100.0));
1419 let c = tree.add(TallLeaf::new(200.0, 100.0));
1420 let content = tree.add(VStack::new().child(a).child(b).child(c));
1421
1422 let scroll = tree.add(ScrollArea::from_id(content));
1423
1424 tree.layout(SizeProposal::exact(200.0, 80.0));
1426
1427 let hit = tree.hit_test(Point::new(50.0, 40.0));
1429 assert!(hit.is_some());
1430
1431 let hit_outside = tree.hit_test(Point::new(50.0, 100.0));
1433 assert!(hit_outside.is_none() || hit_outside == Some(scroll));
1435 }
1436
1437 #[test]
1438 fn scroll_changes_visible_content() {
1439 let mut tree = WidgetTree::new();
1440
1441 let a = tree.add(TallLeaf::new(200.0, 100.0));
1442 let b = tree.add(TallLeaf::new(200.0, 100.0));
1443 let content = tree.add(VStack::new().child(a).child(b));
1444
1445 let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1446
1447 tree.layout(SizeProposal::exact(200.0, 80.0));
1448
1449 assert!(tree.bounds(a).y >= 0.0);
1451
1452 tree.pointer_move(Point::new(50.0, 40.0));
1454
1455 tree.dispatch_event(WidgetEvent::scroll(
1457 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
1458 Default::default(),
1459 ));
1460 tree.layout(SizeProposal::exact(200.0, 80.0));
1461
1462 assert!(tree.bounds(a).y < 0.0);
1464 assert!(tree.bounds(b).y < 80.0);
1466 }
1467
1468 #[test]
1469 fn scroll_accessibility_reports_position() {
1470 let mut tree = WidgetTree::new();
1471 let content = tree.add(TallLeaf::new(200.0, 1000.0));
1472 let scroll = tree.add(ScrollArea::from_id(content));
1473
1474 tree.layout(SizeProposal::exact(200.0, 80.0));
1475
1476 let info = tree.accessibility_node(scroll);
1477 assert_eq!(info.role(), teksilo_core::accesskit::Role::ScrollView);
1478 }
1479
1480 #[test]
1481 fn scroll_offset_is_clamped() {
1482 let mut tree = WidgetTree::new();
1483 let content = tree.add(TallLeaf::new(200.0, 200.0));
1484 let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1485
1486 tree.layout(SizeProposal::exact(200.0, 100.0));
1487
1488 tree.pointer_move(Point::new(50.0, 50.0));
1490
1491 tree.dispatch_event(WidgetEvent::scroll(
1493 ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
1494 Default::default(),
1495 ));
1496 tree.layout(SizeProposal::exact(200.0, 100.0));
1497
1498 let content_y = tree.bounds(content).y;
1500 assert!(content_y >= -100.0 - 0.01);
1501 }
1502
1503 #[test]
1504 fn permanent_scrollbar_reduces_viewport() {
1505 let mut tree = WidgetTree::new();
1506
1507 let content = TallLeaf::new(200.0, 500.0);
1508 let scroll = tree.add(
1509 ScrollArea::new()
1510 .child(content)
1511 .scroll_bar_style(ScrollBarMode::Permanent)
1512 .scroll_bar_thickness(12.0),
1513 );
1514
1515 tree.layout(SizeProposal::exact(200.0, 100.0));
1516
1517 let scroll_bounds = tree.bounds(scroll);
1519 assert!((scroll_bounds.width - 200.0).abs() < 0.01);
1520 assert!((scroll_bounds.height - 100.0).abs() < 0.01);
1521 }
1522
1523 #[test]
1524 fn permanent_scrollbar_scroll_event_updates_content() {
1525 let mut tree = WidgetTree::new();
1526
1527 let leaf = TallLeaf::new(180.0, 500.0);
1528 let scroll = tree.add(
1529 ScrollArea::new()
1530 .child(leaf)
1531 .scroll_bar_style(ScrollBarMode::Permanent)
1532 .smooth_scrolling(false),
1533 );
1534
1535 tree.layout(SizeProposal::exact(200.0, 100.0));
1536
1537 tree.pointer_move(Point::new(50.0, 50.0));
1539 tree.dispatch_event(WidgetEvent::scroll(
1540 ScrollDelta::Pixels { x: 0.0, y: 50.0 },
1541 Default::default(),
1542 ));
1543 tree.layout(SizeProposal::exact(200.0, 100.0));
1544
1545 let children = tree.children(scroll);
1547 assert!(!children.is_empty());
1548 let content_y = tree.bounds(children[0]).y;
1549 assert!(
1550 content_y < 0.0,
1551 "Expected negative y after scroll, got {}",
1552 content_y
1553 );
1554 }
1555
1556 #[test]
1557 fn overlay_mode_has_scrollbar_children() {
1558 let mut tree = WidgetTree::new();
1559 let content = tree.add(TallLeaf::new(200.0, 500.0));
1560 let scroll = tree.add(ScrollArea::from_id(content));
1561
1562 tree.layout(SizeProposal::exact(200.0, 100.0));
1563
1564 let children = tree.children(scroll);
1566 assert_eq!(children.len(), 3, "Overlay mode should have 3 children");
1567
1568 let content_bounds = tree.bounds(children[0]);
1570 assert!(
1571 (content_bounds.width - 200.0).abs() < 0.01,
1572 "Overlay mode should not shrink viewport"
1573 );
1574 }
1575
1576 #[test]
1577 fn scroll_area_new_accepts_inline_widget() {
1578 let mut tree = WidgetTree::new();
1579 let scroll = tree.add(ScrollArea::new().child(TallLeaf::new(200.0, 500.0)));
1581
1582 tree.layout(SizeProposal::exact(200.0, 100.0));
1583
1584 let bounds = tree.bounds(scroll);
1585 assert!((bounds.width - 200.0).abs() < 0.01);
1586 }
1587
1588 #[derive(Debug)]
1590 struct WideLeaf {
1591 width: f32,
1592 height: f32,
1593 }
1594 impl WideLeaf {
1595 fn new(w: f32, h: f32) -> Self {
1596 Self {
1597 width: w,
1598 height: h,
1599 }
1600 }
1601 }
1602 impl Widget for WideLeaf {
1603 fn layout_response(
1604 &self,
1605 _proposal: SizeProposal,
1606 _ctx: &LayoutContext,
1607 ) -> teksilo_core::widget::LayoutResponse {
1608 Size::new(self.width, self.height).into()
1609 }
1610 }
1611
1612 #[test]
1613 fn permanent_horizontal_scrollbar_present() {
1614 let mut tree = WidgetTree::new();
1615 let scroll = tree.add(
1617 ScrollArea::new()
1618 .child(WideLeaf::new(400.0, 500.0))
1619 .scroll_bar_style(ScrollBarMode::Permanent)
1620 .scroll_bar_thickness(12.0),
1621 );
1622
1623 tree.layout(SizeProposal::exact(200.0, 100.0));
1624
1625 let children = tree.children(scroll);
1626 assert_eq!(
1627 children.len(),
1628 3,
1629 "Permanent mode should have content + v_sb + h_sb"
1630 );
1631
1632 let v_sb = tree.bounds(children[1]);
1634 assert!((v_sb.width - 12.0).abs() < 0.01, "v_sb width should be 12");
1635 assert!((v_sb.x - (200.0 - 12.0)).abs() < 0.01, "v_sb at right edge");
1636 assert!(
1637 (v_sb.height - (100.0 - 12.0)).abs() < 0.01,
1638 "v_sb height reduced by h_sb thickness, got {}",
1639 v_sb.height
1640 );
1641
1642 let h_sb = tree.bounds(children[2]);
1644 assert!(
1645 (h_sb.height - 12.0).abs() < 0.01,
1646 "h_sb height should be 12"
1647 );
1648 assert!(
1649 (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1650 "h_sb at bottom edge"
1651 );
1652 assert!(
1653 (h_sb.width - (200.0 - 12.0)).abs() < 0.01,
1654 "h_sb width = bounds.width - v_sb, got {}",
1655 h_sb.width
1656 );
1657 }
1658
1659 #[test]
1660 fn permanent_no_horizontal_when_content_fits() {
1661 let mut tree = WidgetTree::new();
1662 let scroll = tree.add(
1664 ScrollArea::new()
1665 .child(TallLeaf::new(180.0, 500.0))
1666 .scroll_bar_style(ScrollBarMode::Permanent)
1667 .scroll_bar_thickness(12.0),
1668 );
1669
1670 tree.layout(SizeProposal::exact(200.0, 100.0));
1671
1672 let children = tree.children(scroll);
1673 assert_eq!(children.len(), 3);
1674
1675 let v_sb = tree.bounds(children[1]);
1678 assert!(
1679 (v_sb.height - 100.0).abs() < 0.01,
1680 "v_sb should use full height when no h-scroll needed, got {}",
1681 v_sb.height
1682 );
1683 }
1684
1685 #[test]
1686 fn overlay_scrollbar_does_not_reduce_viewport() {
1687 let mut tree = WidgetTree::new();
1688 let scroll = tree.add(
1689 ScrollArea::new()
1690 .child(WideLeaf::new(400.0, 500.0))
1691 .scroll_bar_style(ScrollBarMode::Overlay),
1692 );
1693
1694 tree.layout(SizeProposal::exact(200.0, 100.0));
1695
1696 let children = tree.children(scroll);
1697 assert_eq!(children.len(), 3);
1698
1699 let content = tree.bounds(children[0]);
1701 assert!(
1702 content.width >= 400.0,
1703 "Content should report its full intrinsic width, got {}",
1704 content.width
1705 );
1706
1707 let v_sb = tree.bounds(children[1]);
1709 assert!(
1710 (v_sb.width - 12.0).abs() < 0.01,
1711 "Overlay v_sb should have full thickness for hover expansion, got {}",
1712 v_sb.width
1713 );
1714 assert!(
1715 (v_sb.x - (200.0 - 12.0)).abs() < 0.01,
1716 "Overlay v_sb at right edge"
1717 );
1718
1719 let h_sb = tree.bounds(children[2]);
1721 assert!(
1722 (h_sb.height - 12.0).abs() < 0.01,
1723 "Overlay h_sb should have full thickness for hover expansion, got {}",
1724 h_sb.height
1725 );
1726 assert!(
1727 (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1728 "Overlay h_sb at bottom edge"
1729 );
1730 }
1731
1732 #[test]
1733 fn horizontal_scroll_via_wheel() {
1734 let mut tree = WidgetTree::new();
1735 let scroll = tree.add(
1736 ScrollArea::new()
1737 .child(WideLeaf::new(400.0, 100.0))
1738 .scroll_bar_style(ScrollBarMode::Permanent)
1739 .scroll_bar_thickness(12.0)
1740 .smooth_scrolling(false),
1741 );
1742
1743 tree.layout(SizeProposal::exact(200.0, 100.0));
1744
1745 tree.pointer_move(Point::new(50.0, 50.0));
1746
1747 tree.dispatch_event(WidgetEvent::scroll(
1749 ScrollDelta::Pixels { x: 80.0, y: 0.0 },
1750 Default::default(),
1751 ));
1752 tree.layout(SizeProposal::exact(200.0, 100.0));
1753
1754 let children = tree.children(scroll);
1756 let content_x = tree.bounds(children[0]).x;
1757 assert!(
1758 content_x < 0.0,
1759 "Expected negative x after h-scroll, got {}",
1760 content_x
1761 );
1762 }
1763
1764 #[test]
1767 fn vertical_scrollbar_always_off_hides_scrollbar() {
1768 let mut tree = WidgetTree::new();
1769 let scroll = tree.add(
1770 ScrollArea::new()
1771 .child(TallLeaf::new(200.0, 500.0))
1772 .scroll_bar_style(ScrollBarMode::Permanent)
1773 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1774 .scroll_bar_thickness(12.0),
1775 );
1776
1777 tree.layout(SizeProposal::exact(200.0, 100.0));
1778
1779 let children = tree.children(scroll);
1780 let v_sb = tree.bounds(children[1]);
1782 assert!(
1783 (v_sb.width).abs() < 0.01,
1784 "v_sb should be zero-width, got {}",
1785 v_sb.width
1786 );
1787 assert!(
1788 (v_sb.height).abs() < 0.01,
1789 "v_sb should be zero-height, got {}",
1790 v_sb.height
1791 );
1792
1793 let content = tree.bounds(children[0]);
1795 assert!(
1796 (content.width - 200.0).abs() < 0.01,
1797 "Content should use full width when v_sb is off, got {}",
1798 content.width
1799 );
1800 }
1801
1802 #[test]
1803 fn horizontal_scrollbar_always_off_hides_scrollbar() {
1804 let mut tree = WidgetTree::new();
1805 let scroll = tree.add(
1806 ScrollArea::new()
1807 .child(WideLeaf::new(400.0, 500.0))
1808 .scroll_bar_style(ScrollBarMode::Permanent)
1809 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1810 .scroll_bar_thickness(12.0),
1811 );
1812
1813 tree.layout(SizeProposal::exact(200.0, 100.0));
1814
1815 let children = tree.children(scroll);
1816 let h_sb = tree.bounds(children[2]);
1818 assert!(
1819 (h_sb.width).abs() < 0.01,
1820 "h_sb should be zero-width, got {}",
1821 h_sb.width
1822 );
1823
1824 let v_sb = tree.bounds(children[1]);
1826 assert!(
1827 (v_sb.height - 100.0).abs() < 0.01,
1828 "v_sb should use full height when h_sb off, got {}",
1829 v_sb.height
1830 );
1831 }
1832
1833 #[test]
1834 fn scrollbar_always_on_shows_even_when_content_fits() {
1835 let mut tree = WidgetTree::new();
1836 let scroll = tree.add(
1838 ScrollArea::new()
1839 .child(TallLeaf::new(100.0, 50.0))
1840 .scroll_bar_style(ScrollBarMode::Permanent)
1841 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOn)
1842 .scroll_bar_thickness(12.0),
1843 );
1844
1845 tree.layout(SizeProposal::exact(200.0, 100.0));
1846
1847 let children = tree.children(scroll);
1848 let v_sb = tree.bounds(children[1]);
1849 assert!(
1851 (v_sb.width - 12.0).abs() < 0.01,
1852 "v_sb should be visible (12px) even when content fits, got {}",
1853 v_sb.width
1854 );
1855 }
1856
1857 #[test]
1860 fn widget_resizable_stretches_small_content() {
1861 let mut tree = WidgetTree::new();
1862 let scroll = tree.add(
1864 ScrollArea::new()
1865 .child(TallLeaf::new(100.0, 50.0))
1866 .widget_resizable(true),
1867 );
1868
1869 tree.layout(SizeProposal::exact(200.0, 100.0));
1870
1871 let children = tree.children(scroll);
1872 let content = tree.bounds(children[0]);
1873 assert!(
1875 content.width >= 200.0 - 0.01,
1876 "Resizable content width should fill viewport, got {}",
1877 content.width
1878 );
1879 assert!(
1880 content.height >= 100.0 - 0.01,
1881 "Resizable content height should fill viewport, got {}",
1882 content.height
1883 );
1884 }
1885
1886 #[test]
1887 fn widget_resizable_does_not_shrink_large_content() {
1888 let mut tree = WidgetTree::new();
1889 let scroll = tree.add(
1891 ScrollArea::new()
1892 .child(WideLeaf::new(400.0, 500.0))
1893 .widget_resizable(true),
1894 );
1895
1896 tree.layout(SizeProposal::exact(200.0, 100.0));
1897
1898 let children = tree.children(scroll);
1899 let content = tree.bounds(children[0]);
1900 assert!(
1901 content.width >= 400.0 - 0.01,
1902 "Large content should not be shrunk, got {}",
1903 content.width
1904 );
1905 assert!(
1906 content.height >= 500.0 - 0.01,
1907 "Large content should not be shrunk, got {}",
1908 content.height
1909 );
1910 }
1911
1912 #[test]
1915 fn smooth_scrolling_line_events_use_animation() {
1916 let mut tree = WidgetTree::new();
1917 let scroll = tree.add(
1918 ScrollArea::new()
1919 .child(TallLeaf::new(200.0, 1000.0))
1920 .smooth_scrolling(true),
1921 );
1922
1923 tree.layout(SizeProposal::exact(200.0, 100.0));
1924
1925 tree.pointer_move(Point::new(50.0, 50.0));
1926
1927 tree.dispatch_event(WidgetEvent::scroll(
1929 ScrollDelta::Lines { x: 0.0, y: 5.0 },
1930 Default::default(),
1931 ));
1932
1933 tree.layout(SizeProposal::exact(200.0, 100.0));
1937
1938 tree.tick_animations(Duration::from_millis(75));
1940 tree.layout(SizeProposal::exact(200.0, 100.0));
1941
1942 let children = tree.children(scroll);
1943 let content_y = tree.bounds(children[0]).y;
1944 assert!(
1946 content_y < 0.0,
1947 "Expected partial scroll, got y={}",
1948 content_y
1949 );
1950 assert!(
1951 content_y > -100.0,
1952 "Should not have reached target yet, got y={}",
1953 content_y
1954 );
1955 }
1956
1957 #[test]
1958 fn smooth_scrolling_disabled_jumps_immediately() {
1959 let mut tree = WidgetTree::new();
1960 let scroll = tree.add(
1961 ScrollArea::new()
1962 .child(TallLeaf::new(200.0, 1000.0))
1963 .smooth_scrolling(false),
1964 );
1965
1966 tree.layout(SizeProposal::exact(200.0, 100.0));
1967
1968 tree.pointer_move(Point::new(50.0, 50.0));
1969
1970 tree.dispatch_event(WidgetEvent::scroll(
1971 ScrollDelta::Lines { x: 0.0, y: 5.0 },
1972 Default::default(),
1973 ));
1974 tree.layout(SizeProposal::exact(200.0, 100.0));
1975
1976 let children = tree.children(scroll);
1977 let content_y = tree.bounds(children[0]).y;
1978 assert!(
1980 (content_y - (-100.0)).abs() < 0.01,
1981 "Should jump immediately, got y={}",
1982 content_y
1983 );
1984 }
1985
1986 #[test]
1989 fn preferred_size_overrides_default() {
1990 let mut tree = WidgetTree::new();
1991 let scroll = tree.add(
1992 ScrollArea::new()
1993 .child(TallLeaf::new(200.0, 500.0))
1994 .preferred_size(500.0, 400.0),
1995 );
1996 tree.layout(SizeProposal {
1998 width: None,
1999 height: None,
2000 });
2001 let bounds = tree.bounds(scroll);
2002 assert!(
2003 (bounds.width - 500.0).abs() < 0.01,
2004 "Should use preferred width, got {}",
2005 bounds.width
2006 );
2007 assert!(
2008 (bounds.height - 400.0).abs() < 0.01,
2009 "Should use preferred height, got {}",
2010 bounds.height
2011 );
2012 }
2013
2014 #[test]
2015 fn constrained_proposal_overrides_preferred_size() {
2016 let mut tree = WidgetTree::new();
2017 let scroll = tree.add(
2018 ScrollArea::new()
2019 .child(TallLeaf::new(200.0, 500.0))
2020 .preferred_size(500.0, 400.0),
2021 );
2022 tree.layout(SizeProposal::exact(200.0, 100.0));
2024 let bounds = tree.bounds(scroll);
2025 assert!((bounds.width - 200.0).abs() < 0.01);
2026 assert!((bounds.height - 100.0).abs() < 0.01);
2027 }
2028
2029 #[test]
2032 fn scroll_survives_theme_switch_at_root() {
2033 let mut tree = WidgetTree::new();
2034 let scroll = tree.add(
2035 ScrollArea::new()
2036 .child(TallLeaf::new(200.0, 500.0))
2037 .smooth_scrolling(false),
2038 );
2039 tree.layout(SizeProposal::exact(200.0, 100.0));
2040
2041 tree.pointer_move(Point::new(50.0, 50.0));
2043 tree.dispatch_event(WidgetEvent::scroll(
2044 ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2045 Default::default(),
2046 ));
2047 tree.layout(SizeProposal::exact(200.0, 100.0));
2048
2049 let content = tree.children(scroll)[0];
2050 let content_y_before = tree.bounds(content).y;
2051 assert!(
2052 content_y_before < -100.0,
2053 "Content should have scrolled; got y={}",
2054 content_y_before
2055 );
2056
2057 tree.set_theme(teksilo_core::presets::intui::dark());
2059 tree.layout(SizeProposal::exact(200.0, 100.0));
2060
2061 let content = tree.children(scroll)[0];
2062 let content_y_after = tree.bounds(content).y;
2063 assert!(
2064 (content_y_after - content_y_before).abs() < 0.01,
2065 "Scroll offset should survive theme switch: before={}, after={}",
2066 content_y_before,
2067 content_y_after
2068 );
2069 }
2070
2071 #[derive(Debug)]
2075 struct ScrollParent {
2076 scroll_id: Option<WidgetId>,
2077 }
2078 impl ScrollParent {
2079 fn new() -> Self {
2080 Self { scroll_id: None }
2081 }
2082 }
2083 impl Widget for ScrollParent {
2084 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2085 let id = ctx.add(
2086 ScrollArea::new()
2087 .child(TallLeaf::new(200.0, 500.0))
2088 .smooth_scrolling(false),
2089 );
2090 self.scroll_id = Some(id);
2091 vec![id]
2092 }
2093 fn layout_response(
2094 &self,
2095 proposal: SizeProposal,
2096 ctx: &LayoutContext,
2097 ) -> teksilo_core::widget::LayoutResponse {
2098 self.scroll_id
2099 .and_then(|id| ctx.child_size(id, proposal))
2100 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2101 .into()
2102 }
2103 fn place_children(
2104 &self,
2105 bounds: Rect,
2106 _proposal: SizeProposal,
2107 children: &mut [WidgetPlacement],
2108 _ctx: &LayoutContext,
2109 ) {
2110 if let Some(child) = children.first_mut() {
2111 child.origin = bounds.origin();
2112 child.size = bounds.size();
2113 }
2114 }
2115 }
2116
2117 #[test]
2118 fn scroll_survives_theme_switch_inside_composite() {
2119 let mut tree = WidgetTree::new();
2120 let parent = tree.add(ScrollParent::new());
2121 tree.layout(SizeProposal::exact(200.0, 100.0));
2122
2123 tree.pointer_move(Point::new(50.0, 50.0));
2124 tree.dispatch_event(WidgetEvent::scroll(
2125 ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2126 Default::default(),
2127 ));
2128 tree.layout(SizeProposal::exact(200.0, 100.0));
2129
2130 let scroll_before = tree.children(parent)[0];
2131 let content_before = tree.children(scroll_before)[0];
2132 let y_before = tree.bounds(content_before).y;
2133 assert!(
2134 y_before < -100.0,
2135 "Content should have scrolled; got y={}",
2136 y_before
2137 );
2138
2139 tree.set_theme(teksilo_core::presets::intui::dark());
2140 tree.layout(SizeProposal::exact(200.0, 100.0));
2141
2142 let scroll_after = tree.children(parent)[0];
2143 let content_after = tree.children(scroll_after)[0];
2144 let y_after = tree.bounds(content_after).y;
2145 assert!(
2146 (y_after - y_before).abs() < 0.01,
2147 "Scroll offset should survive theme switch inside composite: before={}, after={}",
2148 y_before,
2149 y_after
2150 );
2151 }
2152
2153 #[test]
2166 fn scroll_into_view_brings_widget_above_viewport_into_view() {
2167 let mut tree = WidgetTree::new();
2168
2169 let header = tree.add(TallLeaf::new(200.0, 50.0));
2173 let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2175 let after = tree.add(TallLeaf::new(200.0, 470.0));
2176 let content = tree.add(VStack::new().child(target).child(after));
2177 let scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
2178 let _root = tree.add(VStack::new().child(header).child(scroll));
2179
2180 tree.layout(SizeProposal::exact(200.0, 250.0));
2181
2182 let scroll_bounds = tree.bounds(scroll);
2183 assert!(
2184 (scroll_bounds.y - 50.0).abs() < 0.01,
2185 "ScrollArea should sit below the header at y=50, got {}",
2186 scroll_bounds.y
2187 );
2188
2189 tree.pointer_move(Point::new(100.0, 100.0));
2191 tree.dispatch_event(WidgetEvent::scroll(
2192 ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2193 Default::default(),
2194 ));
2195 tree.layout(SizeProposal::exact(200.0, 250.0));
2196
2197 let target_before = tree.bounds(target);
2198 assert!(
2199 target_before.bottom() < scroll_bounds.y,
2200 "Target should be above viewport before focus, got y={} (viewport top={})",
2201 target_before.y,
2202 scroll_bounds.y
2203 );
2204
2205 tree.focus(target);
2208 tree.layout(SizeProposal::exact(200.0, 250.0));
2209
2210 let target_after = tree.bounds(target);
2211 let viewport_top = scroll_bounds.y;
2212 let viewport_bottom = scroll_bounds.bottom();
2213 assert!(
2214 target_after.y >= viewport_top - 0.5 && target_after.bottom() <= viewport_bottom + 0.5,
2215 "Target should be inside viewport after focus, got y={}..{} (viewport={}..{})",
2216 target_after.y,
2217 target_after.bottom(),
2218 viewport_top,
2219 viewport_bottom
2220 );
2221 }
2222
2223 struct PinFixture {
2234 tree: WidgetTree,
2235 bounds: Rect,
2236 viewport_h: f32,
2237 scroll_y: Signal<f32>,
2238 max_scroll_y: Signal<f32>,
2239 ratio_y: Signal<f32>,
2240 request: Rc<Cell<(Rect, f32)>>,
2243 }
2244
2245 fn pin_fixture(content_h: f32, viewport_h: f32, past_end: f32) -> PinFixture {
2246 let request = Rc::new(Cell::new((Rect::new(0.0, 0.0, 0.0, 0.0), 0.5)));
2247 let mut tree = WidgetTree::new();
2248
2249 let req = request.clone();
2250 let actor = tree.add(TallLeaf::new(200.0, content_h).focusable(true).on_key(
2251 move |_ev, ctx| {
2252 let (rect, fraction) = req.get();
2253 ctx.ensure_visible_aligned(
2254 rect,
2255 fraction,
2256 teksilo_core::event::ScrollMotion::Instant,
2257 );
2258 EventResponse::Handled
2259 },
2260 ));
2261 let content = tree.add(VStack::new().child(actor));
2262 let sa = ScrollArea::from_id(content)
2263 .smooth_scrolling(false)
2264 .scroll_past_end(past_end);
2265 let scroll_y = sa.scroll_y_signal().clone();
2266 let max_scroll_y = sa.max_scroll_y_signal().clone();
2267 let ratio_y = sa.viewport_ratio_y_signal().clone();
2268 let scroll = tree.add(sa);
2269 tree.layout(SizeProposal::exact(200.0, viewport_h));
2270 tree.focus(actor);
2271 tree.layout(SizeProposal::exact(200.0, viewport_h));
2274 scroll_y.set(0.0);
2275
2276 let bounds = tree.bounds(scroll);
2277 PinFixture {
2278 tree,
2279 bounds,
2280 viewport_h,
2281 scroll_y,
2282 max_scroll_y,
2283 ratio_y,
2284 request,
2285 }
2286 }
2287
2288 impl PinFixture {
2289 fn pin(&mut self, content_y: f32, height: f32, fraction: f32) {
2292 let window_y = self.bounds.y + content_y - self.scroll_y.get();
2293 self.request
2294 .set((Rect::new(0.0, window_y, 200.0, height), fraction));
2295 self.tree.dispatch_event(WidgetEvent::KeyDown {
2296 key: teksilo_core::event::Key::ArrowDown,
2297 modifiers: Default::default(),
2298 text: None,
2299 });
2300 self.tree
2301 .layout(SizeProposal::exact(200.0, self.viewport_h));
2302 }
2303 }
2304
2305 #[test]
2306 fn scroll_past_end_extends_the_range_without_changing_the_content() {
2307 let plain = pin_fixture(300.0, 100.0, 0.0);
2309 assert_eq!(plain.max_scroll_y.get(), 200.0);
2310
2311 let padded = pin_fixture(300.0, 100.0, 0.5);
2313 assert_eq!(
2314 padded.max_scroll_y.get(),
2315 250.0,
2316 "scroll_past_end(0.5) must add half a viewport of range"
2317 );
2318 }
2319
2320 #[test]
2321 fn scroll_past_end_keeps_the_thumb_proportional() {
2322 let f = pin_fixture(300.0, 100.0, 0.5);
2325 let expected = 100.0 / 350.0;
2327 assert!(
2328 (f.ratio_y.get() - expected).abs() < 1e-4,
2329 "thumb ratio must use the extended range, got {}",
2330 f.ratio_y.get()
2331 );
2332 }
2333
2334 #[test]
2335 fn scroll_past_end_lets_the_last_line_reach_a_centre_pin() {
2336 let mut f = pin_fixture(300.0, 100.0, 0.5);
2340 f.pin(280.0, 20.0, 0.5);
2341
2342 assert_eq!(
2346 f.scroll_y.get(),
2347 240.0,
2348 "the last line must be able to sit at the pin"
2349 );
2350 }
2351
2352 #[test]
2353 fn without_scroll_past_end_the_last_line_cannot_reach_the_pin() {
2354 let mut f = pin_fixture(300.0, 100.0, 0.0);
2357 f.pin(280.0, 20.0, 0.5);
2358 assert_eq!(
2359 f.scroll_y.get(),
2360 200.0,
2361 "clamped to the un-extended maximum"
2362 );
2363 }
2364
2365 #[test]
2366 fn a_pin_near_the_document_start_clamps_instead_of_scrolling_negative() {
2367 let mut f = pin_fixture(300.0, 100.0, 0.5);
2370 f.pin(0.0, 20.0, 0.5);
2371 assert_eq!(
2372 f.scroll_y.get(),
2373 0.0,
2374 "the first line must clamp at the top, never scroll past it"
2375 );
2376 }
2377
2378 #[test]
2379 fn a_fraction_pin_places_the_target_at_that_height() {
2380 let mut f = pin_fixture(600.0, 100.0, 0.0);
2382 f.pin(300.0, 20.0, 0.25);
2383 assert_eq!(f.scroll_y.get(), 280.0);
2385 }
2386
2387 #[test]
2388 fn a_pin_re_asserts_on_an_already_visible_target() {
2389 let mut f = pin_fixture(600.0, 100.0, 0.0);
2393 f.scroll_y.set(250.0);
2394 f.tree.layout(SizeProposal::exact(200.0, 100.0));
2395
2396 f.pin(300.0, 20.0, 0.5);
2398
2399 assert_eq!(
2400 f.scroll_y.get(),
2401 260.0,
2402 "a pin must move an already-visible target onto the mark"
2403 );
2404 }
2405
2406 #[test]
2407 fn scroll_into_view_reveals_target_through_two_nested_scroll_areas() {
2408 use crate::primitives::FixedSize;
2415
2416 let mut tree = WidgetTree::new();
2417 let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2419 let inner_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2420 let inner_tail = tree.add(TallLeaf::new(200.0, 100.0));
2421 let inner_content = tree.add(
2422 VStack::new()
2423 .child(inner_spacer)
2424 .child(target)
2425 .child(inner_tail),
2426 );
2427 let inner_sa = tree.add(ScrollArea::from_id(inner_content).smooth_scrolling(false));
2428 let inner_box = tree.add(FixedSize::new().width(200.0).height(80.0).child(inner_sa));
2430 let outer_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2432 let outer_tail = tree.add(TallLeaf::new(200.0, 200.0));
2433 let outer_content = tree.add(
2434 VStack::new()
2435 .child(outer_spacer)
2436 .child(inner_box)
2437 .child(outer_tail),
2438 );
2439 let outer_sa = tree.add(ScrollArea::from_id(outer_content).smooth_scrolling(false));
2440
2441 let sz = SizeProposal::exact(200.0, 100.0);
2443 tree.layout(sz);
2444
2445 tree.focus(target);
2447 tree.layout(sz);
2448
2449 let outer_bounds = tree.bounds(outer_sa);
2450 let t = tree.bounds(target);
2451 assert!(
2452 t.y >= outer_bounds.y - 1.0 && t.bottom() <= outer_bounds.bottom() + 1.0,
2453 "target must be visible in the outer window after both scroll: target y={}..{}, \
2454 outer viewport {}..{}",
2455 t.y,
2456 t.bottom(),
2457 outer_bounds.y,
2458 outer_bounds.bottom()
2459 );
2460 }
2461
2462 #[derive(Debug)]
2467 struct FixedLeaf(f32, f32);
2468 impl Widget for FixedLeaf {
2469 fn layout_response(
2470 &self,
2471 _proposal: SizeProposal,
2472 _ctx: &LayoutContext,
2473 ) -> teksilo_core::widget::LayoutResponse {
2474 Size::new(self.0, self.1).into()
2475 }
2476 }
2477
2478 #[test]
2479 fn rtl_anchors_narrow_content_to_trailing_edge() {
2480 let mut tree = WidgetTree::new();
2484 let content = tree.add(FixedLeaf(120.0, 80.0));
2485 let _scroll = tree.add(ScrollArea::from_id(content));
2486
2487 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
2488 tree.layout(SizeProposal::exact(400.0, 200.0));
2489
2490 let cb = tree.bounds(content);
2491 assert!(
2492 (cb.x - (400.0 - 120.0)).abs() < 0.01,
2493 "RTL content should be flush-right at x=280, got {}",
2494 cb.x
2495 );
2496 }
2497
2498 #[test]
2499 fn ltr_anchors_narrow_content_to_leading_edge() {
2500 let mut tree = WidgetTree::new();
2501 let content = tree.add(FixedLeaf(120.0, 80.0));
2502 let _scroll = tree.add(ScrollArea::from_id(content));
2503
2504 tree.layout(SizeProposal::exact(400.0, 200.0));
2505
2506 let cb = tree.bounds(content);
2507 assert!(
2508 cb.x.abs() < 0.01,
2509 "LTR content should be flush-left at x=0, got {}",
2510 cb.x
2511 );
2512 }
2513
2514 fn nested_scroll_fixture(
2518 inner_overscroll: OverscrollBehavior,
2519 ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
2520 let mut tree = WidgetTree::new();
2521
2522 let inner_content = tree.add(TallLeaf::new(200.0, 300.0));
2523 let inner_sa = ScrollArea::from_id(inner_content)
2524 .smooth_scrolling(false)
2525 .preferred_size(200.0, 100.0)
2526 .overscroll_behavior(inner_overscroll);
2527 let inner_y = inner_sa.scroll_y_signal().clone();
2528 let inner = tree.add(inner_sa);
2529
2530 let filler = tree.add(TallLeaf::new(200.0, 200.0));
2531 let outer_content = tree.add(VStack::new().child(inner).child(filler));
2532 let outer_sa = ScrollArea::from_id(outer_content).smooth_scrolling(false);
2533 let outer_y = outer_sa.scroll_y_signal().clone();
2534 let _outer = tree.add(outer_sa);
2535
2536 tree.layout(SizeProposal::exact(200.0, 150.0));
2537 (tree, inner_y, outer_y)
2538 }
2539
2540 #[test]
2541 fn nested_scroll_chains_to_outer_at_boundary() {
2542 let (mut tree, inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Chain);
2543
2544 tree.pointer_move(Point::new(50.0, 40.0));
2546 tree.dispatch_event(WidgetEvent::scroll(
2547 ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2548 Default::default(),
2549 ));
2550 tree.layout(SizeProposal::exact(200.0, 150.0));
2551
2552 let inner_bottom = inner_y.get();
2553 assert!(inner_bottom > 0.0, "inner should have scrolled down");
2554 assert!(
2555 outer_y.get() < 0.01,
2556 "outer must not move while the inner still absorbs the scroll"
2557 );
2558
2559 tree.pointer_move(Point::new(50.0, 40.0));
2561 tree.dispatch_event(WidgetEvent::scroll(
2562 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2563 Default::default(),
2564 ));
2565 tree.layout(SizeProposal::exact(200.0, 150.0));
2566
2567 assert!(
2568 (inner_y.get() - inner_bottom).abs() < 0.01,
2569 "inner stays clamped at its bottom"
2570 );
2571 assert!(
2572 outer_y.get() > 0.01,
2573 "outer scrolled because the inner chained the boundary scroll"
2574 );
2575 }
2576
2577 #[test]
2578 fn contain_blocks_scroll_chaining() {
2579 let (mut tree, _inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Contain);
2580
2581 tree.pointer_move(Point::new(50.0, 40.0));
2582 tree.dispatch_event(WidgetEvent::scroll(
2583 ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2584 Default::default(),
2585 ));
2586 tree.layout(SizeProposal::exact(200.0, 150.0));
2587
2588 tree.pointer_move(Point::new(50.0, 40.0));
2590 tree.dispatch_event(WidgetEvent::scroll(
2591 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2592 Default::default(),
2593 ));
2594 tree.layout(SizeProposal::exact(200.0, 150.0));
2595
2596 assert!(
2597 outer_y.get() < 0.01,
2598 "Contain must prevent chaining: outer stays put"
2599 );
2600 }
2601
2602 #[derive(Debug)]
2609 struct RecordingLeaf {
2610 width: f32,
2611 height: f32,
2612 log: Rc<std::cell::RefCell<Vec<SizeProposal>>>,
2613 }
2614
2615 impl Widget for RecordingLeaf {
2616 fn layout_response(
2617 &self,
2618 proposal: SizeProposal,
2619 _ctx: &LayoutContext,
2620 ) -> teksilo_core::widget::LayoutResponse {
2621 self.log.borrow_mut().push(proposal);
2622 Size::new(
2623 proposal.width.unwrap_or(self.width),
2624 proposal.height.unwrap_or(self.height),
2625 )
2626 .into()
2627 }
2628 }
2629
2630 #[test]
2631 fn preferred_height_reports_natural_width_when_parent_proposes_unbounded() {
2632 let mut tree = WidgetTree::new();
2635 let content = tree.add(TallLeaf::new(392.0, 500.0));
2636 let scroll = tree.add(ScrollArea::from_id(content).preferred_height(150.0));
2637
2638 tree.layout(SizeProposal {
2640 width: None,
2641 height: None,
2642 });
2643
2644 let bounds = tree.bounds(scroll);
2645 assert!(
2646 (bounds.width - 392.0).abs() < 0.01,
2647 "should report the content's real natural width, got {}",
2648 bounds.width
2649 );
2650 assert!(
2651 (bounds.height - 150.0).abs() < 0.01,
2652 "should still cap the height at preferred_height, got {}",
2653 bounds.height
2654 );
2655 }
2656
2657 #[test]
2658 fn bounded_proposal_never_triggers_an_unbounded_content_query() {
2659 let log: Rc<std::cell::RefCell<Vec<SizeProposal>>> =
2661 Rc::new(std::cell::RefCell::new(Vec::new()));
2662 let mut tree = WidgetTree::new();
2663 let content = tree.add(RecordingLeaf {
2664 width: 900.0,
2665 height: 500.0,
2666 log: log.clone(),
2667 });
2668 tree.add(ScrollArea::from_id(content));
2669
2670 tree.layout(SizeProposal::exact(300.0, 100.0));
2672
2673 let recorded = log.borrow();
2674 assert!(!recorded.is_empty(), "content widget was never laid out");
2675 for proposal in recorded.iter() {
2676 assert!(
2677 proposal.width.is_some(),
2678 "content queried with an unbounded width ({:?}) even though the \
2679 incoming proposal was already bounded — the unbounded natural-width \
2680 measure must only run when `proposal.width` is `None`",
2681 proposal
2682 );
2683 }
2684 }
2685
2686 #[test]
2687 fn exact_proposal_still_wins_over_natural_width() {
2688 let mut tree = WidgetTree::new();
2691 let content = tree.add(TallLeaf::new(900.0, 500.0));
2692 let scroll = tree.add(ScrollArea::from_id(content));
2693
2694 tree.layout(SizeProposal::exact(300.0, 100.0));
2695
2696 let bounds = tree.bounds(scroll);
2697 assert!(
2698 (bounds.width - 300.0).abs() < 0.01,
2699 "exact proposal must win over the content's natural width, got {}",
2700 bounds.width
2701 );
2702 assert!(
2703 (bounds.height - 100.0).abs() < 0.01,
2704 "exact proposal must win over the content's natural height, got {}",
2705 bounds.height
2706 );
2707 }
2708
2709 #[test]
2725 fn cross_axis_overflow_through_a_vstack_is_scrollable() {
2726 use crate::primitives::{HStack, Padding};
2727
2728 let mut tree = WidgetTree::new();
2729 let cells: Vec<_> = (0..4)
2731 .map(|_| tree.add(TallLeaf::new(200.0, 40.0)))
2732 .collect();
2733 let mut row = HStack::new();
2734 for &c in &cells {
2735 row = row.child(c);
2736 }
2737 let row = tree.add(row);
2738 let col = tree.add(VStack::new().child(row));
2739 let padded = tree.add(Padding::uniform(20.0).child(col));
2740 let _scroll = tree.add(ScrollArea::from_id(padded).smooth_scrolling(false));
2741
2742 tree.layout(SizeProposal::exact(600.0, 400.0));
2743
2744 let last = *cells.last().unwrap();
2745 assert!(
2746 tree.bounds(last).x > 600.0,
2747 "precondition: the 4th cell should start beyond the viewport, got x={}",
2748 tree.bounds(last).x
2749 );
2750
2751 tree.pointer_move(Point::new(300.0, 40.0));
2753 tree.dispatch_event(WidgetEvent::scroll(
2754 ScrollDelta::Pixels { x: 300.0, y: 0.0 },
2755 Default::default(),
2756 ));
2757 tree.layout(SizeProposal::exact(600.0, 400.0));
2758
2759 let b = tree.bounds(last);
2760 assert!(
2761 b.x >= 0.0 && b.x + b.width <= 600.5,
2762 "the 4th cell must be reachable by horizontal scrolling; got x={} w={}",
2763 b.x,
2764 b.width
2765 );
2766 }
2767
2768 #[test]
2772 fn restore_scroll_y_lands_on_the_first_measured_layout() {
2773 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(150.0);
2779 let scroll_y = sa.scroll_y_signal().clone();
2780 let max_scroll_y = sa.max_scroll_y_signal().clone();
2781 let _scroll = tree.add(sa);
2782
2783 tree.layout(SizeProposal::exact(200.0, 100.0));
2787
2788 assert_eq!(max_scroll_y.get(), 400.0);
2789 assert_eq!(
2790 scroll_y.get(),
2791 150.0,
2792 "the restored offset must land on the first laid-out frame"
2793 );
2794 }
2795
2796 #[test]
2797 fn restore_scroll_y_is_not_re_applied_after_a_later_reflow() {
2798 let mut tree = WidgetTree::new();
2799 let sa = ScrollArea::new()
2800 .child(TallLeaf::new(200.0, 500.0))
2801 .smooth_scrolling(false)
2802 .restore_scroll_y(150.0);
2803 let scroll_y = sa.scroll_y_signal().clone();
2804 let _scroll = tree.add(sa);
2805
2806 tree.layout(SizeProposal::exact(200.0, 100.0));
2807 assert_eq!(scroll_y.get(), 150.0, "precondition: restore landed once");
2808
2809 scroll_y.set(70.0);
2812 tree.layout(SizeProposal::exact(200.0, 120.0));
2813
2814 assert_eq!(
2815 scroll_y.get(),
2816 70.0,
2817 "a one-shot restore must not re-arm itself on a later reflow"
2818 );
2819 }
2820
2821 #[test]
2822 fn a_restore_the_content_can_never_hold_does_not_pin_the_reader() {
2823 let mut tree = WidgetTree::new();
2833 let sa = ScrollArea::new()
2834 .child(TallLeaf::new(200.0, 150.0))
2835 .smooth_scrolling(false)
2836 .restore_scroll_y(200.0);
2837 let scroll_y = sa.scroll_y_signal().clone();
2838 let _scroll = tree.add(sa);
2839
2840 tree.layout(SizeProposal::exact(200.0, 100.0));
2841 assert_eq!(
2842 scroll_y.get(),
2843 50.0,
2844 "precondition: the offset lands clamped to the range that exists"
2845 );
2846
2847 scroll_y.set(0.0);
2849 tree.layout(SizeProposal::exact(200.0, 100.0));
2850
2851 assert_eq!(
2852 scroll_y.get(),
2853 0.0,
2854 "a drag away from the clamped landing must stand the restore down, \
2855 not be undone by the next layout pass"
2856 );
2857 }
2858
2859 #[test]
2860 fn a_restore_still_waits_out_content_that_is_only_slow_to_measure() {
2861 let mut tree = WidgetTree::new();
2870 let height = Rc::new(Cell::new(150.0));
2871 let sa = ScrollArea::new()
2872 .child(GrowingLeaf::new(200.0, height.clone()))
2873 .smooth_scrolling(false)
2874 .restore_scroll_y(200.0);
2875 let scroll_y = sa.scroll_y_signal().clone();
2876 let _scroll = tree.add(sa);
2877
2878 tree.layout(SizeProposal::exact(200.0, 100.0));
2879 assert_eq!(scroll_y.get(), 50.0, "clamped to the range measured so far");
2880
2881 height.set(400.0);
2882 tree.layout(SizeProposal::exact(200.0, 100.0));
2883 assert_eq!(
2884 scroll_y.get(),
2885 200.0,
2886 "the range grew past the offset, so the offset lands in full"
2887 );
2888
2889 scroll_y.set(10.0);
2891 height.set(900.0);
2892 tree.layout(SizeProposal::exact(200.0, 100.0));
2893 assert_eq!(scroll_y.get(), 10.0, "a one-shot does not re-arm");
2894 }
2895
2896 #[test]
2897 fn restore_scroll_y_past_the_range_never_lets_an_observer_see_the_overshoot() {
2898 let mut tree = WidgetTree::new();
2906 let sa = ScrollArea::new()
2907 .child(TallLeaf::new(200.0, 500.0))
2908 .smooth_scrolling(false)
2909 .restore_scroll_y(9999.0);
2910 let scroll_y = sa.scroll_y_signal().clone();
2911 let max_scroll_y = sa.max_scroll_y_signal().clone();
2912
2913 let seen: Rc<std::cell::RefCell<Vec<f32>>> = Rc::new(std::cell::RefCell::new(Vec::new()));
2914 let recorder = seen.clone();
2915 let _observer = scroll_y.observe(move |v: &f32| recorder.borrow_mut().push(*v));
2916
2917 let _scroll = tree.add(sa);
2918 tree.layout(SizeProposal::exact(200.0, 100.0));
2919
2920 assert_eq!(
2921 scroll_y.get(),
2922 400.0,
2923 "it must settle at the end of the range"
2924 );
2925 assert_eq!(scroll_y.get(), max_scroll_y.get());
2926 let overshoot: Vec<f32> = seen
2927 .borrow()
2928 .iter()
2929 .copied()
2930 .filter(|v| *v > max_scroll_y.get())
2931 .collect();
2932 assert!(
2933 overshoot.is_empty(),
2934 "an observer saw an offset past the end of the content: {overshoot:?}"
2935 );
2936 }
2937
2938 #[test]
2939 fn restore_scroll_y_waits_for_a_range_long_enough_to_hold_it() {
2940 let height = Rc::new(Cell::new(500.0_f32));
2947 let mut tree = WidgetTree::new();
2948 let sa = ScrollArea::new()
2949 .child(GrowingLeaf::new(200.0, height.clone()))
2950 .smooth_scrolling(false)
2951 .restore_scroll_y(11560.0);
2952 let scroll_y = sa.scroll_y_signal().clone();
2953 let max_scroll_y = sa.max_scroll_y_signal().clone();
2954 let _scroll = tree.add(sa);
2955
2956 tree.layout(SizeProposal::exact(200.0, 100.0));
2957 assert_eq!(
2958 max_scroll_y.get(),
2959 400.0,
2960 "precondition: a short first pass"
2961 );
2962 assert_eq!(
2963 scroll_y.get(),
2964 400.0,
2965 "as far down as the content so far allows, so the page is never at the top"
2966 );
2967
2968 height.set(12000.0);
2969 tree.layout(SizeProposal::exact(200.0, 100.0));
2970 assert_eq!(
2971 scroll_y.get(),
2972 11560.0,
2973 "once the content is long enough, the offset must land in full"
2974 );
2975
2976 scroll_y.set(60.0);
2978 height.set(20000.0);
2979 tree.layout(SizeProposal::exact(200.0, 100.0));
2980 assert_eq!(
2981 scroll_y.get(),
2982 60.0,
2983 "a restore already honoured must not re-assert itself on a later reflow"
2984 );
2985 }
2986
2987 #[test]
2988 fn a_reader_scrolling_stands_down_a_restore_that_has_not_landed() {
2989 let height = Rc::new(Cell::new(500.0_f32));
2994 let mut tree = WidgetTree::new();
2995 let sa = ScrollArea::new()
2996 .child(GrowingLeaf::new(200.0, height.clone()))
2997 .smooth_scrolling(false)
2998 .restore_scroll_y(11560.0);
2999 let scroll_y = sa.scroll_y_signal().clone();
3000 let _scroll = tree.add(sa);
3001
3002 tree.layout(SizeProposal::exact(200.0, 100.0));
3003 assert_eq!(scroll_y.get(), 400.0, "precondition: still pending");
3004
3005 tree.pointer_move(Point::new(50.0, 40.0));
3006 tree.dispatch_event(WidgetEvent::scroll(
3007 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3008 Default::default(),
3009 ));
3010 let after_reader = scroll_y.get();
3011
3012 height.set(12000.0);
3013 tree.layout(SizeProposal::exact(200.0, 100.0));
3014 assert_eq!(
3015 scroll_y.get(),
3016 after_reader,
3017 "the content growing must not yank a reader who has already scrolled"
3018 );
3019 }
3020
3021 #[test]
3022 fn without_restore_scroll_y_behaviour_is_unchanged() {
3023 let mut tree = WidgetTree::new();
3026 let sa = ScrollArea::new()
3027 .child(TallLeaf::new(200.0, 500.0))
3028 .smooth_scrolling(false);
3029 let scroll_y = sa.scroll_y_signal().clone();
3030 let _scroll = tree.add(sa);
3031
3032 tree.layout(SizeProposal::exact(200.0, 100.0));
3033 assert_eq!(scroll_y.get(), 0.0);
3034
3035 tree.layout(SizeProposal::exact(200.0, 120.0));
3037 assert_eq!(scroll_y.get(), 0.0);
3038 }
3039
3040 #[test]
3041 fn restore_scroll_y_of_zero_arms_nothing_and_leaves_a_host_write_alone() {
3042 let mut tree = WidgetTree::new();
3048 let sa = ScrollArea::new()
3049 .child(TallLeaf::new(200.0, 500.0))
3050 .smooth_scrolling(false)
3051 .restore_scroll_y(0.0);
3052 let scroll_y = sa.scroll_y_signal().clone();
3053 let _scroll = tree.add(sa);
3054
3055 scroll_y.set(120.0);
3056 tree.layout(SizeProposal::exact(200.0, 100.0));
3057
3058 assert_eq!(
3059 scroll_y.get(),
3060 120.0,
3061 "restore_scroll_y(0.0) armed a restore and overwrote the host's own offset"
3062 );
3063 }
3064
3065 #[test]
3066 fn restore_scroll_y_of_zero_disarms_a_previously_armed_offset() {
3067 let mut tree = WidgetTree::new();
3073 let sa = ScrollArea::new()
3074 .child(TallLeaf::new(200.0, 500.0))
3075 .smooth_scrolling(false)
3076 .restore_scroll_y(150.0)
3077 .restore_scroll_y(0.0);
3078 let scroll_y = sa.scroll_y_signal().clone();
3079 let _scroll = tree.add(sa);
3080
3081 scroll_y.set(120.0);
3082 tree.layout(SizeProposal::exact(200.0, 100.0));
3083
3084 assert_eq!(
3085 scroll_y.get(),
3086 120.0,
3087 "a later restore_scroll_y(0.0) must disarm the earlier pending offset"
3088 );
3089 }
3090}
3091
3092#[cfg(test)]
3098mod pan_tests {
3099 use super::*;
3100 use crate::primitives::VStack;
3101 use std::time::Duration;
3102 use teksilo_canvas::SizeProposal;
3103 use teksilo_core::event::{Modifiers, PointerButton};
3104 use teksilo_core::pointer::clock::ManualClock;
3105 use teksilo_core::pointer::{
3106 BackendDeviceKey, EventTime, PointerId, PointerIdAllocator, PointerInfo, PointerPhase,
3107 PointerSample,
3108 };
3109 use teksilo_core::widget::{LayoutContext, LayoutResponse};
3110 use teksilo_core::widget_tree::WidgetTree;
3111
3112 #[derive(Debug)]
3114 struct TallLeaf {
3115 width: f32,
3116 height: f32,
3117 }
3118
3119 impl TallLeaf {
3120 fn new(width: f32, height: f32) -> Self {
3121 Self { width, height }
3122 }
3123 }
3124
3125 impl Widget for TallLeaf {
3126 fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3127 Size::new(self.width, self.height).into()
3128 }
3129 }
3130
3131 fn contact_id(raw: u64) -> PointerId {
3132 let alloc = PointerIdAllocator::global();
3133 let device = BackendDeviceKey::new(0x5A7E);
3134 let id = alloc.begin(device, raw);
3135 alloc.end(device, raw);
3136 id
3137 }
3138
3139 fn contact(id: PointerId, phase: PointerPhase, at: Point) -> PointerSample {
3140 PointerSample {
3141 pointer: PointerInfo::touch(id, EventTime::ZERO),
3142 phase,
3143 position: at,
3144 button: None,
3145 modifiers: Modifiers::NONE,
3146 coalesced: Vec::new(),
3147 }
3148 }
3149
3150 fn pan_slop() -> f32 {
3151 teksilo_core::gesture::default_profile(teksilo_tokens::PointerKind::Touch)
3152 .pan_slop
3153 .expect("a touch profile pans")
3154 }
3155
3156 fn drag(tree: &mut WidgetTree, id: PointerId, from: Point, dy: f32) -> Point {
3158 tree.dispatch_pointer(contact(id, PointerPhase::Down, from));
3159 let arm = Point::new(from.x, from.y + pan_slop().copysign(dy) + dy.signum());
3160 tree.dispatch_pointer(contact(id, PointerPhase::Move, arm));
3161 let at = Point::new(from.x, arm.y + (dy - (arm.y - from.y)));
3162 tree.dispatch_pointer(contact(id, PointerPhase::Move, at));
3163 at
3164 }
3165
3166 fn area(
3169 build: impl FnOnce(ScrollArea) -> ScrollArea,
3170 ) -> (WidgetTree, Signal<f32>, Signal<Vec2>) {
3171 let mut tree = WidgetTree::new();
3172 let content = tree.add(TallLeaf::new(200.0, 600.0));
3173 let sa = build(
3174 ScrollArea::from_id(content)
3175 .smooth_scrolling(false)
3176 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3177 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff),
3178 );
3179 let y = sa.scroll_y_signal().clone();
3180 let overscroll = sa.overscroll_signal();
3181 tree.add(sa);
3182 tree.layout(SizeProposal::exact(200.0, 100.0));
3183 (tree, y, overscroll)
3184 }
3185
3186 #[test]
3190 fn a_finger_pans_the_content() {
3191 let (mut tree, y, _overscroll) = area(|sa| sa);
3192 drag(&mut tree, contact_id(1), Point::new(100.0, 80.0), -60.0);
3193 assert!(y.get() > 0.0, "the finger scrolled the area: {}", y.get());
3194 }
3195
3196 #[test]
3199 fn a_pan_is_not_smoothed() {
3200 let (mut tree, y, _overscroll) = area(|sa| sa.smooth_scrolling(true));
3201 drag(&mut tree, contact_id(2), Point::new(100.0, 80.0), -60.0);
3202 assert!(y.get() > 0.0);
3203 assert_eq!(y.animation_target(), None);
3204 }
3205
3206 #[test]
3209 fn a_fast_release_flings() {
3210 let (mut tree, y, _overscroll) = area(|sa| sa);
3211 let clock = Rc::new(ManualClock::new(EventTime::ZERO));
3212 tree.set_input_clock(clock.clone());
3213
3214 let finger = contact_id(3);
3215 let from = Point::new(100.0, 95.0);
3216 tree.dispatch_pointer(contact(finger, PointerPhase::Down, from));
3217 let mut at = from.y;
3218 for step in 1..=5 {
3219 clock.set(EventTime::from_millis(step * 4));
3220 at -= 15.0;
3221 tree.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, at)));
3222 }
3223 clock.set(EventTime::from_millis(24));
3224 tree.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, at)));
3225
3226 let at_release = y.get();
3227 assert!(at_release > 0.0);
3228 tree.advance_time(Duration::from_millis(100));
3229 assert!(
3230 y.get() > at_release,
3231 "the coast kept it moving: {at_release} -> {}",
3232 y.get()
3233 );
3234 }
3235
3236 #[test]
3239 fn reduced_motion_collapses_the_fling_to_a_settle() {
3240 let mut tree = WidgetTree::new();
3241 tree.set_accessibility_preferences(false, true, 1.0);
3242 let content = tree.add(TallLeaf::new(200.0, 600.0));
3243 let sa = ScrollArea::from_id(content)
3244 .smooth_scrolling(false)
3245 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3246 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3247 let y = sa.scroll_y_signal().clone();
3248 tree.add(sa);
3249 tree.layout(SizeProposal::exact(200.0, 100.0));
3250
3251 let clock = Rc::new(ManualClock::new(EventTime::ZERO));
3252 tree.set_input_clock(clock.clone());
3253 let finger = contact_id(4);
3254 let from = Point::new(100.0, 95.0);
3255 tree.dispatch_pointer(contact(finger, PointerPhase::Down, from));
3256 let mut at = from.y;
3257 for step in 1..=5 {
3258 clock.set(EventTime::from_millis(step * 4));
3259 at -= 15.0;
3260 tree.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, at)));
3261 }
3262 clock.set(EventTime::from_millis(24));
3263 tree.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, at)));
3264
3265 let at_release = y.get();
3266 assert!(at_release > 0.0, "the pan itself still scrolls");
3267 tree.advance_time(Duration::from_millis(200));
3268 assert_eq!(y.get(), at_release, "and nothing coasts afterwards");
3269 }
3270
3271 #[test]
3275 fn the_rubber_band_holds_and_releases() {
3276 let (mut tree, y, overscroll) = area(|sa| sa.rubber_band(true));
3277 y.set(500.0);
3278
3279 let finger = contact_id(5);
3280 let at = drag(&mut tree, finger, Point::new(100.0, 95.0), -70.0);
3281 assert_eq!(y.get(), 500.0, "the offset stays inside the range");
3282 let held = overscroll.get().y;
3283 assert!(held > 0.0, "the band is holding it past the end");
3284 assert!(held < 70.0, "with decreasing gain: {held}");
3285
3286 tree.dispatch_pointer(contact(finger, PointerPhase::Up, at));
3287 assert_eq!(overscroll.get(), Vec2::ZERO, "the lift released it");
3288 }
3289
3290 #[test]
3293 fn the_band_is_off_by_default() {
3294 let (mut tree, y, overscroll) = area(|sa| sa);
3295 y.set(500.0);
3296 drag(&mut tree, contact_id(6), Point::new(100.0, 95.0), -70.0);
3297 assert_eq!(overscroll.get(), Vec2::ZERO);
3298 assert_eq!(y.get(), 500.0);
3299 }
3300
3301 #[test]
3305 fn a_boundary_pan_chains_the_whole_event_outward() {
3306 let mut tree = WidgetTree::new();
3307 let inner_content = tree.add(TallLeaf::new(200.0, 120.0));
3308 let inner_sa = ScrollArea::from_id(inner_content)
3309 .smooth_scrolling(false)
3310 .preferred_size(200.0, 100.0)
3311 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3312 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3313 let inner_y = inner_sa.scroll_y_signal().clone();
3314 let inner = tree.add(inner_sa);
3315
3316 let filler = tree.add(TallLeaf::new(200.0, 400.0));
3317 let outer_content = tree.add(VStack::new().child(inner).child(filler));
3318 let outer_sa = ScrollArea::from_id(outer_content)
3319 .smooth_scrolling(false)
3320 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3321 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3322 let outer_y = outer_sa.scroll_y_signal().clone();
3323 tree.add(outer_sa);
3324 tree.layout(SizeProposal::exact(200.0, 150.0));
3325
3326 inner_y.set(20.0);
3329 let finger = contact_id(7);
3330 drag(&mut tree, finger, Point::new(100.0, 80.0), -60.0);
3331
3332 assert_eq!(inner_y.get(), 20.0, "the inner area is pinned at its end");
3333 assert!(
3334 outer_y.get() > 0.0,
3335 "so the container took the pan: {}",
3336 outer_y.get()
3337 );
3338 }
3339
3340 #[test]
3343 fn a_mouse_press_and_drag_does_not_pan() {
3344 let (mut tree, y, _overscroll) = area(|sa| sa);
3345 tree.dispatch_event(WidgetEvent::pointer_down(
3346 Point::new(100.0, 80.0),
3347 PointerButton::Primary,
3348 Modifiers::NONE,
3349 ));
3350 tree.pointer_move(Point::new(100.0, 20.0));
3351 assert_eq!(y.get(), 0.0, "a mouse drag is not a pan");
3352
3353 tree.dispatch_event(WidgetEvent::scroll(
3354 teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 40.0 },
3355 Modifiers::NONE,
3356 ));
3357 assert_eq!(y.get(), 40.0, "…and its wheel still scrolls");
3358 }
3359}