Skip to main content

zest_widget/widget/
tileview.rs

1//! Full-area paged tiles that settle exactly one tile per swipe.
2//!
3//! A `Tileview` lays its children out edge-to-edge along one axis — each tile
4//! filling the whole viewport — and uses the shared scroll engine with
5//! [`zest_core::SnapMode::Start`] so every release settles to a tile boundary. Combined
6//! with one snap line per tile, a flick advances or retreats exactly one tile
7//! (the spring always picks the nearest boundary).
8//!
9//! Scroll direction is chosen with [`Tileview::direction`]: horizontal pages
10//! left/right, vertical pages up/down. (Use [`ScrollDirection::Horizontal`] or
11//! [`ScrollDirection::Vertical`]; other values fall back to horizontal.)
12//!
13//! ## Host ownership
14//!
15//! As with every scrollable widget in `zest`, cross-frame state lives on the
16//! host (widgets are transient — see [`scroll`](zest_core::scroll)). The host
17//! owns a [`ScrollState`] plus the current tile index and passes the state by
18//! reference each frame via [`Tileview::scroll_state`]. The tileview reads it
19//! during layout/draw and emits:
20//!
21//! - [`ScrollMsg`] through [`Tileview::on_scroll`] — apply to the owned
22//!   [`ScrollState`] in `update()` exactly as for a scrollable
23//!   [`Column`](super::column::Column).
24//! - the active tile index through [`Tileview::on_change`] — the host computes
25//!   it from its owned state with [`Tileview::current_for`] after each
26//!   [`ScrollMsg`]/`tick`, so the index follows the swipe and commits when the
27//!   page settles.
28
29use super::{Widget, element::Element, scroll_core};
30use alloc::{boxed::Box, vec::Vec};
31use core::marker::PhantomData;
32use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
33use zest_core::{
34    Constraints, Length, RenderError, Renderer, ScrollDirection, ScrollMsg, ScrollState, TouchPhase,
35};
36use zest_theme::Theme;
37
38/// Full-area paged tile container. Snaps one tile per swipe.
39pub struct Tileview<'a, C: PixelColor, M: Clone> {
40    rect: Rectangle,
41    /// Tiles, in order; each fills the whole viewport.
42    tiles: Vec<Element<'a, C, M>>,
43    /// Host-owned scroll state, read each frame.
44    state: ScrollState,
45    /// Paging axis (Horizontal or Vertical).
46    dir: ScrollDirection,
47    /// Callback turning a [`ScrollMsg`] into the host message.
48    on_scroll: Option<Box<dyn Fn(ScrollMsg) -> M + 'a>>,
49    /// Callback firing the active tile index when it changes (host-driven).
50    on_change: Option<Box<dyn Fn(usize) -> M + 'a>>,
51    width: Length,
52    height: Length,
53    /// Cached content extent along the paging axis (tiles laid edge-to-edge).
54    content: Size,
55    _color: PhantomData<C>,
56}
57
58impl<'a, C: PixelColor + 'a, M: Clone + 'a> Tileview<'a, C, M> {
59    /// Create an empty tileview paging horizontally. Add tiles via
60    /// [`Tileview::push`] and supply the host state via
61    /// [`Tileview::scroll_state`]. Position and size are assigned by the
62    /// parent container via `arrange`.
63    pub fn new() -> Self {
64        Self {
65            rect: Rectangle::zero(),
66            tiles: Vec::new(),
67            state: ScrollState::new(),
68            dir: ScrollDirection::Horizontal,
69            on_scroll: None,
70            on_change: None,
71            width: Length::Fill,
72            height: Length::Fill,
73            content: Size::zero(),
74            _color: PhantomData,
75        }
76    }
77
78    /// Paging direction. Only [`ScrollDirection::Horizontal`] and
79    /// [`ScrollDirection::Vertical`] are meaningful; any other value is
80    /// treated as horizontal.
81    #[must_use]
82    pub fn direction(mut self, dir: ScrollDirection) -> Self {
83        self.dir = match dir {
84            ScrollDirection::Vertical => ScrollDirection::Vertical,
85            _ => ScrollDirection::Horizontal,
86        };
87        self
88    }
89
90    /// Push a tile. Tiles are paged in insertion order.
91    #[must_use]
92    pub fn push<W>(mut self, tile: W) -> Self
93    where
94        W: Widget<C, M> + 'a,
95    {
96        self.tiles.push(Element::new(tile));
97        self
98    }
99
100    /// Supply the host-owned [`ScrollState`] read this frame.
101    #[must_use]
102    pub fn scroll_state(mut self, state: &ScrollState) -> Self {
103        self.state = *state;
104        self
105    }
106
107    /// Width sizing intent (the tileview normally fills its parent).
108    #[must_use]
109    pub fn width(mut self, width: impl Into<Length>) -> Self {
110        self.width = width.into();
111        self
112    }
113
114    /// Height sizing intent (the tileview normally fills its parent).
115    #[must_use]
116    pub fn height(mut self, height: impl Into<Length>) -> Self {
117        self.height = height.into();
118        self
119    }
120
121    /// Callback mapping a [`ScrollMsg`] to the host message. Apply the
122    /// message to the owned [`ScrollState`] in `update()`.
123    #[must_use]
124    pub fn on_scroll<F>(mut self, f: F) -> Self
125    where
126        F: Fn(ScrollMsg) -> M + 'a,
127    {
128        self.on_scroll = Some(Box::new(f));
129        self
130    }
131
132    /// Callback fired with the active tile index when it changes.
133    /// Drive it from `update()` via [`Tileview::change_msg`] /
134    /// [`Tileview::current_for`].
135    #[must_use]
136    pub fn on_change<F>(mut self, f: F) -> Self
137    where
138        F: Fn(usize) -> M + 'a,
139    {
140        self.on_change = Some(Box::new(f));
141        self
142    }
143
144    /// Snap-line offsets — one per tile boundary along the paging axis — so a
145    /// release always settles exactly one tile into the viewport.
146    fn snap_lines(&self) -> Vec<i32> {
147        let extent = if self.dir == ScrollDirection::Vertical {
148            self.rect.size.height as i32
149        } else {
150            self.rect.size.width as i32
151        };
152        (0..self.tiles.len() as i32).map(|i| i * extent).collect()
153    }
154
155    /// Active tile index for the current rendered offset (nearest boundary).
156    fn current_index(&self) -> usize {
157        Self::current_for(&self.state, self.rect.size, self.dir, self.tiles.len())
158    }
159
160    /// The active tile index for a host [`ScrollState`], the `viewport` size,
161    /// the paging `dir`, and tile `count`. The host calls this in `update()`
162    /// after applying a [`ScrollMsg`]/`tick` to learn which tile is showing,
163    /// without rebuilding the widget tree.
164    #[must_use]
165    pub fn current_for(
166        state: &ScrollState,
167        viewport: Size,
168        dir: ScrollDirection,
169        count: usize,
170    ) -> usize {
171        if count == 0 {
172            return 0;
173        }
174        let off = scroll_core::render_offset(*state, dir);
175        let (pos, extent) = if dir == ScrollDirection::Vertical {
176            (off.y, viewport.height as i32)
177        } else {
178            (off.x, viewport.width as i32)
179        };
180        let extent = extent.max(1);
181        let idx = (pos + extent / 2).div_euclid(extent);
182        idx.clamp(0, count as i32 - 1) as usize
183    }
184
185    /// Build the [`on_change`](Tileview::on_change) message for the active tile
186    /// when it differs from `previous`. Returns `None` when no callback is set
187    /// or the tile is unchanged. The host calls this from `update()` after
188    /// applying a [`ScrollMsg`]/`tick`.
189    #[must_use]
190    pub fn change_msg(&self, previous: usize) -> Option<M> {
191        let now = self.current_index();
192        if now != previous {
193            self.on_change.as_ref().map(|f| f(now))
194        } else {
195            None
196        }
197    }
198}
199
200impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for Tileview<'a, C, M> {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for Tileview<'a, C, M> {
207    fn measure(&mut self, constraints: Constraints) -> Size {
208        let w = self
209            .width
210            .resolve(constraints.max.width, constraints.max.width);
211        let h = self
212            .height
213            .resolve(constraints.max.height, constraints.max.height);
214        constraints.clamp(Size::new(w, h))
215    }
216
217    fn preferred_size(&self) -> (Length, Length) {
218        (self.width, self.height)
219    }
220
221    fn arrange(&mut self, rect: Rectangle) {
222        self.rect = rect;
223        let n = self.tiles.len() as u32;
224        let vertical = self.dir == ScrollDirection::Vertical;
225        self.content = if vertical {
226            Size::new(rect.size.width, rect.size.height.saturating_mul(n))
227        } else {
228            Size::new(rect.size.width.saturating_mul(n), rect.size.height)
229        };
230
231        // Each tile fills the viewport; lay them edge-to-edge along the paging
232        // axis, shifted by the rendered scroll offset.
233        let off = scroll_core::render_offset(self.state, self.dir);
234        let base = rect.top_left - off;
235        for (i, tile) in self.tiles.iter_mut().enumerate() {
236            let origin = if vertical {
237                Point::new(base.x, base.y + i as i32 * rect.size.height as i32)
238            } else {
239                Point::new(base.x + i as i32 * rect.size.width as i32, base.y)
240            };
241            tile.arrange(Rectangle::new(origin, rect.size));
242        }
243    }
244
245    fn rect(&self) -> Rectangle {
246        self.rect
247    }
248
249    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
250        if self.tiles.is_empty() {
251            return None;
252        }
253        let viewport = self.rect;
254        let content = self.content;
255        let dir = self.dir;
256        let lines = self.snap_lines();
257        let on_scroll = self.on_scroll.as_deref();
258        let tiles = &mut self.tiles;
259        scroll_core::route_touch(
260            self.state,
261            dir,
262            viewport,
263            content,
264            point,
265            phase,
266            &lines,
267            on_scroll,
268            |p, ph| {
269                // Forward to the active tile only (the others are off-screen),
270                // top-most-last like other containers.
271                for tile in tiles.iter_mut().rev() {
272                    if scroll_core::rect_contains(tile.rect(), p) {
273                        if let Some(msg) = tile.handle_touch(p, ph) {
274                            return Some(msg);
275                        }
276                    }
277                }
278                None
279            },
280        )
281    }
282
283    fn mark_pressed(&mut self, point: Point) {
284        // While dragging/animating, stop re-asserting child presses so a tile
285        // control highlighted on Down is cancelled mid-swipe.
286        if matches!(
287            self.state.phase,
288            zest_core::GesturePhase::Dragging
289                | zest_core::GesturePhase::Flinging
290                | zest_core::GesturePhase::Springing
291        ) {
292            return;
293        }
294        for tile in &mut self.tiles {
295            tile.mark_pressed(point);
296        }
297    }
298
299    fn draw<'t>(
300        &self,
301        renderer: &mut dyn Renderer<C>,
302        theme: &Theme<'t, C>,
303    ) -> Result<(), RenderError> {
304        let viewport = self.rect;
305        renderer.push_clip(viewport);
306        for tile in &self.tiles {
307            // Skip tiles fully outside the viewport (only the current and an
308            // adjacent in-transit tile ever overlap).
309            if rects_overlap(tile.rect(), viewport) {
310                tile.draw(renderer, theme)?;
311            }
312        }
313        renderer.pop_clip();
314        Ok(())
315    }
316}
317
318/// Whether two rectangles share any area (half-open).
319fn rects_overlap(a: Rectangle, b: Rectangle) -> bool {
320    let a_br = a.top_left + Point::new(a.size.width as i32, a.size.height as i32);
321    let b_br = b.top_left + Point::new(b.size.width as i32, b.size.height as i32);
322    a.top_left.x < b_br.x && b.top_left.x < a_br.x && a.top_left.y < b_br.y && b.top_left.y < a_br.y
323}