Skip to main content

frust_widgets/
scroll_controller.rs

1//! [`ScrollController`]: a cloneable, reactive-free handle onto a
2//! [`ScrollView`](crate::ScrollView) or a [`ListView`](crate::ListView) — the
3//! programmatic-scroll seam.
4//!
5//! App code keeps a handle in its state, attaches it with
6//! [`ScrollView::controller`](crate::ScrollView::controller) or
7//! [`ListView::controller`](crate::ListView::controller), and drives or reads
8//! the surface from anywhere on the UI thread, inside a dispatch or not:
9//!
10//! - **Writes are recorded, not applied.** [`ScrollController::jump_to`],
11//!   [`ScrollController::animate_to`] and [`ScrollController::scroll_to_item`]
12//!   queue a command the attached widget drains at the start of its next
13//!   layout or paint (whichever runs first — a `ListView` also drains at its
14//!   next rebuild, before it plans the window, so the jump's frame already
15//!   materializes the rows it lands on), so the clamp is always taken against
16//!   the freshly laid-out extent — a jump issued in the same handler that
17//!   appended content lands on the new bottom, not the old one. A command
18//!   queued before any surface attaches (or before the attached one has laid
19//!   out) waits for the first layout. Recording raises
20//!   [`frust_core::mark_pending_result_flush`] so a frame-gated shell runs the
21//!   frame that applies it even when nothing else changed (the
22//!   `PanZoomController`/`NavigatorController` precedent).
23//! - **Reads are the last published snapshot.** [`ScrollController::offset`],
24//!   [`ScrollController::max_offset`] and [`ScrollController::viewport_extent`]
25//!   return what the attached surface last published — after every layout,
26//!   paint and event pass that changed it — and [`ScrollController::on_change`]
27//!   listeners hear each new [`ScrollInfo`] as it is published.
28//! - **One surface per handle.** The most recent attach wins: a second
29//!   surface attaching the same handle takes it over and the first stops
30//!   draining or publishing; dropping the attached widget detaches it.
31//! - **`is_animating` tracks the current binding, never a stale one.**
32//!   [`ScrollController::is_animating`] is true only while the *currently
33//!   attached* binding's surface has a live controller-driven
34//!   [`ScrollController::animate_to`] tween running — never a snapshot of
35//!   whatever the handle last happened to report. Both places current-ness
36//!   changes reconcile it so a detached or superseded binding can never
37//!   leave it stuck true: [`ScrollController::bind`] resets it for the newly
38//!   attached binding, and dropping the binding that was still current
39//!   clears it (`impl Drop for ScrollBinding`) even when the widget itself
40//!   never got to call [`ScrollController::is_animating`]'s setter —
41//!   covering a tween whose surface is dropped, taken over, or rebound
42//!   mid-flight. A widget whose tween genuinely survives a rebind to a
43//!   different handle (the tween belongs to the widget, not the handle)
44//!   republishes its live state onto the freshly bound handle right after
45//!   binding, so a real in-flight tween is still reported, just under the
46//!   new current binding instead of the old one.
47//! - **Item commands are list-only.** [`ScrollController::scroll_to_item`]
48//!   names a row by its [`ChildKey`], which only a keyed
49//!   [`ListView`](crate::ListView) can resolve. The queue therefore holds a
50//!   wider crate-internal command type than the offset-only `ScrollCommand`
51//!   a `ScrollView` matches on: the `ScrollView` drain filters item commands
52//!   out (ignoring each with a debug-build log) and keeps the offset ones in
53//!   recording order, so `ScrollView` never sees — and never has to match
54//!   on — a variant it cannot act on.
55//! - **The queue is bounded, not an unbounded backlog.** Offset commands
56//!   are grouped into segments by the item commands between them, and
57//!   within a segment a `jump_to` supersedes every earlier offset command
58//!   (it cancels any tween and sets the position outright) while an
59//!   `animate_to` supersedes an earlier `animate_to` but keeps a preceding
60//!   `jump_to`, so the tween still starts from the jumped-to position. A
61//!   segment therefore holds at most one jump and one animate in whatever
62//!   order they were recorded — a tight burst (a pointer-drag thumb, say) or
63//!   an alternating jump/animate loop both leave at most two entries — and
64//!   superseding never crosses a [`ScrollController::scroll_to_item`] (an
65//!   offset/item interleaving stays in exact recording order). Item commands
66//!   keep their own ordered channel but are capped at
67//!   [`ITEM_COMMAND_CAP`] entries; recording one past the cap drops the
68//!   *oldest* queued item command (debug-build log) rather than growing
69//!   further, so the whole queue never exceeds the cap plus two offset
70//!   entries per segment. One behavioural consequence: a superseded
71//!   `jump_to` that would, had it actually applied, transiently cross a
72//!   `ListView` near-start/near-end threshold never does — the edge
73//!   callbacks evaluate only the surviving target, not every value a caller
74//!   recorded.
75//! - **The pre-attach backlog survives a bind.** [`ScrollController::bind`]
76//!   does not clear the queue: a command recorded before any surface
77//!   attaches (or before the newly attached one has laid out) waits for that
78//!   surface's first layout, per the apply contract above. The hazard this
79//!   trades for: a handle driven in a loop while nothing is attached cannot
80//!   grow without bound either — superseding and the item cap still apply —
81//!   but it does accumulate up to that bound (at most one jump and one
82//!   animate per segment, up to [`ITEM_COMMAND_CAP`] item commands) rather
83//!   than draining as it is issued.
84//!
85//! See `docs/WIDGETS_ARCHITECTURE.md`'s *Scroll Physics* for the surface the
86//! handle drives.
87
88use std::cell::RefCell;
89use std::fmt;
90use std::rc::{Rc, Weak};
91
92use frust_core::Curve;
93
94use crate::ChildKey;
95use crate::scroll::ScrollInfo;
96
97/// A command recorded on a [`ScrollController`], drained by the attached
98/// widget in recording order.
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub(crate) enum ScrollCommand {
101    /// Move to this offset at once, clamped to `[0, max_offset]`.
102    JumpTo(f64),
103    /// Ease to this offset over the paired [`AnimateTo`]'s duration/curve,
104    /// clamped to `[0, max_offset]`.
105    AnimateTo(f64, AnimateTo),
106}
107
108/// Every command a [`ScrollController`] queues: an offset command any
109/// surface applies, or an item command only a keyed
110/// [`ListView`](crate::ListView) can resolve. Kept apart from
111/// [`ScrollCommand`] so the `ScrollView` drain — which matches exhaustively on
112/// that offset-only enum — never has to grow an arm for a key it has no
113/// rows to resolve against (see the [module docs](self)).
114#[derive(Clone, Copy, Debug, PartialEq)]
115pub(crate) enum ControllerCommand {
116    /// A jump/animate to an offset, applied by every surface.
117    Offset(ScrollCommand),
118    /// Bring a keyed row into view — applied by a keyed `ListView`, ignored
119    /// (with a debug-build log) by a `ScrollView`.
120    ScrollToItem(ScrollToItem),
121}
122
123/// A recorded [`ScrollController::scroll_to_item`] request.
124#[derive(Clone, Copy, Debug, PartialEq)]
125pub(crate) struct ScrollToItem {
126    /// The row's stable identity, as the list's `key_of` produces it.
127    pub(crate) key: ChildKey,
128    /// Where in the viewport the row should land.
129    pub(crate) alignment: ItemAlignment,
130    /// Ease there (through the same tween `animate_to` runs) rather than jump.
131    pub(crate) animated: bool,
132}
133
134/// Where [`ScrollController::scroll_to_item`] lands a row in the viewport.
135///
136/// Every alignment is clamped to the list's `[0, max_offset]` range, so a row
137/// too near either end of the content to reach the requested position stops
138/// at the edge instead (the first row can never be centered).
139#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
140pub enum ItemAlignment {
141    /// The row's top edge at the viewport's top edge.
142    #[default]
143    Start,
144    /// The row's center at the viewport's center.
145    Center,
146    /// The row's bottom edge at the viewport's bottom edge.
147    End,
148    /// The least movement that shows the whole row: nothing at all when it is
149    /// already fully visible, [`ItemAlignment::Start`] when it sits above the
150    /// viewport (or is taller than it), [`ItemAlignment::End`] when below.
151    Nearest,
152}
153
154/// The duration/curve [`ScrollController::animate_to`] eases a programmatic
155/// scroll through. Both fields are required rather than defaulted — Flutter's
156/// `ScrollController.animateTo` keeps `duration`/`curve` required for the
157/// same reason: an app names the motion it wants rather than inheriting a
158/// guessed one.
159#[derive(Clone, Copy, Debug, PartialEq)]
160pub struct AnimateTo {
161    /// How long the tween runs, in milliseconds. A non-positive or
162    /// non-finite value (`<= 0.0`, or `NaN`) behaves like
163    /// [`ScrollController::jump_to`] — an instant move, no animation —
164    /// matching how the app's `reduce_motion` theme flag collapses this call.
165    pub duration_ms: f64,
166    /// The easing curve the tween runs through.
167    pub curve: Curve,
168}
169
170/// How many queued [`ControllerCommand::ScrollToItem`] entries
171/// [`ScrollController::scroll_to_item`] keeps at once. Recording one past
172/// this bound drops the oldest queued item command (debug-build log) — see
173/// the [module docs](self).
174const ITEM_COMMAND_CAP: usize = 8;
175
176/// A subscribed [`ScrollController::on_change`] listener.
177type Listener = Rc<dyn Fn(ScrollInfo)>;
178
179/// The state every clone of a [`ScrollController`] shares with the widget
180/// it is attached to.
181struct Shared {
182    /// The last [`ScrollInfo`] the attached surface published.
183    info: ScrollInfo,
184    /// The attached surface's last laid-out extent along the scroll axis.
185    viewport_extent: f64,
186    /// The token of the binding currently attached, or `None`.
187    attached: Option<u64>,
188    /// The token the next [`ScrollController::bind`] hands out.
189    next_token: u64,
190    /// Commands recorded and not yet drained, in recording order. Bounded —
191    /// see [`ScrollController::record`] and the [module docs](self).
192    commands: Vec<ControllerCommand>,
193    /// Subscribed listeners, keyed by the id their guard removes them by.
194    listeners: Vec<(u64, Listener)>,
195    /// The id the next [`ScrollController::on_change`] hands out.
196    next_listener: u64,
197    /// Whether the *currently attached* binding's surface is running a
198    /// controller-driven [`ScrollController::animate_to`] tween —
199    /// [`ScrollController::is_animating`]'s backing state. Unlike
200    /// `info`/`viewport_extent`, this is a liveness flag, not a last-known
201    /// snapshot: it is true only while the current binding has a live tween,
202    /// and is reconciled back to `false` whenever current-ness changes
203    /// without the widget itself clearing it first — [`ScrollController::bind`]
204    /// resets it for the newly attached binding, and dropping the binding
205    /// that was current clears it too (see `impl Drop for ScrollBinding`).
206    animating: bool,
207}
208
209impl Default for Shared {
210    fn default() -> Self {
211        Self {
212            info: ScrollInfo {
213                offset: 0.0,
214                max_offset: 0.0,
215                overscroll: 0.0,
216            },
217            viewport_extent: 0.0,
218            attached: None,
219            next_token: 0,
220            commands: Vec::new(),
221            listeners: Vec::new(),
222            next_listener: 0,
223            animating: false,
224        }
225    }
226}
227
228/// A cloneable handle onto a [`ScrollView`](crate::ScrollView) or a
229/// [`ListView`](crate::ListView): drive it with [`jump_to`](Self::jump_to)/
230/// [`animate_to`](Self::animate_to) (and, on a keyed list,
231/// [`scroll_to_item`](Self::scroll_to_item)), read where it stands with
232/// [`offset`](Self::offset)/[`max_offset`](Self::max_offset)/
233/// [`viewport_extent`](Self::viewport_extent), and observe it with
234/// [`on_change`](Self::on_change). Every clone shares one state, so the handle
235/// an app keeps in its state and the one attached to the view are the same.
236/// See the [module docs](self) for the apply/publish contract.
237///
238/// ```
239/// use frust_widgets::{ScrollController, ScrollView, scroll_view, text};
240/// let controller = ScrollController::new();
241/// let view: ScrollView<()> = scroll_view(text("hi")).controller(controller.clone());
242/// controller.jump_to(120.0); // applied at the view's next layout or paint
243/// # let _ = view;
244/// ```
245#[derive(Clone, Default)]
246pub struct ScrollController {
247    shared: Rc<RefCell<Shared>>,
248}
249
250impl fmt::Debug for ScrollController {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        let shared = self.shared.borrow();
253        f.debug_struct("ScrollController")
254            .field("info", &shared.info)
255            .field("viewport_extent", &shared.viewport_extent)
256            .field("attached", &shared.attached.is_some())
257            .field("pending_commands", &shared.commands.len())
258            .field("listeners", &shared.listeners.len())
259            .field("animating", &shared.animating)
260            .finish()
261    }
262}
263
264/// Apply the superseding rule to the offset commands of one segment: the
265/// entries from `start` up to the next item command (or the end). Walking
266/// them in recording order, a `JumpTo` drops everything the segment held
267/// before it and an `AnimateTo` drops an earlier `AnimateTo`, so what
268/// survives is at most one jump followed by at most one animate. Called
269/// after every offset record and after an item drop merges two segments.
270fn supersede_segment(commands: &mut Vec<ControllerCommand>, start: usize) {
271    let end = commands[start..]
272        .iter()
273        .position(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
274        .map_or(commands.len(), |index| start + index);
275    let mut jump = None;
276    let mut animate = None;
277    for command in commands.drain(start..end) {
278        match command {
279            ControllerCommand::Offset(ScrollCommand::JumpTo(_)) => {
280                jump = Some(command);
281                animate = None;
282            }
283            ControllerCommand::Offset(ScrollCommand::AnimateTo(..)) => animate = Some(command),
284            ControllerCommand::ScrollToItem(_) => {
285                unreachable!("the segment ends before the next item command")
286            }
287        }
288    }
289    for (offset, command) in [jump, animate].into_iter().flatten().enumerate() {
290        commands.insert(start + offset, command);
291    }
292}
293
294impl ScrollController {
295    /// A controller attached to nothing yet, reading offset, extent and
296    /// viewport `0.0`.
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Scroll to `offset` (px scrolled down) at once.
302    ///
303    /// Applied by the attached surface at the start of its next layout or
304    /// paint, clamped to `[0, max_offset]` against that pass's extent; it stops
305    /// any fling, release-settle or ballistic simulation in flight. `NaN` is
306    /// ignored; `f64::INFINITY` lands on the bottom edge. Issued while nothing
307    /// is attached (or before the attached surface has laid out), it waits for
308    /// the first layout of the next surface to attach.
309    pub fn jump_to(&self, offset: f64) {
310        self.record(ControllerCommand::Offset(ScrollCommand::JumpTo(offset)));
311    }
312
313    /// Ease to `offset` over `options.duration_ms`/`options.curve`, clamped
314    /// to `[0, max_offset]`.
315    ///
316    /// Applied — like [`ScrollController::jump_to`] — at the attached
317    /// surface's next layout or paint, and runs through the same ballistic
318    /// driver a release fling does, so paint cadence, `request_frame` and the
319    /// installed physics' boundary handling are shared rather than
320    /// duplicated. A later `jump_to`/`animate_to` recorded before this one
321    /// finishes replaces it outright (drained in recording order, so the
322    /// later command's own reset always runs after this one's). Any user
323    /// pointer `Down` or wheel input on the surface cancels it too, leaving
324    /// the surface wherever it had eased to — user input always wins. The
325    /// app's `motion.reduce_motion` theme flag collapses this to an instant
326    /// jump. `NaN` is ignored.
327    pub fn animate_to(&self, offset: f64, options: AnimateTo) {
328        self.record(ControllerCommand::Offset(ScrollCommand::AnimateTo(
329            offset, options,
330        )));
331    }
332
333    /// Scroll a keyed [`ListView`](crate::ListView) so the row whose key is
334    /// `key` lands at `alignment` in the viewport — jumping there, or easing
335    /// there when `animated` is `true`.
336    ///
337    /// `key` is the same identity the list's
338    /// [`builder_keyed`](crate::ListView::builder_keyed) `key_of` returns for
339    /// the row (anything that converts into a [`ChildKey`]: an id, a name, a
340    /// `ChildKey` itself). Applied like [`ScrollController::jump_to`] — at the
341    /// attached list's next rebuild, layout or paint, replacing any jump or
342    /// animation in flight — and resolved then against the list's current
343    /// data:
344    ///
345    /// - **Resolution is O(item count) at worst.** The list keeps no
346    ///   key-to-index map beyond its materialized window, so a key outside the
347    ///   window is found by calling `key_of` over the items in order. One scan
348    ///   per request (and per settle frame for a row that left the window),
349    ///   never per frame.
350    /// - **Estimated, then corrected.** In variable-extent mode
351    ///   ([`ListView::estimated_item_extent`](crate::ListView::estimated_item_extent))
352    ///   the row's position is computed from the heights measured so far and
353    ///   the estimate for every row not yet laid out. Once the jump lands (or
354    ///   the animation finishes), each following layout measures the rows now
355    ///   on screen, re-resolves the row's aligned position and corrects to it,
356    ///   for at most a few frames (the list's own docs state the bound) — so
357    ///   the row ends aligned even when the rows ahead of it were never
358    ///   measured. A user `Down`, wheel or `Cancel` ends the request at once.
359    /// - **An unknown key is a no-op.** A key no row carries — or any key on a
360    ///   positional [`ListView::builder`](crate::ListView::builder) list, which
361    ///   has no keys — leaves the list where it is and reports itself only
362    ///   through a debug-build log, since a recorded command has no caller left
363    ///   to return a `Result` to.
364    /// - **Lists only.** A [`ScrollView`](crate::ScrollView) holding this handle
365    ///   ignores the request (debug-build log); its offset commands are
366    ///   unaffected.
367    ///
368    /// `animated` eases through the same tween [`ScrollController::animate_to`]
369    /// runs, timed from the theme's motion tokens (`motion.durations.slow` and
370    /// `motion.easing.spatial`, the neutral scheme's values when no theme is
371    /// threaded); the theme's `motion.reduce_motion` flag collapses it to a
372    /// jump.
373    pub fn scroll_to_item(
374        &self,
375        key: impl Into<ChildKey>,
376        alignment: ItemAlignment,
377        animated: bool,
378    ) {
379        self.record(ControllerCommand::ScrollToItem(ScrollToItem {
380            key: key.into(),
381            alignment,
382            animated,
383        }));
384    }
385
386    /// Whether the *currently attached* surface is running an
387    /// [`animate_to`](Self::animate_to) tween — `false` once it completes, is
388    /// replaced by a later jump/animate, or is interrupted by user input;
389    /// `false` before any surface has ever started one; and `false` once the
390    /// surface that was running one is dropped, taken over, or rebinds to a
391    /// different handle (see the [module docs](self) for the reconciliation
392    /// that guarantees this).
393    pub fn is_animating(&self) -> bool {
394        self.shared.borrow().animating
395    }
396
397    /// The clamped offset the attached surface last published (`0.0` until a
398    /// surface attaches and lays out; the last published value after it
399    /// detaches).
400    pub fn offset(&self) -> f64 {
401        self.shared.borrow().info.offset
402    }
403
404    /// The maximum offset (`content − viewport`, never negative) the attached
405    /// surface last published.
406    pub fn max_offset(&self) -> f64 {
407        self.shared.borrow().info.max_offset
408    }
409
410    /// The attached surface's last laid-out extent along the scroll axis (its
411    /// height, for the vertical [`ScrollView`](crate::ScrollView)).
412    pub fn viewport_extent(&self) -> f64 {
413        self.shared.borrow().viewport_extent
414    }
415
416    /// Whether a surface currently holds this handle.
417    pub fn is_attached(&self) -> bool {
418        self.shared.borrow().attached.is_some()
419    }
420
421    /// Call `listener` with every [`ScrollInfo`] the attached surface
422    /// publishes that differs from the previous one — user drag, wheel,
423    /// fling/settle frames, relayout and applied jumps alike.
424    ///
425    /// The listener runs with no `&mut State` (it may fire during layout or
426    /// paint), so it reacts by writing a signal or recording into the handle,
427    /// never by mutating app state directly. It stays subscribed for as long
428    /// as the returned [`ScrollSubscription`] lives.
429    pub fn on_change<F: Fn(ScrollInfo) + 'static>(&self, listener: F) -> ScrollSubscription {
430        let mut shared = self.shared.borrow_mut();
431        let id = shared.next_listener;
432        shared.next_listener += 1;
433        shared.listeners.push((id, Rc::new(listener)));
434        ScrollSubscription {
435            shared: Rc::downgrade(&self.shared),
436            id,
437        }
438    }
439
440    /// Queue `command` and raise [`frust_core::mark_pending_result_flush`] so a
441    /// frame runs to apply it even when it came from outside any input path.
442    ///
443    /// Within the current segment — the offset commands recorded since the
444    /// last item command — a `jump_to` supersedes every earlier offset
445    /// command (it cancels any tween and sets the position outright, so
446    /// nothing before it can still matter) and an `animate_to` supersedes an
447    /// earlier `animate_to` but keeps a preceding `jump_to`, its start
448    /// position. A segment therefore never holds more than one jump and one
449    /// animate, whatever order a caller records them in. An item command
450    /// keeps its own ordered channel, capped at [`ITEM_COMMAND_CAP`] (oldest
451    /// dropped past the cap, debug-build log). See the [module docs](self)
452    /// for the policy this implements.
453    fn record(&self, command: ControllerCommand) {
454        let mut shared = self.shared.borrow_mut();
455        match command {
456            ControllerCommand::Offset(offset) => {
457                let segment_start = shared
458                    .commands
459                    .iter()
460                    .rposition(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
461                    .map_or(0, |index| index + 1);
462                shared.commands.push(ControllerCommand::Offset(offset));
463                supersede_segment(&mut shared.commands, segment_start);
464            }
465            ControllerCommand::ScrollToItem(item) => {
466                shared.commands.push(ControllerCommand::ScrollToItem(item));
467                let item_count = shared
468                    .commands
469                    .iter()
470                    .filter(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
471                    .count();
472                if item_count > ITEM_COMMAND_CAP {
473                    let oldest = shared
474                        .commands
475                        .iter()
476                        .position(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
477                        .expect("item_count > 0 implies at least one ScrollToItem entry");
478                    if let ControllerCommand::ScrollToItem(dropped) = shared.commands.remove(oldest)
479                    {
480                        drop_oldest_item_command(&dropped);
481                    }
482                    // Dropping the oldest item merges the two segments around
483                    // it; re-apply the superseding rule so the merged segment
484                    // is bounded like every other one.
485                    supersede_segment(&mut shared.commands, 0);
486                }
487            }
488        }
489        drop(shared);
490        frust_core::mark_pending_result_flush();
491    }
492
493    /// Whether `self` and `other` are clones of one handle.
494    pub(crate) fn same(&self, other: &ScrollController) -> bool {
495        Rc::ptr_eq(&self.shared, &other.shared)
496    }
497
498    /// Attach a surface, taking the handle over from whichever one held it.
499    /// Resets `animating` to `false` for the new binding — a stale tween
500    /// reported by whichever surface held the handle before never leaks
501    /// into the new attach; a widget whose tween genuinely survives the
502    /// rebind (see `ScrollWidget`/`ListViewWidget::attach_controller`)
503    /// republishes its live state on the freshly bound handle right after.
504    pub(crate) fn bind(&self) -> ScrollBinding {
505        let mut shared = self.shared.borrow_mut();
506        let token = shared.next_token;
507        shared.next_token += 1;
508        shared.attached = Some(token);
509        shared.animating = false;
510        ScrollBinding {
511            controller: self.clone(),
512            token,
513        }
514    }
515}
516
517/// Keeps an [`ScrollController::on_change`] listener subscribed; dropping it
518/// unsubscribes. Holds the handle weakly, so it never keeps a controller alive.
519#[must_use = "dropping a ScrollSubscription unsubscribes its listener at once"]
520pub struct ScrollSubscription {
521    shared: Weak<RefCell<Shared>>,
522    id: u64,
523}
524
525impl fmt::Debug for ScrollSubscription {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        f.debug_struct("ScrollSubscription")
528            .field("id", &self.id)
529            .finish()
530    }
531}
532
533impl Drop for ScrollSubscription {
534    fn drop(&mut self) {
535        if let Some(shared) = self.shared.upgrade() {
536            shared
537                .borrow_mut()
538                .listeners
539                .retain(|(id, _)| *id != self.id);
540        }
541    }
542}
543
544/// A surface's claim on a [`ScrollController`]. Only the most recent binding
545/// is *current*; an older one (taken over by a later attach) neither drains
546/// nor publishes. Dropping the current binding detaches the handle.
547pub(crate) struct ScrollBinding {
548    controller: ScrollController,
549    token: u64,
550}
551
552impl ScrollBinding {
553    /// The handle this binding claims.
554    pub(crate) fn controller(&self) -> &ScrollController {
555        &self.controller
556    }
557
558    /// Whether this binding still holds the handle.
559    pub(crate) fn is_current(&self) -> bool {
560        self.controller.shared.borrow().attached == Some(self.token)
561    }
562
563    /// Whether this binding holds the handle and has commands to drain.
564    pub(crate) fn has_pending(&self) -> bool {
565        let shared = self.controller.shared.borrow();
566        shared.attached == Some(self.token) && !shared.commands.is_empty()
567    }
568
569    /// Take every queued **offset** command, in recording order — empty unless
570    /// this binding is current. The `ScrollView` drain: an item command
571    /// ([`ScrollController::scroll_to_item`]) has no rows to resolve against
572    /// here, so it is dropped with a debug-build log while the offset commands
573    /// around it keep their order.
574    pub(crate) fn take_commands(&self) -> Vec<ScrollCommand> {
575        self.take_all_commands()
576            .into_iter()
577            .filter_map(|command| match command {
578                ControllerCommand::Offset(command) => Some(command),
579                ControllerCommand::ScrollToItem(item) => {
580                    ignore_item_command(&item);
581                    None
582                }
583            })
584            .collect()
585    }
586
587    /// Take every queued command, item commands included, in recording order
588    /// — empty unless this binding is current. The `ListView` drain.
589    pub(crate) fn take_all_commands(&self) -> Vec<ControllerCommand> {
590        let mut shared = self.controller.shared.borrow_mut();
591        if shared.attached != Some(self.token) {
592            return Vec::new();
593        }
594        std::mem::take(&mut shared.commands)
595    }
596
597    /// Publish the surface's current position, firing every listener when
598    /// `info` differs from the last published value. A no-op unless this
599    /// binding is current. Listeners run after the shared state is released,
600    /// so one may read the handle or record a command re-entrantly.
601    pub(crate) fn publish(&self, info: ScrollInfo, viewport_extent: f64) {
602        let listeners: Vec<Listener> = {
603            let mut shared = self.controller.shared.borrow_mut();
604            if shared.attached != Some(self.token) {
605                return;
606            }
607            shared.viewport_extent = viewport_extent;
608            if shared.info == info {
609                return;
610            }
611            shared.info = info;
612            shared
613                .listeners
614                .iter()
615                .map(|(_, listener)| Rc::clone(listener))
616                .collect()
617        };
618        for listener in listeners {
619            listener(info);
620        }
621    }
622
623    /// Set whether a controller-driven [`ScrollController::animate_to`]
624    /// tween is live, if this binding is still current — a no-op once a
625    /// later attach took the handle over. Mirrors [`ScrollBinding::publish`]'s
626    /// current-binding guard but fires no listener: `is_animating` is a
627    /// plain poll, not an `on_change` event.
628    pub(crate) fn set_animating(&self, animating: bool) {
629        let mut shared = self.controller.shared.borrow_mut();
630        if shared.attached != Some(self.token) {
631            return;
632        }
633        shared.animating = animating;
634    }
635}
636
637/// Report an item command a surface with no keyed rows dropped — a wiring gap
638/// worth hearing about in a debug build, not an error a release build can act
639/// on (the `selection_toolbar` precedent).
640fn ignore_item_command(_item: &ScrollToItem) {
641    #[cfg(debug_assertions)]
642    eprintln!(
643        "frust-widgets: ScrollController::scroll_to_item({:?}, {:?}) ignored: the attached \
644         surface is a ScrollView, which has no keyed rows (attach the handle to a keyed \
645         ListView instead)",
646        _item.key, _item.alignment
647    );
648}
649
650/// Report an item command [`ScrollController::record`] dropped to stay within
651/// [`ITEM_COMMAND_CAP`] — a wiring gap (something recording `scroll_to_item`
652/// faster than the surface drains it) worth hearing about in a debug build,
653/// mirroring [`ignore_item_command`].
654fn drop_oldest_item_command(_item: &ScrollToItem) {
655    #[cfg(debug_assertions)]
656    eprintln!(
657        "frust-widgets: ScrollController::scroll_to_item({:?}, {:?}) dropped: more than \
658         {ITEM_COMMAND_CAP} item commands were queued with nothing draining them",
659        _item.key, _item.alignment
660    );
661}
662
663/// Detaches the handle when the dropped binding is still current, clearing
664/// both `attached` and `animating` in the same branch — a binding taken over
665/// by a later [`ScrollController::bind`] is already stale (its drop touches
666/// neither field, since `attached` no longer names its token and `animating`
667/// already belongs to whichever binding took over). This is what keeps
668/// [`ScrollController::is_animating`] from reporting a tween that ended only
669/// because its surface was dropped mid-flight — the widget's own
670/// `stop_ballistic` never runs in that case, so nothing else would clear it.
671impl Drop for ScrollBinding {
672    fn drop(&mut self) {
673        let mut shared = self.controller.shared.borrow_mut();
674        if shared.attached == Some(self.token) {
675            shared.attached = None;
676            shared.animating = false;
677        }
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use frust_core::{BoxConstraints, BuildCtx, LayoutCtx, View, Widget};
684    use kurbo::Size;
685
686    use super::*;
687    use crate::test_support::leaf;
688    use crate::{ScrollView, scroll_view};
689
690    #[test]
691    fn the_scroll_view_drain_drops_item_commands_and_keeps_offset_order() {
692        let controller = ScrollController::new();
693        let binding = controller.bind();
694        controller.jump_to(10.0);
695        controller.scroll_to_item(ChildKey::new(7u64), ItemAlignment::Center, true);
696        controller.jump_to(20.0);
697        assert!(binding.has_pending());
698        assert_eq!(
699            binding.take_commands(),
700            vec![ScrollCommand::JumpTo(10.0), ScrollCommand::JumpTo(20.0)],
701            "the item command is filtered out, the offset ones keep their order"
702        );
703        assert!(
704            !binding.has_pending(),
705            "the item command was drained, not left queued"
706        );
707    }
708
709    #[test]
710    fn the_list_drain_keeps_every_command_in_recording_order() {
711        let controller = ScrollController::new();
712        let binding = controller.bind();
713        controller.scroll_to_item("row", ItemAlignment::Nearest, false);
714        controller.jump_to(5.0);
715        assert_eq!(
716            binding.take_all_commands(),
717            vec![
718                ControllerCommand::ScrollToItem(ScrollToItem {
719                    key: ChildKey::new("row"),
720                    alignment: ItemAlignment::Nearest,
721                    animated: false,
722                }),
723                ControllerCommand::Offset(ScrollCommand::JumpTo(5.0)),
724            ]
725        );
726    }
727
728    #[test]
729    fn a_scroll_view_ignores_scroll_to_item() {
730        let controller = ScrollController::new();
731        let view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0)).controller(controller.clone());
732        let mut counter = 0u64;
733        let mut widget = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
734        let viewport = BoxConstraints::loose(Size::new(200.0, 100.0));
735
736        controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Start, false);
737        widget.layout(&mut LayoutCtx::new(), &viewport);
738        assert_eq!(
739            controller.offset(),
740            0.0,
741            "an item command moves a ScrollView nowhere"
742        );
743        assert_eq!(
744            controller.max_offset(),
745            900.0,
746            "but the surface still publishes"
747        );
748
749        controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Start, false);
750        controller.jump_to(250.0);
751        widget.layout(&mut LayoutCtx::new(), &viewport);
752        assert_eq!(
753            widget.offset(),
754            250.0,
755            "the offset command after it still applies"
756        );
757        assert_eq!(controller.offset(), 250.0);
758    }
759
760    #[test]
761    fn a_burst_of_jump_to_calls_coalesces_to_the_last_value() {
762        let controller = ScrollController::new();
763        let binding = controller.bind();
764        for offset in 0..50 {
765            controller.jump_to(offset as f64);
766        }
767        assert_eq!(
768            binding.take_all_commands(),
769            vec![ControllerCommand::Offset(ScrollCommand::JumpTo(49.0))],
770            "a run of same-kind offset commands leaves only the last one queued"
771        );
772    }
773
774    #[test]
775    fn jump_to_then_animate_to_keeps_both_in_either_order() {
776        let options = AnimateTo {
777            duration_ms: 200.0,
778            curve: Curve::Linear,
779        };
780
781        let controller = ScrollController::new();
782        let binding = controller.bind();
783        controller.jump_to(10.0);
784        controller.animate_to(20.0, options);
785        assert_eq!(
786            binding.take_all_commands(),
787            vec![
788                ControllerCommand::Offset(ScrollCommand::JumpTo(10.0)),
789                ControllerCommand::Offset(ScrollCommand::AnimateTo(20.0, options)),
790            ],
791            "a jump followed by an animate keeps both, so the tween starts from the jumped position"
792        );
793
794        let controller = ScrollController::new();
795        let binding = controller.bind();
796        controller.animate_to(20.0, options);
797        controller.jump_to(10.0);
798        assert_eq!(
799            binding.take_all_commands(),
800            vec![ControllerCommand::Offset(ScrollCommand::JumpTo(10.0))],
801            "a jump supersedes an earlier animate — it would have cancelled the tween anyway"
802        );
803    }
804
805    #[test]
806    fn alternating_offset_kinds_stay_bounded_to_one_jump_and_one_animate() {
807        let options = AnimateTo {
808            duration_ms: 200.0,
809            curve: Curve::Linear,
810        };
811        let controller = ScrollController::new();
812        let binding = controller.bind();
813        for round in 0..50 {
814            controller.jump_to(round as f64);
815            controller.animate_to(round as f64 + 0.5, options);
816        }
817        assert_eq!(
818            binding.take_all_commands(),
819            vec![
820                ControllerCommand::Offset(ScrollCommand::JumpTo(49.0)),
821                ControllerCommand::Offset(ScrollCommand::AnimateTo(49.5, options)),
822            ],
823            "whatever order kinds alternate in, a segment keeps one jump and one animate"
824        );
825
826        // Item commands break segments, and each segment is bounded on its own.
827        let controller = ScrollController::new();
828        let binding = controller.bind();
829        for round in 0..50 {
830            controller.jump_to(round as f64);
831            controller.scroll_to_item(ChildKey::new(round as u64), ItemAlignment::Start, false);
832        }
833        let queued = binding.take_all_commands();
834        assert!(
835            queued.len() <= 2 * (ITEM_COMMAND_CAP + 1) + ITEM_COMMAND_CAP,
836            "the whole queue stays under the item cap plus two offsets per segment: {}",
837            queued.len()
838        );
839        assert_eq!(
840            queued
841                .iter()
842                .filter(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
843                .count(),
844            ITEM_COMMAND_CAP
845        );
846    }
847
848    #[test]
849    fn scroll_to_item_between_two_jump_to_calls_keeps_all_three_in_order() {
850        let controller = ScrollController::new();
851        let binding = controller.bind();
852        controller.jump_to(10.0);
853        controller.scroll_to_item(ChildKey::new(7u64), ItemAlignment::Center, false);
854        controller.jump_to(20.0);
855        assert_eq!(
856            binding.take_all_commands(),
857            vec![
858                ControllerCommand::Offset(ScrollCommand::JumpTo(10.0)),
859                ControllerCommand::ScrollToItem(ScrollToItem {
860                    key: ChildKey::new(7u64),
861                    alignment: ItemAlignment::Center,
862                    animated: false,
863                }),
864                ControllerCommand::Offset(ScrollCommand::JumpTo(20.0)),
865            ],
866            "an item command in between stops the surrounding jumps from coalescing"
867        );
868    }
869
870    #[test]
871    fn the_item_command_cap_drops_the_oldest_and_keeps_the_newest() {
872        let controller = ScrollController::new();
873        let binding = controller.bind();
874        for key in 0..(ITEM_COMMAND_CAP as u64 + 3) {
875            controller.scroll_to_item(ChildKey::new(key), ItemAlignment::Start, false);
876        }
877        let drained = binding.take_all_commands();
878        assert_eq!(
879            drained.len(),
880            ITEM_COMMAND_CAP,
881            "queuing past the cap drops the oldest rather than growing further"
882        );
883        let expected: Vec<ControllerCommand> = (3..(ITEM_COMMAND_CAP as u64 + 3))
884            .map(|key| {
885                ControllerCommand::ScrollToItem(ScrollToItem {
886                    key: ChildKey::new(key),
887                    alignment: ItemAlignment::Start,
888                    animated: false,
889                })
890            })
891            .collect();
892        assert_eq!(
893            drained, expected,
894            "the surviving entries are the newest ones, oldest-dropped-first, in recording order"
895        );
896    }
897
898    #[test]
899    fn bind_resets_animating_and_drop_clears_it_only_for_the_current_binding() {
900        let controller = ScrollController::new();
901        let first = controller.bind();
902        first.set_animating(true);
903        assert!(controller.is_animating(), "the current binding set it");
904
905        // A fresh bind resets the flag, even though the just-bound-over
906        // first binding never ran its own tear-down.
907        let second = controller.bind();
908        assert!(
909            !controller.is_animating(),
910            "bind() resets animating for the newly attached binding"
911        );
912
913        // The second binding can still set its own live state independently
914        // of the first's stale `true`.
915        second.set_animating(true);
916        assert!(controller.is_animating());
917
918        // Dropping the stale, taken-over first binding must not touch the
919        // current holder's flag.
920        drop(first);
921        assert!(
922            controller.is_animating(),
923            "dropping a non-current binding leaves the current holder's flag alone"
924        );
925        assert!(
926            controller.is_attached(),
927            "dropping a non-current binding does not detach the handle"
928        );
929
930        // Dropping the current binding clears it.
931        drop(second);
932        assert!(
933            !controller.is_animating(),
934            "dropping the current binding clears animating"
935        );
936        assert!(!controller.is_attached());
937    }
938}