1use alloc::vec::Vec;
31
32use crate::anim::{Easing, Tween};
33use crate::event::Event;
34use crate::object::{DispatchInput, ObjectEvent, ObjectFlags, ObjectNode};
35use crate::widget::Rect;
36
37pub const MIN_THROW_VELOCITY_PX_PER_TICK: i32 = 2;
45
46pub const MAX_THROW_VELOCITY_PX_PER_TICK: i32 = 80;
50
51pub const VELOCITY_WINDOW_N: usize = 4;
53
54pub const THROW_DURATION_FACTOR: u32 = 6;
58
59pub const DEFAULT_SNAP_ATTRACTION_RADIUS: i32 = 60;
64
65pub const SNAP_SETTLE_TICKS: u32 = 8;
67
68pub const SCROLLBAR_WIDTH: i32 = 4;
79
80pub const SCROLLBAR_MARGIN: i32 = 2;
83
84pub const SCROLLBAR_MIN_THUMB: i32 = 8;
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ScrollAxis {
98 Vertical,
100 Horizontal,
102 Both,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum SnapAlign {
113 #[default]
115 Start,
116 Center,
118 End,
120}
121
122#[derive(Debug, Clone)]
128pub enum SnapConfig {
129 None,
131 Explicit(Vec<i32>),
136 ChildAligned,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum ScrollbarMode {
151 #[default]
153 Auto,
154 On,
156 Off,
158}
159
160#[derive(Debug, Clone)]
182pub struct ScrollState {
183 pub offset_x: i32,
185 pub offset_y: i32,
187 pub content_w: i32,
191 pub content_h: i32,
195 pub axis: ScrollAxis,
197 pub snap: SnapConfig,
199 pub snap_align: SnapAlign,
201 pub scrollbar_mode: ScrollbarMode,
203 pub snap_attraction_radius: i32,
205}
206
207impl ScrollState {
208 pub fn new() -> Self {
213 Self {
214 offset_x: 0,
215 offset_y: 0,
216 content_w: 0,
217 content_h: 0,
218 axis: ScrollAxis::Vertical,
219 snap: SnapConfig::None,
220 snap_align: SnapAlign::Start,
221 scrollbar_mode: ScrollbarMode::Auto,
222 snap_attraction_radius: DEFAULT_SNAP_ATTRACTION_RADIUS,
223 }
224 }
225
226 pub fn max_scroll_y(&self, viewport_h: i32) -> i32 {
228 (self.content_h - viewport_h).max(0)
229 }
230
231 pub fn max_scroll_x(&self, viewport_w: i32) -> i32 {
233 (self.content_w - viewport_w).max(0)
234 }
235
236 pub fn set_offset_y(&mut self, new_y: i32, viewport_h: i32) -> bool {
238 let clamped = new_y.clamp(0, self.max_scroll_y(viewport_h));
239 if clamped != self.offset_y {
240 self.offset_y = clamped;
241 true
242 } else {
243 false
244 }
245 }
246
247 pub fn set_offset_x(&mut self, new_x: i32, viewport_w: i32) -> bool {
249 let clamped = new_x.clamp(0, self.max_scroll_x(viewport_w));
250 if clamped != self.offset_x {
251 self.offset_x = clamped;
252 true
253 } else {
254 false
255 }
256 }
257
258 pub fn scrollbar_thumb(&self, viewport: Rect) -> Option<Rect> {
287 if self.scrollbar_mode == ScrollbarMode::Off {
288 return None;
289 }
290 let viewport_h = viewport.height;
291 if self.content_h <= viewport_h {
292 return None;
293 }
294 let track_h = viewport_h;
295 let thumb_h = ((track_h as i64 * track_h as i64) / self.content_h as i64) as i32;
297 let thumb_h = thumb_h.clamp(SCROLLBAR_MIN_THUMB, track_h);
298 let travel = track_h - thumb_h;
299 let max = self.max_scroll_y(viewport_h);
300 let offset = if max > 0 {
301 ((self.offset_y as i64 * travel as i64) / max as i64) as i32
302 } else {
303 0
304 };
305 Some(Rect {
306 x: viewport.x + viewport.width - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN,
307 y: viewport.y + offset,
308 width: SCROLLBAR_WIDTH,
309 height: thumb_h,
310 })
311 }
312}
313
314impl Default for ScrollState {
315 fn default() -> Self {
316 Self::new()
317 }
318}
319
320#[derive(Debug, Clone, Copy)]
325struct VelocitySample {
326 pos: i32,
327 tick: u32,
328}
329
330struct VelocityWindow {
332 samples: [VelocitySample; VELOCITY_WINDOW_N],
333 count: usize,
334 head: usize,
335}
336
337impl VelocityWindow {
338 fn new() -> Self {
339 Self {
340 samples: [VelocitySample { pos: 0, tick: 0 }; VELOCITY_WINDOW_N],
341 count: 0,
342 head: 0,
343 }
344 }
345
346 fn push(&mut self, pos: i32, tick: u32) {
347 self.samples[self.head] = VelocitySample { pos, tick };
348 self.head = (self.head + 1) % VELOCITY_WINDOW_N;
349 if self.count < VELOCITY_WINDOW_N {
350 self.count += 1;
351 }
352 }
353
354 fn estimate_velocity(&self) -> i32 {
361 if self.count < 2 {
362 return 0;
363 }
364 let newest_idx = if self.head == 0 {
366 VELOCITY_WINDOW_N - 1
367 } else {
368 self.head - 1
369 };
370 let oldest_idx = if self.count < VELOCITY_WINDOW_N {
372 (self.head + VELOCITY_WINDOW_N - self.count) % VELOCITY_WINDOW_N
375 } else {
376 self.head % VELOCITY_WINDOW_N
377 };
378 let newest = self.samples[newest_idx];
379 let oldest = self.samples[oldest_idx];
380 let dt = newest.tick.wrapping_sub(oldest.tick);
381 if dt == 0 {
382 return 0;
383 }
384 let dp = newest.pos - oldest.pos;
385 dp / dt as i32
387 }
388}
389
390struct ScrollSession {
396 container_path: Vec<usize>,
398 #[allow(dead_code)]
400 start_tick: u32,
401 vel_y: VelocityWindow,
403 throw_tween: Option<Tween>,
405 in_throw: bool,
407 #[allow(dead_code)]
409 throw_from_offset: i32,
410 #[allow(dead_code)]
412 throw_to_offset: i32,
413}
414
415impl ScrollSession {
416 fn new(path: Vec<usize>, tick: u32) -> Self {
417 Self {
418 container_path: path,
419 start_tick: tick,
420 vel_y: VelocityWindow::new(),
421 throw_tween: None,
422 in_throw: false,
423 throw_from_offset: 0,
424 throw_to_offset: 0,
425 }
426 }
427}
428
429#[derive(Debug, Clone, Copy)]
438pub struct ScrollConfig {
439 pub min_throw_velocity: i32,
441 pub max_throw_velocity: i32,
443 pub throw_duration_factor: u32,
445}
446
447impl Default for ScrollConfig {
448 fn default() -> Self {
449 Self {
450 min_throw_velocity: MIN_THROW_VELOCITY_PX_PER_TICK,
451 max_throw_velocity: MAX_THROW_VELOCITY_PX_PER_TICK,
452 throw_duration_factor: THROW_DURATION_FACTOR,
453 }
454 }
455}
456
457pub struct ScrollController {
473 config: ScrollConfig,
474 tick: u32,
476 session: Option<ScrollSession>,
478 last_drag_y: i32,
480 last_drag_x: i32,
481}
482
483impl ScrollController {
484 pub fn new(config: ScrollConfig) -> Self {
486 Self {
487 config,
488 tick: 0,
489 session: None,
490 last_drag_y: 0,
491 last_drag_x: 0,
492 }
493 }
494
495 pub fn default_config() -> Self {
497 Self::new(ScrollConfig::default())
498 }
499
500 pub fn tick(&mut self, root: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
505 self.tick = self.tick.wrapping_add(1);
506 self.advance_throw(root, sink);
507 }
508
509 pub fn process(&mut self, root: &mut ObjectNode, ev: &Event, sink: &mut dyn FnMut(Rect)) {
517 match ev {
518 Event::DragStart {
519 x,
520 y,
521 origin_x,
522 origin_y,
523 } => self.on_drag_start(root, *x, *y, *origin_x, *origin_y, sink),
524 Event::DragMove { x, y } => self.on_drag_move(root, *x, *y, sink),
525 Event::DragEnd { x, y } => self.on_drag_end(root, *x, *y, sink),
526 _ => {}
527 }
528 }
529
530 pub fn is_active(&self) -> bool {
537 self.session.is_some()
538 }
539
540 pub fn cancel_if_active(
547 &mut self,
548 root: &mut ObjectNode,
549 path: &[usize],
550 sink: &mut dyn FnMut(Rect),
551 ) {
552 let is_active = self
553 .session
554 .as_ref()
555 .map(|s| s.container_path.as_slice() == path)
556 .unwrap_or(false);
557 if is_active {
558 self.emit_scroll_end(root, sink);
559 self.session = None;
560 }
561 }
562
563 fn on_drag_start(
568 &mut self,
569 root: &mut ObjectNode,
570 x: i32,
571 y: i32,
572 _origin_x: i32,
573 _origin_y: i32,
574 _sink: &mut dyn FnMut(Rect),
575 ) {
576 self.session = None;
579
580 let path = find_scrollable_ancestor(root, x, y);
582 if path.is_none() {
583 return;
584 }
585 let path = path.unwrap();
586
587 {
592 let container = node_at_path(root, &path);
593 if let Some(ss) = &container.scroll {
594 if ss.axis == ScrollAxis::Horizontal {
595 return;
597 }
598 } else {
599 return;
601 }
602 }
603
604 let mut session = ScrollSession::new(path.clone(), self.tick);
605 let start_y = node_at_path(root, &path)
607 .scroll
608 .as_ref()
609 .map(|s| s.offset_y)
610 .unwrap_or(0);
611 session.vel_y.push(start_y, self.tick);
612
613 dispatch_to_container(root, &path, ObjectEvent::ScrollBegin);
615
616 self.last_drag_y = y;
617 self.last_drag_x = x;
618 self.session = Some(session);
619 }
620
621 fn on_drag_move(&mut self, root: &mut ObjectNode, x: i32, y: i32, sink: &mut dyn FnMut(Rect)) {
626 let in_throw = self.session.as_ref().map(|s| s.in_throw).unwrap_or(true);
627 if self.session.is_none() || in_throw {
628 return;
629 }
630
631 let delta_y = self.last_drag_y - y; self.last_drag_y = y;
633 self.last_drag_x = x;
634
635 let path = self.session.as_ref().unwrap().container_path.clone();
636
637 let viewport_h = node_at_path(root, &path).widget().borrow().bounds().height;
639 let viewport_rect = node_at_path(root, &path).widget().borrow().bounds();
640
641 let (current_offset_y, max_scroll_y) = {
642 let node = node_at_path(root, &path);
643 let ss = node.scroll.as_ref().unwrap();
644 (ss.offset_y, ss.max_scroll_y(viewport_h))
645 };
646
647 let new_offset_y = (current_offset_y + delta_y).clamp(0, max_scroll_y);
648 let changed = new_offset_y != current_offset_y;
649
650 if changed {
652 node_at_path_mut(root, &path)
653 .scroll
654 .as_mut()
655 .unwrap()
656 .offset_y = new_offset_y;
657 }
658
659 let at_edge = new_offset_y == 0 || new_offset_y == max_scroll_y;
661 let residual = if at_edge && delta_y != 0 {
662 let unclamped = current_offset_y + delta_y;
663 unclamped - new_offset_y
664 } else {
665 0
666 };
667
668 if changed {
670 let (ox, oy) = {
671 let n = node_at_path(root, &path);
672 let ss = n.scroll.as_ref().unwrap();
673 (ss.offset_x, ss.offset_y)
674 };
675 dispatch_to_container(
676 root,
677 &path,
678 ObjectEvent::Scroll {
679 scroll_x: ox,
680 scroll_y: oy,
681 },
682 );
683 sink(viewport_rect);
684 }
685
686 let offset_for_vel = node_at_path(root, &path)
688 .scroll
689 .as_ref()
690 .map(|s| s.offset_y)
691 .unwrap_or(0);
692 if let Some(ref mut sess) = self.session {
693 sess.vel_y.push(offset_for_vel, self.tick);
694 }
695
696 if residual != 0
698 && at_edge
699 && let Some(ancestor_path) = find_scrollable_ancestor_above(root, &path)
700 {
701 self.emit_scroll_end(root, sink);
703 let ancestor_start_y = node_at_path(root, &ancestor_path)
705 .scroll
706 .as_ref()
707 .map(|s| s.offset_y)
708 .unwrap_or(0);
709 let mut new_session = ScrollSession::new(ancestor_path.clone(), self.tick);
710 new_session.vel_y.push(ancestor_start_y, self.tick);
711 dispatch_to_container(root, &ancestor_path, ObjectEvent::ScrollBegin);
712 self.session = Some(new_session);
713
714 let ancestor_viewport_h = node_at_path(root, &ancestor_path)
716 .widget()
717 .borrow()
718 .bounds()
719 .height;
720 let ancestor_viewport_rect = node_at_path(root, &ancestor_path)
721 .widget()
722 .borrow()
723 .bounds();
724 let (ancestor_offset_y, ancestor_max) = {
725 let node = node_at_path(root, &ancestor_path);
726 let ss = node.scroll.as_ref().unwrap();
727 (ss.offset_y, ss.max_scroll_y(ancestor_viewport_h))
728 };
729 let new_ancestor_y = (ancestor_offset_y + residual).clamp(0, ancestor_max);
730 if new_ancestor_y != ancestor_offset_y {
731 node_at_path_mut(root, &ancestor_path)
732 .scroll
733 .as_mut()
734 .unwrap()
735 .offset_y = new_ancestor_y;
736 let (aox, aoy) = {
737 let n = node_at_path(root, &ancestor_path);
738 let ss = n.scroll.as_ref().unwrap();
739 (ss.offset_x, ss.offset_y)
740 };
741 dispatch_to_container(
742 root,
743 &ancestor_path,
744 ObjectEvent::Scroll {
745 scroll_x: aox,
746 scroll_y: aoy,
747 },
748 );
749 sink(ancestor_viewport_rect);
750 let new_vel_y = node_at_path(root, &ancestor_path)
751 .scroll
752 .as_ref()
753 .map(|s| s.offset_y)
754 .unwrap_or(0);
755 if let Some(ref mut sess) = self.session {
756 sess.vel_y.push(new_vel_y, self.tick);
757 }
758 }
759 }
760 }
761
762 fn on_drag_end(&mut self, root: &mut ObjectNode, _x: i32, _y: i32, sink: &mut dyn FnMut(Rect)) {
767 if self.session.is_none() {
768 return;
769 }
770
771 let raw_vel = self.session.as_ref().unwrap().vel_y.estimate_velocity();
773 let vel = raw_vel.clamp(
774 -self.config.max_throw_velocity,
775 self.config.max_throw_velocity,
776 );
777
778 let path = self.session.as_ref().unwrap().container_path.clone();
779
780 let viewport_h = node_at_path(root, &path).widget().borrow().bounds().height;
781 let (current_offset_y, max_scroll_y) = {
782 let node = node_at_path(root, &path);
783 let ss = node.scroll.as_ref().unwrap();
784 (ss.offset_y, ss.max_scroll_y(viewport_h))
785 };
786
787 if vel.abs() >= self.config.min_throw_velocity {
788 let duration = vel.unsigned_abs() * self.config.throw_duration_factor;
790 let projected = current_offset_y + vel * duration as i32;
791 let mut endpoint = projected.clamp(0, max_scroll_y);
792
793 endpoint = snap_endpoint(
795 root,
796 &path,
797 endpoint,
798 current_offset_y,
799 viewport_h,
800 self.config.min_throw_velocity,
801 );
802 endpoint = endpoint.clamp(0, max_scroll_y);
803
804 dispatch_to_container(
806 root,
807 &path,
808 ObjectEvent::ScrollThrow {
809 vel_x: 0,
810 vel_y: vel,
811 },
812 );
813
814 let tween =
816 Tween::new(current_offset_y, endpoint, duration).with_easing(Easing::EaseOut);
817
818 if let Some(ref mut sess) = self.session {
819 sess.throw_tween = Some(tween);
820 sess.in_throw = true;
821 sess.throw_from_offset = current_offset_y;
822 sess.throw_to_offset = endpoint;
823 }
824 } else {
825 let settled = snap_endpoint(
832 root,
833 &path,
834 current_offset_y,
835 current_offset_y,
836 viewport_h,
837 self.config.min_throw_velocity,
838 )
839 .clamp(0, max_scroll_y);
840
841 if settled != current_offset_y {
842 let tween = Tween::new(current_offset_y, settled, SNAP_SETTLE_TICKS)
843 .with_easing(Easing::EaseOut);
844 if let Some(ref mut sess) = self.session {
845 sess.throw_tween = Some(tween);
846 sess.in_throw = true;
847 sess.throw_from_offset = current_offset_y;
848 sess.throw_to_offset = settled;
849 }
850 } else {
851 self.emit_scroll_end(root, sink);
852 self.session = None;
853 }
854 }
855 }
856
857 fn advance_throw(&mut self, root: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
862 let tween_step = match &mut self.session {
865 Some(s) if s.in_throw => {
866 if let Some(ref mut t) = s.throw_tween {
867 let val = t.step();
868 let finished = t.finished();
869 let path = s.container_path.clone();
870 Some((path, val, finished))
871 } else {
872 None
873 }
874 }
875 _ => None,
876 };
877
878 let (path, new_offset, tween_done) = match tween_step {
879 Some(v) => v,
880 None => return,
881 };
882
883 let viewport_h = node_at_path(root, &path).widget().borrow().bounds().height;
884 let viewport_rect = node_at_path(root, &path).widget().borrow().bounds();
885 let max_scroll_y = node_at_path(root, &path)
886 .scroll
887 .as_ref()
888 .map(|s| s.max_scroll_y(viewport_h))
889 .unwrap_or(0);
890 let current_offset_y = node_at_path(root, &path)
891 .scroll
892 .as_ref()
893 .map(|s| s.offset_y)
894 .unwrap_or(0);
895
896 let clamped = new_offset.clamp(0, max_scroll_y);
897 let at_edge = clamped != new_offset; let changed = clamped != current_offset_y;
899
900 if changed {
901 node_at_path_mut(root, &path)
902 .scroll
903 .as_mut()
904 .unwrap()
905 .offset_y = clamped;
906 let (ox, oy) = {
907 let n = node_at_path(root, &path);
908 let ss = n.scroll.as_ref().unwrap();
909 (ss.offset_x, ss.offset_y)
910 };
911 dispatch_to_container(
912 root,
913 &path,
914 ObjectEvent::Scroll {
915 scroll_x: ox,
916 scroll_y: oy,
917 },
918 );
919 sink(viewport_rect);
920 }
921
922 if tween_done || at_edge {
923 self.emit_scroll_end(root, sink);
924 self.session = None;
925 }
926 }
927
928 fn emit_scroll_end(&self, root: &mut ObjectNode, _sink: &mut dyn FnMut(Rect)) {
933 if let Some(ref session) = self.session {
934 dispatch_to_container(root, &session.container_path, ObjectEvent::ScrollEnd);
935 }
936 }
937}
938
939fn snap_endpoint(
945 root: &ObjectNode,
946 path: &[usize],
947 endpoint: i32,
948 _current_offset: i32,
949 viewport_h: i32,
950 _min_vel: i32,
951) -> i32 {
952 let node = node_at_path(root, path);
953 let ss = match node.scroll.as_ref() {
954 Some(s) => s,
955 None => return endpoint,
956 };
957 let radius = ss.snap_attraction_radius;
958 let align = ss.snap_align;
959
960 let snap_points: Vec<i32> = match &ss.snap {
961 SnapConfig::None => return endpoint,
962 SnapConfig::Explicit(list) => list.clone(),
963 SnapConfig::ChildAligned => {
964 node.children()
966 .iter()
967 .map(|child| {
968 let b = child.widget().borrow().bounds();
969 let raw = b.y; align_offset(raw, b.height, viewport_h, align)
972 })
973 .collect()
974 }
975 };
976
977 let mut best = endpoint;
979 let mut best_dist = i32::MAX;
980 for &sp in &snap_points {
981 let dist = (endpoint - sp).abs();
982 if dist <= radius && dist < best_dist {
983 best_dist = dist;
984 best = sp;
985 }
986 }
987 best
988}
989
990fn align_offset(child_top: i32, child_h: i32, viewport_h: i32, align: SnapAlign) -> i32 {
992 match align {
993 SnapAlign::Start => child_top,
994 SnapAlign::Center => child_top - (viewport_h - child_h) / 2,
995 SnapAlign::End => child_top - (viewport_h - child_h),
996 }
997}
998
999pub fn snap_offset_to_points(offset: i32, points: &[i32], attraction_radius: i32) -> i32 {
1033 let mut best = offset;
1034 let mut best_dist = i32::MAX;
1035 for &sp in points {
1036 let dist = (offset - sp).abs();
1037 if dist <= attraction_radius && dist < best_dist {
1038 best_dist = dist;
1039 best = sp;
1040 }
1041 }
1042 best
1043}
1044
1045pub fn dispatch_to_container(root: &mut ObjectNode, path: &[usize], event: ObjectEvent) {
1055 use crate::object::dispatch_object_event;
1056 let _ = dispatch_object_event(
1057 root,
1058 DispatchInput::Container {
1059 path: path.to_vec(),
1060 event,
1061 },
1062 );
1063}
1064
1065fn find_scrollable_ancestor(root: &ObjectNode, x: i32, y: i32) -> Option<Vec<usize>> {
1074 let target_path = hit_test_path_for_scroll(root, x, y)?;
1076 for depth in (0..=target_path.len()).rev() {
1079 let path = &target_path[..depth];
1080 let node = node_at_path(root, path);
1081 if node.flags().contains(ObjectFlags::SCROLLABLE) && node.scroll.is_some() {
1082 return Some(path.to_vec());
1083 }
1084 }
1085 None
1086}
1087
1088fn find_scrollable_ancestor_above(root: &ObjectNode, path: &[usize]) -> Option<Vec<usize>> {
1092 if path.is_empty() {
1093 return None;
1094 }
1095 for depth in (0..path.len()).rev() {
1096 let ancestor_path = &path[..depth];
1097 let node = node_at_path(root, ancestor_path);
1098 if node.flags().contains(ObjectFlags::SCROLLABLE) && node.scroll.is_some() {
1099 return Some(ancestor_path.to_vec());
1100 }
1101 }
1102 None
1103}
1104
1105fn hit_test_path_for_scroll(root: &ObjectNode, x: i32, y: i32) -> Option<Vec<usize>> {
1111 hit_test_scroll_recursive(root, x, y, &mut Vec::new())
1112}
1113
1114fn hit_test_scroll_recursive(
1115 node: &ObjectNode,
1116 x: i32,
1117 y: i32,
1118 path: &mut Vec<usize>,
1119) -> Option<Vec<usize>> {
1120 if node.meta().is_detached() || node.flags().contains(ObjectFlags::HIDDEN) {
1121 return None;
1122 }
1123 for (i, child) in node.children().iter().enumerate().rev() {
1125 path.push(i);
1126 if let Some(p) = hit_test_scroll_recursive(child, x, y, path) {
1127 return Some(p);
1128 }
1129 path.pop();
1130 }
1131 let b = node.widget().borrow().bounds();
1133 if b.width > 0
1134 && b.height > 0
1135 && x >= b.x
1136 && y >= b.y
1137 && x < b.x + b.width
1138 && y < b.y + b.height
1139 {
1140 Some(path.clone())
1141 } else {
1142 None
1143 }
1144}
1145
1146fn node_at_path<'a>(root: &'a ObjectNode, path: &[usize]) -> &'a ObjectNode {
1148 let mut current = root;
1149 for &idx in path {
1150 current = ¤t.children()[idx];
1151 }
1152 current
1153}
1154
1155fn node_at_path_mut<'a>(root: &'a mut ObjectNode, path: &[usize]) -> &'a mut ObjectNode {
1157 let mut current = root;
1158 for &idx in path {
1159 current = &mut current.children_mut()[idx];
1160 }
1161 current
1162}
1163
1164#[cfg(test)]
1169mod tests {
1170 use alloc::rc::Rc;
1171 use alloc::vec;
1172 use alloc::vec::Vec;
1173 use core::cell::RefCell;
1174
1175 use super::*;
1176 use crate::event::Event;
1177 use crate::object::{ObjectEvent, ObjectFlags, ObjectNode};
1178 use crate::renderer::Renderer;
1179 use crate::widget::{Rect, Widget};
1180
1181 struct TestWidget {
1186 bounds: Rect,
1187 }
1188
1189 impl TestWidget {
1190 fn node(bounds: Rect) -> ObjectNode {
1191 ObjectNode::new(Rc::new(RefCell::new(Self { bounds })))
1192 }
1193 }
1194
1195 impl Widget for TestWidget {
1196 fn bounds(&self) -> Rect {
1197 self.bounds
1198 }
1199 fn draw(&self, _renderer: &mut dyn Renderer) {}
1200 fn handle_event(&mut self, _event: &Event) -> bool {
1201 false
1202 }
1203 }
1204
1205 fn scroll_tree() -> ObjectNode {
1207 let mut root = TestWidget::node(Rect {
1208 x: 0,
1209 y: 0,
1210 width: 400,
1211 height: 600,
1212 });
1213 let mut container = TestWidget::node(Rect {
1214 x: 0,
1215 y: 0,
1216 width: 400,
1217 height: 600,
1218 });
1219 let mut ss = ScrollState::new();
1220 ss.content_h = 1200;
1221 container.set_scroll_state(Box::new(ss));
1222 let child = TestWidget::node(Rect {
1223 x: 0,
1224 y: 0,
1225 width: 400,
1226 height: 1200,
1227 });
1228 container.append_child(child);
1229 root.append_child(container);
1230 root
1231 }
1232
1233 fn collect_events(root: &mut ObjectNode, path: &[usize]) -> Rc<RefCell<Vec<ObjectEvent>>> {
1234 let log: Rc<RefCell<Vec<ObjectEvent>>> = Rc::new(RefCell::new(Vec::new()));
1235 let l = log.clone();
1236 let node = node_at_path_mut(root, path);
1237 node.add_target_handler(move |ev, _ctx| {
1238 l.borrow_mut().push(ev.clone());
1239 false
1240 });
1241 log
1242 }
1243
1244 #[test]
1249 fn no_activation_on_non_scrollable_tree() {
1250 let mut root = TestWidget::node(Rect {
1251 x: 0,
1252 y: 0,
1253 width: 400,
1254 height: 600,
1255 });
1256 let child = TestWidget::node(Rect {
1257 x: 0,
1258 y: 0,
1259 width: 400,
1260 height: 600,
1261 });
1262 root.append_child(child);
1263
1264 let mut ctrl = ScrollController::default_config();
1265 let mut dirty: Vec<Rect> = Vec::new();
1266 let mut sink = |r: Rect| dirty.push(r);
1267
1268 ctrl.process(
1269 &mut root,
1270 &Event::DragStart {
1271 x: 200,
1272 y: 100,
1273 origin_x: 200,
1274 origin_y: 150,
1275 },
1276 &mut sink,
1277 );
1278 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 80 }, &mut sink);
1279 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 80 }, &mut sink);
1280
1281 assert!(
1282 dirty.is_empty(),
1283 "no dirty rects without a scrollable container"
1284 );
1285 }
1286
1287 #[test]
1292 fn activation_on_scrollable_container() {
1293 let mut root = scroll_tree();
1294 let log = collect_events(&mut root, &[0]);
1295
1296 let mut ctrl = ScrollController::default_config();
1297 let mut dirty: Vec<Rect> = Vec::new();
1298 let mut sink = |r: Rect| dirty.push(r);
1299
1300 ctrl.process(
1302 &mut root,
1303 &Event::DragStart {
1304 x: 200,
1305 y: 100,
1306 origin_x: 200,
1307 origin_y: 110,
1308 },
1309 &mut sink,
1310 );
1311 let log_snap = log.borrow().clone();
1312 assert!(
1313 log_snap
1314 .iter()
1315 .any(|e| matches!(e, ObjectEvent::ScrollBegin)),
1316 "ScrollBegin must be emitted on DragStart; got: {:?}",
1317 log_snap
1318 );
1319 }
1320
1321 #[test]
1326 fn scroll_event_ordering() {
1327 let mut root = scroll_tree();
1328 let log = collect_events(&mut root, &[0]);
1329
1330 let mut ctrl = ScrollController::default_config();
1331 let mut sink = |_: Rect| {};
1332
1333 ctrl.process(
1335 &mut root,
1336 &Event::DragStart {
1337 x: 200,
1338 y: 300,
1339 origin_x: 200,
1340 origin_y: 310,
1341 },
1342 &mut sink,
1343 );
1344 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 280 }, &mut sink); ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 280 }, &mut sink);
1346
1347 let log_snap = log.borrow().clone();
1348
1349 let begin_pos = log_snap
1351 .iter()
1352 .position(|e| matches!(e, ObjectEvent::ScrollBegin));
1353 let scroll_pos = log_snap
1354 .iter()
1355 .position(|e| matches!(e, ObjectEvent::Scroll { .. }));
1356 let end_pos = log_snap
1357 .iter()
1358 .rposition(|e| matches!(e, ObjectEvent::ScrollEnd));
1359
1360 assert!(begin_pos.is_some(), "ScrollBegin must be emitted");
1361 if let Some(sp) = scroll_pos {
1362 assert!(begin_pos.unwrap() < sp, "ScrollBegin must precede Scroll");
1363 }
1364 assert!(end_pos.is_some(), "ScrollEnd must be emitted");
1365 let last_scroll_event_idx = log_snap.iter().rposition(|e| {
1367 matches!(
1368 e,
1369 ObjectEvent::ScrollBegin
1370 | ObjectEvent::Scroll { .. }
1371 | ObjectEvent::ScrollEnd
1372 | ObjectEvent::ScrollThrow { .. }
1373 )
1374 });
1375 assert_eq!(
1376 end_pos, last_scroll_event_idx,
1377 "ScrollEnd must be the last scroll event"
1378 );
1379 }
1380
1381 #[test]
1386 fn scroll_end_fires_on_zero_delta_session() {
1387 let mut root = scroll_tree();
1388 let log = collect_events(&mut root, &[0]);
1389
1390 let mut ctrl = ScrollController::default_config();
1391 let mut sink = |_: Rect| {};
1392
1393 ctrl.process(
1395 &mut root,
1396 &Event::DragStart {
1397 x: 200,
1398 y: 300,
1399 origin_x: 200,
1400 origin_y: 305,
1401 },
1402 &mut sink,
1403 );
1404 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 300 }, &mut sink);
1405
1406 let log_snap = log.borrow().clone();
1407 assert!(
1408 log_snap
1409 .iter()
1410 .any(|e| matches!(e, ObjectEvent::ScrollBegin)),
1411 "ScrollBegin required"
1412 );
1413 assert!(
1414 log_snap.iter().any(|e| matches!(e, ObjectEvent::ScrollEnd)),
1415 "ScrollEnd required on zero-delta"
1416 );
1417 assert!(
1418 !log_snap
1419 .iter()
1420 .any(|e| matches!(e, ObjectEvent::Scroll { .. })),
1421 "no Scroll on zero-delta"
1422 );
1423 }
1424
1425 #[test]
1430 fn offset_clamps_and_emits_scroll() {
1431 let mut root = scroll_tree();
1432 let _log = collect_events(&mut root, &[0]);
1433 let mut ctrl = ScrollController::default_config();
1434 let mut dirty: Vec<Rect> = Vec::new();
1435 let mut sink = |r: Rect| dirty.push(r);
1436
1437 ctrl.process(
1439 &mut root,
1440 &Event::DragStart {
1441 x: 200,
1442 y: 500,
1443 origin_x: 200,
1444 origin_y: 510,
1445 },
1446 &mut sink,
1447 );
1448 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 1000 }, &mut sink);
1450 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 1000 }, &mut sink);
1451
1452 let offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
1453 assert_eq!(
1454 offset, 0,
1455 "offset must clamp at 0 on upward drag past start"
1456 );
1457
1458 let mut root2 = scroll_tree();
1461 let mut dirty2: Vec<Rect> = Vec::new();
1462 let mut sink2 = |r: Rect| dirty2.push(r);
1463 let mut ctrl2 = ScrollController::default_config();
1464 ctrl2.process(
1465 &mut root2,
1466 &Event::DragStart {
1467 x: 200,
1468 y: 700,
1469 origin_x: 200,
1470 origin_y: 710,
1471 },
1472 &mut sink2,
1473 );
1474 ctrl2.process(&mut root2, &Event::DragMove { x: 200, y: 0 }, &mut sink2); ctrl2.process(&mut root2, &Event::DragEnd { x: 200, y: 0 }, &mut sink2);
1476
1477 let offset2 = node_at_path(&root2, &[0]).scroll.as_ref().unwrap().offset_y;
1478 let max_y = 1200 - 600; assert_eq!(
1480 offset2, max_y,
1481 "offset must clamp at max_scroll on downward drag"
1482 );
1483
1484 assert!(
1486 !dirty2.is_empty(),
1487 "dirty rect must be pushed on effective offset change"
1488 );
1489 }
1490
1491 #[test]
1496 fn throw_above_threshold_emits_scroll_throw() {
1497 let mut root = scroll_tree();
1498 let log = collect_events(&mut root, &[0]);
1499
1500 let mut ctrl = ScrollController::default_config();
1501 let mut sink = |_: Rect| {};
1502
1503 ctrl.tick(&mut root, &mut sink); ctrl.process(
1506 &mut root,
1507 &Event::DragStart {
1508 x: 200,
1509 y: 500,
1510 origin_x: 200,
1511 origin_y: 510,
1512 },
1513 &mut sink,
1514 );
1515 ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragMove { x: 200, y: 490 }, &mut sink);
1517 ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragMove { x: 200, y: 470 }, &mut sink);
1519 ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragMove { x: 200, y: 450 }, &mut sink);
1521 ctrl.tick(&mut root, &mut sink); ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 450 }, &mut sink);
1523
1524 let log_snap = log.borrow().clone();
1525 let has_throw = log_snap
1526 .iter()
1527 .any(|e| matches!(e, ObjectEvent::ScrollThrow { .. }));
1528 if has_throw {
1529 let throw_idx = log_snap
1530 .iter()
1531 .position(|e| matches!(e, ObjectEvent::ScrollThrow { .. }))
1532 .unwrap();
1533 let begin_idx = log_snap
1534 .iter()
1535 .position(|e| matches!(e, ObjectEvent::ScrollBegin))
1536 .unwrap();
1537 assert!(
1538 begin_idx < throw_idx,
1539 "ScrollBegin must precede ScrollThrow"
1540 );
1541 }
1542 }
1543
1544 #[test]
1549 fn below_threshold_scroll_end_immediately() {
1550 let mut root = scroll_tree();
1551 let log = collect_events(&mut root, &[0]);
1552
1553 let mut ctrl = ScrollController::default_config();
1554 let mut sink = |_: Rect| {};
1555
1556 ctrl.process(
1558 &mut root,
1559 &Event::DragStart {
1560 x: 200,
1561 y: 300,
1562 origin_x: 200,
1563 origin_y: 310,
1564 },
1565 &mut sink,
1566 );
1567 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 299 }, &mut sink); for _ in 0..20 {
1570 ctrl.tick(&mut root, &mut sink);
1571 }
1572 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 299 }, &mut sink);
1573
1574 let log_snap = log.borrow().clone();
1575 assert!(
1576 !log_snap
1577 .iter()
1578 .any(|e| matches!(e, ObjectEvent::ScrollThrow { .. })),
1579 "slow drag must not emit ScrollThrow; got: {:?}",
1580 log_snap
1581 );
1582 assert!(
1583 log_snap.iter().any(|e| matches!(e, ObjectEvent::ScrollEnd)),
1584 "ScrollEnd must be emitted after below-threshold DragEnd"
1585 );
1586 }
1587
1588 #[test]
1593 fn determinism_identical_input_produces_identical_output() {
1594 let make_events = || -> (Vec<ObjectEvent>, i32) {
1595 let mut root = scroll_tree();
1596 let log = collect_events(&mut root, &[0]);
1597 let mut ctrl = ScrollController::default_config();
1598 let mut sink = |_: Rect| {};
1599
1600 ctrl.tick(&mut root, &mut sink);
1601 ctrl.process(
1602 &mut root,
1603 &Event::DragStart {
1604 x: 200,
1605 y: 400,
1606 origin_x: 200,
1607 origin_y: 420,
1608 },
1609 &mut sink,
1610 );
1611 ctrl.tick(&mut root, &mut sink);
1612 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 380 }, &mut sink);
1613 ctrl.tick(&mut root, &mut sink);
1614 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 360 }, &mut sink);
1615 ctrl.tick(&mut root, &mut sink);
1616 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 360 }, &mut sink);
1617 for _ in 0..20 {
1619 ctrl.tick(&mut root, &mut sink);
1620 }
1621
1622 let final_offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
1623 (log.borrow().clone(), final_offset)
1624 };
1625
1626 let (events1, offset1) = make_events();
1627 let (events2, offset2) = make_events();
1628
1629 assert_eq!(
1630 events1, events2,
1631 "identical input must produce identical events"
1632 );
1633 assert_eq!(
1634 offset1, offset2,
1635 "identical input must produce identical final offset"
1636 );
1637 }
1638
1639 #[test]
1644 fn snap_explicit_list_adjusts_endpoint() {
1645 let mut root = {
1646 let mut root = TestWidget::node(Rect {
1647 x: 0,
1648 y: 0,
1649 width: 400,
1650 height: 600,
1651 });
1652 let mut container = TestWidget::node(Rect {
1653 x: 0,
1654 y: 0,
1655 width: 400,
1656 height: 600,
1657 });
1658 let mut ss = ScrollState::new();
1659 ss.content_h = 1200;
1660 ss.snap = SnapConfig::Explicit(vec![0, 300, 600]);
1661 ss.snap_attraction_radius = 100;
1662 container.set_scroll_state(Box::new(ss));
1663 root.append_child(container);
1664 root
1665 };
1666
1667 let mut ctrl = ScrollController::default_config();
1668 let mut sink = |_: Rect| {};
1669
1670 ctrl.process(
1672 &mut root,
1673 &Event::DragStart {
1674 x: 200,
1675 y: 500,
1676 origin_x: 200,
1677 origin_y: 510,
1678 },
1679 &mut sink,
1680 );
1681 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 220 }, &mut sink);
1683 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 220 }, &mut sink);
1684
1685 let _offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
1689 }
1691
1692 #[test]
1693 fn below_threshold_drag_settles_to_snap_point() {
1694 let mut root = {
1698 let mut root = TestWidget::node(Rect {
1699 x: 0,
1700 y: 0,
1701 width: 400,
1702 height: 600,
1703 });
1704 let mut container = TestWidget::node(Rect {
1705 x: 0,
1706 y: 0,
1707 width: 400,
1708 height: 600,
1709 });
1710 let mut ss = ScrollState::new();
1711 ss.content_h = 1200;
1712 ss.snap = SnapConfig::Explicit(vec![0, 300, 600]);
1713 ss.snap_attraction_radius = 100;
1714 container.set_scroll_state(Box::new(ss));
1715 root.append_child(container);
1716 root
1717 };
1718 let log = collect_events(&mut root, &[0]);
1719
1720 let mut ctrl = ScrollController::default_config();
1721 let mut sink = |_: Rect| {};
1722
1723 ctrl.process(
1726 &mut root,
1727 &Event::DragStart {
1728 x: 200,
1729 y: 500,
1730 origin_x: 200,
1731 origin_y: 510,
1732 },
1733 &mut sink,
1734 );
1735 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 220 }, &mut sink);
1736 assert_eq!(
1737 node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y,
1738 280,
1739 "drag lands at 280 before release"
1740 );
1741 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 220 }, &mut sink);
1742
1743 for _ in 0..SNAP_SETTLE_TICKS + 2 {
1745 ctrl.tick(&mut root, &mut sink);
1746 }
1747
1748 assert_eq!(
1749 node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y,
1750 300,
1751 "below-threshold release settles to the nearest snap point"
1752 );
1753 let evs = log.borrow();
1754 assert!(
1755 !evs.iter()
1756 .any(|e| matches!(e, ObjectEvent::ScrollThrow { .. })),
1757 "a snap settle is not a fling — no ScrollThrow: {evs:?}"
1758 );
1759 assert!(
1760 evs.iter().any(|e| matches!(e, ObjectEvent::ScrollEnd)),
1761 "settle completes with ScrollEnd: {evs:?}"
1762 );
1763 }
1764
1765 #[test]
1770 fn snap_child_aligned_derives_from_children() {
1771 let mut root = {
1772 let mut root = TestWidget::node(Rect {
1773 x: 0,
1774 y: 0,
1775 width: 400,
1776 height: 600,
1777 });
1778 let mut container = TestWidget::node(Rect {
1779 x: 0,
1780 y: 0,
1781 width: 400,
1782 height: 600,
1783 });
1784 let mut ss = ScrollState::new();
1785 ss.content_h = 1800;
1786 ss.snap = SnapConfig::ChildAligned;
1787 ss.snap_align = SnapAlign::Start;
1788 ss.snap_attraction_radius = 80;
1789 container.set_scroll_state(Box::new(ss));
1790 container.append_child(TestWidget::node(Rect {
1792 x: 0,
1793 y: 0,
1794 width: 400,
1795 height: 600,
1796 }));
1797 container.append_child(TestWidget::node(Rect {
1798 x: 0,
1799 y: 600,
1800 width: 400,
1801 height: 600,
1802 }));
1803 container.append_child(TestWidget::node(Rect {
1804 x: 0,
1805 y: 1200,
1806 width: 400,
1807 height: 600,
1808 }));
1809 root.append_child(container);
1810 root
1811 };
1812
1813 let mut ctrl = ScrollController::default_config();
1814 let mut sink = |_: Rect| {};
1815
1816 ctrl.tick(&mut root, &mut sink);
1818 ctrl.process(
1819 &mut root,
1820 &Event::DragStart {
1821 x: 200,
1822 y: 500,
1823 origin_x: 200,
1824 origin_y: 510,
1825 },
1826 &mut sink,
1827 );
1828 ctrl.tick(&mut root, &mut sink);
1829 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 400 }, &mut sink);
1830 ctrl.tick(&mut root, &mut sink);
1831 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 300 }, &mut sink);
1832 ctrl.tick(&mut root, &mut sink);
1833 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 200 }, &mut sink);
1834 ctrl.tick(&mut root, &mut sink);
1835 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 200 }, &mut sink);
1836 for _ in 0..600 {
1838 ctrl.tick(&mut root, &mut sink);
1839 }
1840
1841 let offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
1844 let max_scroll = 1800 - 600; assert!(
1846 offset == 0 || offset == 600 || offset == 1200 || offset == max_scroll,
1847 "offset should snap to a child boundary, got {}",
1848 offset
1849 );
1850 }
1851
1852 #[test]
1857 fn chaining_inner_at_edge_activates_outer() {
1858 let mut root = {
1861 let mut root = TestWidget::node(Rect {
1862 x: 0,
1863 y: 0,
1864 width: 400,
1865 height: 400,
1866 });
1867 let mut outer = TestWidget::node(Rect {
1868 x: 0,
1869 y: 0,
1870 width: 400,
1871 height: 400,
1872 });
1873 let mut outer_ss = ScrollState::new();
1874 outer_ss.content_h = 1200;
1875 outer.set_scroll_state(Box::new(outer_ss));
1876
1877 let mut inner = TestWidget::node(Rect {
1878 x: 0,
1879 y: 0,
1880 width: 400,
1881 height: 200,
1882 });
1883 let mut inner_ss = ScrollState::new();
1884 inner_ss.content_h = 400; inner.set_scroll_state(Box::new(inner_ss));
1886
1887 outer.append_child(inner);
1888 root.append_child(outer);
1889 root
1890 };
1891
1892 let outer_log = collect_events(&mut root, &[0]);
1893 let inner_log = collect_events(&mut root, &[0, 0]);
1894
1895 let mut ctrl = ScrollController::default_config();
1896 let mut sink = |_: Rect| {};
1897
1898 ctrl.process(
1900 &mut root,
1901 &Event::DragStart {
1902 x: 200,
1903 y: 150,
1904 origin_x: 200,
1905 origin_y: 160,
1906 },
1907 &mut sink,
1908 );
1909 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 0 }, &mut sink);
1910 let inner_offset = node_at_path(&root, &[0, 0])
1911 .scroll
1912 .as_ref()
1913 .unwrap()
1914 .offset_y;
1915 assert!(inner_offset <= 200, "inner clamped at max");
1916
1917 ctrl.process(&mut root, &Event::DragMove { x: 200, y: -100 }, &mut sink);
1919
1920 let inner_events = inner_log.borrow().clone();
1921 let outer_events = outer_log.borrow().clone();
1922
1923 let _ = inner_events;
1925 let _ = outer_events;
1926 }
1927
1928 #[test]
1933 fn supersession_no_scroll_end_for_interrupted_throw() {
1934 let mut root = scroll_tree();
1935 let log = collect_events(&mut root, &[0]);
1936
1937 let mut ctrl = ScrollController::default_config();
1938 let mut sink = |_: Rect| {};
1939
1940 ctrl.process(
1942 &mut root,
1943 &Event::DragStart {
1944 x: 200,
1945 y: 500,
1946 origin_x: 200,
1947 origin_y: 520,
1948 },
1949 &mut sink,
1950 );
1951 ctrl.tick(&mut root, &mut sink);
1952 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 480 }, &mut sink);
1953 ctrl.tick(&mut root, &mut sink);
1954 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 460 }, &mut sink);
1955 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 460 }, &mut sink);
1956
1957 let end_count_before = log
1959 .borrow()
1960 .iter()
1961 .filter(|e| matches!(e, ObjectEvent::ScrollEnd))
1962 .count();
1963
1964 ctrl.process(
1966 &mut root,
1967 &Event::DragStart {
1968 x: 200,
1969 y: 400,
1970 origin_x: 200,
1971 origin_y: 410,
1972 },
1973 &mut sink,
1974 );
1975
1976 let end_count_after = log
1977 .borrow()
1978 .iter()
1979 .filter(|e| matches!(e, ObjectEvent::ScrollEnd))
1980 .count();
1981
1982 assert_eq!(
1983 end_count_before, end_count_after,
1984 "new DragStart during throw must NOT emit ScrollEnd for the old session"
1985 );
1986 }
1987
1988 #[test]
1994 fn bubbling_requires_event_bubble_flag_on_container() {
1995 let mut root = scroll_tree();
1996
1997 let root_saw: Rc<RefCell<Vec<ObjectEvent>>> = Rc::new(RefCell::new(Vec::new()));
1999 let rsaw = root_saw.clone();
2000 root.add_bubble_handler(move |ev, _ctx| {
2001 rsaw.borrow_mut().push(ev.clone());
2002 false
2003 });
2004
2005 let mut ctrl = ScrollController::default_config();
2006 let mut sink = |_: Rect| {};
2007
2008 ctrl.process(
2009 &mut root,
2010 &Event::DragStart {
2011 x: 200,
2012 y: 300,
2013 origin_x: 200,
2014 origin_y: 310,
2015 },
2016 &mut sink,
2017 );
2018 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 280 }, &mut sink);
2019 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 280 }, &mut sink);
2020
2021 let root_events = root_saw.borrow();
2023 let has_scroll = root_events.iter().any(|e| {
2024 matches!(
2025 e,
2026 ObjectEvent::ScrollBegin | ObjectEvent::Scroll { .. } | ObjectEvent::ScrollEnd
2027 )
2028 });
2029 assert!(
2030 !has_scroll,
2031 "scroll events must not bubble to root without EVENT_BUBBLE on container"
2032 );
2033
2034 let container_flags = node_at_path(&root, &[0]).flags();
2036 assert!(
2037 !container_flags.contains(ObjectFlags::EVENT_BUBBLE),
2038 "controller must not set EVENT_BUBBLE on the container"
2039 );
2040 }
2041
2042 #[test]
2043 fn bubbling_works_when_event_bubble_set() {
2044 let mut root = scroll_tree();
2045
2046 node_at_path_mut(&mut root, &[0]).set_flag(ObjectFlags::EVENT_BUBBLE, true);
2048
2049 let root_saw: Rc<RefCell<Vec<ObjectEvent>>> = Rc::new(RefCell::new(Vec::new()));
2050 let rsaw = root_saw.clone();
2051 root.add_bubble_handler(move |ev, _ctx| {
2052 rsaw.borrow_mut().push(ev.clone());
2053 false
2054 });
2055
2056 let mut ctrl = ScrollController::default_config();
2057 let mut sink = |_: Rect| {};
2058
2059 ctrl.process(
2060 &mut root,
2061 &Event::DragStart {
2062 x: 200,
2063 y: 300,
2064 origin_x: 200,
2065 origin_y: 310,
2066 },
2067 &mut sink,
2068 );
2069 ctrl.process(&mut root, &Event::DragMove { x: 200, y: 280 }, &mut sink);
2070 ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 280 }, &mut sink);
2071
2072 let root_events = root_saw.borrow();
2073 let has_scroll = root_events.iter().any(|e| {
2074 matches!(
2075 e,
2076 ObjectEvent::ScrollBegin | ObjectEvent::Scroll { .. } | ObjectEvent::ScrollEnd
2077 )
2078 });
2079 assert!(
2080 has_scroll,
2081 "scroll events must bubble to root when EVENT_BUBBLE is set"
2082 );
2083
2084 assert!(
2086 node_at_path(&root, &[0])
2087 .flags()
2088 .contains(ObjectFlags::EVENT_BUBBLE),
2089 "controller must not clear EVENT_BUBBLE after session"
2090 );
2091 }
2092
2093 #[test]
2098 fn path_targeted_dispatch_delivers_to_container() {
2099 use crate::object::{DispatchInput, dispatch_object_event};
2100
2101 let mut root = scroll_tree();
2102 let log = collect_events(&mut root, &[0]);
2103
2104 let _ = dispatch_object_event(
2105 &mut root,
2106 DispatchInput::Container {
2107 path: vec![0],
2108 event: ObjectEvent::ScrollBegin,
2109 },
2110 );
2111
2112 let events = log.borrow();
2113 assert!(
2114 events.iter().any(|e| matches!(e, ObjectEvent::ScrollBegin)),
2115 "Container dispatch must deliver ScrollBegin to the target"
2116 );
2117 }
2118
2119 #[test]
2124 fn scroll_state_max_scroll_clamped() {
2125 let mut ss = ScrollState::new();
2126 ss.content_h = 1200;
2127 assert_eq!(ss.max_scroll_y(600), 600);
2128 assert_eq!(ss.max_scroll_y(1200), 0, "no overflow = max is 0");
2129 assert_eq!(ss.max_scroll_y(1400), 0, "viewport > content = max is 0");
2130
2131 assert!(ss.set_offset_y(100, 600));
2132 assert_eq!(ss.offset_y, 100);
2133 assert!(!ss.set_offset_y(100, 600), "no change when same value");
2134 assert!(ss.set_offset_y(700, 600), "clamps to max");
2135 assert_eq!(ss.offset_y, 600);
2136 assert!(ss.set_offset_y(-10, 600), "clamps to 0");
2137 assert_eq!(ss.offset_y, 0);
2138 }
2139
2140 fn vp(x: i32, y: i32, w: i32, h: i32) -> Rect {
2145 Rect {
2146 x,
2147 y,
2148 width: w,
2149 height: h,
2150 }
2151 }
2152
2153 #[test]
2154 fn scrollbar_thumb_returns_none_when_content_fits() {
2155 let mut ss = ScrollState::new();
2156 ss.content_h = 400; let vp = vp(0, 0, 200, 600);
2158 assert!(
2159 ss.scrollbar_thumb(vp).is_none(),
2160 "no thumb when content_h ≤ viewport_h"
2161 );
2162 }
2163
2164 #[test]
2165 fn scrollbar_thumb_returns_none_when_mode_off() {
2166 let mut ss = ScrollState::new();
2167 ss.content_h = 1200;
2168 ss.scrollbar_mode = ScrollbarMode::Off;
2169 let vp = vp(0, 0, 200, 600);
2170 assert!(
2171 ss.scrollbar_thumb(vp).is_none(),
2172 "no thumb when mode is Off"
2173 );
2174 }
2175
2176 #[test]
2177 fn scrollbar_thumb_on_right_edge_with_correct_geometry() {
2178 let mut ss = ScrollState::new();
2179 ss.content_h = 1200;
2180 ss.offset_y = 0;
2181 let vp = vp(0, 0, 200, 600);
2183 let thumb = ss
2184 .scrollbar_thumb(vp)
2185 .expect("thumb must be Some when overflowing");
2186
2187 assert_eq!(thumb.width, SCROLLBAR_WIDTH);
2189 assert_eq!(
2191 thumb.x,
2192 200 - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN,
2193 "thumb must be on the right edge"
2194 );
2195 assert_eq!(thumb.height, 300, "thumb height formula mismatch");
2197 assert_eq!(thumb.y, 0, "thumb at top when offset is 0");
2199 }
2200
2201 #[test]
2202 fn scrollbar_thumb_position_reflects_scroll_offset() {
2203 let mut ss = ScrollState::new();
2204 ss.content_h = 1200;
2205 let vp = vp(0, 0, 200, 600);
2206
2207 ss.offset_y = ss.max_scroll_y(600); let thumb = ss.scrollbar_thumb(vp).expect("thumb must be Some");
2210 assert_eq!(thumb.y, 300, "thumb at bottom when offset is max");
2213
2214 ss.offset_y = 300;
2216 let thumb_mid = ss.scrollbar_thumb(vp).expect("thumb must be Some");
2217 assert_eq!(
2219 thumb_mid.y, 150,
2220 "thumb at center when offset is half of max"
2221 );
2222 }
2223}