Skip to main content

zest_widget/widget/
scroll_core.rs

1//! Shared scrolling engine reused by every scrollable container.
2//!
3//! The logic lives here as free functions so it is not triplicated across
4//! `Column`/`Row`/`Container`/`Grid`. Because [`ScrollState`] is `Copy`, each
5//! function takes the state *by value* plus a `forward` closure for routing a
6//! touch into the container's children. That sidesteps the borrow checker
7//! aliasing `&self.children` with `&self.scroll` inside a container method.
8//!
9//! Responsibilities:
10//! - [`route_touch`] — the tap-vs-scroll gesture decision flow.
11//! - [`render_offset`] — the pixel offset a container subtracts from child
12//!   positions in `arrange` (rubber-banded while dragging, clamped otherwise).
13//! - [`snap_lines`] — candidate snap offsets derived from child rectangles.
14//! - [`draw_scrollbars`] — track + proportional thumb for both axes, honoring
15//!   every [`ScrollbarMode`].
16
17use alloc::vec::Vec;
18use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
19use zest_core::{
20    GesturePhase, RenderError, Renderer, ScrollDirection, ScrollMsg, ScrollState, ScrollbarMode,
21    SnapMode, TouchPhase,
22};
23use zest_theme::Theme;
24
25/// Width/height (px) of the scrollbar gutter and thumb.
26pub const SCROLLBAR_W: u32 = 8;
27
28/// The pixel offset a container subtracts from each child's position during
29/// `arrange`. While dragging the raw offset is rubber-banded so over-scroll
30/// stretches; otherwise it is clamped to the valid range.
31#[must_use]
32pub fn render_offset(state: ScrollState, dir: ScrollDirection) -> Point {
33    let off = if state.phase == GesturePhase::Dragging {
34        state.rubber_band(state.offset)
35    } else {
36        // During fling/spring the offset may legitimately sit past an edge
37        // (rubber-band overshoot before spring-back); keep it as-is so the
38        // stretch is visible, only forcing axes the container doesn't scroll.
39        state.offset
40    };
41    Point::new(
42        if dir.scrolls_x() { off.x } else { 0 },
43        if dir.scrolls_y() { off.y } else { 0 },
44    )
45}
46
47/// Run the LVGL-style gesture flow for one touch event, returning the host
48/// message to emit (if any). `forward` routes a touch into the container's
49/// children (typically the reverse child iteration) and reports whether a
50/// child consumed it.
51///
52/// Flow:
53/// - `Down`: forward to the child (so a button can highlight) **and** emit
54///   [`ScrollMsg::Press`].
55/// - `Moved` while `Pressing`: if the finger has crossed the threshold, begin
56///   dragging (emit [`ScrollMsg::DragTo`], stop forwarding); else forward.
57/// - `Moved` while `Dragging`: pan (emit [`ScrollMsg::DragTo`]).
58/// - `Up` while `Pressing`: it was a tap — forward to the child; if the child
59///   does nothing, emit [`ScrollMsg::Release`] to reset the phase.
60/// - `Up` while `Dragging`: emit [`ScrollMsg::Release`] (seed fling/spring).
61#[allow(clippy::too_many_arguments)]
62pub fn route_touch<M, F, S>(
63    state: ScrollState,
64    dir: ScrollDirection,
65    viewport: Rectangle,
66    content: Size,
67    point: Point,
68    phase: TouchPhase,
69    snap_lines: &[i32],
70    on_scroll: Option<&S>,
71    mut forward: F,
72) -> Option<M>
73where
74    F: FnMut(Point, TouchPhase) -> Option<M>,
75    S: Fn(ScrollMsg) -> M + ?Sized,
76{
77    let inside = rect_contains(viewport, point);
78    let emit = |sm: ScrollMsg| -> Option<M> { on_scroll.map(|f| f(sm)) };
79
80    match phase {
81        TouchPhase::Down => {
82            // Forward first so a child (button) can mark itself pressed, then
83            // begin the press gesture regardless of whether the child took it.
84            let child_msg = if inside { forward(point, phase) } else { None };
85            if inside {
86                let press = emit(ScrollMsg::Press {
87                    point,
88                    content,
89                    viewport: viewport.size,
90                });
91                // Prefer the child message if any (e.g. a Down that produced
92                // something), else carry the Press so phase advances.
93                child_msg.or(press)
94            } else {
95                child_msg
96            }
97        }
98        TouchPhase::Moved => match state.phase {
99            GesturePhase::Pressing => {
100                let crossed = crossed_threshold(state.press_origin, point, dir);
101                if crossed {
102                    emit(ScrollMsg::DragTo {
103                        point,
104                        content,
105                        viewport: viewport.size,
106                    })
107                } else {
108                    forward(point, phase)
109                }
110            }
111            GesturePhase::Dragging => emit(ScrollMsg::DragTo {
112                point,
113                content,
114                viewport: viewport.size,
115            }),
116            _ => {
117                if inside {
118                    forward(point, phase)
119                } else {
120                    None
121                }
122            }
123        },
124        TouchPhase::Up => match state.phase {
125            GesturePhase::Pressing => {
126                // Tap: let the child fire. If it does, that wins; otherwise
127                // emit Release so the phase returns to Idle.
128                let child_msg = forward(point, phase);
129                child_msg.or_else(|| {
130                    emit(ScrollMsg::Release {
131                        point,
132                        content,
133                        viewport: viewport.size,
134                        snap_lines: snap_lines.to_vec(),
135                    })
136                })
137            }
138            GesturePhase::Dragging => emit(ScrollMsg::Release {
139                point,
140                content,
141                viewport: viewport.size,
142                snap_lines: snap_lines.to_vec(),
143            }),
144            _ => {
145                if inside {
146                    forward(point, phase)
147                } else {
148                    None
149                }
150            }
151        },
152    }
153}
154
155/// Whether the finger has moved far enough from `origin` (Manhattan distance,
156/// restricted to the scrolling axes) to be treated as a scroll rather than a
157/// tap.
158#[must_use]
159pub fn crossed_threshold(origin: Point, point: Point, dir: ScrollDirection) -> bool {
160    let dx = if dir.scrolls_x() {
161        (point.x - origin.x).abs()
162    } else {
163        0
164    };
165    let dy = if dir.scrolls_y() {
166        (point.y - origin.y).abs()
167    } else {
168        0
169    };
170    dx + dy >= zest_core::scroll::SCROLL_THRESHOLD
171}
172
173/// Candidate snap offsets (in offset-space, i.e. valid `ScrollState::offset`
174/// values) derived from `child_rects`. `origin` is the viewport's top-left,
175/// `offset` the scroll offset already applied to the arranged children this
176/// frame (added back so the result is independent of the current scroll), and
177/// `viewport` the viewport size. Returns offsets on the scrolling axis
178/// selected by `dir`.
179///
180/// - [`SnapMode::Start`] — each child's leading edge aligns to the viewport
181///   leading edge.
182/// - [`SnapMode::Center`] — each child's center aligns to the viewport center.
183/// - [`SnapMode::End`] — each child's trailing edge aligns to the viewport
184///   trailing edge.
185#[must_use]
186pub fn snap_lines(
187    child_rects: &[Rectangle],
188    origin: Point,
189    offset: Point,
190    viewport: Size,
191    dir: ScrollDirection,
192    mode: SnapMode,
193) -> Vec<i32> {
194    if mode == SnapMode::None {
195        return Vec::new();
196    }
197    let vertical = dir.scrolls_y();
198    let mut out: Vec<i32> = Vec::with_capacity(child_rects.len());
199    for r in child_rects {
200        // Children are already shifted by `-offset`. The child's leading edge
201        // in offset-space (the offset that puts this child at the viewport
202        // leading edge) is `(top_left - origin) + offset`.
203        let (lead, extent, vp) = if vertical {
204            (
205                r.top_left.y - origin.y + offset.y,
206                r.size.height as i32,
207                viewport.height as i32,
208            )
209        } else {
210            (
211                r.top_left.x - origin.x + offset.x,
212                r.size.width as i32,
213                viewport.width as i32,
214            )
215        };
216        let line = match mode {
217            SnapMode::Center => lead + extent / 2 - vp / 2,
218            SnapMode::End => lead + extent - vp,
219            // Start (and the unreachable None, filtered above) snap to the
220            // leading edge.
221            SnapMode::Start | SnapMode::None => lead,
222        };
223        out.push(line.max(0));
224    }
225    out
226}
227
228/// Draw scrollbar tracks/thumbs after the children have been clipped+drawn.
229/// Honors all four [`ScrollbarMode`]s and both axes per `dir`. Track color is
230/// `theme.background.divider`, thumb is `theme.accent.base`.
231#[allow(clippy::too_many_arguments)]
232pub fn draw_scrollbars<C: PixelColor>(
233    renderer: &mut dyn Renderer<C>,
234    theme: &Theme<'_, C>,
235    state: ScrollState,
236    mode: ScrollbarMode,
237    dir: ScrollDirection,
238    viewport: Rectangle,
239    content: Size,
240) -> Result<(), RenderError> {
241    let want = |overflow: bool| match mode {
242        ScrollbarMode::Off => false,
243        ScrollbarMode::On => true,
244        ScrollbarMode::Auto => overflow,
245        ScrollbarMode::Active => overflow && state.is_active(),
246    };
247
248    let off = render_offset(state, dir);
249
250    if dir.scrolls_y() {
251        let overflow = content.height > viewport.size.height;
252        if want(overflow) && overflow {
253            let track = Rectangle::new(
254                Point::new(
255                    viewport.top_left.x + viewport.size.width.saturating_sub(SCROLLBAR_W) as i32,
256                    viewport.top_left.y,
257                ),
258                Size::new(SCROLLBAR_W, viewport.size.height),
259            );
260            renderer.fill_rect(track, theme.background.divider)?;
261            let thumb = thumb_rect(track, viewport.size.height, content.height, off.y, true);
262            renderer.fill_rect(thumb, theme.accent.base)?;
263        }
264    }
265
266    if dir.scrolls_x() {
267        let overflow = content.width > viewport.size.width;
268        if want(overflow) && overflow {
269            let track = Rectangle::new(
270                Point::new(
271                    viewport.top_left.x,
272                    viewport.top_left.y + viewport.size.height.saturating_sub(SCROLLBAR_W) as i32,
273                ),
274                Size::new(viewport.size.width, SCROLLBAR_W),
275            );
276            renderer.fill_rect(track, theme.background.divider)?;
277            let thumb = thumb_rect(track, viewport.size.width, content.width, off.x, false);
278            renderer.fill_rect(thumb, theme.accent.base)?;
279        }
280    }
281
282    Ok(())
283}
284
285/// Thumb rectangle within `track`. `vp`/`content` are the relevant axis
286/// extents, `offset` the current scroll on that axis, `vertical` selects the
287/// axis. The thumb is sized to the visible fraction (minimum 8 px) and
288/// positioned by the scroll fraction.
289#[must_use]
290pub fn thumb_rect(
291    track: Rectangle,
292    vp: u32,
293    content: u32,
294    offset: i32,
295    vertical: bool,
296) -> Rectangle {
297    let track_len = if vertical {
298        track.size.height
299    } else {
300        track.size.width
301    };
302    let max = (content as i32 - vp as i32).max(0);
303    let visible_frac = vp as f32 / content as f32;
304    let thumb_len = ((track_len as f32 * visible_frac).max(8.0)) as u32;
305    let thumb_len = thumb_len.min(track_len);
306    let scroll_frac = if max > 0 {
307        (offset.clamp(0, max) as f32) / max as f32
308    } else {
309        0.0
310    };
311    let along = ((track_len.saturating_sub(thumb_len)) as f32 * scroll_frac) as i32;
312    if vertical {
313        Rectangle::new(
314            Point::new(track.top_left.x + 2, track.top_left.y + along),
315            Size::new(track.size.width.saturating_sub(4), thumb_len),
316        )
317    } else {
318        Rectangle::new(
319            Point::new(track.top_left.x + along, track.top_left.y + 2),
320            Size::new(thumb_len, track.size.height.saturating_sub(4)),
321        )
322    }
323}
324
325/// Half-open hit test: `point` lies within `rect`.
326#[must_use]
327pub fn rect_contains(rect: Rectangle, point: Point) -> bool {
328    let br = rect.top_left + Point::new(rect.size.width as i32, rect.size.height as i32);
329    point.x >= rect.top_left.x && point.x < br.x && point.y >= rect.top_left.y && point.y < br.y
330}