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    TooManyRegions,
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
47        if let Some(existing) = self
48            .regions
49            .iter_mut()
50            .find(|existing| intersects_or_touches(**existing, rect))
51        {
52            *existing = bounding_rect(*existing, rect);
53            self.compact();
54            return Ok(());
55        }
56
57        self.regions
58            .push(rect)
59            .map_err(|_| DirtySpriteError::TooManyRegions)
60    }
61
62    fn compact(&mut self) {
63        let mut i = 0;
64        while i < self.regions.len() {
65            let mut j = i + 1;
66            while j < self.regions.len() {
67                if intersects_or_touches(self.regions[i], self.regions[j]) {
68                    let merged = bounding_rect(self.regions[i], self.regions[j]);
69                    self.regions[i] = merged;
70                    self.regions.swap_remove(j);
71                } else {
72                    j += 1;
73                }
74            }
75            i += 1;
76        }
77    }
78}
79
80impl<const MAX_REGIONS: usize> Default for RegionSet<MAX_REGIONS> {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86/// Off-screen framebuffer that tracks the rectangles touched by draw calls.
87///
88/// Use a full-screen sprite (`W=320`, `H=240`) when RAM is available, or create
89/// smaller sprites per widget. `N` must be at least `W * H`; it is separate from
90/// `W`/`H` to stay on stable Rust without generic-const arithmetic.
91pub struct DirtySprite<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize>
92where
93    C: PixelColor + Copy + Default,
94{
95    pixels: [C; N],
96    dirty: RegionSet<MAX_REGIONS>,
97}
98
99impl<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize>
100    DirtySprite<C, W, H, N, MAX_REGIONS>
101where
102    C: PixelColor + Copy + Default,
103{
104    pub fn new(clear: C) -> Result<Self, DirtySpriteError> {
105        if N < usize::from(W) * usize::from(H) {
106            return Err(DirtySpriteError::BufferTooSmall);
107        }
108
109        Ok(Self {
110            pixels: [clear; N],
111            dirty: RegionSet::new(),
112        })
113    }
114
115    pub fn dirty_regions(&self) -> impl Iterator<Item = Rectangle> + '_ {
116        self.dirty.iter()
117    }
118
119    pub fn clear_dirty(&mut self) {
120        self.dirty.clear();
121    }
122
123    pub fn pixel(&self, point: Point) -> Option<C> {
124        self.index(point).map(|idx| self.pixels[idx])
125    }
126
127    /// Returns pixels from a clipped sprite-local region in row-major order.
128    pub fn region_pixels(&self, area: Rectangle) -> SpriteRegionPixels<'_, C> {
129        let clipped = clip_to_bounds(area, W, H);
130        SpriteRegionPixels {
131            pixels: &self.pixels[..usize::from(W) * usize::from(H)],
132            stride: usize::from(W),
133            x: clipped.top_left.x.max(0) as usize,
134            y: clipped.top_left.y.max(0) as usize,
135            width: clipped.size.width as usize,
136            height: clipped.size.height as usize,
137            current_x: 0,
138            current_y: 0,
139        }
140    }
141
142    /// Draws a clipped sprite-local region into another draw target.
143    pub fn draw_region_at<T>(
144        &self,
145        target: &mut T,
146        source_area: Rectangle,
147        dest_top_left: Point,
148    ) -> Result<(), T::Error>
149    where
150        T: DrawTarget<Color = C>,
151    {
152        let clipped = clip_to_bounds(source_area, W, H);
153        if clipped.is_zero_sized() {
154            return Ok(());
155        }
156
157        let offset = clipped.top_left - source_area.top_left;
158        let target_area = Rectangle::new(dest_top_left + offset, clipped.size);
159        target.fill_contiguous(&target_area, self.region_pixels(clipped))
160    }
161
162    pub fn set_pixel(&mut self, point: Point, color: C) -> Result<(), DirtySpriteError> {
163        if let Some(idx) = self.index(point)
164            && self.pixels[idx] != color
165        {
166            self.pixels[idx] = color;
167            self.dirty.add(Rectangle::new(point, Size::new(1, 1)))?;
168        }
169        Ok(())
170    }
171
172    /// Repaint only dirty rectangles into a concrete display draw target.
173    pub fn flush_dirty<T>(&mut self, target: &mut T) -> Result<(), T::Error>
174    where
175        T: DrawTarget<Color = C>,
176    {
177        self.flush_dirty_at(target, Point::zero())
178    }
179
180    /// Repaint only dirty rectangles into a concrete display draw target at `origin`.
181    pub fn flush_dirty_at<T>(&mut self, target: &mut T, origin: Point) -> Result<(), T::Error>
182    where
183        T: DrawTarget<Color = C>,
184    {
185        let mut regions = [None; MAX_REGIONS];
186        for (slot, region) in regions.iter_mut().zip(self.dirty.iter()) {
187            *slot = Some(region);
188        }
189
190        for region in regions.into_iter().flatten() {
191            self.draw_region_at(target, region, origin + region.top_left)?;
192        }
193        self.clear_dirty();
194        Ok(())
195    }
196
197    fn index(&self, point: Point) -> Option<usize> {
198        if point.x < 0 || point.y < 0 || point.x >= i32::from(W) || point.y >= i32::from(H) {
199            return None;
200        }
201
202        Some(point.y as usize * usize::from(W) + point.x as usize)
203    }
204}
205
206impl<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize> OriginDimensions
207    for DirtySprite<C, W, H, N, MAX_REGIONS>
208where
209    C: PixelColor + Copy + Default,
210{
211    fn size(&self) -> Size {
212        Size::new(u32::from(W), u32::from(H))
213    }
214}
215
216impl<C, const W: u16, const H: u16, const N: usize, const MAX_REGIONS: usize> DrawTarget
217    for DirtySprite<C, W, H, N, MAX_REGIONS>
218where
219    C: PixelColor + Copy + Default,
220{
221    type Color = C;
222    type Error = Infallible;
223
224    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
225    where
226        I: IntoIterator<Item = Pixel<Self::Color>>,
227    {
228        for Pixel(point, color) in pixels {
229            let _ = self.set_pixel(point, color);
230        }
231        Ok(())
232    }
233
234    fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
235        self.pixels[..usize::from(W) * usize::from(H)].fill(color);
236        let _ = self.dirty.add(Rectangle::new(Point::zero(), self.size()));
237        Ok(())
238    }
239}
240
241/// Iterator over a dirty sprite region in row-major order.
242pub struct SpriteRegionPixels<'a, C>
243where
244    C: PixelColor + Copy + Default,
245{
246    pixels: &'a [C],
247    stride: usize,
248    x: usize,
249    y: usize,
250    width: usize,
251    height: usize,
252    current_x: usize,
253    current_y: usize,
254}
255
256impl<C> Iterator for SpriteRegionPixels<'_, C>
257where
258    C: PixelColor + Copy + Default,
259{
260    type Item = C;
261
262    fn next(&mut self) -> Option<Self::Item> {
263        if self.current_y >= self.height {
264            return None;
265        }
266
267        let index = (self.y + self.current_y) * self.stride + self.x + self.current_x;
268        let color = self.pixels.get(index).copied();
269        self.current_x += 1;
270        if self.current_x >= self.width {
271            self.current_x = 0;
272            self.current_y += 1;
273        }
274        color
275    }
276}
277
278fn clip_to_bounds(rect: Rectangle, width: u16, height: u16) -> Rectangle {
279    let bounds = Rectangle::new(
280        Point::zero(),
281        Size::new(u32::from(width), u32::from(height)),
282    );
283    rect.intersection(&bounds)
284}
285
286fn intersects_or_touches(a: Rectangle, b: Rectangle) -> bool {
287    let a_br = a.bottom_right().unwrap_or(a.top_left);
288    let b_br = b.bottom_right().unwrap_or(b.top_left);
289
290    a.top_left.x <= b_br.x + 1
291        && a_br.x + 1 >= b.top_left.x
292        && a.top_left.y <= b_br.y + 1
293        && a_br.y + 1 >= b.top_left.y
294}
295
296fn bounding_rect(a: Rectangle, b: Rectangle) -> Rectangle {
297    let a_br = a.bottom_right().unwrap_or(a.top_left);
298    let b_br = b.bottom_right().unwrap_or(b.top_left);
299    let min_x = a.top_left.x.min(b.top_left.x);
300    let min_y = a.top_left.y.min(b.top_left.y);
301    let max_x = a_br.x.max(b_br.x);
302    let max_y = a_br.y.max(b_br.y);
303
304    Rectangle::new(
305        Point::new(min_x, min_y),
306        Size::new((max_x - min_x + 1) as u32, (max_y - min_y + 1) as u32),
307    )
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::PrimitiveStyle};
314
315    #[test]
316    fn tracks_dirty_regions_for_drawn_shapes() {
317        let mut sprite = DirtySprite::<Rgb565, 8, 8, 64, 8>::new(Rgb565::BLACK).unwrap();
318        Rectangle::new(Point::new(1, 2), Size::new(3, 4))
319            .into_styled(PrimitiveStyle::with_fill(Rgb565::WHITE))
320            .draw(&mut sprite)
321            .unwrap();
322
323        let regions: std::vec::Vec<_> = sprite.dirty_regions().collect();
324        assert_eq!(
325            regions,
326            std::vec![Rectangle::new(Point::new(1, 2), Size::new(3, 4))]
327        );
328    }
329
330    #[test]
331    fn rejects_too_small_buffer() {
332        let err = match DirtySprite::<Rgb565, 8, 8, 63, 8>::new(Rgb565::BLACK) {
333            Ok(_) => panic!("expected buffer validation to fail"),
334            Err(err) => err,
335        };
336        assert_eq!(err, DirtySpriteError::BufferTooSmall);
337    }
338}