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}