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}