Skip to main content

core_s3/display/
sprite.rs

1use core::convert::Infallible;
2
3use embedded_graphics::{
4    Pixel,
5    draw_target::DrawTarget,
6    geometry::{OriginDimensions, Point, Size},
7    pixelcolor::PixelColor,
8    primitives::Rectangle,
9};
10use heapless::Vec;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum DirtySpriteError {
14    BufferTooSmall,
15    InvalidRegionCapacity,
16}
17
18/// Small fixed-capacity dirty rectangle set.
19pub struct RegionSet<const MAX_REGIONS: usize> {
20    regions: Vec<Rectangle, MAX_REGIONS>,
21}
22
23impl<const MAX_REGIONS: usize> RegionSet<MAX_REGIONS> {
24    pub const fn new() -> Self {
25        Self {
26            regions: Vec::new(),
27        }
28    }
29
30    pub fn clear(&mut self) {
31        self.regions.clear();
32    }
33
34    pub fn is_empty(&self) -> bool {
35        self.regions.is_empty()
36    }
37
38    pub fn iter(&self) -> impl Iterator<Item = Rectangle> + '_ {
39        self.regions.iter().copied()
40    }
41
42    pub fn add(&mut self, rect: Rectangle) -> Result<(), DirtySpriteError> {
43        if rect.is_zero_sized() {
44            return Ok(());
45        }
46        if MAX_REGIONS == 0 {
47            return Err(DirtySpriteError::InvalidRegionCapacity);
48        }
49
50        let mut merged = rect;
51        let mut index = 0;
52        while index < self.regions.len() {
53            if intersects_or_touches(self.regions[index], merged) {
54                merged = bounding_rect(self.regions.remove(index), merged);
55                // The enlarged rectangle can now touch an earlier region.
56                index = 0;
57            } else {
58                index += 1;
59            }
60        }
61
62        if self.regions.push(merged).is_err() {
63            let mut all = merged;
64            for region in self.regions.iter().copied() {
65                all = bounding_rect(all, region);
66            }
67            self.regions.clear();
68            // MAX_REGIONS was checked above, so this cannot fail.
69            let _ = self.regions.push(all);
70        }
71        self.regions.as_mut_slice().sort_unstable_by_key(|region| {
72            (
73                region.top_left.y,
74                region.top_left.x,
75                region.size.height,
76                region.size.width,
77            )
78        });
79        Ok(())
80    }
81}
82
83impl<const MAX_REGIONS: usize> Default for RegionSet<MAX_REGIONS> {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89/// Off-screen framebuffer that tracks the rectangles touched by draw calls.
90///
91/// Use a full-screen sprite (`W=320`, `H=240`) when RAM is available, or create
92/// smaller sprites per widget. `N` must be at least `W * H`; it is separate from
93/// `W`/`H` to stay on stable Rust without generic-const arithmetic.
94pub struct DirtySprite<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize>
95where
96    C: PixelColor + Copy + Default,
97{
98    pixels: [C; N],
99    dirty: RegionSet<MAX_REGIONS>,
100}
101
102impl<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize>
103    DirtySprite<C, W, H, N, MAX_REGIONS>
104where
105    C: PixelColor + Copy + Default,
106{
107    pub fn new(clear: C) -> Result<Self, DirtySpriteError> {
108        if N < usize::from(W) * usize::from(H) {
109            return Err(DirtySpriteError::BufferTooSmall);
110        }
111        if MAX_REGIONS == 0 {
112            return Err(DirtySpriteError::InvalidRegionCapacity);
113        }
114
115        Ok(Self {
116            pixels: [clear; N],
117            dirty: RegionSet::new(),
118        })
119    }
120
121    pub fn dirty_regions(&self) -> impl Iterator<Item = Rectangle> + '_ {
122        self.dirty.iter()
123    }
124
125    pub fn clear_dirty(&mut self) {
126        self.dirty.clear();
127    }
128
129    /// Marks the portion of `area` inside the sprite as dirty.
130    pub fn invalidate(&mut self, area: Rectangle) -> Result<(), DirtySpriteError> {
131        self.dirty.add(clip_to_bounds(area, W, H))
132    }
133
134    /// Marks the entire sprite as dirty.
135    pub fn invalidate_all(&mut self) -> Result<(), DirtySpriteError> {
136        self.invalidate(Rectangle::new(Point::zero(), self.size()))
137    }
138
139    pub fn pixel(&self, point: Point) -> Option<C> {
140        self.index(point).map(|idx| self.pixels[idx])
141    }
142
143    /// Returns pixels from a clipped sprite-local region in row-major order.
144    pub fn region_pixels(&self, area: Rectangle) -> SpriteRegionPixels<'_, C> {
145        let clipped = clip_to_bounds(area, W, H);
146        SpriteRegionPixels {
147            pixels: &self.pixels[..usize::from(W) * usize::from(H)],
148            stride: usize::from(W),
149            x: clipped.top_left.x.max(0) as usize,
150            y: clipped.top_left.y.max(0) as usize,
151            width: clipped.size.width as usize,
152            height: clipped.size.height as usize,
153            current_x: 0,
154            current_y: 0,
155        }
156    }
157
158    /// Draws a clipped sprite-local region into another draw target.
159    pub fn draw_region_at<T>(
160        &self,
161        target: &mut T,
162        source_area: Rectangle,
163        dest_top_left: Point,
164    ) -> Result<(), T::Error>
165    where
166        T: DrawTarget<Color = C>,
167    {
168        let clipped = clip_to_bounds(source_area, W, H);
169        if clipped.is_zero_sized() {
170            return Ok(());
171        }
172
173        let offset = clipped.top_left - source_area.top_left;
174        let target_area = Rectangle::new(dest_top_left + offset, clipped.size);
175        target.fill_contiguous(&target_area, self.region_pixels(clipped))
176    }
177
178    pub fn set_pixel(&mut self, point: Point, color: C) -> Result<(), DirtySpriteError> {
179        if let Some(idx) = self.index(point)
180            && self.pixels[idx] != color
181        {
182            self.dirty.add(Rectangle::new(point, Size::new(1, 1)))?;
183            self.pixels[idx] = color;
184        }
185        Ok(())
186    }
187
188    /// Repaint only dirty rectangles into a concrete display draw target.
189    pub fn flush_dirty<T>(&mut self, target: &mut T) -> Result<(), T::Error>
190    where
191        T: DrawTarget<Color = C>,
192    {
193        self.flush_dirty_at(target, Point::zero())
194    }
195
196    /// Repaint only dirty rectangles into a concrete display draw target at `origin`.
197    pub fn flush_dirty_at<T>(&mut self, target: &mut T, origin: Point) -> Result<(), T::Error>
198    where
199        T: DrawTarget<Color = C>,
200    {
201        let mut regions = [None; MAX_REGIONS];
202        for (slot, region) in regions.iter_mut().zip(self.dirty.iter()) {
203            *slot = Some(region);
204        }
205
206        for region in regions.into_iter().flatten() {
207            self.draw_region_at(target, region, origin + region.top_left)?;
208        }
209        self.clear_dirty();
210        Ok(())
211    }
212
213    fn index(&self, point: Point) -> Option<usize> {
214        if point.x < 0 || point.y < 0 || point.x >= i32::from(W) || point.y >= i32::from(H) {
215            return None;
216        }
217
218        Some(point.y as usize * usize::from(W) + point.x as usize)
219    }
220}
221
222impl<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize> OriginDimensions
223    for DirtySprite<C, W, H, N, MAX_REGIONS>
224where
225    C: PixelColor + Copy + Default,
226{
227    fn size(&self) -> Size {
228        Size::new(u32::from(W), u32::from(H))
229    }
230}
231
232impl<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize> DrawTarget
233    for DirtySprite<C, W, H, N, MAX_REGIONS>
234where
235    C: PixelColor + Copy + Default,
236{
237    type Color = C;
238    type Error = Infallible;
239
240    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
241    where
242        I: IntoIterator<Item = Pixel<Self::Color>>,
243    {
244        for Pixel(point, color) in pixels {
245            let _ = self.set_pixel(point, color);
246        }
247        Ok(())
248    }
249
250    fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
251        // Construction guarantees a nonzero region capacity, so invalidation is infallible.
252        let _ = self.invalidate_all();
253        self.pixels[..usize::from(W) * usize::from(H)].fill(color);
254        Ok(())
255    }
256}
257
258/// Iterator over a dirty sprite region in row-major order.
259pub struct SpriteRegionPixels<'a, C>
260where
261    C: PixelColor + Copy + Default,
262{
263    pixels: &'a [C],
264    stride: usize,
265    x: usize,
266    y: usize,
267    width: usize,
268    height: usize,
269    current_x: usize,
270    current_y: usize,
271}
272
273impl<C> Iterator for SpriteRegionPixels<'_, C>
274where
275    C: PixelColor + Copy + Default,
276{
277    type Item = C;
278
279    fn next(&mut self) -> Option<Self::Item> {
280        if self.current_y >= self.height {
281            return None;
282        }
283
284        let index = (self.y + self.current_y) * self.stride + self.x + self.current_x;
285        let color = self.pixels.get(index).copied();
286        self.current_x += 1;
287        if self.current_x >= self.width {
288            self.current_x = 0;
289            self.current_y += 1;
290        }
291        color
292    }
293}
294
295fn clip_to_bounds(rect: Rectangle, width: u16, height: u16) -> Rectangle {
296    let bounds = Rectangle::new(
297        Point::zero(),
298        Size::new(u32::from(width), u32::from(height)),
299    );
300    rect.intersection(&bounds)
301}
302
303fn intersects_or_touches(a: Rectangle, b: Rectangle) -> bool {
304    let a_br = a.bottom_right().unwrap_or(a.top_left);
305    let b_br = b.bottom_right().unwrap_or(b.top_left);
306
307    a.top_left.x <= b_br.x + 1
308        && a_br.x + 1 >= b.top_left.x
309        && a.top_left.y <= b_br.y + 1
310        && a_br.y + 1 >= b.top_left.y
311}
312
313fn bounding_rect(a: Rectangle, b: Rectangle) -> Rectangle {
314    let a_br = a.bottom_right().unwrap_or(a.top_left);
315    let b_br = b.bottom_right().unwrap_or(b.top_left);
316    let min_x = a.top_left.x.min(b.top_left.x);
317    let min_y = a.top_left.y.min(b.top_left.y);
318    let max_x = a_br.x.max(b_br.x);
319    let max_y = a_br.y.max(b_br.y);
320
321    Rectangle::new(
322        Point::new(min_x, min_y),
323        Size::new((max_x - min_x + 1) as u32, (max_y - min_y + 1) as u32),
324    )
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::PrimitiveStyle};
331
332    fn rect(x: i32, y: i32, width: u32, height: u32) -> Rectangle {
333        Rectangle::new(Point::new(x, y), Size::new(width, height))
334    }
335
336    fn regions<const N: usize>(set: &RegionSet<N>) -> std::vec::Vec<Rectangle> {
337        set.iter().collect()
338    }
339
340    #[test]
341    fn keeps_non_overlapping_regions_separate() {
342        let mut set = RegionSet::<4>::new();
343        set.add(rect(0, 0, 1, 1)).unwrap();
344        set.add(rect(3, 3, 1, 1)).unwrap();
345        assert_eq!(regions(&set), std::vec![rect(0, 0, 1, 1), rect(3, 3, 1, 1)]);
346    }
347
348    #[test]
349    fn merges_touching_intersecting_and_transitively_connected_regions() {
350        let mut set = RegionSet::<5>::new();
351        set.add(rect(0, 0, 2, 2)).unwrap();
352        set.add(rect(5, 0, 2, 2)).unwrap();
353        set.add(rect(1, 1, 2, 2)).unwrap();
354        assert_eq!(regions(&set), std::vec![rect(0, 0, 3, 3), rect(5, 0, 2, 2)]);
355
356        set.add(rect(3, 1, 2, 1)).unwrap();
357        assert_eq!(regions(&set), std::vec![rect(0, 0, 7, 3)]);
358    }
359
360    #[test]
361    fn overflow_collapses_every_region_and_new_rectangle_to_a_bounding_box() {
362        let mut set = RegionSet::<2>::new();
363        set.add(rect(0, 1, 1, 1)).unwrap();
364        set.add(rect(4, 4, 1, 1)).unwrap();
365        set.add(rect(8, 0, 2, 1)).unwrap();
366        assert_eq!(regions(&set), std::vec![rect(0, 0, 10, 5)]);
367    }
368
369    #[test]
370    fn rejects_zero_region_capacity() {
371        let err = match DirtySprite::<Rgb565, 2, 2, 4, 0>::new(Rgb565::BLACK) {
372            Ok(_) => panic!("expected region capacity validation to fail"),
373            Err(err) => err,
374        };
375        assert_eq!(err, DirtySpriteError::InvalidRegionCapacity);
376        assert_eq!(
377            RegionSet::<0>::new().add(rect(0, 0, 1, 1)),
378            Err(DirtySpriteError::InvalidRegionCapacity)
379        );
380    }
381
382    #[test]
383    fn invalidate_clips_every_edge_and_ignores_outside_and_empty_areas() {
384        let cases = [
385            (rect(-2, 1, 4, 2), Some(rect(0, 1, 2, 2))),
386            (rect(3, 1, 4, 2), Some(rect(3, 1, 1, 2))),
387            (rect(1, -2, 2, 4), Some(rect(1, 0, 2, 2))),
388            (rect(1, 3, 2, 4), Some(rect(1, 3, 2, 1))),
389            (rect(-5, -5, 2, 2), None),
390            (rect(1, 1, 0, 3), None),
391        ];
392
393        for (area, expected) in cases {
394            let mut sprite = DirtySprite::<Rgb565, 4, 4, 16, 4>::new(Rgb565::BLACK).unwrap();
395            sprite.invalidate(area).unwrap();
396            assert_eq!(
397                sprite.dirty_regions().collect::<std::vec::Vec<_>>(),
398                expected.into_iter().collect::<std::vec::Vec<_>>()
399            );
400        }
401
402        let mut sprite = DirtySprite::<Rgb565, 4, 4, 16, 1>::new(Rgb565::BLACK).unwrap();
403        sprite.invalidate_all().unwrap();
404        assert_eq!(
405            sprite.dirty_regions().collect::<std::vec::Vec<_>>(),
406            std::vec![rect(0, 0, 4, 4)]
407        );
408    }
409
410    #[test]
411    fn merging_is_deterministic_across_insertion_orders() {
412        let inputs = [rect(0, 0, 2, 2), rect(5, 0, 2, 2), rect(2, 0, 3, 2)];
413        let mut forward = RegionSet::<4>::new();
414        let mut reverse = RegionSet::<4>::new();
415        for area in inputs {
416            forward.add(area).unwrap();
417        }
418        for area in inputs.into_iter().rev() {
419            reverse.add(area).unwrap();
420        }
421        assert_eq!(regions(&forward), std::vec![rect(0, 0, 7, 2)]);
422        assert_eq!(regions(&forward), regions(&reverse));
423    }
424
425    #[test]
426    fn tracks_dirty_regions_for_drawn_shapes() {
427        let mut sprite = DirtySprite::<Rgb565, 8, 8, 64, 8>::new(Rgb565::BLACK).unwrap();
428        Rectangle::new(Point::new(1, 2), Size::new(3, 4))
429            .into_styled(PrimitiveStyle::with_fill(Rgb565::WHITE))
430            .draw(&mut sprite)
431            .unwrap();
432
433        assert_eq!(
434            sprite.dirty_regions().collect::<std::vec::Vec<_>>(),
435            std::vec![rect(1, 2, 3, 4)]
436        );
437    }
438
439    #[test]
440    fn rejects_too_small_buffer() {
441        let err = match DirtySprite::<Rgb565, 8, 8, 63, 8>::new(Rgb565::BLACK) {
442            Ok(_) => panic!("expected buffer validation to fail"),
443            Err(err) => err,
444        };
445        assert_eq!(err, DirtySpriteError::BufferTooSmall);
446    }
447
448    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
449    enum TargetError {
450        WriteFailed,
451    }
452
453    struct TestTarget {
454        fail_on_call: Option<usize>,
455        calls: usize,
456        pixels: std::vec::Vec<Pixel<Rgb565>>,
457    }
458
459    impl TestTarget {
460        fn successful() -> Self {
461            Self {
462                fail_on_call: None,
463                calls: 0,
464                pixels: std::vec::Vec::new(),
465            }
466        }
467    }
468
469    impl OriginDimensions for TestTarget {
470        fn size(&self) -> Size {
471            Size::new(8, 8)
472        }
473    }
474
475    impl DrawTarget for TestTarget {
476        type Color = Rgb565;
477        type Error = TargetError;
478
479        fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
480        where
481            I: IntoIterator<Item = Pixel<Self::Color>>,
482        {
483            self.calls += 1;
484            if self.fail_on_call == Some(self.calls) {
485                return Err(TargetError::WriteFailed);
486            }
487            self.pixels.extend(pixels);
488            Ok(())
489        }
490    }
491
492    #[test]
493    fn successful_flush_clears_dirty_state() {
494        let mut sprite = DirtySprite::<Rgb565, 4, 4, 16, 4>::new(Rgb565::BLACK).unwrap();
495        sprite.set_pixel(Point::new(1, 1), Rgb565::WHITE).unwrap();
496        let mut target = TestTarget::successful();
497
498        sprite.flush_dirty(&mut target).unwrap();
499
500        assert!(sprite.dirty_regions().next().is_none());
501        assert_eq!(
502            target.pixels,
503            std::vec![Pixel(Point::new(1, 1), Rgb565::WHITE)]
504        );
505    }
506
507    #[test]
508    fn failed_flush_preserves_all_dirty_state_for_retry() {
509        let mut sprite = DirtySprite::<Rgb565, 4, 4, 16, 4>::new(Rgb565::BLACK).unwrap();
510        sprite.set_pixel(Point::new(0, 0), Rgb565::WHITE).unwrap();
511        sprite.set_pixel(Point::new(3, 3), Rgb565::WHITE).unwrap();
512        let expected = sprite.dirty_regions().collect::<std::vec::Vec<_>>();
513        let mut failing = TestTarget {
514            fail_on_call: Some(2),
515            calls: 0,
516            pixels: std::vec::Vec::new(),
517        };
518
519        assert_eq!(
520            sprite.flush_dirty(&mut failing),
521            Err(TargetError::WriteFailed)
522        );
523        assert_eq!(
524            sprite.dirty_regions().collect::<std::vec::Vec<_>>(),
525            expected
526        );
527
528        let mut retry = TestTarget::successful();
529        sprite.flush_dirty(&mut retry).unwrap();
530        assert!(sprite.dirty_regions().next().is_none());
531        assert_eq!(retry.pixels.len(), 2);
532    }
533}