Skip to main content

rlvgl_core/
scroll.rs

1//! LPAR-05 scroll runtime: state, controller, and snap logic.
2//!
3//! A [`ScrollController`] consumes the `DragStart`/`DragMove`/`DragEnd`
4//! event stream (from a `DragRecognizer`) and drives scroll offsets on
5//! `ObjectNode`-based scroll containers.  All timing is tick-driven — no wall
6//! clock (LPAR-05 §8.1).  Identical `(event, tick)` input sequences produce
7//! identical scroll trajectories (determinism guarantee, LPAR-05 §8.1).
8//!
9//! # Scroll session lifecycle (LPAR-05 §7.4)
10//!
11//! ```text
12//! DragStart  →  ScrollBegin (on container)
13//! DragMove   →  Scroll (per effective offset change)
14//! DragEnd    →  [if vel ≥ MIN] ScrollThrow + tick-driven Scroll … ScrollEnd
15//!            →  [if vel < MIN] ScrollEnd immediately
16//! ```
17//!
18//! # Throw and snap (LPAR-05 §8–§9)
19//!
20//! Throw uses a [`crate::anim::Tween`] with [`crate::anim::Easing::EaseOut`].
21//! Snap adjusts the tween endpoint before the first momentum tick.
22//!
23//! # Nested scroll chaining (LPAR-05 §10)
24//!
25//! When the active (inner) container reaches its edge with residual motion,
26//! [`ScrollEnd`](crate::object::ObjectEvent::ScrollEnd) is emitted on the inner
27//! container and [`ScrollBegin`](crate::object::ObjectEvent::ScrollBegin) on the
28//! nearest `SCROLLABLE` ancestor.
29
30use 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
37// ---------------------------------------------------------------------------
38// Tuning constants (LPAR-05 §8)
39// ---------------------------------------------------------------------------
40
41/// Minimum throw velocity (pixels per tick) to enter momentum mode.
42///
43/// Drags ending below this threshold emit `ScrollEnd` immediately.
44pub const MIN_THROW_VELOCITY_PX_PER_TICK: i32 = 2;
45
46/// Maximum throw velocity cap (pixels per tick).
47///
48/// Pathological high-speed flings are clamped to this value.
49pub const MAX_THROW_VELOCITY_PX_PER_TICK: i32 = 80;
50
51/// Number of recent `DragMove` events used for velocity estimation.
52pub const VELOCITY_WINDOW_N: usize = 4;
53
54/// Throw duration = `initial_velocity.abs() * THROW_DURATION_FACTOR` ticks.
55///
56/// Higher initial velocity → longer throw.
57pub const THROW_DURATION_FACTOR: u32 = 6;
58
59/// Default snap-attraction radius in pixels.
60///
61/// When the throw endpoint is within this many pixels of a snap point, the
62/// endpoint is adjusted to the snap point.
63pub const DEFAULT_SNAP_ATTRACTION_RADIUS: i32 = 60;
64
65/// Duration of the short snap-settle tween in ticks.
66pub const SNAP_SETTLE_TICKS: u32 = 8;
67
68// ---------------------------------------------------------------------------
69// Scrollbar geometry constants (LPAR-05 §11.3)
70//
71// These mirror the constants in `widgets/src/scroll_view.rs` (which are
72// private there).  The canonical reference is `ScrollView::scrollbar_thumb`
73// (lines 139–158 of that file).  If those values change, update here too.
74// ---------------------------------------------------------------------------
75
76/// Width in pixels of the scrollbar thumb (matches `SCROLLBAR_WIDTH` in
77/// `widgets/src/scroll_view.rs`).
78pub const SCROLLBAR_WIDTH: i32 = 4;
79
80/// Gap between the scrollbar thumb and the viewport's right edge (matches
81/// `SCROLLBAR_MARGIN` in `widgets/src/scroll_view.rs`).
82pub const SCROLLBAR_MARGIN: i32 = 2;
83
84/// Minimum scrollbar thumb height so it stays visible on long content (matches
85/// `SCROLLBAR_MIN_THUMB` in `widgets/src/scroll_view.rs`).
86pub const SCROLLBAR_MIN_THUMB: i32 = 8;
87
88// ---------------------------------------------------------------------------
89// ScrollAxis
90// ---------------------------------------------------------------------------
91
92/// The scroll axis supported by a container.
93///
94/// LPAR-05 §3: vertical is fully frozen; horizontal and both are additive
95/// (available for future extension; v1 implementation is vertical-first).
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ScrollAxis {
98    /// Container scrolls vertically only.
99    Vertical,
100    /// Container scrolls horizontally only (additive, not wired in v1).
101    Horizontal,
102    /// Container scrolls in both axes (additive, not wired in v1).
103    Both,
104}
105
106// ---------------------------------------------------------------------------
107// SnapAlign
108// ---------------------------------------------------------------------------
109
110/// Snap alignment: how a snap point aligns within the viewport (LPAR-05 §9.3).
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum SnapAlign {
113    /// The snap point aligns with the start (top/left) edge of the viewport.
114    #[default]
115    Start,
116    /// The snap point aligns with the center of the viewport.
117    Center,
118    /// The snap point aligns with the end (bottom/right) edge of the viewport.
119    End,
120}
121
122// ---------------------------------------------------------------------------
123// SnapConfig
124// ---------------------------------------------------------------------------
125
126/// Snap point specification for a scroll container (LPAR-05 §9.2).
127#[derive(Debug, Clone)]
128pub enum SnapConfig {
129    /// No snapping.
130    None,
131    /// Explicit list of absolute offset values (e.g. `[0, 200, 400]`).
132    ///
133    /// The controller snaps to the nearest point in the list within the
134    /// attraction radius.
135    Explicit(Vec<i32>),
136    /// Snap points derived from the bounds of the container's direct children.
137    ///
138    /// Each child's leading edge (top for vertical, left for horizontal) is a
139    /// snap point.  Alignment controls which edge of the viewport the snap
140    /// point aligns to (LPAR-05 §9.3).
141    ChildAligned,
142}
143
144// ---------------------------------------------------------------------------
145// ScrollbarMode
146// ---------------------------------------------------------------------------
147
148/// Scrollbar display mode (LPAR-05 §11.2).
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum ScrollbarMode {
151    /// Scrollbar is visible only during an active scroll session.
152    #[default]
153    Auto,
154    /// Scrollbar is always visible when content overflows.
155    On,
156    /// No scrollbar drawn.
157    Off,
158}
159
160// ---------------------------------------------------------------------------
161// ScrollState
162// ---------------------------------------------------------------------------
163
164/// Scroll metadata owned by a scrollable [`ObjectNode`].
165///
166/// Attach via [`ObjectNode::set_scroll_state`](crate::object::ObjectNode::set_scroll_state).
167/// The node's `SCROLLABLE` flag is set automatically.
168///
169/// # Content extent and viewport
170///
171/// The viewport size is derived from the node's `Widget::bounds()` at the
172/// time of each scroll computation.  The maximum scroll offset is:
173///
174/// ```text
175/// max_scroll_y = (content_h - viewport_h).max(0)
176/// max_scroll_x = (content_w - viewport_w).max(0)
177/// ```
178///
179/// LPAR-05 §5: `SCROLLABLE` is independent of actual overflow; the content
180/// extent is supplied explicitly (LPAR-10 will compute it from layout later).
181#[derive(Debug, Clone)]
182pub struct ScrollState {
183    /// Current horizontal scroll offset in logical pixels.
184    pub offset_x: i32,
185    /// Current vertical scroll offset in logical pixels.
186    pub offset_y: i32,
187    /// Explicit content width in logical pixels.
188    ///
189    /// The maximum horizontal scroll offset is `(content_w - viewport_w).max(0)`.
190    pub content_w: i32,
191    /// Explicit content height in logical pixels.
192    ///
193    /// The maximum vertical scroll offset is `(content_h - viewport_h).max(0)`.
194    pub content_h: i32,
195    /// Scroll axis supported by this container.
196    pub axis: ScrollAxis,
197    /// Snap configuration.
198    pub snap: SnapConfig,
199    /// Snap alignment (which viewport edge a snap point aligns to).
200    pub snap_align: SnapAlign,
201    /// Scrollbar display mode.
202    pub scrollbar_mode: ScrollbarMode,
203    /// Snap attraction radius in pixels.
204    pub snap_attraction_radius: i32,
205}
206
207impl ScrollState {
208    /// Create default scroll state for a vertical-only container.
209    ///
210    /// Content extent must be updated via [`Self::content_w`] /
211    /// [`Self::content_h`] before scroll is meaningful.
212    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    /// Return the maximum allowed vertical scroll offset given a viewport height.
227    pub fn max_scroll_y(&self, viewport_h: i32) -> i32 {
228        (self.content_h - viewport_h).max(0)
229    }
230
231    /// Return the maximum allowed horizontal scroll offset given a viewport width.
232    pub fn max_scroll_x(&self, viewport_w: i32) -> i32 {
233        (self.content_w - viewport_w).max(0)
234    }
235
236    /// Clamp and set the vertical offset; returns `true` if it changed.
237    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    /// Clamp and set the horizontal offset; returns `true` if it changed.
248    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    /// Compute the vertical scrollbar thumb rect in screen space for the given
259    /// viewport `Rect`, using the same formula as
260    /// `ScrollView::scrollbar_thumb()` (`widgets/src/scroll_view.rs` lines
261    /// 139–158).
262    ///
263    /// Returns `None` when:
264    /// - `scrollbar_mode` is [`ScrollbarMode::Off`], or
265    /// - the content does not overflow the viewport (`content_h ≤ viewport.height`).
266    ///
267    /// The returned rect is positioned on the **right edge** of `viewport` with
268    /// width [`SCROLLBAR_WIDTH`] and a gap of [`SCROLLBAR_MARGIN`] pixels.
269    /// Thumb height is computed as:
270    ///
271    /// ```text
272    /// thumb_h = (viewport_h² / content_h).clamp(SCROLLBAR_MIN_THUMB, viewport_h)
273    /// ```
274    ///
275    /// Thumb position reflects the current [`offset_y`](Self::offset_y):
276    /// at offset 0 the thumb sits at the top of the track; at `max_scroll_y`
277    /// the thumb sits at the bottom.
278    ///
279    /// # Notes
280    ///
281    /// - **Rendering and Auto-mode visibility** are the consuming widget's
282    ///   responsibility.  This method returns geometry only; whether the
283    ///   scrollbar is currently visible (e.g. during an active session in `Auto`
284    ///   mode) is managed by the caller.
285    /// - **Horizontal axis** is deferred (v1 vertical-first, LPAR-05 §17).
286    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        // Use i64 intermediate to avoid overflow on large content heights.
296        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// ---------------------------------------------------------------------------
321// VelocityWindow — sliding window for velocity estimation (LPAR-05 §8.2)
322// ---------------------------------------------------------------------------
323
324#[derive(Debug, Clone, Copy)]
325struct VelocitySample {
326    pos: i32,
327    tick: u32,
328}
329
330/// Sliding window velocity estimator for the drag stream.
331struct 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    /// Estimate velocity in pixels per tick over the window.
355    ///
356    /// Returns 0 if the window is empty.  If Δticks == 0 (all samples in the
357    /// same tick), the window cannot estimate velocity; returns 0 (spec §8.2:
358    /// "window is widened" — here we treat it as zero to avoid divide-by-zero;
359    /// callers may widen by using more samples or returning 0).
360    fn estimate_velocity(&self) -> i32 {
361        if self.count < 2 {
362            return 0;
363        }
364        // The most recent sample is at head - 1 (wrapping).
365        let newest_idx = if self.head == 0 {
366            VELOCITY_WINDOW_N - 1
367        } else {
368            self.head - 1
369        };
370        // The oldest sample we have.
371        let oldest_idx = if self.count < VELOCITY_WINDOW_N {
372            // Samples are at [0..count), head points just past the newest.
373            // oldest = (head - count + N) % N
374            (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        // Integer division with sign preservation.
386        dp / dt as i32
387    }
388}
389
390// ---------------------------------------------------------------------------
391// ScrollSession — active drag/throw session
392// ---------------------------------------------------------------------------
393
394/// Internal state for one active scroll session.
395struct ScrollSession {
396    /// Structural path from root to the active container node.
397    container_path: Vec<usize>,
398    /// Tick at which the session started.
399    #[allow(dead_code)]
400    start_tick: u32,
401    /// Velocity window for throw estimation.
402    vel_y: VelocityWindow,
403    /// Current throw/snap tween, if in throw/snap-settle phase.
404    throw_tween: Option<Tween>,
405    /// Whether the session is in throw/snap-settle phase (post-DragEnd).
406    in_throw: bool,
407    /// Starting offset at the time throw began (for tween from/to).
408    #[allow(dead_code)]
409    throw_from_offset: i32,
410    /// Target offset the throw tween is moving toward.
411    #[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// ---------------------------------------------------------------------------
430// ScrollController
431// ---------------------------------------------------------------------------
432
433/// Scroll controller configuration.
434///
435/// All constants are exposed here so callers can override defaults while
436/// preserving the determinism guarantee.
437#[derive(Debug, Clone, Copy)]
438pub struct ScrollConfig {
439    /// Minimum throw velocity (pixels per tick) to enter momentum mode.
440    pub min_throw_velocity: i32,
441    /// Maximum throw velocity cap (pixels per tick).
442    pub max_throw_velocity: i32,
443    /// Throw duration scale factor.
444    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
457/// LPAR-05 scroll controller.
458///
459/// Consumes `DragStart`/`DragMove`/`DragEnd` events and tick advances to drive
460/// scroll offsets on `ObjectNode`-based scroll containers.  Call
461/// [`process`](Self::process) on every drag event and
462/// [`tick`](Self::tick) on every [`Event::Tick`](crate::event::Event::Tick).
463///
464/// # Determinism
465///
466/// Given identical `(event, tick)` input sequences, the controller produces
467/// identical emitted-event and offset sequences (LPAR-05 §8.1).
468///
469/// # Thread safety
470///
471/// `ScrollController` is not `Send` or `Sync`.  Use it from a single thread.
472pub struct ScrollController {
473    config: ScrollConfig,
474    /// Current tick counter, incremented on each `Event::Tick`.
475    tick: u32,
476    /// Active scroll session, if any.
477    session: Option<ScrollSession>,
478    /// Last drag position (for computing deltas in DragMove).
479    last_drag_y: i32,
480    last_drag_x: i32,
481}
482
483impl ScrollController {
484    /// Create a new controller with the given configuration.
485    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    /// Create a new controller with default configuration.
496    pub fn default_config() -> Self {
497        Self::new(ScrollConfig::default())
498    }
499
500    /// Advance the internal tick counter and process any active throw/snap tween.
501    ///
502    /// Call this once per `Event::Tick`. The `sink` callback receives each
503    /// viewport dirty rect for every effective offset change.
504    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    /// Process a drag event (`DragStart`, `DragMove`, or `DragEnd`).
510    ///
511    /// Pass the drag stream event from the `DragRecognizer`. Other events are
512    /// silently ignored (use [`tick`](Self::tick) for `Event::Tick`).
513    ///
514    /// The `sink` callback receives viewport dirty rects for every effective
515    /// offset change.
516    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    /// Return `true` when a scroll session is currently active (between
531    /// `ScrollBegin` and `ScrollEnd`, inclusive of the throw phase).
532    ///
533    /// Use this from the platform layer to determine whether a drag event has
534    /// been absorbed by the scroll controller so that it MUST NOT also be
535    /// dispatched as a normal drag gesture (LPAR-05 §7.2).
536    pub fn is_active(&self) -> bool {
537        self.session.is_some()
538    }
539
540    /// Notify the controller that the container at `path` is being invalidated
541    /// (detached, hidden, or `SCROLLABLE` cleared) during an active session.
542    ///
543    /// If the active session targets that container, `ScrollEnd` is emitted
544    /// before the structural change becomes visible (LPAR-05 §7.6). Call this
545    /// before mutating the node.
546    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    // -----------------------------------------------------------------------
564    // DragStart handler
565    // -----------------------------------------------------------------------
566
567    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        // §8.5 supersession: a new DragStart during an active throw silently
577        // cancels the old session — no ScrollEnd for the interrupted session.
578        self.session = None;
579
580        // Find the deepest SCROLLABLE ancestor of the hit-test target.
581        let path = find_scrollable_ancestor(root, x, y);
582        if path.is_none() {
583            return;
584        }
585        let path = path.unwrap();
586
587        // Check axis alignment: vertical scroll requires vertical drag direction.
588        // At DragStart, we don't know direction yet (threshold just crossed).
589        // We activate optimistically and check axis on DragMove.
590        // For now: activate if the container's axis is Vertical or Both.
591        {
592            let container = node_at_path(root, &path);
593            if let Some(ss) = &container.scroll {
594                if ss.axis == ScrollAxis::Horizontal {
595                    // Horizontal-only: do not activate (v1 is vertical-first).
596                    return;
597                }
598            } else {
599                // No scroll state even though SCROLLABLE is set; skip.
600                return;
601            }
602        }
603
604        let mut session = ScrollSession::new(path.clone(), self.tick);
605        // Record start position.
606        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        // Emit ScrollBegin.
614        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    // -----------------------------------------------------------------------
622    // DragMove handler
623    // -----------------------------------------------------------------------
624
625    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; // drag DOWN = positive offset (scroll content up)
632        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        // Get current viewport height and offset.
638        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        // Update the offset.
651        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        // Check for chaining: at edge with residual delta.
660        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        // Emit Scroll if changed.
669        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        // Push velocity sample.
687        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        // Handle chaining: inner at edge → hand off to outer SCROLLABLE ancestor.
697        if residual != 0
698            && at_edge
699            && let Some(ancestor_path) = find_scrollable_ancestor_above(root, &path)
700        {
701            // Emit ScrollEnd on inner.
702            self.emit_scroll_end(root, sink);
703            // Begin new session on ancestor.
704            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            // Apply residual to ancestor.
715            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    // -----------------------------------------------------------------------
763    // DragEnd handler
764    // -----------------------------------------------------------------------
765
766    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        // Estimate velocity.
772        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            // Compute projected endpoint: offset + vel * duration (in ticks).
789            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            // Snap: adjust endpoint before first momentum Scroll.
794            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            // Emit ScrollThrow.
805            dispatch_to_container(
806                root,
807                &path,
808                ObjectEvent::ScrollThrow {
809                    vel_x: 0,
810                    vel_y: vel,
811                },
812            );
813
814            // Set up throw tween.
815            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            // Below the throw threshold. Per LPAR-05 §9.1, snapping settles at
826            // the end of a drag OR a throw: if a snap config attracts the
827            // resting offset to a nearby point, animate a short settle tween
828            // (no `ScrollThrow` — this is a correction, not a fling) and let
829            // `advance_throw` emit `ScrollEnd` on completion. Otherwise the
830            // session ends immediately.
831            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    // -----------------------------------------------------------------------
858    // Throw tick advance
859    // -----------------------------------------------------------------------
860
861    fn advance_throw(&mut self, root: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
862        // Step the tween and capture results, then drop the borrow before
863        // mutating root.
864        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; // Tween went outside bounds.
898        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    // -----------------------------------------------------------------------
929    // Helper: emit ScrollEnd to current session's container
930    // -----------------------------------------------------------------------
931
932    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
939// ---------------------------------------------------------------------------
940// Snap endpoint adjustment (LPAR-05 §9)
941// ---------------------------------------------------------------------------
942
943/// Adjust a throw endpoint to the nearest snap point within the attraction radius.
944fn 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            // Derive snap points from direct children's bounds.
965            node.children()
966                .iter()
967                .map(|child| {
968                    let b = child.widget().borrow().bounds();
969                    let raw = b.y; // top edge of child
970                    // Apply alignment offset.
971                    align_offset(raw, b.height, viewport_h, align)
972                })
973                .collect()
974        }
975    };
976
977    // Find the nearest snap point within the attraction radius.
978    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
990/// Convert a child top edge to the effective snap offset for the given alignment.
991fn 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
999// ---------------------------------------------------------------------------
1000// Public snap helper (LPAR-13 §5.F — shared snap-point math)
1001// ---------------------------------------------------------------------------
1002
1003/// Find the nearest snap point to `offset` within the given `points` list.
1004///
1005/// This is the pure snap-attraction core factored out of the private
1006/// `snap_endpoint` function.  The [`ScrollController`] drives the same math
1007/// through `snap_endpoint`; widget-level snap consumers (e.g. `Roller`)
1008/// call this helper directly so there is exactly one snap implementation.
1009///
1010/// # Parameters
1011///
1012/// - `offset` — the candidate scroll offset to be adjusted.
1013/// - `points` — sorted or unsorted list of exact snap-point offsets.
1014/// - `attraction_radius` — maximum distance within which a snap point attracts.
1015///   Use [`DEFAULT_SNAP_ATTRACTION_RADIUS`] unless overriding.
1016///
1017/// # Returns
1018///
1019/// The nearest snap point if one is within `attraction_radius`; otherwise
1020/// returns `offset` unchanged.
1021///
1022/// # Example
1023///
1024/// ```
1025/// use rlvgl_core::scroll::{snap_offset_to_points, DEFAULT_SNAP_ATTRACTION_RADIUS};
1026/// let points = [0, 40, 80, 120];
1027/// // 45 is within 60 px of snap point 40 → returns 40.
1028/// assert_eq!(snap_offset_to_points(45, &points, DEFAULT_SNAP_ATTRACTION_RADIUS), 40);
1029/// // 200 is farther than 60 px from any snap point → unchanged.
1030/// assert_eq!(snap_offset_to_points(200, &points, DEFAULT_SNAP_ATTRACTION_RADIUS), 200);
1031/// ```
1032pub 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
1045// ---------------------------------------------------------------------------
1046// Path dispatch helper (LPAR-05 §6.3)
1047// ---------------------------------------------------------------------------
1048
1049/// Dispatch a scroll `ObjectEvent` to the container at `path` and bubble per
1050/// existing `EVENT_BUBBLE` gating.
1051///
1052/// This is the LPAR-05 path-targeted dispatch: it does not use hit testing.
1053/// The [`ObjectFlags::EVENT_BUBBLE`] flag is **never** mutated (LPAR-05 §6.3).
1054pub 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
1065// ---------------------------------------------------------------------------
1066// Tree traversal helpers
1067// ---------------------------------------------------------------------------
1068
1069/// Find the deepest `SCROLLABLE` ancestor of the hit-test target at `(x, y)`.
1070///
1071/// Returns the structural path to that container, or `None` if no scrollable
1072/// ancestor exists (LPAR-05 §7.2).
1073fn find_scrollable_ancestor(root: &ObjectNode, x: i32, y: i32) -> Option<Vec<usize>> {
1074    // Hit-test to find the target path.
1075    let target_path = hit_test_path_for_scroll(root, x, y)?;
1076    // Walk from target toward root, find the deepest SCROLLABLE ancestor.
1077    // "Deepest" means the one closest to the target (longest path prefix).
1078    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
1088/// Find the nearest `SCROLLABLE` ancestor ABOVE the container at `path`.
1089///
1090/// Used for chaining (LPAR-05 §10.2).
1091fn 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
1105/// Hit-test, returning the structural path to the topmost hit node.
1106///
1107/// Unlike the main hit_test_path (which requires CLICKABLE), this one finds
1108/// the deepest node containing `(x, y)` regardless of flags, so we can walk
1109/// its ancestors to find a SCROLLABLE container.
1110fn 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    // Check children first (reverse for topmost).
1124    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    // Check this node.
1132    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
1146/// Navigate to a node by structural path (immutable).
1147fn node_at_path<'a>(root: &'a ObjectNode, path: &[usize]) -> &'a ObjectNode {
1148    let mut current = root;
1149    for &idx in path {
1150        current = &current.children()[idx];
1151    }
1152    current
1153}
1154
1155/// Navigate to a node by structural path (mutable).
1156fn 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// ---------------------------------------------------------------------------
1165// Tests
1166// ---------------------------------------------------------------------------
1167
1168#[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    // -----------------------------------------------------------------------
1182    // Minimal test widget
1183    // -----------------------------------------------------------------------
1184
1185    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    /// Build: root(400x600) → container(400x600, SCROLLABLE, content 400x1200) → child(400x1200)
1206    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    // -----------------------------------------------------------------------
1245    // 1. No activation on a non-scrollable tree
1246    // -----------------------------------------------------------------------
1247
1248    #[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    // -----------------------------------------------------------------------
1288    // 2. Activation on SCROLLABLE+axis-aligned
1289    // -----------------------------------------------------------------------
1290
1291    #[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        // DragStart anywhere inside the container.
1301        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    // -----------------------------------------------------------------------
1322    // 3. ScrollBegin precedes Scroll; ScrollEnd is last; zero-delta session
1323    // -----------------------------------------------------------------------
1324
1325    #[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        // Drag that produces actual scroll.
1334        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); // delta = +20 downward
1345        ctrl.process(&mut root, &Event::DragEnd { x: 200, y: 280 }, &mut sink);
1346
1347        let log_snap = log.borrow().clone();
1348
1349        // There must be a ScrollBegin before any Scroll.
1350        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        // ScrollEnd must be the last scroll event.
1366        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    // -----------------------------------------------------------------------
1382    // 4. ScrollEnd fires even on zero-delta session
1383    // -----------------------------------------------------------------------
1384
1385    #[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        // DragStart then immediate DragEnd with no DragMove.
1394        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    // -----------------------------------------------------------------------
1426    // 5. Offset clamps to [0, max_scroll]; effective change emits Scroll + dirty rect
1427    // -----------------------------------------------------------------------
1428
1429    #[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        // Drag heavily upward (should clamp at 0).
1438        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        // Move up a LOT — should clamp at 0.
1449        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        // Now drag downward past max_scroll.
1459        // Use DragStart at y=700 so a DragMove to y=0 gives delta_y=700 > max_scroll=600.
1460        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); // delta_y = 700 > max_scroll=600
1475        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; // 600
1479        assert_eq!(
1480            offset2, max_y,
1481            "offset must clamp at max_scroll on downward drag"
1482        );
1483
1484        // Should have emitted at least one dirty rect.
1485        assert!(
1486            !dirty2.is_empty(),
1487            "dirty rect must be pushed on effective offset change"
1488        );
1489    }
1490
1491    // -----------------------------------------------------------------------
1492    // 6. Throw: above-threshold → ScrollThrow once, then Scroll until ScrollEnd
1493    // -----------------------------------------------------------------------
1494
1495    #[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        // Build up some velocity: tick a few times, then drag fast.
1504        ctrl.tick(&mut root, &mut sink); // tick 1
1505        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); // tick 2
1516        ctrl.process(&mut root, &Event::DragMove { x: 200, y: 490 }, &mut sink);
1517        ctrl.tick(&mut root, &mut sink); // tick 3
1518        ctrl.process(&mut root, &Event::DragMove { x: 200, y: 470 }, &mut sink);
1519        ctrl.tick(&mut root, &mut sink); // tick 4
1520        ctrl.process(&mut root, &Event::DragMove { x: 200, y: 450 }, &mut sink);
1521        ctrl.tick(&mut root, &mut sink); // tick 5
1522        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    // -----------------------------------------------------------------------
1545    // 7. Below-threshold: ScrollEnd immediately after DragEnd
1546    // -----------------------------------------------------------------------
1547
1548    #[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        // Single slow move.
1557        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); // 1 px delta
1568        // Many ticks before DragEnd → velocity very low.
1569        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    // -----------------------------------------------------------------------
1589    // 8. Determinism: identical (event, tick) script → identical results
1590    // -----------------------------------------------------------------------
1591
1592    #[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            // Run 20 ticks to settle any throw.
1618            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    // -----------------------------------------------------------------------
1640    // 9. Snap: explicit-list settle
1641    // -----------------------------------------------------------------------
1642
1643    #[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        // Drag to roughly 280 (near snap point 300).
1671        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        // DragMove: move 280 pixels down (delta_y = +280).
1682        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        // If throw velocity is above threshold, the throw endpoint should snap to 300.
1686        // If below threshold, the offset stays at ~280 (no snap during drag, only at throw).
1687        // We just confirm no panic and that snap logic runs.
1688        let _offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
1689        // The test mainly verifies no panic and snap code runs; exact offset depends on velocity.
1690    }
1691
1692    #[test]
1693    fn below_threshold_drag_settles_to_snap_point() {
1694        // A slow drag (velocity below the throw threshold) that rests near a
1695        // snap point MUST settle to it via a tween (LPAR-05 §9.1: snapping at
1696        // the end of a drag OR throw), not stop wherever the finger lifted.
1697        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        // Single move to offset 280 with no intervening ticks → dt == 0 →
1724        // velocity 0 → below the throw threshold.
1725        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        // Drive the settle tween to completion.
1744        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    // -----------------------------------------------------------------------
1766    // 10. Child-aligned snap
1767    // -----------------------------------------------------------------------
1768
1769    #[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            // Three children at y=0, y=600, y=1200.
1791            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        // Use ticks between events to produce a non-zero velocity estimate.
1817        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        // Run enough ticks to fully settle the throw (duration ≈ vel*FACTOR ≤ 80*6=480 ticks).
1837        for _ in 0..600 {
1838            ctrl.tick(&mut root, &mut sink);
1839        }
1840
1841        // After settling, offset should be at a child boundary (snap point),
1842        // or at the maximum scroll offset.
1843        let offset = node_at_path(&root, &[0]).scroll.as_ref().unwrap().offset_y;
1844        let max_scroll = 1800 - 600; // 1200
1845        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    // -----------------------------------------------------------------------
1853    // 11. Chaining: inner-at-edge hands residual to outer
1854    // -----------------------------------------------------------------------
1855
1856    #[test]
1857    fn chaining_inner_at_edge_activates_outer() {
1858        // Build: root → outer(SCROLLABLE, 400x400, content 400x1200)
1859        //                 → inner(SCROLLABLE, 400x200, content 400x400)
1860        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; // max_scroll = 400-200 = 200
1885            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        // First scroll inner to its max offset.
1899        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        // Continue drag (residual should chain to outer).
1918        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        // Just verify no panic.
1924        let _ = inner_events;
1925        let _ = outer_events;
1926    }
1927
1928    // -----------------------------------------------------------------------
1929    // 12. Supersession: new DragStart during throw → no ScrollEnd for old session
1930    // -----------------------------------------------------------------------
1931
1932    #[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        // Start a throw.
1941        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        // Count ScrollEnd events so far.
1958        let end_count_before = log
1959            .borrow()
1960            .iter()
1961            .filter(|e| matches!(e, ObjectEvent::ScrollEnd))
1962            .count();
1963
1964        // New DragStart during throw — should NOT emit ScrollEnd for old session.
1965        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    // -----------------------------------------------------------------------
1989    // 13. Bubbling: scroll event reaches ancestor ONLY with EVENT_BUBBLE set;
1990    //     controller never mutates the flag.
1991    // -----------------------------------------------------------------------
1992
1993    #[test]
1994    fn bubbling_requires_event_bubble_flag_on_container() {
1995        let mut root = scroll_tree();
1996
1997        // Register a bubble handler on root.
1998        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        // Container [0] does NOT have EVENT_BUBBLE, so root should NOT see scroll events.
2022        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        // Verify controller did not mutate EVENT_BUBBLE.
2035        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        // Set EVENT_BUBBLE on the container.
2047        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        // Verify EVENT_BUBBLE was NOT cleared by the controller.
2085        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    // -----------------------------------------------------------------------
2094    // 14. Path-targeted dispatch delivers to container
2095    // -----------------------------------------------------------------------
2096
2097    #[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    // -----------------------------------------------------------------------
2120    // 15. ScrollState accessors
2121    // -----------------------------------------------------------------------
2122
2123    #[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    // -----------------------------------------------------------------------
2141    // 16. scrollbar_thumb geometry
2142    // -----------------------------------------------------------------------
2143
2144    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; // ≤ viewport height
2157        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        // viewport: x=0, y=0, w=200, h=600
2182        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        // Width must equal SCROLLBAR_WIDTH.
2188        assert_eq!(thumb.width, SCROLLBAR_WIDTH);
2189        // x must be at right edge: vp.x + vp.width - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN
2190        assert_eq!(
2191            thumb.x,
2192            200 - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN,
2193            "thumb must be on the right edge"
2194        );
2195        // thumb_h = (600² / 1200).clamp(8, 600) = 300
2196        assert_eq!(thumb.height, 300, "thumb height formula mismatch");
2197        // at offset 0: thumb.y == viewport.y
2198        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        // At max offset the thumb should be at the bottom of the track.
2208        ss.offset_y = ss.max_scroll_y(600); // 600
2209        let thumb = ss.scrollbar_thumb(vp).expect("thumb must be Some");
2210        // thumb_h = 300; travel = 600 - 300 = 300.
2211        // offset = (600 * 300) / 600 = 300
2212        assert_eq!(thumb.y, 300, "thumb at bottom when offset is max");
2213
2214        // At halfway the thumb should be at the center.
2215        ss.offset_y = 300;
2216        let thumb_mid = ss.scrollbar_thumb(vp).expect("thumb must be Some");
2217        // offset = (300 * 300) / 600 = 150
2218        assert_eq!(
2219            thumb_mid.y, 150,
2220            "thumb at center when offset is half of max"
2221        );
2222    }
2223}