device-envoy-core 0.1.4

Shared traits and data types for device-envoy platform crates
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Splits a display region into tiles so drawing needs only one small buffer.
//!
//! The CYD draws into a single shared pixel buffer that is
//! flushed in pieces. These types describe *where* those pieces live in screen
//! coordinates and *how big* the shared buffer must be, without knowing anything
//! about what an app draws into them.
//!
//! The primary type is [`TileGrid`]: callers give it a display region and the
//! number of tile columns and rows. Pass the grid to
//! [`CydDisplay::for_each_tile`] to redraw and flush that region one tile at a
//! time. Use
//! `embedded_graphics::primitives::Rectangle` plus [`max_rectangle_pixel_count`]
//! when sizing a shared buffer around fixed regions.

use embedded_graphics::{
    prelude::{Point, Size},
    primitives::Rectangle,
};

use super::super::CydDisplay;

/// Returns the frame-buffer capacity needed for one rectangular region.
///
/// ```rust,no_run
/// use device_envoy_core::cyd::display::tiling::rectangle_pixel_count;
/// use embedded_graphics::{prelude::{Point, Size}, primitives::Rectangle};
///
/// const STATUS_REGION: Rectangle =
///     Rectangle::new(Point::new(0, 0), Size::new(160, 40));
/// const FRAME_PIXELS: usize = rectangle_pixel_count(STATUS_REGION);
///
/// assert_eq!(FRAME_PIXELS, 6_400);
/// ```
#[must_use]
pub const fn rectangle_pixel_count(rectangle: Rectangle) -> usize {
    (rectangle.size.width * rectangle.size.height) as usize
}

/// Returns the capacity needed to reuse one frame buffer for either rectangle.
///
/// ```rust,no_run
/// use device_envoy_core::cyd::display::tiling::max_rectangle_pixel_count;
/// use embedded_graphics::{prelude::{Point, Size}, primitives::Rectangle};
///
/// const HEADER: Rectangle = Rectangle::new(Point::zero(), Size::new(320, 40));
/// const FOOTER: Rectangle = Rectangle::new(Point::new(0, 210), Size::new(320, 30));
/// const FRAME_PIXELS: usize = max_rectangle_pixel_count(HEADER, FOOTER);
///
/// assert_eq!(FRAME_PIXELS, 12_800);
/// ```
#[must_use]
pub const fn max_rectangle_pixel_count(first: Rectangle, second: Rectangle) -> usize {
    if rectangle_pixel_count(first) > rectangle_pixel_count(second) {
        rectangle_pixel_count(first)
    } else {
        rectangle_pixel_count(second)
    }
}

/// A display region divided into tiles for low-memory drawing.
///
/// [`CydDisplay::for_each_tile`] redraws the same logical-display-coordinate
/// scene into each tile, clipping drawing to that tile before flushing it.
///
/// The tiled region may cover the full display or only a subregion.
/// Some tiles may be smaller when the region does not divide evenly.
///
/// # Example
///
/// ```rust,no_run
/// use device_envoy_core::{
///     UnwrapInfallible,
///     cyd::{
///         CydDisplay,
///         display::{CydFrame, tiling::TileGrid},
///     },
/// };
/// use embedded_graphics::{
///     Drawable,
///     pixelcolor::Rgb565,
///     prelude::{Point, Primitive, RgbColor, Size},
///     primitives::{Circle, Line, PrimitiveStyle, Rectangle},
/// };
///
/// // Tile the entire 320 × 240 display. The grid could instead cover a subregion.
/// const GRID: TileGrid = TileGrid::new(
///     Rectangle::new(Point::zero(), Size::new(320, 240)),
///     4, // columns
///     3, // rows
/// );
/// async fn draw<D: CydDisplay>(display: &mut D) -> Result<(), D::Error> {
///     display
///         .for_each_tile(GRID, |frame| {
///             frame.fill(Rgb565::BLACK);
///             Circle::new(Point::new(85, 45), 150)
///                 .into_styled(PrimitiveStyle::with_fill(Rgb565::BLUE))
///                 .draw(frame)
///                 .unwrap_infallible();
///             Line::new(Point::new(20, 210), Point::new(300, 30))
///                 .into_styled(PrimitiveStyle::with_stroke(Rgb565::YELLOW, 5))
///                 .draw(frame)
///                 .unwrap_infallible();
///             // Outline the current tile's frame rectangle so the tiling is visible.
///             frame
///                 .rectangle()
///                 .into_styled(PrimitiveStyle::with_stroke(Rgb565::WHITE, 1))
///                 .draw(frame)
///                 .unwrap_infallible();
///         })
///         .await
/// }
///
/// assert_eq!(GRID.max_tile_pixel_count(), 80 * 80);
/// ```
///
/// A `320 × 240` region split into `4 × 3` tiles uses one `80 × 80` frame buffer.
/// The white outlines show the individual frames; the scene remains continuous
/// across their boundaries.
#[cfg_attr(
    feature = "doc-images",
    doc = ::embed_doc_image::embed_image!("tile_grid", "docs/assets/tile_grid.png")
)]
#[cfg_attr(
    feature = "doc-images",
    doc = "\n![A circle and diagonal line drawn continuously across a four-by-three tile grid][tile_grid]\n"
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TileGrid {
    rectangle: Rectangle,
    columns: usize,
    rows: usize,
}

impl TileGrid {
    /// Creates a grid splitting `rectangle` into `columns` × `rows` tiles.
    ///
    /// `rectangle.top_left` determines where the tiled region is drawn and
    /// flushed on the display.
    ///
    /// Panics if either count is zero or exceeds the corresponding rectangle
    /// dimension. See the [`TileGrid` example](TileGrid) for construction,
    /// buffer sizing, and tiled drawing.
    #[must_use]
    pub const fn new(rectangle: Rectangle, columns: usize, rows: usize) -> Self {
        assert!(columns > 0, "columns must be greater than zero");
        assert!(rows > 0, "rows must be greater than zero");
        assert!(
            columns <= rectangle.size.width as usize,
            "columns must not exceed rectangle width in pixels"
        );
        assert!(
            rows <= rectangle.size.height as usize,
            "rows must not exceed rectangle height in pixels"
        );
        Self {
            rectangle,
            columns,
            rows,
        }
    }

    /// Returns the display region covered by this grid.
    ///
    #[must_use]
    pub const fn rectangle(&self) -> Rectangle {
        self.rectangle
    }

    /// Number of tile columns the rectangle is split into.
    ///
    #[must_use]
    pub const fn columns(&self) -> usize {
        self.columns
    }

    /// Number of tile rows the rectangle is split into.
    ///
    #[must_use]
    pub const fn rows(&self) -> usize {
        self.rows
    }

    /// Nominal tile width: the rectangle width divided by the column count, rounded up.
    ///
    #[must_use]
    pub const fn tile_width(&self) -> usize {
        (self.rectangle.size.width as usize).div_ceil(self.columns)
    }

    /// Nominal tile height: the rectangle height divided by the row count, rounded up.
    ///
    #[must_use]
    pub const fn tile_height(&self) -> usize {
        (self.rectangle.size.height as usize).div_ceil(self.rows)
    }

    /// Largest pixel count any single tile can have.
    ///
    /// Use this as the reusable frame-buffer capacity for
    /// [`CydDisplay::for_each_tile`]. Edge tiles may be smaller when the region
    /// does not divide evenly.
    ///
    /// See the [`TileGrid` example](TileGrid).
    #[must_use]
    pub const fn max_tile_pixel_count(&self) -> usize {
        let widest = min_usize(self.tile_width(), self.rectangle.size.width as usize);
        let tallest = min_usize(self.tile_height(), self.rectangle.size.height as usize);
        widest * tallest
    }

    /// The tile at `(column, row)` as a [`Rectangle`] in logical display
    /// coordinates, or `None` if it lies outside the rectangle.
    ///
    /// The final column/row of a grid may be narrower/shorter than the nominal
    /// tile size when the rectangle does not divide evenly by the tile counts, so
    /// always use the returned rectangle's `size` rather than the grid's derived
    /// tile size when allocating a frame.
    #[must_use]
    pub(crate) fn tile(&self, column: usize, row: usize) -> Option<Rectangle> {
        let tile_width = self.tile_width();
        let tile_height = self.tile_height();
        let column_offset = column * tile_width;
        let row_offset = row * tile_height;

        let region_width = self.rectangle.size.width as usize;
        let region_height = self.rectangle.size.height as usize;
        if column_offset >= region_width || row_offset >= region_height {
            return None;
        }

        let width = min_usize(tile_width, region_width - column_offset);
        let height = min_usize(tile_height, region_height - row_offset);
        let size = Size::new(width as u32, height as u32);
        let top_left = Point::new(
            self.rectangle.top_left.x + column_offset as i32,
            self.rectangle.top_left.y + row_offset as i32,
        );
        Some(Rectangle::new(top_left, size))
    }
}

const fn min_usize(first: usize, second: usize) -> usize {
    if first < second { first } else { second }
}

/// Internal tile sequence used by `CydDisplay::for_each_tile`.
///
/// Created internally by [`CydDisplay::for_each_tile`]. This deliberately does *not* implement
/// [`Iterator`]: each yielded frame borrows the device's
/// single reusable frame buffer, so only one frame can be live at a time.
/// Iterate with a `while let Some(mut frame) = tiles.next()` loop.
/// See the [tiled draw loop](CydDisplay::for_each_tile).
pub(crate) struct Tiles<'a, C: CydDisplay> {
    cyd: &'a mut C,
    grid: TileGrid,
    column: usize,
    row: usize,
}

impl<'a, C: CydDisplay> Tiles<'a, C> {
    pub(crate) fn new(cyd: &'a mut C, grid: TileGrid) -> Self {
        Self {
            cyd,
            grid,
            column: 0,
            row: 0,
        }
    }
}

impl<C: CydDisplay> Tiles<'_, C> {
    /// Borrow the next tile-backed frame, cleared to the device background
    /// color, or `None` once every tile has been yielded.
    ///
    /// Tiles are visited in row-major order (each row left-to-right), skipping
    /// any `(column, row)` that falls entirely outside the grid rectangle.
    ///
    /// See the [`CydDisplay::for_each_tile` example](CydDisplay::for_each_tile).
    // Each yielded frame borrows the device's single reusable frame buffer, so
    // it cannot implement `Iterator` (whose `next` returns an item that outlives
    // the `&mut self` borrow). The `next` name is the intended call shape, so
    // allow the trait-shape lint here.
    #[allow(clippy::should_implement_trait)]
    pub(crate) fn next(&mut self) -> Option<C::Frame<'_>> {
        let (columns, rows) = (self.grid.columns(), self.grid.rows());
        loop {
            if self.row >= rows {
                return None;
            }
            let rectangle = self.grid.tile(self.column, self.row);
            self.column += 1;
            if self.column >= columns {
                self.column = 0;
                self.row += 1;
            }
            if let Some(rectangle) = rectangle {
                return Some(super::super::backend::DisplayBackend::create_frame_mut(
                    self.cyd, rectangle,
                ));
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Body rectangle used by the dance app: 240×286 starting just below a 34 px
    // text band, split into a 3×3 tile grid (derived tile size 80×96).
    const BODY_GRID: TileGrid =
        TileGrid::new(Rectangle::new(Point::new(0, 34), Size::new(240, 286)), 3, 3);

    #[test]
    fn exact_fit_columns_and_rows() {
        assert_eq!(BODY_GRID.columns(), 3);
        assert_eq!(BODY_GRID.rows(), 3);
        // 240 / 3 = 80, ceil(286 / 3) = 96.
        assert_eq!(BODY_GRID.tile_width(), 80);
        assert_eq!(BODY_GRID.tile_height(), 96);
    }

    #[test]
    fn final_row_is_clipped() {
        // Origin y = 34, rectangle height 286 → last row (row 2) starts at offset
        // 192 and is clipped from 96 to 94 px high.
        let tile = BODY_GRID.tile(0, 2).expect("tile (0, 2) is in range");
        assert_eq!(tile.top_left, Point::new(0, 34 + 192));
        assert_eq!(tile.size.height, 94);
        assert_eq!(tile.size.width, 80);
    }

    #[test]
    fn exact_division_has_no_clipping() {
        // 240×288 rectangle in a 3×3 grid divides evenly into 80×96 tiles.
        let grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 288)), 3, 3);
        assert_eq!(grid.tile_width(), 80);
        assert_eq!(grid.tile_height(), 96);
        let tile = grid.tile(2, 2).expect("tile (2, 2) is in range");
        assert_eq!(tile.size, Size::new(80, 96));
    }

    #[test]
    fn final_column_and_row_clipping_for_uneven_dimensions() {
        // 250×290 rectangle in a 4×4 grid: tile size ceil(250/4)=63, ceil(290/4)=73.
        // Last column clips to 250 - 3*63 = 61 px, last row to 290 - 3*73 = 71 px.
        let grid = TileGrid::new(Rectangle::new(Point::new(5, 7), Size::new(250, 290)), 4, 4);
        assert_eq!(grid.columns(), 4);
        assert_eq!(grid.rows(), 4);
        assert_eq!(grid.tile_width(), 63);
        assert_eq!(grid.tile_height(), 73);

        let last_column = grid.tile(3, 0).expect("tile (3, 0) is in range");
        assert_eq!(last_column.top_left, Point::new(5 + 189, 7));
        assert_eq!(last_column.size.width, 61);
        assert_eq!(last_column.size.height, 73);

        let last_row = grid.tile(0, 3).expect("tile (0, 3) is in range");
        assert_eq!(last_row.size.height, 71);

        let corner = grid.tile(3, 3).expect("tile (3, 3) is in range");
        assert_eq!(corner.size, Size::new(61, 71));

        // Out of range in either axis is None.
        assert_eq!(grid.tile(4, 0), None);
        assert_eq!(grid.tile(0, 4), None);
    }

    #[test]
    fn max_tile_pixel_count_is_full_tile() {
        assert_eq!(BODY_GRID.max_tile_pixel_count(), 80 * 96);

        // Rectangle smaller in one axis than its single tile still reports the
        // clipped max: a 1×1 grid over 40×50 has a 40×50 tile.
        let small = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(40, 50)), 1, 1);
        assert_eq!(small.max_tile_pixel_count(), 40 * 50);
    }

    #[test]
    #[should_panic(expected = "columns must be greater than zero")]
    fn zero_columns_panics() {
        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 286)), 0, 3);
    }

    #[test]
    #[should_panic(expected = "rows must be greater than zero")]
    fn zero_rows_panics() {
        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 286)), 3, 0);
    }

    #[test]
    #[should_panic(expected = "columns must not exceed rectangle width")]
    fn too_many_columns_panics() {
        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(4, 286)), 5, 3);
    }

    #[test]
    #[should_panic(expected = "rows must not exceed rectangle height")]
    fn too_many_rows_panics() {
        let _tile_grid = TileGrid::new(Rectangle::new(Point::new(0, 0), Size::new(240, 4)), 3, 5);
    }

    #[test]
    fn text_band_pixel_count() {
        let text_band = Rectangle::new(Point::new(0, 0), Size::new(240, 34));
        assert_eq!(
            (text_band.size.width * text_band.size.height) as usize,
            8160
        );
    }

    #[test]
    fn tile_grid_is_row_major() {
        // Row-major walk over (column, row): each row left-to-right, top-to-bottom.
        let top_left = |column, row| BODY_GRID.tile(column, row).expect("tile in range").top_left;
        assert_eq!(top_left(0, 0), Point::new(0, 34));
        assert_eq!(top_left(1, 0), Point::new(80, 34));
        assert_eq!(top_left(2, 0), Point::new(160, 34));
        assert_eq!(top_left(0, 1), Point::new(0, 34 + 96));
    }
}