Skip to main content

device_envoy_core/cyd/display/
tiling.rs

1//! Splits a display region into tiles so drawing needs only one small buffer.
2//!
3//! The CYD draws into a single shared pixel buffer that is
4//! flushed in pieces. These types describe *where* those pieces live in screen
5//! coordinates and *how big* the shared buffer must be, without knowing anything
6//! about what an app draws into them.
7//!
8//! The primary type is [`TileGrid`]: callers give it a display region and the
9//! number of tile columns and rows. Pass the grid to
10//! [`CydDisplay::for_each_tile`] to redraw and flush that region one tile at a
11//! time. Use
12//! `embedded_graphics::primitives::Rectangle` plus [`max_rectangle_pixel_count`]
13//! when sizing a shared buffer around fixed regions.
14
15use embedded_graphics::{
16    prelude::{Point, Size},
17    primitives::Rectangle,
18};
19
20use super::super::CydDisplay;
21
22/// Returns the frame-buffer capacity needed for one rectangular region.
23///
24/// ```rust,no_run
25/// use device_envoy_core::cyd::display::tiling::rectangle_pixel_count;
26/// use embedded_graphics::{prelude::{Point, Size}, primitives::Rectangle};
27///
28/// const STATUS_REGION: Rectangle =
29///     Rectangle::new(Point::new(0, 0), Size::new(160, 40));
30/// const FRAME_PIXELS: usize = rectangle_pixel_count(STATUS_REGION);
31///
32/// assert_eq!(FRAME_PIXELS, 6_400);
33/// ```
34#[must_use]
35pub const fn rectangle_pixel_count(rectangle: Rectangle) -> usize {
36    (rectangle.size.width * rectangle.size.height) as usize
37}
38
39/// Returns the capacity needed to reuse one frame buffer for either rectangle.
40///
41/// ```rust,no_run
42/// use device_envoy_core::cyd::display::tiling::max_rectangle_pixel_count;
43/// use embedded_graphics::{prelude::{Point, Size}, primitives::Rectangle};
44///
45/// const HEADER: Rectangle = Rectangle::new(Point::zero(), Size::new(320, 40));
46/// const FOOTER: Rectangle = Rectangle::new(Point::new(0, 210), Size::new(320, 30));
47/// const FRAME_PIXELS: usize = max_rectangle_pixel_count(HEADER, FOOTER);
48///
49/// assert_eq!(FRAME_PIXELS, 12_800);
50/// ```
51#[must_use]
52pub const fn max_rectangle_pixel_count(first: Rectangle, second: Rectangle) -> usize {
53    if rectangle_pixel_count(first) > rectangle_pixel_count(second) {
54        rectangle_pixel_count(first)
55    } else {
56        rectangle_pixel_count(second)
57    }
58}
59
60/// A display region divided into tiles for low-memory drawing.
61///
62/// [`CydDisplay::for_each_tile`] redraws the same logical-display-coordinate
63/// scene into each tile, clipping drawing to that tile before flushing it.
64///
65/// The tiled region may cover the full display or only a subregion.
66/// Some tiles may be smaller when the region does not divide evenly.
67///
68/// # Example
69///
70/// ```rust,no_run
71/// use device_envoy_core::{
72///     UnwrapInfallible,
73///     cyd::{
74///         CydDisplay,
75///         display::{CydFrame, tiling::TileGrid},
76///     },
77/// };
78/// use embedded_graphics::{
79///     Drawable,
80///     pixelcolor::Rgb565,
81///     prelude::{Point, Primitive, RgbColor, Size},
82///     primitives::{Circle, Line, PrimitiveStyle, Rectangle},
83/// };
84///
85/// // Tile the entire 320 × 240 display. The grid could instead cover a subregion.
86/// const GRID: TileGrid = TileGrid::new(
87///     Rectangle::new(Point::zero(), Size::new(320, 240)),
88///     4, // columns
89///     3, // rows
90/// );
91/// async fn draw<D: CydDisplay>(display: &mut D) -> Result<(), D::Error> {
92///     display
93///         .for_each_tile(GRID, |frame| {
94///             frame.fill(Rgb565::BLACK);
95///             Circle::new(Point::new(85, 45), 150)
96///                 .into_styled(PrimitiveStyle::with_fill(Rgb565::BLUE))
97///                 .draw(frame)
98///                 .unwrap_infallible();
99///             Line::new(Point::new(20, 210), Point::new(300, 30))
100///                 .into_styled(PrimitiveStyle::with_stroke(Rgb565::YELLOW, 5))
101///                 .draw(frame)
102///                 .unwrap_infallible();
103///             // Outline the current tile's frame rectangle so the tiling is visible.
104///             frame
105///                 .rectangle()
106///                 .into_styled(PrimitiveStyle::with_stroke(Rgb565::WHITE, 1))
107///                 .draw(frame)
108///                 .unwrap_infallible();
109///         })
110///         .await
111/// }
112///
113/// assert_eq!(GRID.max_tile_pixel_count(), 80 * 80);
114/// ```
115///
116/// A `320 × 240` region split into `4 × 3` tiles uses one `80 × 80` frame buffer.
117/// The white outlines show the individual frames; the scene remains continuous
118/// across their boundaries.
119#[cfg_attr(
120    feature = "doc-images",
121    doc = ::embed_doc_image::embed_image!("tile_grid", "docs/assets/tile_grid.png")
122)]
123#[cfg_attr(
124    feature = "doc-images",
125    doc = "\n![A circle and diagonal line drawn continuously across a four-by-three tile grid][tile_grid]\n"
126)]
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct TileGrid {
129    rectangle: Rectangle,
130    columns: usize,
131    rows: usize,
132}
133
134impl TileGrid {
135    /// Creates a grid splitting `rectangle` into `columns` × `rows` tiles.
136    ///
137    /// `rectangle.top_left` determines where the tiled region is drawn and
138    /// flushed on the display.
139    ///
140    /// Panics if either count is zero or exceeds the corresponding rectangle
141    /// dimension. See the [`TileGrid` example](TileGrid) for construction,
142    /// buffer sizing, and tiled drawing.
143    #[must_use]
144    pub const fn new(rectangle: Rectangle, columns: usize, rows: usize) -> Self {
145        assert!(columns > 0, "columns must be greater than zero");
146        assert!(rows > 0, "rows must be greater than zero");
147        assert!(
148            columns <= rectangle.size.width as usize,
149            "columns must not exceed rectangle width in pixels"
150        );
151        assert!(
152            rows <= rectangle.size.height as usize,
153            "rows must not exceed rectangle height in pixels"
154        );
155        Self {
156            rectangle,
157            columns,
158            rows,
159        }
160    }
161
162    /// Returns the display region covered by this grid.
163    ///
164    #[must_use]
165    pub const fn rectangle(&self) -> Rectangle {
166        self.rectangle
167    }
168
169    /// Number of tile columns the rectangle is split into.
170    ///
171    #[must_use]
172    pub const fn columns(&self) -> usize {
173        self.columns
174    }
175
176    /// Number of tile rows the rectangle is split into.
177    ///
178    #[must_use]
179    pub const fn rows(&self) -> usize {
180        self.rows
181    }
182
183    /// Nominal tile width: the rectangle width divided by the column count, rounded up.
184    ///
185    #[must_use]
186    pub const fn tile_width(&self) -> usize {
187        (self.rectangle.size.width as usize).div_ceil(self.columns)
188    }
189
190    /// Nominal tile height: the rectangle height divided by the row count, rounded up.
191    ///
192    #[must_use]
193    pub const fn tile_height(&self) -> usize {
194        (self.rectangle.size.height as usize).div_ceil(self.rows)
195    }
196
197    /// Largest pixel count any single tile can have.
198    ///
199    /// Use this as the reusable frame-buffer capacity for
200    /// [`CydDisplay::for_each_tile`]. Edge tiles may be smaller when the region
201    /// does not divide evenly.
202    ///
203    /// See the [`TileGrid` example](TileGrid).
204    #[must_use]
205    pub const fn max_tile_pixel_count(&self) -> usize {
206        let widest = min_usize(self.tile_width(), self.rectangle.size.width as usize);
207        let tallest = min_usize(self.tile_height(), self.rectangle.size.height as usize);
208        widest * tallest
209    }
210
211    /// The tile at `(column, row)` as a [`Rectangle`] in logical display
212    /// coordinates, or `None` if it lies outside the rectangle.
213    ///
214    /// The final column/row of a grid may be narrower/shorter than the nominal
215    /// tile size when the rectangle does not divide evenly by the tile counts, so
216    /// always use the returned rectangle's `size` rather than the grid's derived
217    /// tile size when allocating a frame.
218    #[must_use]
219    pub(crate) fn tile(&self, column: usize, row: usize) -> Option<Rectangle> {
220        let tile_width = self.tile_width();
221        let tile_height = self.tile_height();
222        let column_offset = column * tile_width;
223        let row_offset = row * tile_height;
224
225        let region_width = self.rectangle.size.width as usize;
226        let region_height = self.rectangle.size.height as usize;
227        if column_offset >= region_width || row_offset >= region_height {
228            return None;
229        }
230
231        let width = min_usize(tile_width, region_width - column_offset);
232        let height = min_usize(tile_height, region_height - row_offset);
233        let size = Size::new(width as u32, height as u32);
234        let top_left = Point::new(
235            self.rectangle.top_left.x + column_offset as i32,
236            self.rectangle.top_left.y + row_offset as i32,
237        );
238        Some(Rectangle::new(top_left, size))
239    }
240}
241
242const fn min_usize(first: usize, second: usize) -> usize {
243    if first < second { first } else { second }
244}
245
246/// Internal tile sequence used by `CydDisplay::for_each_tile`.
247///
248/// Created internally by [`CydDisplay::for_each_tile`]. This deliberately does *not* implement
249/// [`Iterator`]: each yielded frame borrows the device's
250/// single reusable frame buffer, so only one frame can be live at a time.
251/// Iterate with a `while let Some(mut frame) = tiles.next()` loop.
252/// See the [tiled draw loop](CydDisplay::for_each_tile).
253pub(crate) struct Tiles<'a, C: CydDisplay> {
254    cyd: &'a mut C,
255    grid: TileGrid,
256    column: usize,
257    row: usize,
258}
259
260impl<'a, C: CydDisplay> Tiles<'a, C> {
261    pub(crate) fn new(cyd: &'a mut C, grid: TileGrid) -> Self {
262        Self {
263            cyd,
264            grid,
265            column: 0,
266            row: 0,
267        }
268    }
269}
270
271impl<C: CydDisplay> Tiles<'_, C> {
272    /// Borrow the next tile-backed frame, cleared to the device background
273    /// color, or `None` once every tile has been yielded.
274    ///
275    /// Tiles are visited in row-major order (each row left-to-right), skipping
276    /// any `(column, row)` that falls entirely outside the grid rectangle.
277    ///
278    /// See the [`CydDisplay::for_each_tile` example](CydDisplay::for_each_tile).
279    // Each yielded frame borrows the device's single reusable frame buffer, so
280    // it cannot implement `Iterator` (whose `next` returns an item that outlives
281    // the `&mut self` borrow). The `next` name is the intended call shape, so
282    // allow the trait-shape lint here.
283    #[allow(clippy::should_implement_trait)]
284    pub(crate) fn next(&mut self) -> Option<C::Frame<'_>> {
285        let (columns, rows) = (self.grid.columns(), self.grid.rows());
286        loop {
287            if self.row >= rows {
288                return None;
289            }
290            let rectangle = self.grid.tile(self.column, self.row);
291            self.column += 1;
292            if self.column >= columns {
293                self.column = 0;
294                self.row += 1;
295            }
296            if let Some(rectangle) = rectangle {
297                return Some(super::super::backend::DisplayBackend::create_frame_mut(
298                    self.cyd, rectangle,
299                ));
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    // Body rectangle used by the dance app: 240×286 starting just below a 34 px
310    // text band, split into a 3×3 tile grid (derived tile size 80×96).
311    const BODY_GRID: TileGrid =
312        TileGrid::new(Rectangle::new(Point::new(0, 34), Size::new(240, 286)), 3, 3);
313
314    #[test]
315    fn exact_fit_columns_and_rows() {
316        assert_eq!(BODY_GRID.columns(), 3);
317        assert_eq!(BODY_GRID.rows(), 3);
318        // 240 / 3 = 80, ceil(286 / 3) = 96.
319        assert_eq!(BODY_GRID.tile_width(), 80);
320        assert_eq!(BODY_GRID.tile_height(), 96);
321    }
322
323    #[test]
324    fn final_row_is_clipped() {
325        // Origin y = 34, rectangle height 286 → last row (row 2) starts at offset
326        // 192 and is clipped from 96 to 94 px high.
327        let tile = BODY_GRID.tile(0, 2).expect("tile (0, 2) is in range");
328        assert_eq!(tile.top_left, Point::new(0, 34 + 192));
329        assert_eq!(tile.size.height, 94);
330        assert_eq!(tile.size.width, 80);
331    }
332
333    #[test]
334    fn exact_division_has_no_clipping() {
335        // 240×288 rectangle in a 3×3 grid divides evenly into 80×96 tiles.
336        let grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 288)), 3, 3);
337        assert_eq!(grid.tile_width(), 80);
338        assert_eq!(grid.tile_height(), 96);
339        let tile = grid.tile(2, 2).expect("tile (2, 2) is in range");
340        assert_eq!(tile.size, Size::new(80, 96));
341    }
342
343    #[test]
344    fn final_column_and_row_clipping_for_uneven_dimensions() {
345        // 250×290 rectangle in a 4×4 grid: tile size ceil(250/4)=63, ceil(290/4)=73.
346        // Last column clips to 250 - 3*63 = 61 px, last row to 290 - 3*73 = 71 px.
347        let grid = TileGrid::new(Rectangle::new(Point::new(5, 7), Size::new(250, 290)), 4, 4);
348        assert_eq!(grid.columns(), 4);
349        assert_eq!(grid.rows(), 4);
350        assert_eq!(grid.tile_width(), 63);
351        assert_eq!(grid.tile_height(), 73);
352
353        let last_column = grid.tile(3, 0).expect("tile (3, 0) is in range");
354        assert_eq!(last_column.top_left, Point::new(5 + 189, 7));
355        assert_eq!(last_column.size.width, 61);
356        assert_eq!(last_column.size.height, 73);
357
358        let last_row = grid.tile(0, 3).expect("tile (0, 3) is in range");
359        assert_eq!(last_row.size.height, 71);
360
361        let corner = grid.tile(3, 3).expect("tile (3, 3) is in range");
362        assert_eq!(corner.size, Size::new(61, 71));
363
364        // Out of range in either axis is None.
365        assert_eq!(grid.tile(4, 0), None);
366        assert_eq!(grid.tile(0, 4), None);
367    }
368
369    #[test]
370    fn max_tile_pixel_count_is_full_tile() {
371        assert_eq!(BODY_GRID.max_tile_pixel_count(), 80 * 96);
372
373        // Rectangle smaller in one axis than its single tile still reports the
374        // clipped max: a 1×1 grid over 40×50 has a 40×50 tile.
375        let small = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(40, 50)), 1, 1);
376        assert_eq!(small.max_tile_pixel_count(), 40 * 50);
377    }
378
379    #[test]
380    #[should_panic(expected = "columns must be greater than zero")]
381    fn zero_columns_panics() {
382        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 286)), 0, 3);
383    }
384
385    #[test]
386    #[should_panic(expected = "rows must be greater than zero")]
387    fn zero_rows_panics() {
388        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 286)), 3, 0);
389    }
390
391    #[test]
392    #[should_panic(expected = "columns must not exceed rectangle width")]
393    fn too_many_columns_panics() {
394        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(4, 286)), 5, 3);
395    }
396
397    #[test]
398    #[should_panic(expected = "rows must not exceed rectangle height")]
399    fn too_many_rows_panics() {
400        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 4)), 3, 5);
401    }
402
403    #[test]
404    fn text_band_pixel_count() {
405        let text_band = Rectangle::new(Point::new(0, 0), Size::new(240, 34));
406        assert_eq!(
407            (text_band.size.width * text_band.size.height) as usize,
408            8160
409        );
410    }
411
412    #[test]
413    fn tile_grid_is_row_major() {
414        // Row-major walk over (column, row): each row left-to-right, top-to-bottom.
415        let top_left = |column, row| BODY_GRID.tile(column, row).expect("tile in range").top_left;
416        assert_eq!(top_left(0, 0), Point::new(0, 34));
417        assert_eq!(top_left(1, 0), Point::new(80, 34));
418        assert_eq!(top_left(2, 0), Point::new(160, 34));
419        assert_eq!(top_left(0, 1), Point::new(0, 34 + 96));
420    }
421}