1use std::cell::{Cell, RefCell};
53use std::rc::Rc;
54use std::time::Duration;
55
56use teksilo_canvas::{Point, Rect, Size, SizeProposal, Vec2};
57use teksilo_core::accessibility::AccessNodeBuilder;
58use teksilo_core::binding::BindingLevel;
59use teksilo_core::build_context::BuildContext;
60use teksilo_core::color_prop::ColorProp;
61use teksilo_core::event::{EventResponse, WidgetEvent};
62use teksilo_core::kinetic::KineticScroller;
63use teksilo_core::pointer::touch_action::PanAxes;
64use teksilo_core::pointer::{ScrollPhase, ScrollSource};
65use teksilo_core::signal::{Prop, Signal};
66use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
67use teksilo_core::widget_builder::HandlerSet;
68use teksilo_core::widget_id::WidgetId;
69use teksilo_tokens::{Easing, OverscrollStyle, RevealPolicy};
70
71use crate::common::scroll::OverscrollBehavior;
72use crate::common::scrollable::{
73 ScrollableAxes, ScrollableBehavior, handle_scroll_event, shift_wheel_remap,
74};
75use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum ScrollBarMode {
80 #[default]
84 Overlay,
85 Permanent,
89 Thin,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
103pub enum ScrollBarPolicy {
104 #[default]
106 AsNeeded,
107 AlwaysOn,
109 AlwaysOff,
119}
120
121pub struct ScrollArea {
127 content_child: Option<Box<dyn Widget>>,
128 content_child_id: Option<WidgetId>,
129 scroll_bar_style: ScrollBarMode,
130 vertical_policy: ScrollBarPolicy,
132 horizontal_policy: ScrollBarPolicy,
133 line_height: f32,
135 scroll_bar_thickness: f32,
137 scroll_bar_thumb_color: Option<ColorProp>,
141 widget_resizable: bool,
143 smooth_scrolling: bool,
145 smooth_scroll_duration: Duration,
147 preferred_size: Option<Size>,
150 preferred_height: Option<f32>,
152 overscroll_behavior: OverscrollBehavior,
156 scroll_past_end: Prop<f32>,
159
160 scroll_y: Signal<f32>,
163 scroll_x: Signal<f32>,
165 max_scroll_y: Signal<f32>,
167 max_scroll_x: Signal<f32>,
169 viewport_ratio_y: Signal<f32>,
171 viewport_ratio_x: Signal<f32>,
173
174 child_ids: Vec<WidgetId>,
177
178 content_size: Cell<Size>,
180 viewport_size: Rc<Cell<Size>>,
185 viewport_origin: Rc<Cell<Point>>,
190
191 pending_restore_y: Rc<Cell<Option<f32>>>,
197 restore_wrote_y: Rc<Cell<Option<f32>>>,
212
213 scroller: Rc<RefCell<KineticScroller>>,
219 overscroll: Signal<Vec2>,
222 rubber_band: bool,
225 scrollbar_reveal: Signal<bool>,
228 shift_wheel_horizontal: bool,
231 reveal_bars_on_hover: bool,
234}
235
236impl Default for ScrollArea {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242impl std::fmt::Debug for ScrollArea {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("ScrollArea")
245 .field("scroll_y", &self.scroll_y.get())
246 .field("scroll_x", &self.scroll_x.get())
247 .field("style", &self.scroll_bar_style)
248 .field("v_policy", &self.vertical_policy)
249 .field("h_policy", &self.horizontal_policy)
250 .field("widget_resizable", &self.widget_resizable)
251 .field("content_size", &self.content_size.get())
252 .field("viewport_size", &self.viewport_size.get())
253 .finish()
254 }
255}
256
257impl ScrollArea {
258 pub fn new() -> Self {
260 Self {
261 content_child: None,
262 content_child_id: None,
263 scroll_bar_style: ScrollBarMode::default(),
264 vertical_policy: ScrollBarPolicy::default(),
265 horizontal_policy: ScrollBarPolicy::default(),
266 line_height: 20.0,
267 scroll_bar_thickness: 12.0,
268 scroll_bar_thumb_color: None,
269 widget_resizable: false,
270 smooth_scrolling: true,
271 smooth_scroll_duration: Duration::from_millis(150),
272 preferred_size: None,
273 preferred_height: None,
274 overscroll_behavior: OverscrollBehavior::default(),
275 scroll_past_end: Prop::Static(0.0),
276 scroll_y: Signal::new_animated(0.0),
277 scroll_x: Signal::new_animated(0.0),
278 max_scroll_y: Signal::new(0.0),
279 max_scroll_x: Signal::new(0.0),
280 viewport_ratio_y: Signal::new(1.0),
281 viewport_ratio_x: Signal::new(1.0),
282 child_ids: Vec::new(),
283 content_size: Cell::new(Size::ZERO),
284 viewport_size: Rc::new(Cell::new(Size::ZERO)),
285 viewport_origin: Rc::new(Cell::new(Point::ZERO)),
286 pending_restore_y: Rc::new(Cell::new(None)),
287 restore_wrote_y: Rc::new(Cell::new(None)),
288 scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
289 overscroll: Signal::new(Vec2::ZERO),
290 rubber_band: false,
291 scrollbar_reveal: Signal::new(false),
292 shift_wheel_horizontal: true,
293 reveal_bars_on_hover: false,
294 }
295 }
296
297 pub fn rubber_band(mut self, enabled: bool) -> Self {
307 self.rubber_band = enabled;
308 self
309 }
310
311 pub fn overscroll_signal(&self) -> Signal<Vec2> {
317 self.overscroll.clone()
318 }
319
320 pub fn child(mut self, child: impl teksilo_core::IntoTeksiChild) -> Self {
322 match teksilo_core::IntoTeksiChild::into_pending(child) {
323 teksilo_core::PendingChild::Id(id) => {
324 self.content_child_id = Some(id);
325 self.content_child = None;
326 self
327 }
328 teksilo_core::PendingChild::Deferred(w) => {
329 self.content_child = Some(w);
330 self.content_child_id = None;
331 self
332 }
333 }
334 }
335 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
342 match widget {
343 Some(w) => self.child(w),
344 None => self,
345 }
346 }
347
348 pub fn from_id(child: WidgetId) -> Self {
350 let mut sa = Self::new();
351 sa.content_child_id = Some(child);
352 sa
353 }
354
355 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
357 self.scroll_bar_style = style;
358 self
359 }
360
361 pub fn scroll_bar_thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
370 self.scroll_bar_thumb_color = Some(color.into());
371 self
372 }
373
374 pub fn vertical_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
376 self.vertical_policy = policy;
377 self
378 }
379
380 pub fn horizontal_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
382 self.horizontal_policy = policy;
383 self
384 }
385
386 pub fn line_height(mut self, lh: f32) -> Self {
388 self.line_height = lh;
389 self
390 }
391
392 pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self {
394 self.scroll_bar_thickness = thickness;
395 self
396 }
397
398 pub fn widget_resizable(mut self, resizable: bool) -> Self {
401 self.widget_resizable = resizable;
402 self
403 }
404
405 pub fn shift_wheel_scrolls_horizontally(mut self, enabled: bool) -> Self {
416 self.shift_wheel_horizontal = enabled;
417 self
418 }
419
420 pub fn reveal_bars_on_hover(mut self, enabled: bool) -> Self {
430 self.reveal_bars_on_hover = enabled;
431 self
432 }
433
434 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
441 self.smooth_scrolling = enabled;
442 self
443 }
444
445 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
447 self.smooth_scroll_duration = duration;
448 self
449 }
450
451 pub fn scroll_past_end(mut self, fraction: impl Into<Prop<f32>>) -> Self {
473 self.scroll_past_end = fraction.into();
474 self
475 }
476
477 pub fn preferred_size(mut self, width: f32, height: f32) -> Self {
488 self.preferred_size = Some(Size::new(width, height));
489 self
490 }
491
492 fn natural_content_width(&self, ctx: &LayoutContext) -> f32 {
505 if let Some(&child) = self.child_ids.first()
508 && let Some(size) = ctx.child_size(
509 child,
510 SizeProposal {
511 width: None,
512 height: None,
513 },
514 )
515 && size.width > 0.0
516 {
517 return size.width;
518 }
519 let cached = self.content_size.get().width;
520 if cached > 0.0 { cached } else { 300.0 }
521 }
522
523 pub fn preferred_height(mut self, height: f32) -> Self {
533 self.preferred_height = Some(height);
534 self
535 }
536
537 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
541 self.overscroll_behavior = behavior;
542 self
543 }
544
545 pub fn restore_scroll_y(self, offset: f32) -> Self {
568 self.pending_restore_y.set((offset > 0.0).then_some(offset));
572 self.restore_wrote_y.set(None);
577 self
578 }
579
580 pub fn scroll_y_signal(&self) -> &Signal<f32> {
582 &self.scroll_y
583 }
584
585 pub fn scroll_x_signal(&self) -> &Signal<f32> {
587 &self.scroll_x
588 }
589
590 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
596 &self.max_scroll_y
597 }
598
599 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
604 &self.viewport_ratio_y
605 }
606
607 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
611 &self.max_scroll_x
612 }
613
614 pub(crate) fn viewport_size_cell(&self) -> Rc<Cell<Size>> {
624 self.viewport_size.clone()
625 }
626
627 fn clamp_and_set_scroll(&self) {
628 let max_y = self.max_scroll_y.get();
629 let max_x = self.max_scroll_x.get();
630 let cur_y = self.scroll_y.get();
631 let cur_x = self.scroll_x.get();
632 let clamped_y = cur_y.clamp(0.0, max_y);
633 let clamped_x = cur_x.clamp(0.0, max_x);
634 if (clamped_y - cur_y).abs() > f32::EPSILON {
635 self.scroll_y.set(clamped_y);
636 }
637 if (clamped_x - cur_x).abs() > f32::EPSILON {
638 self.scroll_x.set(clamped_x);
639 }
640 }
641}
642
643impl Widget for ScrollArea {
644 fn as_any(&self) -> Option<&dyn std::any::Any> {
649 Some(self)
650 }
651
652 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
653 let mut ids = Vec::new();
654
655 let content_id = if let Some(child) = self.content_child.take() {
657 ctx.add_boxed(child)
658 } else if let Some(id) = self.content_child_id.take() {
659 id
660 } else if !self.child_ids.is_empty() {
661 return self.child_ids.clone();
663 } else {
664 self.child_ids.clear();
670 return Vec::new();
671 };
672 ids.push(content_id);
673
674 let visual = match self.scroll_bar_style {
676 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
677 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
678 ScrollBarMode::Thin => ScrollBarVisual::Thin,
679 };
680 let thickness = self.scroll_bar_thickness; let mut v_scrollbar = ScrollBar::new(
684 ScrollBarOrientation::Vertical,
685 self.scroll_y.clone(),
686 self.max_scroll_y.clone(),
687 self.viewport_ratio_y.clone(),
688 )
689 .thickness(thickness)
690 .visual(visual)
691 .reveal(self.scrollbar_reveal.clone());
695 if let Some(tint) = &self.scroll_bar_thumb_color {
696 v_scrollbar = v_scrollbar.thumb_color(tint.clone());
697 }
698 let v_id = ctx.add(v_scrollbar);
699 ids.push(v_id);
700
701 let mut h_scrollbar = ScrollBar::new(
703 ScrollBarOrientation::Horizontal,
704 self.scroll_x.clone(),
705 self.max_scroll_x.clone(),
706 self.viewport_ratio_x.clone(),
707 )
708 .thickness(thickness)
709 .visual(visual)
710 .reveal(self.scrollbar_reveal.clone());
711 if let Some(tint) = &self.scroll_bar_thumb_color {
712 h_scrollbar = h_scrollbar.thumb_color(tint.clone());
713 }
714 let h_id = ctx.add(h_scrollbar);
715 ids.push(h_id);
716
717 ctx.register_animated_signal(&self.scroll_y);
719 ctx.register_animated_signal(&self.scroll_x);
720
721 let self_id = ctx.self_id();
723 let registry = ctx.binding_registry();
724 self.scroll_y
725 .bind_to(self_id, registry, BindingLevel::Relayout);
726 self.scroll_x
727 .bind_to(self_id, registry, BindingLevel::Relayout);
728 self.scroll_past_end
732 .register_if_bound(self_id, registry, BindingLevel::Relayout);
733
734 self.child_ids = ids.clone();
735
736 let scroll_y = self.scroll_y.clone();
738 let scroll_x = self.scroll_x.clone();
739 let max_scroll_y = self.max_scroll_y.clone();
740 let max_scroll_x = self.max_scroll_x.clone();
741 let viewport_size = self.viewport_size.clone();
742 let viewport_origin = self.viewport_origin.clone();
743 let line_height = self.line_height;
744 let smooth_scrolling = self.smooth_scrolling;
745 let smooth_scroll_duration = self.smooth_scroll_duration;
746 let overscroll_behavior = self.overscroll_behavior;
747
748 let clamp_and_set = {
749 let scroll_y = scroll_y.clone();
750 let scroll_x = scroll_x.clone();
751 let max_scroll_y = max_scroll_y.clone();
752 let max_scroll_x = max_scroll_x.clone();
753 move || {
754 let max_y = max_scroll_y.get();
755 let max_x = max_scroll_x.get();
756 let cur_y = scroll_y.get();
757 let cur_x = scroll_x.get();
758 let clamped_y = cur_y.clamp(0.0, max_y);
759 let clamped_x = cur_x.clamp(0.0, max_x);
760 if (clamped_y - cur_y).abs() > f32::EPSILON {
761 scroll_y.set(clamped_y);
762 }
763 if (clamped_x - cur_x).abs() > f32::EPSILON {
764 scroll_x.set(clamped_x);
765 }
766 }
767 };
768
769 let mut handlers = HandlerSet::new().clips_children(true);
770
771 let hover_reveal = Signal::new(false);
779 let pan_active = Rc::new(Cell::new(false));
780 let reveal_at_rest = ctx.theme().input.reveal == RevealPolicy::Always;
781 if self.reveal_bars_on_hover && self.scroll_bar_style != ScrollBarMode::Permanent {
782 let inside = Signal::new(false);
783 let own = Signal::new(false);
784 for source in [&inside, &own] {
785 let (inside, own, hover_reveal) =
786 (inside.clone(), own.clone(), hover_reveal.clone());
787 ctx.effect(source, move |_| {
788 let want = inside.get() || own.get();
789 if hover_reveal.get() != want {
790 hover_reveal.set(want);
791 }
792 });
793 }
794 {
795 let reveal = self.scrollbar_reveal.clone();
796 let pan_active = pan_active.clone();
797 ctx.effect(&hover_reveal, move |hovered| {
798 let want = *hovered || pan_active.get() || reveal_at_rest;
799 if reveal.get() != want {
800 reveal.set(want);
801 }
802 });
803 }
804 let own_for_hover = own.clone();
805 handlers = handlers
806 .hover_within(inside)
807 .on_hover(move |entered, _ctx| own_for_hover.set(entered));
808 }
809
810 {
821 let scroll_y = scroll_y.clone();
828 let scroll_x = scroll_x.clone();
829 let max_scroll_y = max_scroll_y.clone();
830 let max_scroll_x = max_scroll_x.clone();
831 let viewport_size = viewport_size.clone();
832 let viewport_origin = viewport_origin.clone();
833 let pending_restore_y = self.pending_restore_y.clone();
839 let restore_wrote_y = self.restore_wrote_y.clone();
840 let reveal = self.scrollbar_reveal.clone();
845 let hover_reveal = hover_reveal.clone();
849 let pan_active = pan_active.clone();
850
851 let own_arm = move |event: &WidgetEvent,
852 ctx: &mut teksilo_core::widget::EventContext|
853 -> Option<EventResponse> {
854 match event {
855 WidgetEvent::Scroll { phase, .. } => {
856 pending_restore_y.set(None);
857 restore_wrote_y.set(None);
858 if ctx.scroll_source() == ScrollSource::TouchPan {
859 let in_flight = !matches!(
860 phase,
861 ScrollPhase::Ended
862 | ScrollPhase::MomentumEnded
863 | ScrollPhase::Cancelled
864 );
865 pan_active.set(in_flight);
866 let want = in_flight || reveal_at_rest || hover_reveal.get();
867 if reveal.get() != want {
868 reveal.set(want);
869 }
870 }
871 None
876 }
877 WidgetEvent::ScrollIntoView {
878 target_bounds,
879 margin,
880 align,
881 motion,
882 applied_scroll,
883 } => {
884 pending_restore_y.set(None);
885 restore_wrote_y.set(None);
886 let vp = viewport_size.get();
893 let vo = viewport_origin.get();
894 let sy = scroll_y.get();
895 let sx = scroll_x.get();
896
897 let viewport_top = sy;
905 let viewport_bottom = viewport_top + vp.height;
906 let target_top = target_bounds.y - vo.y + sy - margin;
907 let target_bottom = target_top + target_bounds.height + margin * 2.0;
908
909 let mut new_y = sy;
910 match align {
911 teksilo_core::event::ScrollAlign::Fraction(f) => {
917 let target_top = target_bounds.y - vo.y + sy;
918 new_y = target_top - (vp.height - target_bounds.height) * f;
919 }
920 teksilo_core::event::ScrollAlign::Minimal => {
921 if !(target_top <= viewport_top && target_bottom >= viewport_bottom)
922 {
923 if target_top < viewport_top {
924 new_y = target_top;
925 } else if target_bottom > viewport_bottom {
926 new_y = target_bottom - vp.height;
927 }
928 }
929 }
930 }
931
932 let viewport_left = sx;
933 let viewport_right = viewport_left + vp.width;
934 let target_left = target_bounds.x - vo.x + sx - margin;
935 let target_right = target_left + target_bounds.width + margin * 2.0;
936
937 let mut new_x = sx;
938 if !(target_left <= viewport_left && target_right >= viewport_right) {
939 if target_left < viewport_left {
940 new_x = target_left;
941 } else if target_right > viewport_right {
942 new_x = target_right - vp.width;
943 }
944 }
945
946 let new_y = new_y.clamp(0.0, max_scroll_y.get());
951 let new_x = new_x.clamp(0.0, max_scroll_x.get());
952
953 match motion {
954 teksilo_core::event::ScrollMotion::Smooth if smooth_scrolling => {
955 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
956 scroll_x.animate_to(new_x, smooth_scroll_duration, Easing::EaseOut);
957 }
958 _ => {
959 scroll_y.set(new_y);
960 scroll_x.set(new_x);
961 }
962 }
963 if let Some(cell) = applied_scroll
969 && let Ok(mut d) = cell.lock()
970 {
971 *d = teksilo_canvas::Point::new(new_x - sx, new_y - sy);
972 }
973 Some(EventResponse::Handled)
974 }
975 _ => None,
976 }
977 };
978
979 let axes = ScrollableAxes {
980 x: self.scroll_x.clone(),
981 y: self.scroll_y.clone(),
982 max_x: self.max_scroll_x.clone(),
983 max_y: self.max_scroll_y.clone(),
984 overscroll: self.overscroll.clone(),
985 };
986 let remap_axes = axes.clone();
987 let behavior = ScrollableBehavior::new(axes)
988 .with_scroller(self.scroller.clone())
989 .axes(PanAxes::BOTH)
993 .overscroll(overscroll_behavior)
994 .rubber_band(self.rubber_band)
995 .overscroll_style(OverscrollStyle::RubberBand)
996 .smooth(smooth_scrolling)
997 .smooth_duration(smooth_scroll_duration)
998 .line_height(line_height)
999 .reduced_motion(ctx.prefers_reduced_motion())
1000 .physics(ctx.theme().input.scroll_physics);
1001 let shift_remap = self.shift_wheel_horizontal;
1010 let scroller = behavior.scroller();
1011 let options = behavior.options();
1012 let max_x = self.max_scroll_x.clone();
1013 let behavior = behavior.before(move |event, ctx| {
1014 if let Some(response) = own_arm(event, ctx) {
1015 return Some(response);
1016 }
1017 if shift_remap
1018 && max_x.get() > 0.0
1019 && let Some(remapped) = shift_wheel_remap(event, ctx)
1020 {
1021 return Some(handle_scroll_event(
1022 &remapped,
1023 &remap_axes,
1024 &scroller,
1025 &options,
1026 ctx,
1027 ));
1028 }
1029 None
1030 });
1031 handlers = behavior.install(handlers);
1032 }
1033
1034 {
1036 let scroll_y = scroll_y.clone();
1037 let scroll_x = scroll_x.clone();
1038 let viewport_size = viewport_size.clone();
1039 let clamp_and_set = clamp_and_set.clone();
1040 handlers = handlers.on_access_action(move |action, _ctx| match action {
1041 teksilo_core::accesskit::Action::ScrollDown => {
1042 let step = viewport_size.get().height * 0.9;
1043 scroll_y.set(scroll_y.get() + step);
1044 clamp_and_set();
1045 EventResponse::Handled
1046 }
1047 teksilo_core::accesskit::Action::ScrollUp => {
1048 let step = viewport_size.get().height * 0.9;
1049 scroll_y.set(scroll_y.get() - step);
1050 clamp_and_set();
1051 EventResponse::Handled
1052 }
1053 teksilo_core::accesskit::Action::ScrollRight => {
1054 let step = viewport_size.get().width * 0.9;
1055 scroll_x.set(scroll_x.get() + step);
1056 clamp_and_set();
1057 EventResponse::Handled
1058 }
1059 teksilo_core::accesskit::Action::ScrollLeft => {
1060 let step = viewport_size.get().width * 0.9;
1061 scroll_x.set(scroll_x.get() - step);
1062 clamp_and_set();
1063 EventResponse::Handled
1064 }
1065 _ => EventResponse::Ignored,
1066 });
1067 }
1068
1069 ctx.apply_self_handlers(handlers);
1070
1071 ids
1072 }
1073
1074 fn layout_response(
1075 &self,
1076 proposal: SizeProposal,
1077 ctx: &LayoutContext,
1078 ) -> teksilo_core::widget::LayoutResponse {
1079 let (default_w, default_h) = if let Some(pref) = self.preferred_size {
1085 (pref.width, pref.height)
1086 } else {
1087 let h = self.preferred_height.unwrap_or(200.0);
1088 let w = if proposal.width.is_none() {
1099 self.natural_content_width(ctx)
1100 } else {
1101 0.0
1102 };
1103 (w, h)
1104 };
1105 proposal.resolve(default_w, default_h).into()
1106 }
1107
1108 fn place_children(
1109 &self,
1110 bounds: Rect,
1111 _proposal: SizeProposal,
1112 children: &mut [WidgetPlacement],
1113 ctx: &LayoutContext,
1114 ) {
1115 if children.is_empty() {
1116 return;
1117 }
1118
1119 let has_v = children.len() > 1;
1124 let has_h = children.len() > 2;
1125 let v_off = self.vertical_policy == ScrollBarPolicy::AlwaysOff;
1126 let _h_off = self.horizontal_policy == ScrollBarPolicy::AlwaysOff;
1127
1128 let sb_thickness = self.scroll_bar_thickness;
1130
1131 let resolve_show = |policy: ScrollBarPolicy, has_bar: bool, overflows: bool| -> bool {
1135 has_bar
1136 && match policy {
1137 ScrollBarPolicy::AlwaysOn => true,
1138 ScrollBarPolicy::AlwaysOff => false,
1139 ScrollBarPolicy::AsNeeded => overflows,
1140 }
1141 };
1142
1143 let v_reserved_1 = match self.scroll_bar_style {
1145 ScrollBarMode::Permanent if has_v && !v_off => sb_thickness,
1146 _ => 0.0,
1147 };
1148 let vp_w1 = (bounds.width - v_reserved_1).max(0.0);
1149 let content_size_1 = ctx
1150 .child_size(
1151 children[0].id,
1152 SizeProposal {
1153 width: Some(vp_w1),
1154 height: None,
1155 },
1156 )
1157 .unwrap_or(Size::new(vp_w1, bounds.height));
1158
1159 let show_v_1 = resolve_show(
1160 self.vertical_policy,
1161 has_v,
1162 content_size_1.height > bounds.height + 0.5,
1163 );
1164 let show_h_1 = resolve_show(
1165 self.horizontal_policy,
1166 has_h,
1167 content_size_1.width > vp_w1 + 0.5,
1168 );
1169
1170 let v_res = match self.scroll_bar_style {
1172 ScrollBarMode::Permanent if show_v_1 => sb_thickness,
1173 _ => 0.0,
1174 };
1175 let h_res = match self.scroll_bar_style {
1176 ScrollBarMode::Permanent if show_h_1 => sb_thickness,
1177 _ => 0.0,
1178 };
1179
1180 let vp_h_after_h = (bounds.height - h_res).max(0.0);
1182 let new_needs_v = content_size_1.height > vp_h_after_h + 0.5;
1183 let show_v = resolve_show(self.vertical_policy, has_v, new_needs_v);
1184 let new_v_res = match self.scroll_bar_style {
1185 ScrollBarMode::Permanent if show_v => sb_thickness,
1186 _ => 0.0,
1187 };
1188
1189 let (viewport_width, content_size, show_h) = if (new_v_res - v_res).abs() > 0.01 {
1190 let vp_w2 = (bounds.width - new_v_res).max(0.0);
1192 let cs2 = ctx
1193 .child_size(
1194 children[0].id,
1195 SizeProposal {
1196 width: Some(vp_w2),
1197 height: None,
1198 },
1199 )
1200 .unwrap_or(Size::new(vp_w2, bounds.height));
1201 let sh2 = resolve_show(self.horizontal_policy, has_h, cs2.width > vp_w2 + 0.5);
1202 (vp_w2, cs2, sh2)
1203 } else {
1204 (
1205 (bounds.width - new_v_res).max(0.0),
1206 content_size_1,
1207 show_h_1,
1208 )
1209 };
1210
1211 let v_reserved = new_v_res;
1212 let h_reserved = match self.scroll_bar_style {
1213 ScrollBarMode::Permanent if show_h => sb_thickness,
1214 _ => 0.0,
1215 };
1216 let viewport_height = (bounds.height - h_reserved).max(0.0);
1217
1218 let placed_content_size = if self.widget_resizable {
1220 Size::new(
1221 content_size.width.max(viewport_width),
1222 content_size.height.max(viewport_height),
1223 )
1224 } else {
1225 content_size
1226 };
1227
1228 self.content_size.set(placed_content_size);
1241 self.viewport_size
1242 .set(Size::new(viewport_width, viewport_height));
1243 self.viewport_origin.set(bounds.origin());
1244 self.scroller
1249 .borrow_mut()
1250 .set_viewport(Vec2::new(viewport_width, viewport_height));
1251
1252 let set_if_changed = |sig: &Signal<f32>, v: f32| {
1259 if (sig.get() - v).abs() > f32::EPSILON {
1260 sig.set(v);
1261 }
1262 };
1263
1264 let past_end = (self.scroll_past_end.get().max(0.0)) * viewport_height;
1269 let scrollable_height = placed_content_size.height + past_end;
1270
1271 let max_y = (scrollable_height - viewport_height).max(0.0);
1272 let max_x = (placed_content_size.width - viewport_width).max(0.0);
1273 set_if_changed(&self.max_scroll_y, max_y);
1274 set_if_changed(&self.max_scroll_x, max_x);
1275
1276 let ratio_y = if scrollable_height > 0.0 {
1277 (viewport_height / scrollable_height).clamp(0.0, 1.0)
1278 } else {
1279 1.0
1280 };
1281 let ratio_x = if placed_content_size.width > 0.0 {
1282 (viewport_width / placed_content_size.width).clamp(0.0, 1.0)
1283 } else {
1284 1.0
1285 };
1286 set_if_changed(&self.viewport_ratio_y, ratio_y);
1287 set_if_changed(&self.viewport_ratio_x, ratio_x);
1288
1289 if let Some(ours) = self.restore_wrote_y.get()
1318 && (self.scroll_y.get() - ours).abs() > f32::EPSILON
1319 {
1320 self.pending_restore_y.set(None);
1321 self.restore_wrote_y.set(None);
1322 }
1323 if let Some(pending) = self.pending_restore_y.get()
1324 && max_y > 0.0
1325 {
1326 let landed = pending.min(max_y);
1327 if (landed - self.scroll_y.get()).abs() > f32::EPSILON {
1328 self.scroll_y.set(landed);
1329 }
1330 if max_y >= pending {
1331 self.pending_restore_y.set(None);
1332 self.restore_wrote_y.set(None);
1333 } else {
1334 self.restore_wrote_y.set(Some(landed));
1337 }
1338 }
1339
1340 self.clamp_and_set_scroll();
1341 let scroll_y = self.scroll_y.get();
1342 let scroll_x = self.scroll_x.get();
1343
1344 let content_x = if ctx.is_rtl() {
1353 bounds.right() - placed_content_size.width + scroll_x
1354 } else {
1355 bounds.x - scroll_x
1356 };
1357 children[0].origin = Point::new(content_x, bounds.y - scroll_y);
1358 children[0].size = placed_content_size;
1359
1360 if has_v {
1362 if show_v {
1363 let sb_x = if ctx.is_rtl() {
1364 bounds.x
1365 } else {
1366 bounds.right() - sb_thickness
1367 };
1368 let sb_h = if h_reserved > 0.0
1369 || (matches!(
1370 self.scroll_bar_style,
1371 ScrollBarMode::Overlay | ScrollBarMode::Thin
1372 ) && show_h)
1373 {
1374 bounds.height - sb_thickness
1375 } else {
1376 bounds.height
1377 };
1378 children[1].origin = Point::new(sb_x, bounds.y);
1379 children[1].size = Size::new(sb_thickness, sb_h);
1380 } else {
1381 children[1].origin = Point::new(bounds.x, bounds.y);
1383 children[1].size = Size::ZERO;
1384 }
1385 }
1386
1387 if has_h {
1389 if show_h {
1390 let sb_y = bounds.bottom() - sb_thickness;
1391 let sb_x = if ctx.is_rtl() && v_reserved > 0.0 {
1392 bounds.x + sb_thickness
1393 } else {
1394 bounds.x
1395 };
1396 let sb_w = if v_reserved > 0.0
1397 || (matches!(
1398 self.scroll_bar_style,
1399 ScrollBarMode::Overlay | ScrollBarMode::Thin
1400 ) && show_v)
1401 {
1402 bounds.width - sb_thickness
1403 } else {
1404 bounds.width
1405 };
1406 children[2].origin = Point::new(sb_x, sb_y);
1407 children[2].size = Size::new(sb_w, sb_thickness);
1408 } else {
1409 children[2].origin = Point::new(bounds.x, bounds.y);
1410 children[2].size = Size::ZERO;
1411 }
1412 }
1413 }
1414
1415 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
1416 }
1418
1419 fn children(&self) -> Vec<WidgetId> {
1420 self.child_ids.clone()
1421 }
1422
1423 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1424 builder.set_role(teksilo_core::accesskit::Role::ScrollView);
1425 builder.inner_mut().set_clips_children();
1426
1427 let scroll_y = self.scroll_y.get();
1428 let scroll_x = self.scroll_x.get();
1429 let max_y = self.max_scroll_y.get();
1430 let max_x = self.max_scroll_x.get();
1431
1432 builder.inner_mut().set_scroll_y(scroll_y as f64);
1433 builder.inner_mut().set_scroll_y_min(0.0);
1434 builder.inner_mut().set_scroll_y_max(max_y as f64);
1435 builder.inner_mut().set_scroll_x(scroll_x as f64);
1436 builder.inner_mut().set_scroll_x_min(0.0);
1437 builder.inner_mut().set_scroll_x_max(max_x as f64);
1438
1439 if max_y > 0.0 {
1442 if scroll_y < max_y {
1443 builder.add_action(teksilo_core::accesskit::Action::ScrollDown);
1444 }
1445 if scroll_y > 0.0 {
1446 builder.add_action(teksilo_core::accesskit::Action::ScrollUp);
1447 }
1448 }
1449 if max_x > 0.0 {
1450 if scroll_x < max_x {
1451 builder.add_action(teksilo_core::accesskit::Action::ScrollRight);
1452 }
1453 if scroll_x > 0.0 {
1454 builder.add_action(teksilo_core::accesskit::Action::ScrollLeft);
1455 }
1456 }
1457 }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462 use super::*;
1463 use teksilo_canvas::SizeProposal;
1464 use teksilo_core::event::{Modifiers, ScrollDelta};
1465 use teksilo_core::widget::LayoutContext;
1466 use teksilo_core::widget_tree::WidgetTree;
1467
1468 use teksilo_core::widget_builder::WidgetBuilder;
1469
1470 use crate::primitives::VStack;
1471
1472 #[derive(Debug)]
1474 struct TallLeaf {
1475 width: f32,
1476 height: f32,
1477 }
1478
1479 impl TallLeaf {
1480 fn new(w: f32, h: f32) -> Self {
1481 Self {
1482 width: w,
1483 height: h,
1484 }
1485 }
1486 }
1487
1488 impl Widget for TallLeaf {
1489 fn layout_response(
1490 &self,
1491 proposal: SizeProposal,
1492 _ctx: &LayoutContext,
1493 ) -> teksilo_core::widget::LayoutResponse {
1494 Size::new(
1495 proposal.width.unwrap_or(self.width),
1496 proposal.height.unwrap_or(self.height),
1497 )
1498 .into()
1499 }
1500 }
1501
1502 #[derive(Debug)]
1507 struct GrowingLeaf {
1508 width: f32,
1509 height: Rc<Cell<f32>>,
1510 }
1511
1512 impl GrowingLeaf {
1513 fn new(w: f32, height: Rc<Cell<f32>>) -> Self {
1514 Self { width: w, height }
1515 }
1516 }
1517
1518 impl Widget for GrowingLeaf {
1519 fn layout_response(
1520 &self,
1521 proposal: SizeProposal,
1522 _ctx: &LayoutContext,
1523 ) -> teksilo_core::widget::LayoutResponse {
1524 Size::new(
1525 proposal.width.unwrap_or(self.width),
1526 proposal.height.unwrap_or(self.height.get()),
1527 )
1528 .into()
1529 }
1530 }
1531
1532 #[test]
1533 fn scroll_area_clips_hit_test() {
1534 let mut tree = WidgetTree::new();
1535
1536 let a = tree.add(TallLeaf::new(200.0, 100.0));
1538 let b = tree.add(TallLeaf::new(200.0, 100.0));
1539 let c = tree.add(TallLeaf::new(200.0, 100.0));
1540 let content = tree.add(VStack::new().child(a).child(b).child(c));
1541
1542 let scroll = tree.add(ScrollArea::from_id(content));
1543
1544 tree.layout(SizeProposal::exact(200.0, 80.0));
1546
1547 let hit = tree.hit_test(Point::new(50.0, 40.0));
1549 assert!(hit.is_some());
1550
1551 let hit_outside = tree.hit_test(Point::new(50.0, 100.0));
1553 assert!(hit_outside.is_none() || hit_outside == Some(scroll));
1555 }
1556
1557 #[test]
1558 fn scroll_changes_visible_content() {
1559 let mut tree = WidgetTree::new();
1560
1561 let a = tree.add(TallLeaf::new(200.0, 100.0));
1562 let b = tree.add(TallLeaf::new(200.0, 100.0));
1563 let content = tree.add(VStack::new().child(a).child(b));
1564
1565 let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1566
1567 tree.layout(SizeProposal::exact(200.0, 80.0));
1568
1569 assert!(tree.bounds(a).y >= 0.0);
1571
1572 tree.pointer_move(Point::new(50.0, 40.0));
1574
1575 tree.dispatch_event(WidgetEvent::scroll(
1577 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
1578 Default::default(),
1579 ));
1580 tree.layout(SizeProposal::exact(200.0, 80.0));
1581
1582 assert!(tree.bounds(a).y < 0.0);
1584 assert!(tree.bounds(b).y < 80.0);
1586 }
1587
1588 #[test]
1589 fn scroll_accessibility_reports_position() {
1590 let mut tree = WidgetTree::new();
1591 let content = tree.add(TallLeaf::new(200.0, 1000.0));
1592 let scroll = tree.add(ScrollArea::from_id(content));
1593
1594 tree.layout(SizeProposal::exact(200.0, 80.0));
1595
1596 let info = tree.accessibility_node(scroll);
1597 assert_eq!(info.role(), teksilo_core::accesskit::Role::ScrollView);
1598 }
1599
1600 #[test]
1601 fn scroll_offset_is_clamped() {
1602 let mut tree = WidgetTree::new();
1603 let content = tree.add(TallLeaf::new(200.0, 200.0));
1604 let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1605
1606 tree.layout(SizeProposal::exact(200.0, 100.0));
1607
1608 tree.pointer_move(Point::new(50.0, 50.0));
1610
1611 tree.dispatch_event(WidgetEvent::scroll(
1613 ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
1614 Default::default(),
1615 ));
1616 tree.layout(SizeProposal::exact(200.0, 100.0));
1617
1618 let content_y = tree.bounds(content).y;
1620 assert!(content_y >= -100.0 - 0.01);
1621 }
1622
1623 #[test]
1624 fn permanent_scrollbar_reduces_viewport() {
1625 let mut tree = WidgetTree::new();
1626
1627 let content = TallLeaf::new(200.0, 500.0);
1628 let scroll = tree.add(
1629 ScrollArea::new()
1630 .child(content)
1631 .scroll_bar_style(ScrollBarMode::Permanent)
1632 .scroll_bar_thickness(12.0),
1633 );
1634
1635 tree.layout(SizeProposal::exact(200.0, 100.0));
1636
1637 let scroll_bounds = tree.bounds(scroll);
1639 assert!((scroll_bounds.width - 200.0).abs() < 0.01);
1640 assert!((scroll_bounds.height - 100.0).abs() < 0.01);
1641 }
1642
1643 #[test]
1644 fn permanent_scrollbar_scroll_event_updates_content() {
1645 let mut tree = WidgetTree::new();
1646
1647 let leaf = TallLeaf::new(180.0, 500.0);
1648 let scroll = tree.add(
1649 ScrollArea::new()
1650 .child(leaf)
1651 .scroll_bar_style(ScrollBarMode::Permanent)
1652 .smooth_scrolling(false),
1653 );
1654
1655 tree.layout(SizeProposal::exact(200.0, 100.0));
1656
1657 tree.pointer_move(Point::new(50.0, 50.0));
1659 tree.dispatch_event(WidgetEvent::scroll(
1660 ScrollDelta::Pixels { x: 0.0, y: 50.0 },
1661 Default::default(),
1662 ));
1663 tree.layout(SizeProposal::exact(200.0, 100.0));
1664
1665 let children = tree.children(scroll);
1667 assert!(!children.is_empty());
1668 let content_y = tree.bounds(children[0]).y;
1669 assert!(
1670 content_y < 0.0,
1671 "Expected negative y after scroll, got {}",
1672 content_y
1673 );
1674 }
1675
1676 #[test]
1677 fn overlay_mode_has_scrollbar_children() {
1678 let mut tree = WidgetTree::new();
1679 let content = tree.add(TallLeaf::new(200.0, 500.0));
1680 let scroll = tree.add(ScrollArea::from_id(content));
1681
1682 tree.layout(SizeProposal::exact(200.0, 100.0));
1683
1684 let children = tree.children(scroll);
1686 assert_eq!(children.len(), 3, "Overlay mode should have 3 children");
1687
1688 let content_bounds = tree.bounds(children[0]);
1690 assert!(
1691 (content_bounds.width - 200.0).abs() < 0.01,
1692 "Overlay mode should not shrink viewport"
1693 );
1694 }
1695
1696 #[test]
1697 fn scroll_area_new_accepts_inline_widget() {
1698 let mut tree = WidgetTree::new();
1699 let scroll = tree.add(ScrollArea::new().child(TallLeaf::new(200.0, 500.0)));
1701
1702 tree.layout(SizeProposal::exact(200.0, 100.0));
1703
1704 let bounds = tree.bounds(scroll);
1705 assert!((bounds.width - 200.0).abs() < 0.01);
1706 }
1707
1708 #[derive(Debug)]
1710 struct WideLeaf {
1711 width: f32,
1712 height: f32,
1713 }
1714 impl WideLeaf {
1715 fn new(w: f32, h: f32) -> Self {
1716 Self {
1717 width: w,
1718 height: h,
1719 }
1720 }
1721 }
1722 impl Widget for WideLeaf {
1723 fn layout_response(
1724 &self,
1725 _proposal: SizeProposal,
1726 _ctx: &LayoutContext,
1727 ) -> teksilo_core::widget::LayoutResponse {
1728 Size::new(self.width, self.height).into()
1729 }
1730 }
1731
1732 #[test]
1733 fn permanent_horizontal_scrollbar_present() {
1734 let mut tree = WidgetTree::new();
1735 let scroll = tree.add(
1737 ScrollArea::new()
1738 .child(WideLeaf::new(400.0, 500.0))
1739 .scroll_bar_style(ScrollBarMode::Permanent)
1740 .scroll_bar_thickness(12.0),
1741 );
1742
1743 tree.layout(SizeProposal::exact(200.0, 100.0));
1744
1745 let children = tree.children(scroll);
1746 assert_eq!(
1747 children.len(),
1748 3,
1749 "Permanent mode should have content + v_sb + h_sb"
1750 );
1751
1752 let v_sb = tree.bounds(children[1]);
1754 assert!((v_sb.width - 12.0).abs() < 0.01, "v_sb width should be 12");
1755 assert!((v_sb.x - (200.0 - 12.0)).abs() < 0.01, "v_sb at right edge");
1756 assert!(
1757 (v_sb.height - (100.0 - 12.0)).abs() < 0.01,
1758 "v_sb height reduced by h_sb thickness, got {}",
1759 v_sb.height
1760 );
1761
1762 let h_sb = tree.bounds(children[2]);
1764 assert!(
1765 (h_sb.height - 12.0).abs() < 0.01,
1766 "h_sb height should be 12"
1767 );
1768 assert!(
1769 (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1770 "h_sb at bottom edge"
1771 );
1772 assert!(
1773 (h_sb.width - (200.0 - 12.0)).abs() < 0.01,
1774 "h_sb width = bounds.width - v_sb, got {}",
1775 h_sb.width
1776 );
1777 }
1778
1779 #[test]
1780 fn permanent_no_horizontal_when_content_fits() {
1781 let mut tree = WidgetTree::new();
1782 let scroll = tree.add(
1784 ScrollArea::new()
1785 .child(TallLeaf::new(180.0, 500.0))
1786 .scroll_bar_style(ScrollBarMode::Permanent)
1787 .scroll_bar_thickness(12.0),
1788 );
1789
1790 tree.layout(SizeProposal::exact(200.0, 100.0));
1791
1792 let children = tree.children(scroll);
1793 assert_eq!(children.len(), 3);
1794
1795 let v_sb = tree.bounds(children[1]);
1798 assert!(
1799 (v_sb.height - 100.0).abs() < 0.01,
1800 "v_sb should use full height when no h-scroll needed, got {}",
1801 v_sb.height
1802 );
1803 }
1804
1805 #[test]
1806 fn overlay_scrollbar_does_not_reduce_viewport() {
1807 let mut tree = WidgetTree::new();
1808 let scroll = tree.add(
1809 ScrollArea::new()
1810 .child(WideLeaf::new(400.0, 500.0))
1811 .scroll_bar_style(ScrollBarMode::Overlay),
1812 );
1813
1814 tree.layout(SizeProposal::exact(200.0, 100.0));
1815
1816 let children = tree.children(scroll);
1817 assert_eq!(children.len(), 3);
1818
1819 let content = tree.bounds(children[0]);
1821 assert!(
1822 content.width >= 400.0,
1823 "Content should report its full intrinsic width, got {}",
1824 content.width
1825 );
1826
1827 let v_sb = tree.bounds(children[1]);
1829 assert!(
1830 (v_sb.width - 12.0).abs() < 0.01,
1831 "Overlay v_sb should have full thickness for hover expansion, got {}",
1832 v_sb.width
1833 );
1834 assert!(
1835 (v_sb.x - (200.0 - 12.0)).abs() < 0.01,
1836 "Overlay v_sb at right edge"
1837 );
1838
1839 let h_sb = tree.bounds(children[2]);
1841 assert!(
1842 (h_sb.height - 12.0).abs() < 0.01,
1843 "Overlay h_sb should have full thickness for hover expansion, got {}",
1844 h_sb.height
1845 );
1846 assert!(
1847 (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1848 "Overlay h_sb at bottom edge"
1849 );
1850 }
1851
1852 #[test]
1853 fn horizontal_scroll_via_wheel() {
1854 let mut tree = WidgetTree::new();
1855 let scroll = tree.add(
1856 ScrollArea::new()
1857 .child(WideLeaf::new(400.0, 100.0))
1858 .scroll_bar_style(ScrollBarMode::Permanent)
1859 .scroll_bar_thickness(12.0)
1860 .smooth_scrolling(false),
1861 );
1862
1863 tree.layout(SizeProposal::exact(200.0, 100.0));
1864
1865 tree.pointer_move(Point::new(50.0, 50.0));
1866
1867 tree.dispatch_event(WidgetEvent::scroll(
1869 ScrollDelta::Pixels { x: 80.0, y: 0.0 },
1870 Default::default(),
1871 ));
1872 tree.layout(SizeProposal::exact(200.0, 100.0));
1873
1874 let children = tree.children(scroll);
1876 let content_x = tree.bounds(children[0]).x;
1877 assert!(
1878 content_x < 0.0,
1879 "Expected negative x after h-scroll, got {}",
1880 content_x
1881 );
1882 }
1883
1884 #[test]
1887 fn vertical_scrollbar_always_off_hides_scrollbar() {
1888 let mut tree = WidgetTree::new();
1889 let scroll = tree.add(
1890 ScrollArea::new()
1891 .child(TallLeaf::new(200.0, 500.0))
1892 .scroll_bar_style(ScrollBarMode::Permanent)
1893 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1894 .scroll_bar_thickness(12.0),
1895 );
1896
1897 tree.layout(SizeProposal::exact(200.0, 100.0));
1898
1899 let children = tree.children(scroll);
1900 let v_sb = tree.bounds(children[1]);
1902 assert!(
1903 (v_sb.width).abs() < 0.01,
1904 "v_sb should be zero-width, got {}",
1905 v_sb.width
1906 );
1907 assert!(
1908 (v_sb.height).abs() < 0.01,
1909 "v_sb should be zero-height, got {}",
1910 v_sb.height
1911 );
1912
1913 let content = tree.bounds(children[0]);
1915 assert!(
1916 (content.width - 200.0).abs() < 0.01,
1917 "Content should use full width when v_sb is off, got {}",
1918 content.width
1919 );
1920 }
1921
1922 #[test]
1923 fn horizontal_scrollbar_always_off_hides_scrollbar() {
1924 let mut tree = WidgetTree::new();
1925 let scroll = tree.add(
1926 ScrollArea::new()
1927 .child(WideLeaf::new(400.0, 500.0))
1928 .scroll_bar_style(ScrollBarMode::Permanent)
1929 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1930 .scroll_bar_thickness(12.0),
1931 );
1932
1933 tree.layout(SizeProposal::exact(200.0, 100.0));
1934
1935 let children = tree.children(scroll);
1936 let h_sb = tree.bounds(children[2]);
1938 assert!(
1939 (h_sb.width).abs() < 0.01,
1940 "h_sb should be zero-width, got {}",
1941 h_sb.width
1942 );
1943
1944 let v_sb = tree.bounds(children[1]);
1946 assert!(
1947 (v_sb.height - 100.0).abs() < 0.01,
1948 "v_sb should use full height when h_sb off, got {}",
1949 v_sb.height
1950 );
1951 }
1952
1953 #[test]
1954 fn scrollbar_always_on_shows_even_when_content_fits() {
1955 let mut tree = WidgetTree::new();
1956 let scroll = tree.add(
1958 ScrollArea::new()
1959 .child(TallLeaf::new(100.0, 50.0))
1960 .scroll_bar_style(ScrollBarMode::Permanent)
1961 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOn)
1962 .scroll_bar_thickness(12.0),
1963 );
1964
1965 tree.layout(SizeProposal::exact(200.0, 100.0));
1966
1967 let children = tree.children(scroll);
1968 let v_sb = tree.bounds(children[1]);
1969 assert!(
1971 (v_sb.width - 12.0).abs() < 0.01,
1972 "v_sb should be visible (12px) even when content fits, got {}",
1973 v_sb.width
1974 );
1975 }
1976
1977 #[test]
1980 fn widget_resizable_stretches_small_content() {
1981 let mut tree = WidgetTree::new();
1982 let scroll = tree.add(
1984 ScrollArea::new()
1985 .child(TallLeaf::new(100.0, 50.0))
1986 .widget_resizable(true),
1987 );
1988
1989 tree.layout(SizeProposal::exact(200.0, 100.0));
1990
1991 let children = tree.children(scroll);
1992 let content = tree.bounds(children[0]);
1993 assert!(
1995 content.width >= 200.0 - 0.01,
1996 "Resizable content width should fill viewport, got {}",
1997 content.width
1998 );
1999 assert!(
2000 content.height >= 100.0 - 0.01,
2001 "Resizable content height should fill viewport, got {}",
2002 content.height
2003 );
2004 }
2005
2006 #[test]
2007 fn widget_resizable_does_not_shrink_large_content() {
2008 let mut tree = WidgetTree::new();
2009 let scroll = tree.add(
2011 ScrollArea::new()
2012 .child(WideLeaf::new(400.0, 500.0))
2013 .widget_resizable(true),
2014 );
2015
2016 tree.layout(SizeProposal::exact(200.0, 100.0));
2017
2018 let children = tree.children(scroll);
2019 let content = tree.bounds(children[0]);
2020 assert!(
2021 content.width >= 400.0 - 0.01,
2022 "Large content should not be shrunk, got {}",
2023 content.width
2024 );
2025 assert!(
2026 content.height >= 500.0 - 0.01,
2027 "Large content should not be shrunk, got {}",
2028 content.height
2029 );
2030 }
2031
2032 #[test]
2035 fn smooth_scrolling_line_events_use_animation() {
2036 let mut tree = WidgetTree::new();
2037 let scroll = tree.add(
2038 ScrollArea::new()
2039 .child(TallLeaf::new(200.0, 1000.0))
2040 .smooth_scrolling(true),
2041 );
2042
2043 tree.layout(SizeProposal::exact(200.0, 100.0));
2044
2045 tree.pointer_move(Point::new(50.0, 50.0));
2046
2047 tree.dispatch_event(WidgetEvent::scroll(
2049 ScrollDelta::Lines { x: 0.0, y: 5.0 },
2050 Default::default(),
2051 ));
2052
2053 tree.layout(SizeProposal::exact(200.0, 100.0));
2057
2058 tree.tick_animations(Duration::from_millis(75));
2060 tree.layout(SizeProposal::exact(200.0, 100.0));
2061
2062 let children = tree.children(scroll);
2063 let content_y = tree.bounds(children[0]).y;
2064 assert!(
2066 content_y < 0.0,
2067 "Expected partial scroll, got y={}",
2068 content_y
2069 );
2070 assert!(
2071 content_y > -100.0,
2072 "Should not have reached target yet, got y={}",
2073 content_y
2074 );
2075 }
2076
2077 #[test]
2078 fn smooth_scrolling_disabled_jumps_immediately() {
2079 let mut tree = WidgetTree::new();
2080 let scroll = tree.add(
2081 ScrollArea::new()
2082 .child(TallLeaf::new(200.0, 1000.0))
2083 .smooth_scrolling(false),
2084 );
2085
2086 tree.layout(SizeProposal::exact(200.0, 100.0));
2087
2088 tree.pointer_move(Point::new(50.0, 50.0));
2089
2090 tree.dispatch_event(WidgetEvent::scroll(
2091 ScrollDelta::Lines { x: 0.0, y: 5.0 },
2092 Default::default(),
2093 ));
2094 tree.layout(SizeProposal::exact(200.0, 100.0));
2095
2096 let children = tree.children(scroll);
2097 let content_y = tree.bounds(children[0]).y;
2098 assert!(
2100 (content_y - (-100.0)).abs() < 0.01,
2101 "Should jump immediately, got y={}",
2102 content_y
2103 );
2104 }
2105
2106 #[test]
2109 fn preferred_size_overrides_default() {
2110 let mut tree = WidgetTree::new();
2111 let scroll = tree.add(
2112 ScrollArea::new()
2113 .child(TallLeaf::new(200.0, 500.0))
2114 .preferred_size(500.0, 400.0),
2115 );
2116 tree.layout(SizeProposal {
2118 width: None,
2119 height: None,
2120 });
2121 let bounds = tree.bounds(scroll);
2122 assert!(
2123 (bounds.width - 500.0).abs() < 0.01,
2124 "Should use preferred width, got {}",
2125 bounds.width
2126 );
2127 assert!(
2128 (bounds.height - 400.0).abs() < 0.01,
2129 "Should use preferred height, got {}",
2130 bounds.height
2131 );
2132 }
2133
2134 #[test]
2135 fn constrained_proposal_overrides_preferred_size() {
2136 let mut tree = WidgetTree::new();
2137 let scroll = tree.add(
2138 ScrollArea::new()
2139 .child(TallLeaf::new(200.0, 500.0))
2140 .preferred_size(500.0, 400.0),
2141 );
2142 tree.layout(SizeProposal::exact(200.0, 100.0));
2144 let bounds = tree.bounds(scroll);
2145 assert!((bounds.width - 200.0).abs() < 0.01);
2146 assert!((bounds.height - 100.0).abs() < 0.01);
2147 }
2148
2149 #[test]
2152 fn scroll_survives_theme_switch_at_root() {
2153 let mut tree = WidgetTree::new();
2154 let scroll = tree.add(
2155 ScrollArea::new()
2156 .child(TallLeaf::new(200.0, 500.0))
2157 .smooth_scrolling(false),
2158 );
2159 tree.layout(SizeProposal::exact(200.0, 100.0));
2160
2161 tree.pointer_move(Point::new(50.0, 50.0));
2163 tree.dispatch_event(WidgetEvent::scroll(
2164 ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2165 Default::default(),
2166 ));
2167 tree.layout(SizeProposal::exact(200.0, 100.0));
2168
2169 let content = tree.children(scroll)[0];
2170 let content_y_before = tree.bounds(content).y;
2171 assert!(
2172 content_y_before < -100.0,
2173 "Content should have scrolled; got y={}",
2174 content_y_before
2175 );
2176
2177 tree.set_theme(teksilo_core::presets::intui::dark());
2179 tree.layout(SizeProposal::exact(200.0, 100.0));
2180
2181 let content = tree.children(scroll)[0];
2182 let content_y_after = tree.bounds(content).y;
2183 assert!(
2184 (content_y_after - content_y_before).abs() < 0.01,
2185 "Scroll offset should survive theme switch: before={}, after={}",
2186 content_y_before,
2187 content_y_after
2188 );
2189 }
2190
2191 #[derive(Debug)]
2195 struct ScrollParent {
2196 scroll_id: Option<WidgetId>,
2197 }
2198 impl ScrollParent {
2199 fn new() -> Self {
2200 Self { scroll_id: None }
2201 }
2202 }
2203 impl Widget for ScrollParent {
2204 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2205 let id = ctx.add(
2206 ScrollArea::new()
2207 .child(TallLeaf::new(200.0, 500.0))
2208 .smooth_scrolling(false),
2209 );
2210 self.scroll_id = Some(id);
2211 vec![id]
2212 }
2213 fn layout_response(
2214 &self,
2215 proposal: SizeProposal,
2216 ctx: &LayoutContext,
2217 ) -> teksilo_core::widget::LayoutResponse {
2218 self.scroll_id
2219 .and_then(|id| ctx.child_size(id, proposal))
2220 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2221 .into()
2222 }
2223 fn place_children(
2224 &self,
2225 bounds: Rect,
2226 _proposal: SizeProposal,
2227 children: &mut [WidgetPlacement],
2228 _ctx: &LayoutContext,
2229 ) {
2230 if let Some(child) = children.first_mut() {
2231 child.origin = bounds.origin();
2232 child.size = bounds.size();
2233 }
2234 }
2235 }
2236
2237 #[test]
2238 fn scroll_survives_theme_switch_inside_composite() {
2239 let mut tree = WidgetTree::new();
2240 let parent = tree.add(ScrollParent::new());
2241 tree.layout(SizeProposal::exact(200.0, 100.0));
2242
2243 tree.pointer_move(Point::new(50.0, 50.0));
2244 tree.dispatch_event(WidgetEvent::scroll(
2245 ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2246 Default::default(),
2247 ));
2248 tree.layout(SizeProposal::exact(200.0, 100.0));
2249
2250 let scroll_before = tree.children(parent)[0];
2251 let content_before = tree.children(scroll_before)[0];
2252 let y_before = tree.bounds(content_before).y;
2253 assert!(
2254 y_before < -100.0,
2255 "Content should have scrolled; got y={}",
2256 y_before
2257 );
2258
2259 tree.set_theme(teksilo_core::presets::intui::dark());
2260 tree.layout(SizeProposal::exact(200.0, 100.0));
2261
2262 let scroll_after = tree.children(parent)[0];
2263 let content_after = tree.children(scroll_after)[0];
2264 let y_after = tree.bounds(content_after).y;
2265 assert!(
2266 (y_after - y_before).abs() < 0.01,
2267 "Scroll offset should survive theme switch inside composite: before={}, after={}",
2268 y_before,
2269 y_after
2270 );
2271 }
2272
2273 #[test]
2286 fn scroll_into_view_brings_widget_above_viewport_into_view() {
2287 let mut tree = WidgetTree::new();
2288
2289 let header = tree.add(TallLeaf::new(200.0, 50.0));
2293 let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2295 let after = tree.add(TallLeaf::new(200.0, 470.0));
2296 let content = tree.add(VStack::new().child(target).child(after));
2297 let scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
2298 let _root = tree.add(VStack::new().child(header).child(scroll));
2299
2300 tree.layout(SizeProposal::exact(200.0, 250.0));
2301
2302 let scroll_bounds = tree.bounds(scroll);
2303 assert!(
2304 (scroll_bounds.y - 50.0).abs() < 0.01,
2305 "ScrollArea should sit below the header at y=50, got {}",
2306 scroll_bounds.y
2307 );
2308
2309 tree.pointer_move(Point::new(100.0, 100.0));
2311 tree.dispatch_event(WidgetEvent::scroll(
2312 ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2313 Default::default(),
2314 ));
2315 tree.layout(SizeProposal::exact(200.0, 250.0));
2316
2317 let target_before = tree.bounds(target);
2318 assert!(
2319 target_before.bottom() < scroll_bounds.y,
2320 "Target should be above viewport before focus, got y={} (viewport top={})",
2321 target_before.y,
2322 scroll_bounds.y
2323 );
2324
2325 tree.focus(target);
2328 tree.layout(SizeProposal::exact(200.0, 250.0));
2329
2330 let target_after = tree.bounds(target);
2331 let viewport_top = scroll_bounds.y;
2332 let viewport_bottom = scroll_bounds.bottom();
2333 assert!(
2334 target_after.y >= viewport_top - 0.5 && target_after.bottom() <= viewport_bottom + 0.5,
2335 "Target should be inside viewport after focus, got y={}..{} (viewport={}..{})",
2336 target_after.y,
2337 target_after.bottom(),
2338 viewport_top,
2339 viewport_bottom
2340 );
2341 }
2342
2343 struct PinFixture {
2354 tree: WidgetTree,
2355 bounds: Rect,
2356 viewport_h: f32,
2357 scroll_y: Signal<f32>,
2358 max_scroll_y: Signal<f32>,
2359 ratio_y: Signal<f32>,
2360 request: Rc<Cell<(Rect, f32)>>,
2363 }
2364
2365 fn pin_fixture(content_h: f32, viewport_h: f32, past_end: f32) -> PinFixture {
2366 let request = Rc::new(Cell::new((Rect::new(0.0, 0.0, 0.0, 0.0), 0.5)));
2367 let mut tree = WidgetTree::new();
2368
2369 let req = request.clone();
2370 let actor = tree.add(TallLeaf::new(200.0, content_h).focusable(true).on_key(
2371 move |_ev, ctx| {
2372 let (rect, fraction) = req.get();
2373 ctx.ensure_visible_aligned(
2374 rect,
2375 fraction,
2376 teksilo_core::event::ScrollMotion::Instant,
2377 );
2378 EventResponse::Handled
2379 },
2380 ));
2381 let content = tree.add(VStack::new().child(actor));
2382 let sa = ScrollArea::from_id(content)
2383 .smooth_scrolling(false)
2384 .scroll_past_end(past_end);
2385 let scroll_y = sa.scroll_y_signal().clone();
2386 let max_scroll_y = sa.max_scroll_y_signal().clone();
2387 let ratio_y = sa.viewport_ratio_y_signal().clone();
2388 let scroll = tree.add(sa);
2389 tree.layout(SizeProposal::exact(200.0, viewport_h));
2390 tree.focus(actor);
2391 tree.layout(SizeProposal::exact(200.0, viewport_h));
2394 scroll_y.set(0.0);
2395
2396 let bounds = tree.bounds(scroll);
2397 PinFixture {
2398 tree,
2399 bounds,
2400 viewport_h,
2401 scroll_y,
2402 max_scroll_y,
2403 ratio_y,
2404 request,
2405 }
2406 }
2407
2408 impl PinFixture {
2409 fn pin(&mut self, content_y: f32, height: f32, fraction: f32) {
2412 let window_y = self.bounds.y + content_y - self.scroll_y.get();
2413 self.request
2414 .set((Rect::new(0.0, window_y, 200.0, height), fraction));
2415 self.tree.dispatch_event(WidgetEvent::KeyDown {
2416 key: teksilo_core::event::Key::ArrowDown,
2417 modifiers: Default::default(),
2418 text: None,
2419 });
2420 self.tree
2421 .layout(SizeProposal::exact(200.0, self.viewport_h));
2422 }
2423 }
2424
2425 #[test]
2426 fn scroll_past_end_extends_the_range_without_changing_the_content() {
2427 let plain = pin_fixture(300.0, 100.0, 0.0);
2429 assert_eq!(plain.max_scroll_y.get(), 200.0);
2430
2431 let padded = pin_fixture(300.0, 100.0, 0.5);
2433 assert_eq!(
2434 padded.max_scroll_y.get(),
2435 250.0,
2436 "scroll_past_end(0.5) must add half a viewport of range"
2437 );
2438 }
2439
2440 #[test]
2441 fn scroll_past_end_keeps_the_thumb_proportional() {
2442 let f = pin_fixture(300.0, 100.0, 0.5);
2445 let expected = 100.0 / 350.0;
2447 assert!(
2448 (f.ratio_y.get() - expected).abs() < 1e-4,
2449 "thumb ratio must use the extended range, got {}",
2450 f.ratio_y.get()
2451 );
2452 }
2453
2454 #[test]
2455 fn scroll_past_end_lets_the_last_line_reach_a_centre_pin() {
2456 let mut f = pin_fixture(300.0, 100.0, 0.5);
2460 f.pin(280.0, 20.0, 0.5);
2461
2462 assert_eq!(
2466 f.scroll_y.get(),
2467 240.0,
2468 "the last line must be able to sit at the pin"
2469 );
2470 }
2471
2472 #[test]
2473 fn without_scroll_past_end_the_last_line_cannot_reach_the_pin() {
2474 let mut f = pin_fixture(300.0, 100.0, 0.0);
2477 f.pin(280.0, 20.0, 0.5);
2478 assert_eq!(
2479 f.scroll_y.get(),
2480 200.0,
2481 "clamped to the un-extended maximum"
2482 );
2483 }
2484
2485 #[test]
2486 fn a_pin_near_the_document_start_clamps_instead_of_scrolling_negative() {
2487 let mut f = pin_fixture(300.0, 100.0, 0.5);
2490 f.pin(0.0, 20.0, 0.5);
2491 assert_eq!(
2492 f.scroll_y.get(),
2493 0.0,
2494 "the first line must clamp at the top, never scroll past it"
2495 );
2496 }
2497
2498 #[test]
2499 fn a_fraction_pin_places_the_target_at_that_height() {
2500 let mut f = pin_fixture(600.0, 100.0, 0.0);
2502 f.pin(300.0, 20.0, 0.25);
2503 assert_eq!(f.scroll_y.get(), 280.0);
2505 }
2506
2507 #[test]
2508 fn a_pin_re_asserts_on_an_already_visible_target() {
2509 let mut f = pin_fixture(600.0, 100.0, 0.0);
2513 f.scroll_y.set(250.0);
2514 f.tree.layout(SizeProposal::exact(200.0, 100.0));
2515
2516 f.pin(300.0, 20.0, 0.5);
2518
2519 assert_eq!(
2520 f.scroll_y.get(),
2521 260.0,
2522 "a pin must move an already-visible target onto the mark"
2523 );
2524 }
2525
2526 #[test]
2527 fn scroll_into_view_reveals_target_through_two_nested_scroll_areas() {
2528 use crate::primitives::FixedSize;
2535
2536 let mut tree = WidgetTree::new();
2537 let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2539 let inner_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2540 let inner_tail = tree.add(TallLeaf::new(200.0, 100.0));
2541 let inner_content = tree.add(
2542 VStack::new()
2543 .child(inner_spacer)
2544 .child(target)
2545 .child(inner_tail),
2546 );
2547 let inner_sa = tree.add(ScrollArea::from_id(inner_content).smooth_scrolling(false));
2548 let inner_box = tree.add(FixedSize::new().width(200.0).height(80.0).child(inner_sa));
2550 let outer_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2552 let outer_tail = tree.add(TallLeaf::new(200.0, 200.0));
2553 let outer_content = tree.add(
2554 VStack::new()
2555 .child(outer_spacer)
2556 .child(inner_box)
2557 .child(outer_tail),
2558 );
2559 let outer_sa = tree.add(ScrollArea::from_id(outer_content).smooth_scrolling(false));
2560
2561 let sz = SizeProposal::exact(200.0, 100.0);
2563 tree.layout(sz);
2564
2565 tree.focus(target);
2567 tree.layout(sz);
2568
2569 let outer_bounds = tree.bounds(outer_sa);
2570 let t = tree.bounds(target);
2571 assert!(
2572 t.y >= outer_bounds.y - 1.0 && t.bottom() <= outer_bounds.bottom() + 1.0,
2573 "target must be visible in the outer window after both scroll: target y={}..{}, \
2574 outer viewport {}..{}",
2575 t.y,
2576 t.bottom(),
2577 outer_bounds.y,
2578 outer_bounds.bottom()
2579 );
2580 }
2581
2582 #[derive(Debug)]
2587 struct FixedLeaf(f32, f32);
2588 impl Widget for FixedLeaf {
2589 fn layout_response(
2590 &self,
2591 _proposal: SizeProposal,
2592 _ctx: &LayoutContext,
2593 ) -> teksilo_core::widget::LayoutResponse {
2594 Size::new(self.0, self.1).into()
2595 }
2596 }
2597
2598 #[test]
2599 fn rtl_anchors_narrow_content_to_trailing_edge() {
2600 let mut tree = WidgetTree::new();
2604 let content = tree.add(FixedLeaf(120.0, 80.0));
2605 let _scroll = tree.add(ScrollArea::from_id(content));
2606
2607 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
2608 tree.layout(SizeProposal::exact(400.0, 200.0));
2609
2610 let cb = tree.bounds(content);
2611 assert!(
2612 (cb.x - (400.0 - 120.0)).abs() < 0.01,
2613 "RTL content should be flush-right at x=280, got {}",
2614 cb.x
2615 );
2616 }
2617
2618 #[test]
2619 fn ltr_anchors_narrow_content_to_leading_edge() {
2620 let mut tree = WidgetTree::new();
2621 let content = tree.add(FixedLeaf(120.0, 80.0));
2622 let _scroll = tree.add(ScrollArea::from_id(content));
2623
2624 tree.layout(SizeProposal::exact(400.0, 200.0));
2625
2626 let cb = tree.bounds(content);
2627 assert!(
2628 cb.x.abs() < 0.01,
2629 "LTR content should be flush-left at x=0, got {}",
2630 cb.x
2631 );
2632 }
2633
2634 fn nested_scroll_fixture(
2638 inner_overscroll: OverscrollBehavior,
2639 ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
2640 let mut tree = WidgetTree::new();
2641
2642 let inner_content = tree.add(TallLeaf::new(200.0, 300.0));
2643 let inner_sa = ScrollArea::from_id(inner_content)
2644 .smooth_scrolling(false)
2645 .preferred_size(200.0, 100.0)
2646 .overscroll_behavior(inner_overscroll);
2647 let inner_y = inner_sa.scroll_y_signal().clone();
2648 let inner = tree.add(inner_sa);
2649
2650 let filler = tree.add(TallLeaf::new(200.0, 200.0));
2651 let outer_content = tree.add(VStack::new().child(inner).child(filler));
2652 let outer_sa = ScrollArea::from_id(outer_content).smooth_scrolling(false);
2653 let outer_y = outer_sa.scroll_y_signal().clone();
2654 let _outer = tree.add(outer_sa);
2655
2656 tree.layout(SizeProposal::exact(200.0, 150.0));
2657 (tree, inner_y, outer_y)
2658 }
2659
2660 #[test]
2661 fn nested_scroll_chains_to_outer_at_boundary() {
2662 let (mut tree, inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Chain);
2663
2664 tree.pointer_move(Point::new(50.0, 40.0));
2666 tree.dispatch_event(WidgetEvent::scroll(
2667 ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2668 Default::default(),
2669 ));
2670 tree.layout(SizeProposal::exact(200.0, 150.0));
2671
2672 let inner_bottom = inner_y.get();
2673 assert!(inner_bottom > 0.0, "inner should have scrolled down");
2674 assert!(
2675 outer_y.get() < 0.01,
2676 "outer must not move while the inner still absorbs the scroll"
2677 );
2678
2679 tree.pointer_move(Point::new(50.0, 40.0));
2681 tree.dispatch_event(WidgetEvent::scroll(
2682 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2683 Default::default(),
2684 ));
2685 tree.layout(SizeProposal::exact(200.0, 150.0));
2686
2687 assert!(
2688 (inner_y.get() - inner_bottom).abs() < 0.01,
2689 "inner stays clamped at its bottom"
2690 );
2691 assert!(
2692 outer_y.get() > 0.01,
2693 "outer scrolled because the inner chained the boundary scroll"
2694 );
2695 }
2696
2697 #[test]
2698 fn contain_blocks_scroll_chaining() {
2699 let (mut tree, _inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Contain);
2700
2701 tree.pointer_move(Point::new(50.0, 40.0));
2702 tree.dispatch_event(WidgetEvent::scroll(
2703 ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2704 Default::default(),
2705 ));
2706 tree.layout(SizeProposal::exact(200.0, 150.0));
2707
2708 tree.pointer_move(Point::new(50.0, 40.0));
2710 tree.dispatch_event(WidgetEvent::scroll(
2711 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2712 Default::default(),
2713 ));
2714 tree.layout(SizeProposal::exact(200.0, 150.0));
2715
2716 assert!(
2717 outer_y.get() < 0.01,
2718 "Contain must prevent chaining: outer stays put"
2719 );
2720 }
2721
2722 #[derive(Debug)]
2729 struct RecordingLeaf {
2730 width: f32,
2731 height: f32,
2732 log: Rc<std::cell::RefCell<Vec<SizeProposal>>>,
2733 }
2734
2735 impl Widget for RecordingLeaf {
2736 fn layout_response(
2737 &self,
2738 proposal: SizeProposal,
2739 _ctx: &LayoutContext,
2740 ) -> teksilo_core::widget::LayoutResponse {
2741 self.log.borrow_mut().push(proposal);
2742 Size::new(
2743 proposal.width.unwrap_or(self.width),
2744 proposal.height.unwrap_or(self.height),
2745 )
2746 .into()
2747 }
2748 }
2749
2750 #[test]
2751 fn preferred_height_reports_natural_width_when_parent_proposes_unbounded() {
2752 let mut tree = WidgetTree::new();
2755 let content = tree.add(TallLeaf::new(392.0, 500.0));
2756 let scroll = tree.add(ScrollArea::from_id(content).preferred_height(150.0));
2757
2758 tree.layout(SizeProposal {
2760 width: None,
2761 height: None,
2762 });
2763
2764 let bounds = tree.bounds(scroll);
2765 assert!(
2766 (bounds.width - 392.0).abs() < 0.01,
2767 "should report the content's real natural width, got {}",
2768 bounds.width
2769 );
2770 assert!(
2771 (bounds.height - 150.0).abs() < 0.01,
2772 "should still cap the height at preferred_height, got {}",
2773 bounds.height
2774 );
2775 }
2776
2777 #[test]
2778 fn bounded_proposal_never_triggers_an_unbounded_content_query() {
2779 let log: Rc<std::cell::RefCell<Vec<SizeProposal>>> =
2781 Rc::new(std::cell::RefCell::new(Vec::new()));
2782 let mut tree = WidgetTree::new();
2783 let content = tree.add(RecordingLeaf {
2784 width: 900.0,
2785 height: 500.0,
2786 log: log.clone(),
2787 });
2788 tree.add(ScrollArea::from_id(content));
2789
2790 tree.layout(SizeProposal::exact(300.0, 100.0));
2792
2793 let recorded = log.borrow();
2794 assert!(!recorded.is_empty(), "content widget was never laid out");
2795 for proposal in recorded.iter() {
2796 assert!(
2797 proposal.width.is_some(),
2798 "content queried with an unbounded width ({:?}) even though the \
2799 incoming proposal was already bounded — the unbounded natural-width \
2800 measure must only run when `proposal.width` is `None`",
2801 proposal
2802 );
2803 }
2804 }
2805
2806 #[test]
2807 fn exact_proposal_still_wins_over_natural_width() {
2808 let mut tree = WidgetTree::new();
2811 let content = tree.add(TallLeaf::new(900.0, 500.0));
2812 let scroll = tree.add(ScrollArea::from_id(content));
2813
2814 tree.layout(SizeProposal::exact(300.0, 100.0));
2815
2816 let bounds = tree.bounds(scroll);
2817 assert!(
2818 (bounds.width - 300.0).abs() < 0.01,
2819 "exact proposal must win over the content's natural width, got {}",
2820 bounds.width
2821 );
2822 assert!(
2823 (bounds.height - 100.0).abs() < 0.01,
2824 "exact proposal must win over the content's natural height, got {}",
2825 bounds.height
2826 );
2827 }
2828
2829 #[test]
2845 fn cross_axis_overflow_through_a_vstack_is_scrollable() {
2846 use crate::primitives::{HStack, Padding};
2847
2848 let mut tree = WidgetTree::new();
2849 let cells: Vec<_> = (0..4)
2851 .map(|_| tree.add(TallLeaf::new(200.0, 40.0)))
2852 .collect();
2853 let mut row = HStack::new();
2854 for &c in &cells {
2855 row = row.child(c);
2856 }
2857 let row = tree.add(row);
2858 let col = tree.add(VStack::new().child(row));
2859 let padded = tree.add(Padding::uniform(20.0).child(col));
2860 let _scroll = tree.add(ScrollArea::from_id(padded).smooth_scrolling(false));
2861
2862 tree.layout(SizeProposal::exact(600.0, 400.0));
2863
2864 let last = *cells.last().unwrap();
2865 assert!(
2866 tree.bounds(last).x > 600.0,
2867 "precondition: the 4th cell should start beyond the viewport, got x={}",
2868 tree.bounds(last).x
2869 );
2870
2871 tree.pointer_move(Point::new(300.0, 40.0));
2873 tree.dispatch_event(WidgetEvent::scroll(
2874 ScrollDelta::Pixels { x: 300.0, y: 0.0 },
2875 Default::default(),
2876 ));
2877 tree.layout(SizeProposal::exact(600.0, 400.0));
2878
2879 let b = tree.bounds(last);
2880 assert!(
2881 b.x >= 0.0 && b.x + b.width <= 600.5,
2882 "the 4th cell must be reachable by horizontal scrolling; got x={} w={}",
2883 b.x,
2884 b.width
2885 );
2886 }
2887
2888 #[test]
2892 fn restore_scroll_y_lands_on_the_first_measured_layout() {
2893 let mut tree = WidgetTree::new();
2895 let sa = ScrollArea::new()
2896 .child(TallLeaf::new(200.0, 500.0))
2897 .smooth_scrolling(false)
2898 .restore_scroll_y(150.0);
2899 let scroll_y = sa.scroll_y_signal().clone();
2900 let max_scroll_y = sa.max_scroll_y_signal().clone();
2901 let _scroll = tree.add(sa);
2902
2903 tree.layout(SizeProposal::exact(200.0, 100.0));
2907
2908 assert_eq!(max_scroll_y.get(), 400.0);
2909 assert_eq!(
2910 scroll_y.get(),
2911 150.0,
2912 "the restored offset must land on the first laid-out frame"
2913 );
2914 }
2915
2916 #[test]
2917 fn restore_scroll_y_is_not_re_applied_after_a_later_reflow() {
2918 let mut tree = WidgetTree::new();
2919 let sa = ScrollArea::new()
2920 .child(TallLeaf::new(200.0, 500.0))
2921 .smooth_scrolling(false)
2922 .restore_scroll_y(150.0);
2923 let scroll_y = sa.scroll_y_signal().clone();
2924 let _scroll = tree.add(sa);
2925
2926 tree.layout(SizeProposal::exact(200.0, 100.0));
2927 assert_eq!(scroll_y.get(), 150.0, "precondition: restore landed once");
2928
2929 scroll_y.set(70.0);
2932 tree.layout(SizeProposal::exact(200.0, 120.0));
2933
2934 assert_eq!(
2935 scroll_y.get(),
2936 70.0,
2937 "a one-shot restore must not re-arm itself on a later reflow"
2938 );
2939 }
2940
2941 #[test]
2942 fn a_restore_the_content_can_never_hold_does_not_pin_the_reader() {
2943 let mut tree = WidgetTree::new();
2953 let sa = ScrollArea::new()
2954 .child(TallLeaf::new(200.0, 150.0))
2955 .smooth_scrolling(false)
2956 .restore_scroll_y(200.0);
2957 let scroll_y = sa.scroll_y_signal().clone();
2958 let _scroll = tree.add(sa);
2959
2960 tree.layout(SizeProposal::exact(200.0, 100.0));
2961 assert_eq!(
2962 scroll_y.get(),
2963 50.0,
2964 "precondition: the offset lands clamped to the range that exists"
2965 );
2966
2967 scroll_y.set(0.0);
2969 tree.layout(SizeProposal::exact(200.0, 100.0));
2970
2971 assert_eq!(
2972 scroll_y.get(),
2973 0.0,
2974 "a drag away from the clamped landing must stand the restore down, \
2975 not be undone by the next layout pass"
2976 );
2977 }
2978
2979 #[test]
2980 fn a_restore_still_waits_out_content_that_is_only_slow_to_measure() {
2981 let mut tree = WidgetTree::new();
2990 let height = Rc::new(Cell::new(150.0));
2991 let sa = ScrollArea::new()
2992 .child(GrowingLeaf::new(200.0, height.clone()))
2993 .smooth_scrolling(false)
2994 .restore_scroll_y(200.0);
2995 let scroll_y = sa.scroll_y_signal().clone();
2996 let _scroll = tree.add(sa);
2997
2998 tree.layout(SizeProposal::exact(200.0, 100.0));
2999 assert_eq!(scroll_y.get(), 50.0, "clamped to the range measured so far");
3000
3001 height.set(400.0);
3002 tree.layout(SizeProposal::exact(200.0, 100.0));
3003 assert_eq!(
3004 scroll_y.get(),
3005 200.0,
3006 "the range grew past the offset, so the offset lands in full"
3007 );
3008
3009 scroll_y.set(10.0);
3011 height.set(900.0);
3012 tree.layout(SizeProposal::exact(200.0, 100.0));
3013 assert_eq!(scroll_y.get(), 10.0, "a one-shot does not re-arm");
3014 }
3015
3016 #[test]
3017 fn restore_scroll_y_past_the_range_never_lets_an_observer_see_the_overshoot() {
3018 let mut tree = WidgetTree::new();
3026 let sa = ScrollArea::new()
3027 .child(TallLeaf::new(200.0, 500.0))
3028 .smooth_scrolling(false)
3029 .restore_scroll_y(9999.0);
3030 let scroll_y = sa.scroll_y_signal().clone();
3031 let max_scroll_y = sa.max_scroll_y_signal().clone();
3032
3033 let seen: Rc<std::cell::RefCell<Vec<f32>>> = Rc::new(std::cell::RefCell::new(Vec::new()));
3034 let recorder = seen.clone();
3035 let _observer = scroll_y.observe(move |v: &f32| recorder.borrow_mut().push(*v));
3036
3037 let _scroll = tree.add(sa);
3038 tree.layout(SizeProposal::exact(200.0, 100.0));
3039
3040 assert_eq!(
3041 scroll_y.get(),
3042 400.0,
3043 "it must settle at the end of the range"
3044 );
3045 assert_eq!(scroll_y.get(), max_scroll_y.get());
3046 let overshoot: Vec<f32> = seen
3047 .borrow()
3048 .iter()
3049 .copied()
3050 .filter(|v| *v > max_scroll_y.get())
3051 .collect();
3052 assert!(
3053 overshoot.is_empty(),
3054 "an observer saw an offset past the end of the content: {overshoot:?}"
3055 );
3056 }
3057
3058 #[test]
3059 fn restore_scroll_y_waits_for_a_range_long_enough_to_hold_it() {
3060 let height = Rc::new(Cell::new(500.0_f32));
3067 let mut tree = WidgetTree::new();
3068 let sa = ScrollArea::new()
3069 .child(GrowingLeaf::new(200.0, height.clone()))
3070 .smooth_scrolling(false)
3071 .restore_scroll_y(11560.0);
3072 let scroll_y = sa.scroll_y_signal().clone();
3073 let max_scroll_y = sa.max_scroll_y_signal().clone();
3074 let _scroll = tree.add(sa);
3075
3076 tree.layout(SizeProposal::exact(200.0, 100.0));
3077 assert_eq!(
3078 max_scroll_y.get(),
3079 400.0,
3080 "precondition: a short first pass"
3081 );
3082 assert_eq!(
3083 scroll_y.get(),
3084 400.0,
3085 "as far down as the content so far allows, so the page is never at the top"
3086 );
3087
3088 height.set(12000.0);
3089 tree.layout(SizeProposal::exact(200.0, 100.0));
3090 assert_eq!(
3091 scroll_y.get(),
3092 11560.0,
3093 "once the content is long enough, the offset must land in full"
3094 );
3095
3096 scroll_y.set(60.0);
3098 height.set(20000.0);
3099 tree.layout(SizeProposal::exact(200.0, 100.0));
3100 assert_eq!(
3101 scroll_y.get(),
3102 60.0,
3103 "a restore already honoured must not re-assert itself on a later reflow"
3104 );
3105 }
3106
3107 #[test]
3108 fn a_reader_scrolling_stands_down_a_restore_that_has_not_landed() {
3109 let height = Rc::new(Cell::new(500.0_f32));
3114 let mut tree = WidgetTree::new();
3115 let sa = ScrollArea::new()
3116 .child(GrowingLeaf::new(200.0, height.clone()))
3117 .smooth_scrolling(false)
3118 .restore_scroll_y(11560.0);
3119 let scroll_y = sa.scroll_y_signal().clone();
3120 let _scroll = tree.add(sa);
3121
3122 tree.layout(SizeProposal::exact(200.0, 100.0));
3123 assert_eq!(scroll_y.get(), 400.0, "precondition: still pending");
3124
3125 tree.pointer_move(Point::new(50.0, 40.0));
3126 tree.dispatch_event(WidgetEvent::scroll(
3127 ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3128 Default::default(),
3129 ));
3130 let after_reader = scroll_y.get();
3131
3132 height.set(12000.0);
3133 tree.layout(SizeProposal::exact(200.0, 100.0));
3134 assert_eq!(
3135 scroll_y.get(),
3136 after_reader,
3137 "the content growing must not yank a reader who has already scrolled"
3138 );
3139 }
3140
3141 #[test]
3142 fn without_restore_scroll_y_behaviour_is_unchanged() {
3143 let mut tree = WidgetTree::new();
3146 let sa = ScrollArea::new()
3147 .child(TallLeaf::new(200.0, 500.0))
3148 .smooth_scrolling(false);
3149 let scroll_y = sa.scroll_y_signal().clone();
3150 let _scroll = tree.add(sa);
3151
3152 tree.layout(SizeProposal::exact(200.0, 100.0));
3153 assert_eq!(scroll_y.get(), 0.0);
3154
3155 tree.layout(SizeProposal::exact(200.0, 120.0));
3157 assert_eq!(scroll_y.get(), 0.0);
3158 }
3159
3160 #[test]
3161 fn restore_scroll_y_of_zero_arms_nothing_and_leaves_a_host_write_alone() {
3162 let mut tree = WidgetTree::new();
3168 let sa = ScrollArea::new()
3169 .child(TallLeaf::new(200.0, 500.0))
3170 .smooth_scrolling(false)
3171 .restore_scroll_y(0.0);
3172 let scroll_y = sa.scroll_y_signal().clone();
3173 let _scroll = tree.add(sa);
3174
3175 scroll_y.set(120.0);
3176 tree.layout(SizeProposal::exact(200.0, 100.0));
3177
3178 assert_eq!(
3179 scroll_y.get(),
3180 120.0,
3181 "restore_scroll_y(0.0) armed a restore and overwrote the host's own offset"
3182 );
3183 }
3184
3185 #[test]
3186 fn restore_scroll_y_of_zero_disarms_a_previously_armed_offset() {
3187 let mut tree = WidgetTree::new();
3193 let sa = ScrollArea::new()
3194 .child(TallLeaf::new(200.0, 500.0))
3195 .smooth_scrolling(false)
3196 .restore_scroll_y(150.0)
3197 .restore_scroll_y(0.0);
3198 let scroll_y = sa.scroll_y_signal().clone();
3199 let _scroll = tree.add(sa);
3200
3201 scroll_y.set(120.0);
3202 tree.layout(SizeProposal::exact(200.0, 100.0));
3203
3204 assert_eq!(
3205 scroll_y.get(),
3206 120.0,
3207 "a later restore_scroll_y(0.0) must disarm the earlier pending offset"
3208 );
3209 }
3210
3211 fn shift_wheel_area(wide: bool, knob: Option<bool>) -> (WidgetTree, WidgetId) {
3217 let mut tree = WidgetTree::new();
3218 let mut area = ScrollArea::new()
3219 .child(WideLeaf::new(if wide { 400.0 } else { 150.0 }, 500.0))
3220 .scroll_bar_style(ScrollBarMode::Permanent)
3221 .scroll_bar_thickness(12.0)
3222 .smooth_scrolling(false);
3223 if let Some(on) = knob {
3224 area = area.shift_wheel_scrolls_horizontally(on);
3225 }
3226 let id = tree.add(area);
3227 tree.layout(SizeProposal::exact(200.0, 100.0));
3228 tree.pointer_move(Point::new(50.0, 50.0));
3229 (tree, id)
3230 }
3231
3232 fn offsets(tree: &WidgetTree, id: WidgetId) -> (f32, f32) {
3233 let any = tree.widget_as_any(id).unwrap();
3234 let area = any.downcast_ref::<ScrollArea>().unwrap();
3235 (area.scroll_x_signal().get(), area.scroll_y_signal().get())
3236 }
3237
3238 fn wheel(tree: &mut WidgetTree, x: f32, y: f32, modifiers: Modifiers) {
3239 tree.dispatch_event(WidgetEvent::scroll(ScrollDelta::Lines { x, y }, modifiers));
3240 tree.layout(SizeProposal::exact(200.0, 100.0));
3241 }
3242
3243 #[test]
3244 fn shift_wheel_scrolls_x_on_a_two_axis_area() {
3245 let (mut tree, id) = shift_wheel_area(true, None);
3246 wheel(&mut tree, 0.0, 3.0, Modifiers::SHIFT);
3247 let (x, y) = offsets(&tree, id);
3248 assert!(x > 0.0, "Shift+vertical wheel must scroll x, got {x}");
3249 assert_eq!(y, 0.0, "and must not also scroll y");
3250 }
3251
3252 #[test]
3253 fn shift_wheel_is_ignored_on_a_vertical_only_area() {
3254 let (mut tree, id) = shift_wheel_area(false, None);
3255 wheel(&mut tree, 0.0, 3.0, Modifiers::SHIFT);
3256 let (x, y) = offsets(&tree, id);
3257 assert_eq!(x, 0.0);
3258 assert!(
3259 y > 0.0,
3260 "no horizontal range: Shift+wheel keeps scrolling y"
3261 );
3262 }
3263
3264 #[test]
3265 fn shift_wheel_with_the_knob_off_scrolls_y() {
3266 let (mut tree, id) = shift_wheel_area(true, Some(false));
3267 wheel(&mut tree, 0.0, 3.0, Modifiers::SHIFT);
3268 let (x, y) = offsets(&tree, id);
3269 assert_eq!(x, 0.0);
3270 assert!(y > 0.0, "knob off: Shift+wheel scrolls y, got {y}");
3271 }
3272
3273 #[test]
3274 fn a_real_horizontal_delta_passes_through_shift() {
3275 let (mut tree, id) = shift_wheel_area(true, None);
3276 wheel(&mut tree, 2.0, 3.0, Modifiers::SHIFT);
3279 let (x, y) = offsets(&tree, id);
3280 assert!(x > 0.0, "the horizontal delta lands on x, got {x}");
3281 assert!(y > 0.0, "the vertical delta is not thrown away, got {y}");
3282 }
3283
3284 #[test]
3285 fn a_plain_wheel_still_scrolls_y_on_a_two_axis_area() {
3286 let (mut tree, id) = shift_wheel_area(true, None);
3287 wheel(&mut tree, 0.0, 3.0, Modifiers::NONE);
3288 let (x, y) = offsets(&tree, id);
3289 assert_eq!(x, 0.0);
3290 assert!(y > 0.0);
3291 }
3292
3293 fn hover_area(on: bool, mode: ScrollBarMode) -> (WidgetTree, Signal<bool>) {
3296 let mut tree = WidgetTree::new();
3297 let area = ScrollArea::new()
3298 .child(WideLeaf::new(400.0, 500.0))
3299 .scroll_bar_style(mode)
3300 .reveal_bars_on_hover(on);
3301 let reveal = area.scrollbar_reveal.clone();
3302 tree.add(area);
3303 tree.layout(SizeProposal::exact(200.0, 100.0));
3304 (tree, reveal)
3305 }
3306
3307 #[test]
3308 fn hovering_the_area_raises_the_reveal_and_leaving_lowers_it() {
3309 let (mut tree, reveal) = hover_area(true, ScrollBarMode::Overlay);
3310 assert!(!reveal.get(), "nothing is hovered yet");
3311
3312 tree.pointer_move(Point::new(50.0, 50.0));
3313 assert!(
3314 reveal.get(),
3315 "the pointer over the content reveals the bars"
3316 );
3317
3318 tree.pointer_move(Point::new(500.0, 500.0));
3319 assert!(!reveal.get(), "leaving the area hides them again");
3320 }
3321
3322 #[test]
3323 fn hover_does_not_reveal_unless_asked() {
3324 let (mut tree, reveal) = hover_area(false, ScrollBarMode::Overlay);
3325 tree.pointer_move(Point::new(50.0, 50.0));
3326 assert!(!reveal.get());
3327 }
3328
3329 #[test]
3330 fn hover_reveal_leaves_a_permanent_bar_alone() {
3331 let (mut tree, reveal) = hover_area(true, ScrollBarMode::Permanent);
3332 tree.pointer_move(Point::new(50.0, 50.0));
3333 assert!(!reveal.get());
3334 }
3335}
3336
3337#[cfg(test)]
3343mod pan_tests {
3344 use super::*;
3345 use crate::primitives::VStack;
3346 use std::time::Duration;
3347 use teksilo_canvas::SizeProposal;
3348 use teksilo_core::event::{Modifiers, PointerButton};
3349 use teksilo_core::pointer::clock::ManualClock;
3350 use teksilo_core::pointer::{
3351 BackendDeviceKey, EventTime, PointerId, PointerIdAllocator, PointerInfo, PointerPhase,
3352 PointerSample,
3353 };
3354 use teksilo_core::widget::{LayoutContext, LayoutResponse};
3355 use teksilo_core::widget_tree::WidgetTree;
3356
3357 #[derive(Debug)]
3359 struct TallLeaf {
3360 width: f32,
3361 height: f32,
3362 }
3363
3364 impl TallLeaf {
3365 fn new(width: f32, height: f32) -> Self {
3366 Self { width, height }
3367 }
3368 }
3369
3370 impl Widget for TallLeaf {
3371 fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3372 Size::new(self.width, self.height).into()
3373 }
3374 }
3375
3376 fn contact_id(raw: u64) -> PointerId {
3377 let alloc = PointerIdAllocator::global();
3378 let device = BackendDeviceKey::new(0x5A7E);
3379 let id = alloc.begin(device, raw);
3380 alloc.end(device, raw);
3381 id
3382 }
3383
3384 fn contact(id: PointerId, phase: PointerPhase, at: Point) -> PointerSample {
3385 PointerSample {
3386 pointer: PointerInfo::touch(id, EventTime::ZERO),
3387 phase,
3388 position: at,
3389 button: None,
3390 modifiers: Modifiers::NONE,
3391 coalesced: Vec::new(),
3392 }
3393 }
3394
3395 fn pan_slop() -> f32 {
3396 teksilo_core::gesture::default_profile(teksilo_tokens::PointerKind::Touch)
3397 .pan_slop
3398 .expect("a touch profile pans")
3399 }
3400
3401 fn drag(tree: &mut WidgetTree, id: PointerId, from: Point, dy: f32) -> Point {
3403 tree.dispatch_pointer(contact(id, PointerPhase::Down, from));
3404 let arm = Point::new(from.x, from.y + pan_slop().copysign(dy) + dy.signum());
3405 tree.dispatch_pointer(contact(id, PointerPhase::Move, arm));
3406 let at = Point::new(from.x, arm.y + (dy - (arm.y - from.y)));
3407 tree.dispatch_pointer(contact(id, PointerPhase::Move, at));
3408 at
3409 }
3410
3411 fn area(
3414 build: impl FnOnce(ScrollArea) -> ScrollArea,
3415 ) -> (WidgetTree, Signal<f32>, Signal<Vec2>) {
3416 let mut tree = WidgetTree::new();
3417 let content = tree.add(TallLeaf::new(200.0, 600.0));
3418 let sa = build(
3419 ScrollArea::from_id(content)
3420 .smooth_scrolling(false)
3421 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3422 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff),
3423 );
3424 let y = sa.scroll_y_signal().clone();
3425 let overscroll = sa.overscroll_signal();
3426 tree.add(sa);
3427 tree.layout(SizeProposal::exact(200.0, 100.0));
3428 (tree, y, overscroll)
3429 }
3430
3431 #[test]
3435 fn a_finger_pans_the_content() {
3436 let (mut tree, y, _overscroll) = area(|sa| sa);
3437 drag(&mut tree, contact_id(1), Point::new(100.0, 80.0), -60.0);
3438 assert!(y.get() > 0.0, "the finger scrolled the area: {}", y.get());
3439 }
3440
3441 #[test]
3444 fn a_pan_is_not_smoothed() {
3445 let (mut tree, y, _overscroll) = area(|sa| sa.smooth_scrolling(true));
3446 drag(&mut tree, contact_id(2), Point::new(100.0, 80.0), -60.0);
3447 assert!(y.get() > 0.0);
3448 assert_eq!(y.animation_target(), None);
3449 }
3450
3451 #[test]
3454 fn a_fast_release_flings() {
3455 let (mut tree, y, _overscroll) = area(|sa| sa);
3456 let clock = Rc::new(ManualClock::new(EventTime::ZERO));
3457 tree.set_input_clock(clock.clone());
3458
3459 let finger = contact_id(3);
3460 let from = Point::new(100.0, 95.0);
3461 tree.dispatch_pointer(contact(finger, PointerPhase::Down, from));
3462 let mut at = from.y;
3463 for step in 1..=5 {
3464 clock.set(EventTime::from_millis(step * 4));
3465 at -= 15.0;
3466 tree.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, at)));
3467 }
3468 clock.set(EventTime::from_millis(24));
3469 tree.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, at)));
3470
3471 let at_release = y.get();
3472 assert!(at_release > 0.0);
3473 tree.advance_time(Duration::from_millis(100));
3474 assert!(
3475 y.get() > at_release,
3476 "the coast kept it moving: {at_release} -> {}",
3477 y.get()
3478 );
3479 }
3480
3481 #[test]
3484 fn reduced_motion_collapses_the_fling_to_a_settle() {
3485 let mut tree = WidgetTree::new();
3486 tree.set_accessibility_preferences(false, true, 1.0);
3487 let content = tree.add(TallLeaf::new(200.0, 600.0));
3488 let sa = ScrollArea::from_id(content)
3489 .smooth_scrolling(false)
3490 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3491 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3492 let y = sa.scroll_y_signal().clone();
3493 tree.add(sa);
3494 tree.layout(SizeProposal::exact(200.0, 100.0));
3495
3496 let clock = Rc::new(ManualClock::new(EventTime::ZERO));
3497 tree.set_input_clock(clock.clone());
3498 let finger = contact_id(4);
3499 let from = Point::new(100.0, 95.0);
3500 tree.dispatch_pointer(contact(finger, PointerPhase::Down, from));
3501 let mut at = from.y;
3502 for step in 1..=5 {
3503 clock.set(EventTime::from_millis(step * 4));
3504 at -= 15.0;
3505 tree.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, at)));
3506 }
3507 clock.set(EventTime::from_millis(24));
3508 tree.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, at)));
3509
3510 let at_release = y.get();
3511 assert!(at_release > 0.0, "the pan itself still scrolls");
3512 tree.advance_time(Duration::from_millis(200));
3513 assert_eq!(y.get(), at_release, "and nothing coasts afterwards");
3514 }
3515
3516 #[test]
3520 fn the_rubber_band_holds_and_releases() {
3521 let (mut tree, y, overscroll) = area(|sa| sa.rubber_band(true));
3522 y.set(500.0);
3523
3524 let finger = contact_id(5);
3525 let at = drag(&mut tree, finger, Point::new(100.0, 95.0), -70.0);
3526 assert_eq!(y.get(), 500.0, "the offset stays inside the range");
3527 let held = overscroll.get().y;
3528 assert!(held > 0.0, "the band is holding it past the end");
3529 assert!(held < 70.0, "with decreasing gain: {held}");
3530
3531 tree.dispatch_pointer(contact(finger, PointerPhase::Up, at));
3532 assert_eq!(overscroll.get(), Vec2::ZERO, "the lift released it");
3533 }
3534
3535 #[test]
3538 fn the_band_is_off_by_default() {
3539 let (mut tree, y, overscroll) = area(|sa| sa);
3540 y.set(500.0);
3541 drag(&mut tree, contact_id(6), Point::new(100.0, 95.0), -70.0);
3542 assert_eq!(overscroll.get(), Vec2::ZERO);
3543 assert_eq!(y.get(), 500.0);
3544 }
3545
3546 #[test]
3550 fn a_boundary_pan_chains_the_whole_event_outward() {
3551 let mut tree = WidgetTree::new();
3552 let inner_content = tree.add(TallLeaf::new(200.0, 120.0));
3553 let inner_sa = ScrollArea::from_id(inner_content)
3554 .smooth_scrolling(false)
3555 .preferred_size(200.0, 100.0)
3556 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3557 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3558 let inner_y = inner_sa.scroll_y_signal().clone();
3559 let inner = tree.add(inner_sa);
3560
3561 let filler = tree.add(TallLeaf::new(200.0, 400.0));
3562 let outer_content = tree.add(VStack::new().child(inner).child(filler));
3563 let outer_sa = ScrollArea::from_id(outer_content)
3564 .smooth_scrolling(false)
3565 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3566 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3567 let outer_y = outer_sa.scroll_y_signal().clone();
3568 tree.add(outer_sa);
3569 tree.layout(SizeProposal::exact(200.0, 150.0));
3570
3571 inner_y.set(20.0);
3574 let finger = contact_id(7);
3575 drag(&mut tree, finger, Point::new(100.0, 80.0), -60.0);
3576
3577 assert_eq!(inner_y.get(), 20.0, "the inner area is pinned at its end");
3578 assert!(
3579 outer_y.get() > 0.0,
3580 "so the container took the pan: {}",
3581 outer_y.get()
3582 );
3583 }
3584
3585 #[test]
3588 fn a_mouse_press_and_drag_does_not_pan() {
3589 let (mut tree, y, _overscroll) = area(|sa| sa);
3590 tree.dispatch_event(WidgetEvent::pointer_down(
3591 Point::new(100.0, 80.0),
3592 PointerButton::Primary,
3593 Modifiers::NONE,
3594 ));
3595 tree.pointer_move(Point::new(100.0, 20.0));
3596 assert_eq!(y.get(), 0.0, "a mouse drag is not a pan");
3597
3598 tree.dispatch_event(WidgetEvent::scroll(
3599 teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 40.0 },
3600 Modifiers::NONE,
3601 ));
3602 assert_eq!(y.get(), 40.0, "…and its wheel still scrolls");
3603 }
3604}