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 moves nothing.** A key no row carries — or any key
360    ///   on a positional [`ListView::builder`](crate::ListView::builder) list,
361    ///   which has no keys — leaves the list where it is and reports itself
362    ///   only through a debug-build log, since a recorded command has no caller
363    ///   left to return a `Result` to. It still replaces what an earlier
364    ///   command left in flight, as every item request does: a running
365    ///   [`ScrollController::animate_to`] (or animated `scroll_to_item`) tween
366    ///   stops where it stands and an earlier request stops correcting, while a
367    ///   user's own fling, settle or drag is left alone. `Nearest` on a row
368    ///   already fully visible behaves the same way.
369    /// - **Lists only.** A [`ScrollView`](crate::ScrollView) holding this handle
370    ///   ignores the request (debug-build log); its offset commands are
371    ///   unaffected.
372    ///
373    /// `animated` eases through the same tween [`ScrollController::animate_to`]
374    /// runs, timed from the theme's motion tokens (`motion.durations.slow` and
375    /// `motion.easing.spatial`, the neutral scheme's values when no theme is
376    /// threaded); the theme's `motion.reduce_motion` flag collapses it to a
377    /// jump.
378    pub fn scroll_to_item(
379        &self,
380        key: impl Into<ChildKey>,
381        alignment: ItemAlignment,
382        animated: bool,
383    ) {
384        self.record(ControllerCommand::ScrollToItem(ScrollToItem {
385            key: key.into(),
386            alignment,
387            animated,
388        }));
389    }
390
391    /// Whether the *currently attached* surface is running an
392    /// [`animate_to`](Self::animate_to) tween — `false` once it completes, is
393    /// replaced by a later jump/animate, or is interrupted by user input;
394    /// `false` before any surface has ever started one; and `false` once the
395    /// surface that was running one is dropped, taken over, or rebinds to a
396    /// different handle (see the [module docs](self) for the reconciliation
397    /// that guarantees this).
398    pub fn is_animating(&self) -> bool {
399        self.shared.borrow().animating
400    }
401
402    /// The clamped offset the attached surface last published (`0.0` until a
403    /// surface attaches and lays out; the last published value after it
404    /// detaches).
405    pub fn offset(&self) -> f64 {
406        self.shared.borrow().info.offset
407    }
408
409    /// The maximum offset (`content − viewport`, never negative) the attached
410    /// surface last published.
411    pub fn max_offset(&self) -> f64 {
412        self.shared.borrow().info.max_offset
413    }
414
415    /// The attached surface's last laid-out extent along the scroll axis (its
416    /// height, for the vertical [`ScrollView`](crate::ScrollView)).
417    pub fn viewport_extent(&self) -> f64 {
418        self.shared.borrow().viewport_extent
419    }
420
421    /// Whether a surface currently holds this handle.
422    pub fn is_attached(&self) -> bool {
423        self.shared.borrow().attached.is_some()
424    }
425
426    /// Call `listener` with every [`ScrollInfo`] the attached surface
427    /// publishes that differs from the previous one — user drag, wheel,
428    /// fling/settle frames, relayout and applied jumps alike.
429    ///
430    /// The listener runs with no `&mut State` (it may fire during layout or
431    /// paint), so it reacts by writing a signal or recording into the handle,
432    /// never by mutating app state directly. It stays subscribed for as long
433    /// as the returned [`ScrollSubscription`] lives.
434    pub fn on_change<F: Fn(ScrollInfo) + 'static>(&self, listener: F) -> ScrollSubscription {
435        let mut shared = self.shared.borrow_mut();
436        let id = shared.next_listener;
437        shared.next_listener += 1;
438        shared.listeners.push((id, Rc::new(listener)));
439        ScrollSubscription {
440            shared: Rc::downgrade(&self.shared),
441            id,
442        }
443    }
444
445    /// Queue `command` and raise [`frust_core::mark_pending_result_flush`] so a
446    /// frame runs to apply it even when it came from outside any input path.
447    ///
448    /// Within the current segment — the offset commands recorded since the
449    /// last item command — a `jump_to` supersedes every earlier offset
450    /// command (it cancels any tween and sets the position outright, so
451    /// nothing before it can still matter) and an `animate_to` supersedes an
452    /// earlier `animate_to` but keeps a preceding `jump_to`, its start
453    /// position. A segment therefore never holds more than one jump and one
454    /// animate, whatever order a caller records them in. An item command
455    /// keeps its own ordered channel, capped at [`ITEM_COMMAND_CAP`] (oldest
456    /// dropped past the cap, debug-build log). See the [module docs](self)
457    /// for the policy this implements.
458    fn record(&self, command: ControllerCommand) {
459        let mut shared = self.shared.borrow_mut();
460        match command {
461            ControllerCommand::Offset(offset) => {
462                let segment_start = shared
463                    .commands
464                    .iter()
465                    .rposition(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
466                    .map_or(0, |index| index + 1);
467                shared.commands.push(ControllerCommand::Offset(offset));
468                supersede_segment(&mut shared.commands, segment_start);
469            }
470            ControllerCommand::ScrollToItem(item) => {
471                shared.commands.push(ControllerCommand::ScrollToItem(item));
472                let item_count = shared
473                    .commands
474                    .iter()
475                    .filter(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
476                    .count();
477                if item_count > ITEM_COMMAND_CAP {
478                    let oldest = shared
479                        .commands
480                        .iter()
481                        .position(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
482                        .expect("item_count > 0 implies at least one ScrollToItem entry");
483                    if let ControllerCommand::ScrollToItem(dropped) = shared.commands.remove(oldest)
484                    {
485                        drop_oldest_item_command(&dropped);
486                    }
487                    // Dropping the oldest item merges the two segments around
488                    // it; re-apply the superseding rule so the merged segment
489                    // is bounded like every other one.
490                    supersede_segment(&mut shared.commands, 0);
491                }
492            }
493        }
494        drop(shared);
495        frust_core::mark_pending_result_flush();
496    }
497
498    /// Whether `self` and `other` are clones of one handle.
499    pub(crate) fn same(&self, other: &ScrollController) -> bool {
500        Rc::ptr_eq(&self.shared, &other.shared)
501    }
502
503    /// Attach a surface, taking the handle over from whichever one held it.
504    /// Resets `animating` to `false` for the new binding — a stale tween
505    /// reported by whichever surface held the handle before never leaks
506    /// into the new attach; a widget whose tween genuinely survives the
507    /// rebind (see `ScrollSurfaceCore::attach_controller` in `scroll_core.rs`)
508    /// republishes its live state on the freshly bound handle right after.
509    pub(crate) fn bind(&self) -> ScrollBinding {
510        let mut shared = self.shared.borrow_mut();
511        let token = shared.next_token;
512        shared.next_token += 1;
513        shared.attached = Some(token);
514        shared.animating = false;
515        ScrollBinding {
516            controller: self.clone(),
517            token,
518        }
519    }
520}
521
522/// Keeps an [`ScrollController::on_change`] listener subscribed; dropping it
523/// unsubscribes. Holds the handle weakly, so it never keeps a controller alive.
524#[must_use = "dropping a ScrollSubscription unsubscribes its listener at once"]
525pub struct ScrollSubscription {
526    shared: Weak<RefCell<Shared>>,
527    id: u64,
528}
529
530impl fmt::Debug for ScrollSubscription {
531    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532        f.debug_struct("ScrollSubscription")
533            .field("id", &self.id)
534            .finish()
535    }
536}
537
538impl Drop for ScrollSubscription {
539    fn drop(&mut self) {
540        if let Some(shared) = self.shared.upgrade() {
541            shared
542                .borrow_mut()
543                .listeners
544                .retain(|(id, _)| *id != self.id);
545        }
546    }
547}
548
549/// A surface's claim on a [`ScrollController`]. Only the most recent binding
550/// is *current*; an older one (taken over by a later attach) neither drains
551/// nor publishes. Dropping the current binding detaches the handle.
552pub(crate) struct ScrollBinding {
553    controller: ScrollController,
554    token: u64,
555}
556
557impl ScrollBinding {
558    /// The handle this binding claims.
559    pub(crate) fn controller(&self) -> &ScrollController {
560        &self.controller
561    }
562
563    /// Whether this binding still holds the handle.
564    pub(crate) fn is_current(&self) -> bool {
565        self.controller.shared.borrow().attached == Some(self.token)
566    }
567
568    /// Whether this binding holds the handle and has commands to drain.
569    pub(crate) fn has_pending(&self) -> bool {
570        let shared = self.controller.shared.borrow();
571        shared.attached == Some(self.token) && !shared.commands.is_empty()
572    }
573
574    /// Take every queued **offset** command, in recording order — empty unless
575    /// this binding is current. The `ScrollView` drain: an item command
576    /// ([`ScrollController::scroll_to_item`]) has no rows to resolve against
577    /// here, so it is dropped with a debug-build log while the offset commands
578    /// around it keep their order.
579    pub(crate) fn take_commands(&self) -> Vec<ScrollCommand> {
580        self.take_all_commands()
581            .into_iter()
582            .filter_map(|command| match command {
583                ControllerCommand::Offset(command) => Some(command),
584                ControllerCommand::ScrollToItem(item) => {
585                    ignore_item_command(&item);
586                    None
587                }
588            })
589            .collect()
590    }
591
592    /// Take every queued command, item commands included, in recording order
593    /// — empty unless this binding is current. The `ListView` drain.
594    pub(crate) fn take_all_commands(&self) -> Vec<ControllerCommand> {
595        let mut shared = self.controller.shared.borrow_mut();
596        if shared.attached != Some(self.token) {
597            return Vec::new();
598        }
599        std::mem::take(&mut shared.commands)
600    }
601
602    /// Publish the surface's current position, firing every listener when
603    /// `info` differs from the last published value. A no-op unless this
604    /// binding is current. Listeners run after the shared state is released,
605    /// so one may read the handle or record a command re-entrantly.
606    pub(crate) fn publish(&self, info: ScrollInfo, viewport_extent: f64) {
607        let listeners: Vec<Listener> = {
608            let mut shared = self.controller.shared.borrow_mut();
609            if shared.attached != Some(self.token) {
610                return;
611            }
612            shared.viewport_extent = viewport_extent;
613            if shared.info == info {
614                return;
615            }
616            shared.info = info;
617            shared
618                .listeners
619                .iter()
620                .map(|(_, listener)| Rc::clone(listener))
621                .collect()
622        };
623        for listener in listeners {
624            listener(info);
625        }
626    }
627
628    /// Set whether a controller-driven [`ScrollController::animate_to`]
629    /// tween is live, if this binding is still current — a no-op once a
630    /// later attach took the handle over. Mirrors [`ScrollBinding::publish`]'s
631    /// current-binding guard but fires no listener: `is_animating` is a
632    /// plain poll, not an `on_change` event.
633    pub(crate) fn set_animating(&self, animating: bool) {
634        let mut shared = self.controller.shared.borrow_mut();
635        if shared.attached != Some(self.token) {
636            return;
637        }
638        shared.animating = animating;
639    }
640}
641
642/// Report an item command a surface with no keyed rows dropped — a wiring gap
643/// worth hearing about in a debug build, not an error a release build can act
644/// on (the `selection_toolbar` precedent).
645fn ignore_item_command(_item: &ScrollToItem) {
646    #[cfg(debug_assertions)]
647    eprintln!(
648        "frust-widgets: ScrollController::scroll_to_item({:?}, {:?}) ignored: the attached \
649         surface is a ScrollView, which has no keyed rows (attach the handle to a keyed \
650         ListView instead)",
651        _item.key, _item.alignment
652    );
653}
654
655/// Report an item command [`ScrollController::record`] dropped to stay within
656/// [`ITEM_COMMAND_CAP`] — a wiring gap (something recording `scroll_to_item`
657/// faster than the surface drains it) worth hearing about in a debug build,
658/// mirroring [`ignore_item_command`].
659fn drop_oldest_item_command(_item: &ScrollToItem) {
660    #[cfg(debug_assertions)]
661    eprintln!(
662        "frust-widgets: ScrollController::scroll_to_item({:?}, {:?}) dropped: more than \
663         {ITEM_COMMAND_CAP} item commands were queued with nothing draining them",
664        _item.key, _item.alignment
665    );
666}
667
668/// Detaches the handle when the dropped binding is still current, clearing
669/// both `attached` and `animating` in the same branch — a binding taken over
670/// by a later [`ScrollController::bind`] is already stale (its drop touches
671/// neither field, since `attached` no longer names its token and `animating`
672/// already belongs to whichever binding took over). This is what keeps
673/// [`ScrollController::is_animating`] from reporting a tween that ended only
674/// because its surface was dropped mid-flight — the widget's own
675/// `stop_ballistic` never runs in that case, so nothing else would clear it.
676impl Drop for ScrollBinding {
677    fn drop(&mut self) {
678        let mut shared = self.controller.shared.borrow_mut();
679        if shared.attached == Some(self.token) {
680            shared.attached = None;
681            shared.animating = false;
682        }
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use frust_core::{BoxConstraints, BuildCtx, LayoutCtx, View, Widget};
689    use kurbo::Size;
690
691    use super::*;
692    use crate::test_support::leaf;
693    use crate::{ScrollView, scroll_view};
694
695    #[test]
696    fn the_scroll_view_drain_drops_item_commands_and_keeps_offset_order() {
697        let controller = ScrollController::new();
698        let binding = controller.bind();
699        controller.jump_to(10.0);
700        controller.scroll_to_item(ChildKey::new(7u64), ItemAlignment::Center, true);
701        controller.jump_to(20.0);
702        assert!(binding.has_pending());
703        assert_eq!(
704            binding.take_commands(),
705            vec![ScrollCommand::JumpTo(10.0), ScrollCommand::JumpTo(20.0)],
706            "the item command is filtered out, the offset ones keep their order"
707        );
708        assert!(
709            !binding.has_pending(),
710            "the item command was drained, not left queued"
711        );
712    }
713
714    #[test]
715    fn the_list_drain_keeps_every_command_in_recording_order() {
716        let controller = ScrollController::new();
717        let binding = controller.bind();
718        controller.scroll_to_item("row", ItemAlignment::Nearest, false);
719        controller.jump_to(5.0);
720        assert_eq!(
721            binding.take_all_commands(),
722            vec![
723                ControllerCommand::ScrollToItem(ScrollToItem {
724                    key: ChildKey::new("row"),
725                    alignment: ItemAlignment::Nearest,
726                    animated: false,
727                }),
728                ControllerCommand::Offset(ScrollCommand::JumpTo(5.0)),
729            ]
730        );
731    }
732
733    #[test]
734    fn a_scroll_view_ignores_scroll_to_item() {
735        let controller = ScrollController::new();
736        let view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0)).controller(controller.clone());
737        let mut counter = 0u64;
738        let mut widget = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
739        let viewport = BoxConstraints::loose(Size::new(200.0, 100.0));
740
741        controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Start, false);
742        widget.layout(&mut LayoutCtx::new(), &viewport);
743        assert_eq!(
744            controller.offset(),
745            0.0,
746            "an item command moves a ScrollView nowhere"
747        );
748        assert_eq!(
749            controller.max_offset(),
750            900.0,
751            "but the surface still publishes"
752        );
753
754        controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Start, false);
755        controller.jump_to(250.0);
756        widget.layout(&mut LayoutCtx::new(), &viewport);
757        assert_eq!(
758            widget.offset(),
759            250.0,
760            "the offset command after it still applies"
761        );
762        assert_eq!(controller.offset(), 250.0);
763    }
764
765    #[test]
766    fn a_burst_of_jump_to_calls_coalesces_to_the_last_value() {
767        let controller = ScrollController::new();
768        let binding = controller.bind();
769        for offset in 0..50 {
770            controller.jump_to(offset as f64);
771        }
772        assert_eq!(
773            binding.take_all_commands(),
774            vec![ControllerCommand::Offset(ScrollCommand::JumpTo(49.0))],
775            "a run of same-kind offset commands leaves only the last one queued"
776        );
777    }
778
779    #[test]
780    fn jump_to_then_animate_to_keeps_both_in_either_order() {
781        let options = AnimateTo {
782            duration_ms: 200.0,
783            curve: Curve::Linear,
784        };
785
786        let controller = ScrollController::new();
787        let binding = controller.bind();
788        controller.jump_to(10.0);
789        controller.animate_to(20.0, options);
790        assert_eq!(
791            binding.take_all_commands(),
792            vec![
793                ControllerCommand::Offset(ScrollCommand::JumpTo(10.0)),
794                ControllerCommand::Offset(ScrollCommand::AnimateTo(20.0, options)),
795            ],
796            "a jump followed by an animate keeps both, so the tween starts from the jumped position"
797        );
798
799        let controller = ScrollController::new();
800        let binding = controller.bind();
801        controller.animate_to(20.0, options);
802        controller.jump_to(10.0);
803        assert_eq!(
804            binding.take_all_commands(),
805            vec![ControllerCommand::Offset(ScrollCommand::JumpTo(10.0))],
806            "a jump supersedes an earlier animate — it would have cancelled the tween anyway"
807        );
808    }
809
810    #[test]
811    fn alternating_offset_kinds_stay_bounded_to_one_jump_and_one_animate() {
812        let options = AnimateTo {
813            duration_ms: 200.0,
814            curve: Curve::Linear,
815        };
816        let controller = ScrollController::new();
817        let binding = controller.bind();
818        for round in 0..50 {
819            controller.jump_to(round as f64);
820            controller.animate_to(round as f64 + 0.5, options);
821        }
822        assert_eq!(
823            binding.take_all_commands(),
824            vec![
825                ControllerCommand::Offset(ScrollCommand::JumpTo(49.0)),
826                ControllerCommand::Offset(ScrollCommand::AnimateTo(49.5, options)),
827            ],
828            "whatever order kinds alternate in, a segment keeps one jump and one animate"
829        );
830
831        // Item commands break segments, and each segment is bounded on its own.
832        let controller = ScrollController::new();
833        let binding = controller.bind();
834        for round in 0..50 {
835            controller.jump_to(round as f64);
836            controller.scroll_to_item(ChildKey::new(round as u64), ItemAlignment::Start, false);
837        }
838        let queued = binding.take_all_commands();
839        assert!(
840            queued.len() <= 2 * (ITEM_COMMAND_CAP + 1) + ITEM_COMMAND_CAP,
841            "the whole queue stays under the item cap plus two offsets per segment: {}",
842            queued.len()
843        );
844        assert_eq!(
845            queued
846                .iter()
847                .filter(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
848                .count(),
849            ITEM_COMMAND_CAP
850        );
851    }
852
853    #[test]
854    fn scroll_to_item_between_two_jump_to_calls_keeps_all_three_in_order() {
855        let controller = ScrollController::new();
856        let binding = controller.bind();
857        controller.jump_to(10.0);
858        controller.scroll_to_item(ChildKey::new(7u64), ItemAlignment::Center, false);
859        controller.jump_to(20.0);
860        assert_eq!(
861            binding.take_all_commands(),
862            vec![
863                ControllerCommand::Offset(ScrollCommand::JumpTo(10.0)),
864                ControllerCommand::ScrollToItem(ScrollToItem {
865                    key: ChildKey::new(7u64),
866                    alignment: ItemAlignment::Center,
867                    animated: false,
868                }),
869                ControllerCommand::Offset(ScrollCommand::JumpTo(20.0)),
870            ],
871            "an item command in between stops the surrounding jumps from coalescing"
872        );
873    }
874
875    #[test]
876    fn the_item_command_cap_drops_the_oldest_and_keeps_the_newest() {
877        let controller = ScrollController::new();
878        let binding = controller.bind();
879        for key in 0..(ITEM_COMMAND_CAP as u64 + 3) {
880            controller.scroll_to_item(ChildKey::new(key), ItemAlignment::Start, false);
881        }
882        let drained = binding.take_all_commands();
883        assert_eq!(
884            drained.len(),
885            ITEM_COMMAND_CAP,
886            "queuing past the cap drops the oldest rather than growing further"
887        );
888        let expected: Vec<ControllerCommand> = (3..(ITEM_COMMAND_CAP as u64 + 3))
889            .map(|key| {
890                ControllerCommand::ScrollToItem(ScrollToItem {
891                    key: ChildKey::new(key),
892                    alignment: ItemAlignment::Start,
893                    animated: false,
894                })
895            })
896            .collect();
897        assert_eq!(
898            drained, expected,
899            "the surviving entries are the newest ones, oldest-dropped-first, in recording order"
900        );
901    }
902
903    #[test]
904    fn bind_resets_animating_and_drop_clears_it_only_for_the_current_binding() {
905        let controller = ScrollController::new();
906        let first = controller.bind();
907        first.set_animating(true);
908        assert!(controller.is_animating(), "the current binding set it");
909
910        // A fresh bind resets the flag, even though the just-bound-over
911        // first binding never ran its own tear-down.
912        let second = controller.bind();
913        assert!(
914            !controller.is_animating(),
915            "bind() resets animating for the newly attached binding"
916        );
917
918        // The second binding can still set its own live state independently
919        // of the first's stale `true`.
920        second.set_animating(true);
921        assert!(controller.is_animating());
922
923        // Dropping the stale, taken-over first binding must not touch the
924        // current holder's flag.
925        drop(first);
926        assert!(
927            controller.is_animating(),
928            "dropping a non-current binding leaves the current holder's flag alone"
929        );
930        assert!(
931            controller.is_attached(),
932            "dropping a non-current binding does not detach the handle"
933        );
934
935        // Dropping the current binding clears it.
936        drop(second);
937        assert!(
938            !controller.is_animating(),
939            "dropping the current binding clears animating"
940        );
941        assert!(!controller.is_attached());
942    }
943}