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}