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}