Skip to main content

device_envoy_core/cyd/display/
tiling.rs

1//! Splits the screen or a rectangle into a grid of 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 rectangular body area
9//! and the number of tile columns and rows; it derives the per-tile size with
10//! ceiling division and clips the final column/row to the rectangle edges. See
11//! [`CydDisplay::tiles`] for the canonical tiled draw loop, and use
12//! [`embedded_graphics::primitives::Rectangle`] plus
13//! [`max_rectangle_pixel_count`] when sizing a shared buffer around fixed
14//! regions.
15
16use embedded_graphics::{
17    prelude::{Point, Size},
18    primitives::Rectangle,
19};
20
21use super::super::CydDisplay;
22
23/// Pixel count for a rectangle.
24#[must_use]
25pub const fn rectangle_pixel_count(rectangle: Rectangle) -> usize {
26    (rectangle.size.width * rectangle.size.height) as usize
27}
28
29/// Maximum pixel count of two rectangles.
30#[must_use]
31pub const fn max_rectangle_pixel_count(first: Rectangle, second: Rectangle) -> usize {
32    if rectangle_pixel_count(first) > rectangle_pixel_count(second) {
33        rectangle_pixel_count(first)
34    } else {
35        rectangle_pixel_count(second)
36    }
37}
38
39/// A rectangular body area split into a grid of `columns` × `rows` tiles.
40///
41/// `top_left` and `size` describe the rectangle in screen coordinates; callers
42/// specify how many tile columns and rows to split it into, and the per-tile
43/// size is derived with ceiling division ([`tile_width`](Self::tile_width) /
44/// [`tile_height`](Self::tile_height)). The final column and row are clipped to
45/// the rectangle's right and bottom edges.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct TileGrid {
48    pub top_left: Point,
49    pub size: Size,
50    columns: usize,
51    rows: usize,
52}
53
54impl TileGrid {
55    /// Build a grid splitting `size` into `columns` × `rows` tiles.
56    ///
57    /// Const-asserts that the counts are positive and do not exceed the rectangle's
58    /// pixel dimensions, so an over-fine grid fails to compile. See
59    /// [`CydDisplay::tiles`] for the canonical draw loop that consumes the grid.
60    #[must_use]
61    pub const fn new(top_left: Point, size: Size, columns: usize, rows: usize) -> Self {
62        assert!(columns > 0, "columns must be greater than zero");
63        assert!(rows > 0, "rows must be greater than zero");
64        assert!(
65            columns <= size.width as usize,
66            "columns must not exceed rectangle width in pixels"
67        );
68        assert!(
69            rows <= size.height as usize,
70            "rows must not exceed rectangle height in pixels"
71        );
72        Self {
73            top_left,
74            size,
75            columns,
76            rows,
77        }
78    }
79
80    /// Number of tile columns the rectangle is split into.
81    #[must_use]
82    pub const fn columns(&self) -> usize {
83        self.columns
84    }
85
86    /// Number of tile rows the rectangle is split into.
87    #[must_use]
88    pub const fn rows(&self) -> usize {
89        self.rows
90    }
91
92    /// Nominal tile width: the rectangle width divided by the column count, rounded up.
93    #[must_use]
94    pub const fn tile_width(&self) -> usize {
95        (self.size.width as usize).div_ceil(self.columns)
96    }
97
98    /// Nominal tile height: the rectangle height divided by the row count, rounded up.
99    #[must_use]
100    pub const fn tile_height(&self) -> usize {
101        (self.size.height as usize).div_ceil(self.rows)
102    }
103
104    /// Largest pixel count any single tile can have.
105    ///
106    /// The biggest tile is the top-left one, whose dimensions are the derived tile
107    /// size clipped to the rectangle (in case the rectangle is smaller than one tile).
108    #[must_use]
109    pub const fn max_tile_pixel_count(&self) -> usize {
110        let widest = min_usize(self.tile_width(), self.size.width as usize);
111        let tallest = min_usize(self.tile_height(), self.size.height as usize);
112        widest * tallest
113    }
114
115    /// The tile at `(column, row)` as a [`Rectangle`] in physical-screen
116    /// coordinates, or `None` if it lies outside the rectangle.
117    ///
118    /// The final column/row of a grid may be narrower/shorter than the nominal
119    /// tile size when the rectangle does not divide evenly by the tile counts, so
120    /// always use the returned rectangle's `size` rather than the grid's derived
121    /// tile size when allocating a frame.
122    #[must_use]
123    pub(crate) fn tile(&self, column: usize, row: usize) -> Option<Rectangle> {
124        let tile_width = self.tile_width();
125        let tile_height = self.tile_height();
126        let column_offset = column * tile_width;
127        let row_offset = row * tile_height;
128
129        let region_width = self.size.width as usize;
130        let region_height = self.size.height as usize;
131        if column_offset >= region_width || row_offset >= region_height {
132            return None;
133        }
134
135        let width = min_usize(tile_width, region_width - column_offset);
136        let height = min_usize(tile_height, region_height - row_offset);
137        let size = Size::new(width as u32, height as u32);
138        let top_left = Point::new(
139            self.top_left.x + column_offset as i32,
140            self.top_left.y + row_offset as i32,
141        );
142        Some(Rectangle::new(top_left, size))
143    }
144}
145
146const fn min_usize(first: usize, second: usize) -> usize {
147    if first < second { first } else { second }
148}
149
150/// A lending/streaming iterator over a [`TileGrid`]'s tiles.
151///
152/// Created by [`CydDisplay::tiles`]. This deliberately does *not* implement
153/// [`Iterator`]: each yielded frame borrows the device's
154/// single reusable frame buffer, so only one frame can be live at a time.
155/// Iterate with a `while let Some(mut frame) = tiles.next()` loop.
156pub struct Tiles<'a, C: CydDisplay> {
157    cyd: &'a mut C,
158    grid: TileGrid,
159    column: usize,
160    row: usize,
161}
162
163impl<'a, C: CydDisplay> Tiles<'a, C> {
164    pub(crate) fn new(cyd: &'a mut C, grid: TileGrid) -> Self {
165        Self {
166            cyd,
167            grid,
168            column: 0,
169            row: 0,
170        }
171    }
172}
173
174impl<C: CydDisplay> Tiles<'_, C> {
175    /// Borrow the next tile-backed frame, cleared to the device background
176    /// color, or `None` once every tile has been yielded.
177    ///
178    /// Tiles are visited in row-major order (each row left-to-right), skipping
179    /// any `(column, row)` that falls entirely outside the grid rectangle.
180    // This is a lending iterator: each yielded frame borrows the device's single
181    // reusable frame buffer, so it cannot implement `Iterator` (whose `next`
182    // returns an item that outlives the `&mut self` borrow). The `next` name is
183    // the intended call shape, so allow the trait-shape lint here.
184    #[allow(clippy::should_implement_trait)]
185    pub fn next(&mut self) -> Option<C::Frame<'_>> {
186        let (columns, rows) = (self.grid.columns(), self.grid.rows());
187        loop {
188            if self.row >= rows {
189                return None;
190            }
191            let rectangle = self.grid.tile(self.column, self.row);
192            self.column += 1;
193            if self.column >= columns {
194                self.column = 0;
195                self.row += 1;
196            }
197            if let Some(rectangle) = rectangle {
198                let tile_top_left = rectangle.top_left;
199                return Some(
200                    self.cyd
201                        .frame_mut_with_tile_top_left(rectangle, tile_top_left),
202                );
203            }
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    // Body rectangle used by the dance app: 240×286 starting just below a 34 px
213    // text band, split into a 3×3 tile grid (derived tile size 80×96).
214    const BODY_GRID: TileGrid = TileGrid::new(Point::new(0, 34), Size::new(240, 286), 3, 3);
215
216    #[test]
217    fn exact_fit_columns_and_rows() {
218        assert_eq!(BODY_GRID.columns(), 3);
219        assert_eq!(BODY_GRID.rows(), 3);
220        // 240 / 3 = 80, ceil(286 / 3) = 96.
221        assert_eq!(BODY_GRID.tile_width(), 80);
222        assert_eq!(BODY_GRID.tile_height(), 96);
223    }
224
225    #[test]
226    fn final_row_is_clipped() {
227        // Origin y = 34, rectangle height 286 → last row (row 2) starts at offset
228        // 192 and is clipped from 96 to 94 px high.
229        let tile = BODY_GRID.tile(0, 2).expect("tile (0, 2) is in range");
230        assert_eq!(tile.top_left, Point::new(0, 34 + 192));
231        assert_eq!(tile.size.height, 94);
232        assert_eq!(tile.size.width, 80);
233    }
234
235    #[test]
236    fn exact_division_has_no_clipping() {
237        // 240×288 rectangle in a 3×3 grid divides evenly into 80×96 tiles.
238        let grid = TileGrid::new(Point::new(0, 0), Size::new(240, 288), 3, 3);
239        assert_eq!(grid.tile_width(), 80);
240        assert_eq!(grid.tile_height(), 96);
241        let tile = grid.tile(2, 2).expect("tile (2, 2) is in range");
242        assert_eq!(tile.size, Size::new(80, 96));
243    }
244
245    #[test]
246    fn final_column_and_row_clipping_for_uneven_dimensions() {
247        // 250×290 rectangle in a 4×4 grid: tile size ceil(250/4)=63, ceil(290/4)=73.
248        // Last column clips to 250 - 3*63 = 61 px, last row to 290 - 3*73 = 71 px.
249        let grid = TileGrid::new(Point::new(5, 7), Size::new(250, 290), 4, 4);
250        assert_eq!(grid.columns(), 4);
251        assert_eq!(grid.rows(), 4);
252        assert_eq!(grid.tile_width(), 63);
253        assert_eq!(grid.tile_height(), 73);
254
255        let last_column = grid.tile(3, 0).expect("tile (3, 0) is in range");
256        assert_eq!(last_column.top_left, Point::new(5 + 189, 7));
257        assert_eq!(last_column.size.width, 61);
258        assert_eq!(last_column.size.height, 73);
259
260        let last_row = grid.tile(0, 3).expect("tile (0, 3) is in range");
261        assert_eq!(last_row.size.height, 71);
262
263        let corner = grid.tile(3, 3).expect("tile (3, 3) is in range");
264        assert_eq!(corner.size, Size::new(61, 71));
265
266        // Out of range in either axis is None.
267        assert_eq!(grid.tile(4, 0), None);
268        assert_eq!(grid.tile(0, 4), None);
269    }
270
271    #[test]
272    fn max_tile_pixel_count_is_full_tile() {
273        assert_eq!(BODY_GRID.max_tile_pixel_count(), 80 * 96);
274
275        // Rectangle smaller in one axis than its single tile still reports the
276        // clipped max: a 1×1 grid over 40×50 has a 40×50 tile.
277        let small = TileGrid::new(Point::new(0, 0), Size::new(40, 50), 1, 1);
278        assert_eq!(small.max_tile_pixel_count(), 40 * 50);
279    }
280
281    #[test]
282    #[should_panic(expected = "columns must be greater than zero")]
283    fn zero_columns_panics() {
284        let _ = TileGrid::new(Point::new(0, 0), Size::new(240, 286), 0, 3);
285    }
286
287    #[test]
288    #[should_panic(expected = "rows must be greater than zero")]
289    fn zero_rows_panics() {
290        let _ = TileGrid::new(Point::new(0, 0), Size::new(240, 286), 3, 0);
291    }
292
293    #[test]
294    #[should_panic(expected = "columns must not exceed rectangle width")]
295    fn too_many_columns_panics() {
296        let _ = TileGrid::new(Point::new(0, 0), Size::new(4, 286), 5, 3);
297    }
298
299    #[test]
300    #[should_panic(expected = "rows must not exceed rectangle height")]
301    fn too_many_rows_panics() {
302        let _ = TileGrid::new(Point::new(0, 0), Size::new(240, 4), 3, 5);
303    }
304
305    #[test]
306    fn text_band_pixel_count() {
307        let text_band = Rectangle::new(Point::new(0, 0), Size::new(240, 34));
308        assert_eq!(
309            (text_band.size.width * text_band.size.height) as usize,
310            8160
311        );
312    }
313
314    #[test]
315    fn tile_grid_is_row_major() {
316        // Row-major walk over (column, row): each row left-to-right, top-to-bottom.
317        let top_left = |column, row| BODY_GRID.tile(column, row).expect("tile in range").top_left;
318        assert_eq!(top_left(0, 0), Point::new(0, 34));
319        assert_eq!(top_left(1, 0), Point::new(80, 34));
320        assert_eq!(top_left(2, 0), Point::new(160, 34));
321        assert_eq!(top_left(0, 1), Point::new(0, 34 + 96));
322    }
323}