Skip to main content

zest_core/
scroll.rs

1//! LVGL-style scroll state + math, living next to [`layout`](crate::layout).
2//!
3//! Widgets in `zest` are transient: the [`Runtime`](crate::runtime::Runtime)
4//! rebuilds the whole tree from `view(&self)` every frame, so a container
5//! cannot itself remember a drag origin or a fling velocity. All cross-frame
6//! scroll/gesture state therefore lives in a single host-owned value —
7//! [`ScrollState`] — which the screen stores as a field and passes by
8//! reference into `view`. Containers only *read* it during measure/arrange/
9//! draw and *emit* [`ScrollMsg`] in `handle_touch`; the owned copy is mutated
10//! only in `update()` via [`ScrollState::apply`] and [`ScrollState::tick`].
11//!
12//! Touch events carry no timestamp (see [`event`](crate::event)), so velocity
13//! for fling is sampled in `update()` from `embassy_time::Instant` deltas the
14//! caller passes as `now_ms`, never inside `handle_touch`. Momentum is driven
15//! by a self-rescheduling [`tick_task`] (a 16 ms `Timer`-delayed [`Task`]),
16//! not a subscription, so the animation cleanly stops when it settles.
17//!
18//! The math deliberately avoids `libm`: the tap-vs-scroll threshold uses
19//! Manhattan distance, and friction is a per-frame-constant multiply (no
20//! `powf`/`sqrt`), valid because the tick `dt` is approximately constant at
21//! 16 ms.
22
23use crate::application::Task;
24use alloc::vec::Vec;
25use embassy_time::{Duration, Timer};
26use embedded_graphics::prelude::*;
27
28/// Pixel distance a finger must travel before a press is reinterpreted as a
29/// scroll (Manhattan distance, `|dx| + |dy|`).
30pub const SCROLL_THRESHOLD: i32 = 8;
31/// Velocity multiplier applied each animation frame during a fling.
32pub const FRICTION: f32 = 0.95;
33/// Minimum fling speed (px/s, per axis) below which a fling becomes a spring.
34pub const MIN_FLING_V: f32 = 20.0;
35/// Maximum fling speed (px/s, per axis) the release sample is capped to.
36pub const MAX_FLING_V: f32 = 4000.0;
37/// Rubber-band resistance coefficient: larger → stiffer over-scroll.
38pub const RUBBER_C: f32 = 0.55;
39/// Spring stiffness: fraction of the remaining distance closed each frame.
40pub const SPRING_K: f32 = 0.25;
41/// Animation frame period in milliseconds (~60 fps).
42pub const TICK_MS: u64 = 16;
43
44/// Which axes a container scrolls on.
45#[derive(Copy, Clone, Debug, PartialEq, Eq)]
46pub enum ScrollDirection {
47    /// Scroll vertically only.
48    Vertical,
49    /// Scroll horizontally only.
50    Horizontal,
51    /// Scroll on both axes.
52    Both,
53    /// No scrolling; the container does not pan.
54    None,
55}
56
57impl ScrollDirection {
58    /// True if vertical panning is permitted.
59    #[must_use]
60    pub fn scrolls_y(self) -> bool {
61        matches!(self, ScrollDirection::Vertical | ScrollDirection::Both)
62    }
63
64    /// True if horizontal panning is permitted.
65    #[must_use]
66    pub fn scrolls_x(self) -> bool {
67        matches!(self, ScrollDirection::Horizontal | ScrollDirection::Both)
68    }
69}
70
71/// When the scrollbar thumb is visible.
72#[derive(Copy, Clone, Debug, PartialEq, Eq)]
73pub enum ScrollbarMode {
74    /// Never draw the scrollbar.
75    Off,
76    /// Always draw the scrollbar.
77    On,
78    /// Draw the scrollbar only when content overflows the viewport.
79    Auto,
80    /// Draw the scrollbar only while a gesture or animation is active.
81    Active,
82}
83
84/// How scrolling settles to content boundaries.
85#[derive(Copy, Clone, Debug, PartialEq, Eq)]
86pub enum SnapMode {
87    /// No snapping; settle at the released offset (clamped).
88    None,
89    /// Snap a child's leading edge to the viewport's leading edge.
90    Start,
91    /// Snap a child's center to the viewport's center.
92    Center,
93    /// Snap a child's trailing edge to the viewport's trailing edge.
94    End,
95}
96
97/// Gesture / animation phase of a [`ScrollState`].
98#[derive(Copy, Clone, Debug, PartialEq, Eq)]
99pub enum GesturePhase {
100    /// No interaction; offset is static.
101    Idle,
102    /// Finger is down but has not yet crossed the scroll threshold.
103    Pressing,
104    /// Finger is down and panning the content 1:1.
105    Dragging,
106    /// Finger released; coasting under friction.
107    Flinging,
108    /// Settling toward an edge or snap target via spring.
109    Springing,
110}
111
112/// Cross-frame scroll/gesture/velocity state owned by the host screen.
113///
114/// `Copy` so the `scroll_core` engine can take it by value (see this module's
115/// header for why).
116#[derive(Copy, Clone, Debug)]
117pub struct ScrollState {
118    /// Current committed scroll offset in pixels (subtracted from children).
119    pub offset: Point,
120    /// Sub-pixel integrator for smooth fling/spring motion.
121    pub accum: (f32, f32),
122    /// Current gesture/animation phase.
123    pub phase: GesturePhase,
124    /// Screen point where the active press began.
125    pub press_origin: Point,
126    /// `offset` captured at the moment of press (drag is relative to this).
127    pub offset_at_press: Point,
128    /// Most recent point seen during the gesture.
129    pub last_point: Point,
130    /// Current velocity in px/s, sampled at release.
131    pub velocity: (f32, f32),
132    /// Point of the last velocity sample.
133    pub last_sample_point: Point,
134    /// Millisecond timestamp of the last velocity sample.
135    pub last_sample_ms: u64,
136    /// Spring destination while [`GesturePhase::Springing`].
137    pub spring_target: Point,
138    /// Cached maximum scrollable offset (`max(content - viewport, 0)`).
139    pub max_offset: Point,
140    /// Cached content size from the last layout pass.
141    pub content: Size,
142    /// Cached viewport size from the last layout pass.
143    pub viewport: Size,
144}
145
146impl Default for ScrollState {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl ScrollState {
153    /// A fresh, idle scroll state at offset zero.
154    #[must_use]
155    pub const fn new() -> Self {
156        Self {
157            offset: Point::new(0, 0),
158            accum: (0.0, 0.0),
159            phase: GesturePhase::Idle,
160            press_origin: Point::new(0, 0),
161            offset_at_press: Point::new(0, 0),
162            last_point: Point::new(0, 0),
163            velocity: (0.0, 0.0),
164            last_sample_point: Point::new(0, 0),
165            last_sample_ms: 0,
166            spring_target: Point::new(0, 0),
167            max_offset: Point::new(0, 0),
168            content: Size::new(0, 0),
169            viewport: Size::new(0, 0),
170        }
171    }
172
173    /// True while coasting ([`GesturePhase::Flinging`]) or settling
174    /// ([`GesturePhase::Springing`]) — i.e. the host should keep ticking.
175    #[must_use]
176    pub fn is_animating(&self) -> bool {
177        matches!(self.phase, GesturePhase::Flinging | GesturePhase::Springing)
178    }
179
180    /// True while any interaction is in progress (phase is not idle).
181    #[must_use]
182    pub fn is_active(&self) -> bool {
183        self.phase != GesturePhase::Idle
184    }
185
186    /// Record the cached geometry from a [`ScrollMsg`] so subsequent math
187    /// (clamp/rubber-band/spring) needs no layout access.
188    fn cache_geometry(&mut self, content: Size, viewport: Size) {
189        self.content = content;
190        self.viewport = viewport;
191        self.max_offset = Point::new(
192            (content.width as i32 - viewport.width as i32).max(0),
193            (content.height as i32 - viewport.height as i32).max(0),
194        );
195    }
196
197    /// Clamp `offset` into the valid `[0, max_offset]` range on both axes.
198    #[must_use]
199    pub fn clamp_offset(&self, offset: Point) -> Point {
200        Point::new(
201            offset.x.clamp(0, self.max_offset.x),
202            offset.y.clamp(0, self.max_offset.y),
203        )
204    }
205
206    /// Apply rubber-band resistance to a raw drag offset: any overshoot past
207    /// `[0, max_offset]` is compressed by `resist(o, dim) = o*dim/(dim+o*C)`.
208    #[must_use]
209    pub fn rubber_band(&self, offset: Point) -> Point {
210        Point::new(
211            rubber_axis(offset.x, self.max_offset.x, self.viewport.width),
212            rubber_axis(offset.y, self.max_offset.y, self.viewport.height),
213        )
214    }
215
216    /// Nearest snap line to `offset` among `lines`, clamped to range. Empty
217    /// `lines` (or [`SnapMode::None`]) returns the clamped `offset`.
218    #[must_use]
219    pub fn nearest_snap(&self, offset: Point, snap: SnapMode, snap_lines: &[i32]) -> Point {
220        let clamped = self.clamp_offset(offset);
221        if snap == SnapMode::None || snap_lines.is_empty() {
222            return clamped;
223        }
224        // Snap lines apply to the scrolling axis. Vertical containers store
225        // line values as y offsets; horizontal as x. We pick whichever axis
226        // actually overflows (the one with a non-zero max).
227        if self.max_offset.y > 0 {
228            let target = nearest(clamped.y, snap_lines).clamp(0, self.max_offset.y);
229            Point::new(clamped.x, target)
230        } else if self.max_offset.x > 0 {
231            let target = nearest(clamped.x, snap_lines).clamp(0, self.max_offset.x);
232            Point::new(target, clamped.y)
233        } else {
234            clamped
235        }
236    }
237
238    /// Single dispatch over a [`ScrollMsg`]; `now_ms` is the caller's
239    /// millisecond clock (from `embassy_time::Instant`) used for velocity
240    /// sampling. Mutates the state in place.
241    pub fn apply(&mut self, msg: ScrollMsg, now_ms: u64) {
242        match msg {
243            ScrollMsg::Press {
244                point,
245                content,
246                viewport,
247            } => self.on_press(point, content, viewport, now_ms),
248            ScrollMsg::DragTo {
249                point,
250                content,
251                viewport,
252            } => self.on_move(point, content, viewport, now_ms),
253            ScrollMsg::Release {
254                point,
255                content,
256                viewport,
257                snap_lines,
258            } => self.on_release(point, content, viewport, &snap_lines, now_ms),
259        }
260    }
261
262    fn on_press(&mut self, point: Point, content: Size, viewport: Size, now_ms: u64) {
263        self.cache_geometry(content, viewport);
264        // Reset all gesture state so a prior fling/spring can't leak in.
265        self.phase = GesturePhase::Pressing;
266        self.press_origin = point;
267        self.offset_at_press = self.clamp_offset(self.offset);
268        self.offset = self.offset_at_press;
269        self.last_point = point;
270        self.velocity = (0.0, 0.0);
271        self.accum = (0.0, 0.0);
272        self.last_sample_point = point;
273        self.last_sample_ms = now_ms;
274    }
275
276    fn on_move(&mut self, point: Point, content: Size, viewport: Size, now_ms: u64) {
277        self.cache_geometry(content, viewport);
278        if self.phase == GesturePhase::Idle {
279            // Defensive: a move with no press — treat as a press.
280            self.on_press(point, content, viewport, now_ms);
281            return;
282        }
283        self.phase = GesturePhase::Dragging;
284        // Pan relative to the offset captured at press. A drag DOWN
285        // (point.y increases) reveals earlier content → offset decreases.
286        let raw = Point::new(
287            self.offset_at_press.x - (point.x - self.press_origin.x),
288            self.offset_at_press.y - (point.y - self.press_origin.y),
289        );
290        self.offset = self.rubber_band(raw);
291        // Velocity sample for the eventual fling: px/s over the elapsed dt.
292        let dt = now_ms.saturating_sub(self.last_sample_ms);
293        if dt > 0 {
294            let dx = (point.x - self.last_sample_point.x) as f32;
295            let dy = (point.y - self.last_sample_point.y) as f32;
296            let s = 1000.0 / dt as f32;
297            // Finger motion is opposite to offset motion → negate.
298            self.velocity = (clamp_v(-dx * s), clamp_v(-dy * s));
299            self.last_sample_point = point;
300            self.last_sample_ms = now_ms;
301        }
302        self.last_point = point;
303    }
304
305    fn on_release(
306        &mut self,
307        point: Point,
308        content: Size,
309        viewport: Size,
310        snap_lines: &[i32],
311        now_ms: u64,
312    ) {
313        self.cache_geometry(content, viewport);
314        let _ = (point, now_ms);
315        self.accum = (0.0, 0.0);
316
317        let past_edge = self.offset != self.clamp_offset(self.offset);
318        let fast = self.velocity.0.abs() >= MIN_FLING_V || self.velocity.1.abs() >= MIN_FLING_V;
319
320        if !past_edge && fast {
321            self.phase = GesturePhase::Flinging;
322        } else {
323            // Settle: spring toward the nearest snap line, else the edge.
324            self.spring_target =
325                self.nearest_snap(self.offset, snap_mode_from(snap_lines), snap_lines);
326            self.phase = GesturePhase::Springing;
327        }
328    }
329
330    /// Advance one animation frame. `dt_ms` is elapsed milliseconds since the
331    /// last tick; `snap`/`snap_lines` describe the settle target. Transitions
332    /// Flinging → Springing → Idle and updates `offset` in place.
333    pub fn tick(&mut self, dt_ms: u32, snap: SnapMode, snap_lines: &[i32]) {
334        let dt = (dt_ms.max(1) as f32) / 1000.0;
335        match self.phase {
336            GesturePhase::Flinging => {
337                // Integrate position with sub-pixel accumulator.
338                self.accum.0 += self.velocity.0 * dt;
339                self.accum.1 += self.velocity.1 * dt;
340                let step = Point::new(self.accum.0 as i32, self.accum.1 as i32);
341                self.accum.0 -= step.x as f32;
342                self.accum.1 -= step.y as f32;
343                self.offset += step;
344                // Decay (per-frame-constant friction — no powf needed).
345                self.velocity.0 *= FRICTION;
346                self.velocity.1 *= FRICTION;
347
348                let past_edge = self.offset != self.clamp_offset(self.offset);
349                let slow =
350                    self.velocity.0.abs() < MIN_FLING_V && self.velocity.1.abs() < MIN_FLING_V;
351                if past_edge || slow {
352                    self.spring_target = self.nearest_snap(self.offset, snap, snap_lines);
353                    self.velocity = (0.0, 0.0);
354                    self.accum = (0.0, 0.0);
355                    self.phase = GesturePhase::Springing;
356                }
357            }
358            GesturePhase::Springing => {
359                let dx = (self.spring_target.x - self.offset.x) as f32;
360                let dy = (self.spring_target.y - self.offset.y) as f32;
361                if dx.abs() < 1.0 && dy.abs() < 1.0 {
362                    self.offset = self.spring_target;
363                    self.accum = (0.0, 0.0);
364                    self.velocity = (0.0, 0.0);
365                    self.phase = GesturePhase::Idle;
366                } else {
367                    self.accum.0 += dx * SPRING_K;
368                    self.accum.1 += dy * SPRING_K;
369                    let step = Point::new(self.accum.0 as i32, self.accum.1 as i32);
370                    self.accum.0 -= step.x as f32;
371                    self.accum.1 -= step.y as f32;
372                    // Guarantee progress even when the per-frame step rounds
373                    // to zero, so the spring always reaches its target.
374                    let step = Point::new(nudge(step.x, dx), nudge(step.y, dy));
375                    self.offset += step;
376                }
377            }
378            _ => {}
379        }
380    }
381}
382
383/// Message a scrollable container emits in `handle_touch`; the host applies it
384/// in `update()`. Carries the geometry (and snap lines on release) so the
385/// owned [`ScrollState`] can integrate without touching layout.
386#[derive(Clone, Debug)]
387pub enum ScrollMsg {
388    /// Finger landed inside the viewport.
389    Press {
390        /// Touch point in screen space.
391        point: Point,
392        /// Content size measured this frame.
393        content: Size,
394        /// Viewport size this frame.
395        viewport: Size,
396    },
397    /// Finger moved while dragging (pan).
398    DragTo {
399        /// Touch point in screen space.
400        point: Point,
401        /// Content size measured this frame.
402        content: Size,
403        /// Viewport size this frame.
404        viewport: Size,
405    },
406    /// Finger released after a drag (seed fling / spring-back).
407    Release {
408        /// Touch point in screen space.
409        point: Point,
410        /// Content size measured this frame.
411        content: Size,
412        /// Viewport size this frame.
413        viewport: Size,
414        /// Candidate snap offsets from child positions (empty = no snap).
415        snap_lines: Vec<i32>,
416    },
417}
418
419/// Self-rescheduling animation clock: a [`Task`] that waits one frame
420/// (~16 ms) and then yields `msg`, so the host's `ScrollTick` arm can call
421/// [`ScrollState::tick`] and re-arm while [`ScrollState::is_animating`].
422///
423/// This drives momentum without a perpetual `time::every` subscription: the
424/// loop stops the moment the host stops returning another `tick_task`.
425#[must_use]
426pub fn tick_task<M: 'static>(msg: M) -> Task<M> {
427    Task::perform(async move {
428        Timer::after(Duration::from_millis(TICK_MS)).await;
429        Some(msg)
430    })
431}
432
433// ---- free helpers ------------------------------------------------------
434
435/// Cap a velocity component to `±MAX_FLING_V`.
436fn clamp_v(v: f32) -> f32 {
437    v.clamp(-MAX_FLING_V, MAX_FLING_V)
438}
439
440/// Rubber-band one axis: pass-through inside `[0, max]`, compressed outside.
441fn rubber_axis(value: i32, max: i32, dim: u32) -> i32 {
442    if value < 0 {
443        -resist(-value, dim)
444    } else if value > max {
445        max + resist(value - max, dim)
446    } else {
447        value
448    }
449}
450
451/// Diminishing-returns resistance: `o*dim/(dim + o*RUBBER_C)`.
452fn resist(overshoot: i32, dim: u32) -> i32 {
453    let o = overshoot as f32;
454    let d = (dim.max(1)) as f32;
455    (o * d / (d + o * RUBBER_C)) as i32
456}
457
458/// Nearest value in `lines` to `value` (linear scan; lists are small).
459fn nearest(value: i32, lines: &[i32]) -> i32 {
460    let mut best = lines[0];
461    let mut best_d = (value - best).abs();
462    for &l in &lines[1..] {
463        let d = (value - l).abs();
464        if d < best_d {
465            best_d = d;
466            best = l;
467        }
468    }
469    best
470}
471
472/// Ensure a rounded spring step moves at least one pixel toward the target
473/// (sign of `delta`) so settling never stalls.
474fn nudge(step: i32, delta: f32) -> i32 {
475    if step != 0 {
476        step
477    } else if delta > 0.5 {
478        1
479    } else if delta < -0.5 {
480        -1
481    } else {
482        0
483    }
484}
485
486/// Snapping is on iff snap lines exist. Returns `Start` as a generic "snap"
487/// marker — the edge/center/end geometry is already baked into the line values
488/// by `scroll_core::snap_lines`.
489fn snap_mode_from(snap_lines: &[i32]) -> SnapMode {
490    if snap_lines.is_empty() {
491        SnapMode::None
492    } else {
493        SnapMode::Start
494    }
495}