Skip to main content

damage_rects/
lib.rs

1//! Accumulate dirty rectangles and emit minimal redraw regions for
2//! partial GPU canvas updates.
3//!
4//! Every interactive GPU-rendered app has a choice: redraw the whole
5//! frame every tick, or track which regions changed and redraw only
6//! those. The first is simple but wastes power; the second is what
7//! editors, design tools, terminals, and dashboards actually do.
8//!
9//! This crate is the bookkeeping — you call [`DamageTracker::add`] when
10//! state changes, and at frame time you ask for the union of pending
11//! regions via [`DamageTracker::merged`]. It has no opinion on your
12//! coordinate system, no viewport tracking, and no GPU dependency —
13//! pairs with any 2D renderer (Skia, wgpu, raw Metal, softbuffer).
14//!
15//! # Example
16//!
17//! ```
18//! use damage_rects::{DamageRect, DamageTracker};
19//!
20//! let mut tracker = DamageTracker::new();
21//!
22//! // during state updates:
23//! tracker.add(DamageRect::new(10.0, 20.0, 100.0, 30.0)); // a line changed
24//! tracker.add(DamageRect::new(50.0, 30.0, 80.0, 40.0));  // cursor moved
25//!
26//! // at frame time:
27//! if let Some(redraw_region) = tracker.merged() {
28//!     // render(redraw_region.x, redraw_region.y, redraw_region.width, redraw_region.height)
29//!     tracker.clear();
30//! }
31//! ```
32//!
33//! # Full-damage shortcut
34//!
35//! Some events invalidate the whole viewport (window resize, theme
36//! switch). Call [`DamageTracker::mark_full`] and subsequent
37//! [`DamageTracker::merged`] returns `None`; consult
38//! [`DamageTracker::is_full`] and render the whole viewport in that
39//! case. The flag prevents collecting individual rects you don't need.
40
41#![deny(missing_docs)]
42
43use core::fmt;
44
45/// An axis-aligned rectangle in `f32` space. Coordinate system is up to
46/// the caller — the library treats `y` as "down" only in the sense that
47/// [`DamageRect::bottom`] returns `y + height`.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct DamageRect {
50    /// Left edge.
51    pub x: f32,
52    /// Top edge.
53    pub y: f32,
54    /// Width; must be non-negative.
55    pub width: f32,
56    /// Height; must be non-negative.
57    pub height: f32,
58}
59
60impl DamageRect {
61    /// Construct a new rectangle.
62    ///
63    /// Negative `width` or `height` is not rejected but produces nonsense
64    /// from [`DamageRect::intersects`] and [`DamageRect::union`]; the
65    /// caller is responsible for ensuring non-negativity.
66    #[inline]
67    #[must_use]
68    pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
69        Self {
70            x,
71            y,
72            width,
73            height,
74        }
75    }
76
77    /// Right edge (`x + width`).
78    #[inline]
79    #[must_use]
80    pub fn right(&self) -> f32 {
81        self.x + self.width
82    }
83
84    /// Bottom edge (`y + height`).
85    #[inline]
86    #[must_use]
87    pub fn bottom(&self) -> f32 {
88        self.y + self.height
89    }
90
91    /// Area (`width * height`).
92    #[inline]
93    #[must_use]
94    pub fn area(&self) -> f32 {
95        self.width * self.height
96    }
97
98    /// Whether `self` overlaps `other` (zero-area edge contact returns
99    /// `false`).
100    #[inline]
101    #[must_use]
102    pub fn intersects(&self, other: &DamageRect) -> bool {
103        self.x < other.right()
104            && other.x < self.right()
105            && self.y < other.bottom()
106            && other.y < self.bottom()
107    }
108
109    /// Smallest rectangle that contains both `self` and `other`.
110    #[must_use]
111    pub fn union(&self, other: &DamageRect) -> DamageRect {
112        let x = self.x.min(other.x);
113        let y = self.y.min(other.y);
114        let right = self.right().max(other.right());
115        let bottom = self.bottom().max(other.bottom());
116        DamageRect::new(x, y, right - x, bottom - y)
117    }
118
119    /// Whether the point `(px, py)` lies inside `self`. Matches
120    /// half-open semantics: left/top inclusive, right/bottom exclusive.
121    #[inline]
122    #[must_use]
123    pub fn contains_point(&self, px: f32, py: f32) -> bool {
124        px >= self.x && px < self.right() && py >= self.y && py < self.bottom()
125    }
126}
127
128impl fmt::Display for DamageRect {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        write!(
131            f,
132            "[{:.1}, {:.1}] {:.1}x{:.1}",
133            self.x, self.y, self.width, self.height
134        )
135    }
136}
137
138/// Accumulates dirty rectangles across a frame and emits a merged
139/// redraw region at frame time.
140///
141/// Two orthogonal states:
142///
143/// - **full-damage flag** — set by [`mark_full`](Self::mark_full) when
144///   something invalidates the whole viewport. In this state the
145///   individual rect list is ignored.
146/// - **pending rects** — added via [`add`](Self::add).
147///
148/// [`merged`](Self::merged) unions all pending rects into one;
149/// [`rects`](Self::rects) exposes the raw list for callers that want
150/// to implement their own coalescing strategy (conservative — keep
151/// small rects, aggressive — union everything, or threshold — fall
152/// back to full viewport when dirty area exceeds N% of total).
153#[derive(Debug, Clone, Default)]
154pub struct DamageTracker {
155    rects: Vec<DamageRect>,
156    full: bool,
157}
158
159impl DamageTracker {
160    /// New empty tracker. Initial state: no damage.
161    #[inline]
162    #[must_use]
163    pub const fn new() -> Self {
164        Self {
165            rects: Vec::new(),
166            full: false,
167        }
168    }
169
170    /// New tracker pre-flagged for full-viewport damage. Useful on
171    /// startup or after a window resize.
172    #[inline]
173    #[must_use]
174    pub const fn with_full_damage() -> Self {
175        Self {
176            rects: Vec::new(),
177            full: true,
178        }
179    }
180
181    /// Add a dirty rectangle. Ignored if the full-damage flag is set.
182    pub fn add(&mut self, rect: DamageRect) {
183        if !self.full {
184            self.rects.push(rect);
185        }
186    }
187
188    /// Mark the entire viewport as dirty; clears individual rects.
189    pub fn mark_full(&mut self) {
190        self.rects.clear();
191        self.full = true;
192    }
193
194    /// Reset to no-damage state.
195    pub fn clear(&mut self) {
196        self.rects.clear();
197        self.full = false;
198    }
199
200    /// Whether the full-damage flag is set.
201    #[inline]
202    #[must_use]
203    pub fn is_full(&self) -> bool {
204        self.full
205    }
206
207    /// Whether there is anything to redraw (full flag or any rects).
208    #[inline]
209    #[must_use]
210    pub fn has_damage(&self) -> bool {
211        self.full || !self.rects.is_empty()
212    }
213
214    /// Immutable view of the pending rects (empty if none or if full
215    /// flag is set — check [`is_full`](Self::is_full) first).
216    #[inline]
217    #[must_use]
218    pub fn rects(&self) -> &[DamageRect] {
219        &self.rects
220    }
221
222    /// Union of all pending rects, or `None` if the full flag is set or
223    /// there are no rects.
224    ///
225    /// When the full flag is set, returns `None` — the caller should
226    /// consult [`is_full`](Self::is_full) and render the whole viewport.
227    /// The library doesn't know the viewport size so it cannot produce
228    /// the full-viewport rect on your behalf.
229    #[must_use]
230    pub fn merged(&self) -> Option<DamageRect> {
231        if self.full {
232            return None;
233        }
234        self.rects.iter().copied().reduce(|a, b| a.union(&b))
235    }
236
237    /// Sum of the individual rect areas. **Over-counts overlap** — if
238    /// rects A and B overlap by area X, the returned value is
239    /// `area(A) + area(B)` which double-counts X.
240    ///
241    /// Use this as a cheap upper bound when deciding whether to fall
242    /// back to full viewport redraw via a threshold.
243    #[must_use]
244    pub fn area_upper_bound(&self) -> f32 {
245        self.rects.iter().map(DamageRect::area).sum()
246    }
247
248    /// Number of pending rects (zero if full flag is set).
249    #[inline]
250    #[must_use]
251    pub fn len(&self) -> usize {
252        self.rects.len()
253    }
254
255    /// Whether the pending rect list is empty. Note: can return `true`
256    /// while [`is_full`](Self::is_full) also returns `true` — use
257    /// [`has_damage`](Self::has_damage) for "anything to redraw".
258    #[inline]
259    #[must_use]
260    pub fn is_empty(&self) -> bool {
261        self.rects.is_empty()
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    const EPSILON: f32 = 0.001;
270
271    fn approx_eq(a: f32, b: f32) -> bool {
272        (a - b).abs() < EPSILON
273    }
274
275    #[test]
276    fn new_tracker_has_no_damage() {
277        let t = DamageTracker::new();
278        assert!(!t.is_full());
279        assert!(!t.has_damage());
280        assert!(t.merged().is_none());
281    }
282
283    #[test]
284    fn with_full_damage_sets_flag() {
285        let t = DamageTracker::with_full_damage();
286        assert!(t.is_full());
287        assert!(t.has_damage());
288        assert!(t.merged().is_none()); // documented: full returns None
289    }
290
291    #[test]
292    fn add_then_merged_returns_union() {
293        let mut t = DamageTracker::new();
294        t.add(DamageRect::new(0.0, 0.0, 100.0, 20.0));
295        t.add(DamageRect::new(50.0, 10.0, 100.0, 30.0));
296        let merged = t.merged().expect("has merged");
297        assert!(approx_eq(merged.x, 0.0));
298        assert!(approx_eq(merged.y, 0.0));
299        assert!(approx_eq(merged.right(), 150.0));
300        assert!(approx_eq(merged.bottom(), 40.0));
301    }
302
303    #[test]
304    fn mark_full_clears_individual_rects() {
305        let mut t = DamageTracker::new();
306        t.add(DamageRect::new(0.0, 0.0, 10.0, 10.0));
307        t.add(DamageRect::new(50.0, 50.0, 10.0, 10.0));
308        t.mark_full();
309        assert!(t.is_full());
310        assert_eq!(t.len(), 0);
311    }
312
313    #[test]
314    fn add_is_ignored_when_full() {
315        let mut t = DamageTracker::with_full_damage();
316        t.add(DamageRect::new(0.0, 0.0, 10.0, 10.0));
317        assert!(t.is_full());
318        assert_eq!(t.len(), 0);
319    }
320
321    #[test]
322    fn clear_resets_both_flag_and_rects() {
323        let mut t = DamageTracker::with_full_damage();
324        t.clear();
325        assert!(!t.is_full());
326        assert!(!t.has_damage());
327    }
328
329    #[test]
330    fn area_upper_bound_overcounts_overlap() {
331        let mut t = DamageTracker::new();
332        t.add(DamageRect::new(0.0, 0.0, 10.0, 10.0)); // area 100
333        t.add(DamageRect::new(5.0, 5.0, 10.0, 10.0)); // area 100, overlaps
334        // Actual covered area is ~175; upper bound over-counts by 25.
335        assert!(approx_eq(t.area_upper_bound(), 200.0));
336    }
337
338    #[test]
339    fn single_rect_merges_to_itself() {
340        let mut t = DamageTracker::new();
341        let r = DamageRect::new(1.0, 2.0, 3.0, 4.0);
342        t.add(r);
343        assert_eq!(t.merged(), Some(r));
344    }
345
346    #[test]
347    fn rect_intersects() {
348        let a = DamageRect::new(0.0, 0.0, 10.0, 10.0);
349        let b = DamageRect::new(5.0, 5.0, 10.0, 10.0);
350        assert!(a.intersects(&b));
351        assert!(b.intersects(&a));
352
353        let c = DamageRect::new(20.0, 20.0, 10.0, 10.0);
354        assert!(!a.intersects(&c));
355
356        // Edge contact does not count as intersection.
357        let d = DamageRect::new(10.0, 0.0, 10.0, 10.0);
358        assert!(!a.intersects(&d));
359    }
360
361    #[test]
362    fn rect_union() {
363        let a = DamageRect::new(0.0, 0.0, 10.0, 10.0);
364        let b = DamageRect::new(20.0, 5.0, 10.0, 10.0);
365        let u = a.union(&b);
366        assert!(approx_eq(u.x, 0.0));
367        assert!(approx_eq(u.y, 0.0));
368        assert!(approx_eq(u.right(), 30.0));
369        assert!(approx_eq(u.bottom(), 15.0));
370    }
371
372    #[test]
373    fn contains_point_half_open() {
374        let r = DamageRect::new(0.0, 0.0, 10.0, 10.0);
375        assert!(r.contains_point(0.0, 0.0)); // top-left inclusive
376        assert!(r.contains_point(5.0, 5.0));
377        assert!(!r.contains_point(10.0, 5.0)); // right exclusive
378        assert!(!r.contains_point(5.0, 10.0)); // bottom exclusive
379        assert!(!r.contains_point(-1.0, 5.0));
380    }
381}