Skip to main content

sequoia_view/
damage.rs

1//! Cross-frame invalidation model.
2//!
3//! `DamageTracker` accumulates marks from a single frame so the runtime can:
4//! - **skip the whole frame** when there is no paint or animation work,
5//! - **limit repaint** to the damage bounding box (Phase 2 — scissor),
6//! - **not skip the frame** while an animation is running (`request_frame`).
7//!
8//! # What triggers a frame
9//!
10//! - `Runtime::request_rebuild` (signals, effects, view state, mount/unmount)
11//!   → `Dirty::LAYOUT | Dirty::CHILDREN`: rebuild in `settle`, then full paint.
12//! - `Runtime::mark_damage(.., Dirty::PAINT)` (hover/press/focus)
13//!   → `PAINT` + element bounding box: paint without rebuild.
14//! - `PaintCtx::request_frame` (animation) → motion: paint the next frame.
15//! - `Runtime::mark_full_damage` (resize, DPI, theme, new root)
16//!   → full viewport and layer cache invalidation.
17//! - `Runtime::layout` with a different viewport → full paint + `LAYOUT`.
18//!
19//! `HIT_TEST` and `DATA` are bookkeeping markers only: they require no pixels
20//! and do not create a frame on their own.
21//!
22//! # Frame phases
23//!
24//! 1. `tick()` → `settle()` coalesces dirty state per element and rebuilds only
25//!    elements with `needs_layout()`. Paint-only marks do not trigger a rebuild.
26//! 2. `layout()` resolves geometry; a viewport change forces a full paint.
27//! 3. `paint_if_dirty()`:
28//!    - takes `take_motion()` (consumes the request from the previous frame),
29//!    - if there is no motion and no `needs_repaint()` → `record_skipped`,
30//!      return `Skipped`,
31//!    - otherwise: paint (partial bbox or full), `after_paint()`, return `Painted`.
32//!
33//! # Aggregation
34//!
35//! - repeated marks of the same element: OR the flag, the element enters the queue once,
36//! - many elements: one `settle`, one paint and one bbox (union) per frame,
37//! - marks outside the viewport still contribute to the bbox until there is `full`/layout.
38
39use sequoia_core::{Dirty, Rect};
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum FrameOutcome {
43    Painted,
44    Skipped,
45}
46
47/// Upper bound on disjoint damage regions kept for one frame.
48///
49/// Extra marks collapse into a single bounding box so the renderer never
50/// records an unbounded number of scissored passes.
51pub(crate) const MAX_DAMAGE_REGIONS: usize = 8;
52
53#[derive(Clone, Debug)]
54pub struct DamageTracker {
55    // — current frame state —
56    flags: Dirty,
57    regions: Vec<Rect>,
58    full: bool,
59
60    // — "motion" (request_frame from the previous frame) —
61    motion_pending: bool,
62
63    // — statistics —
64    frames_painted: u64,
65    frames_skipped: u64,
66}
67
68impl Default for DamageTracker {
69    fn default() -> Self {
70        Self {
71            flags: Dirty::empty(),
72            regions: Vec::new(),
73            full: false,
74            motion_pending: false,
75            frames_painted: 0,
76            frames_skipped: 0,
77        }
78    }
79}
80
81impl DamageTracker {
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    // ──────────────────────────────────────────────────────────
87    // Markers (called during a frame, before paint)
88    // ──────────────────────────────────────────────────────────
89
90    /// Marks a specific area (window coords) as dirty.
91    pub fn mark_rect(&mut self, rect: Rect) {
92        if rect.is_empty() {
93            return;
94        }
95        self.flags |= Dirty::PAINT;
96        if !self.full {
97            self.add_region(rect);
98        }
99    }
100
101    fn add_region(&mut self, rect: Rect) {
102        let mut merged = rect;
103        let mut i = 0;
104        while i < self.regions.len() {
105            if self.regions[i].intersects(merged) {
106                merged = merged.union(self.regions.remove(i));
107            } else {
108                i += 1;
109            }
110        }
111        self.regions.push(merged);
112        if self.regions.len() > MAX_DAMAGE_REGIONS {
113            let all = self.regions.drain(..).fold(Rect::ZERO, Rect::union);
114            self.regions.push(all);
115        }
116    }
117
118    /// Whole viewport needs repainting (resize, DPI, theme, root mount/unmount).
119    pub fn mark_full(&mut self) {
120        self.full = true;
121        self.flags |= Dirty::PAINT | Dirty::SURFACE;
122    }
123
124    /// The change affects measurement/layout (not just pixels).
125    pub fn mark_layout(&mut self) {
126        self.flags |= Dirty::LAYOUT;
127    }
128
129    /// Raw mark (e.g. `Dirty::OVERLAY`, `Dirty::CANVAS`).
130    pub fn mark(&mut self, flags: Dirty) {
131        self.flags |= flags;
132    }
133
134    // ──────────────────────────────────────────────────────────
135    // Motion (animations, spinner, scroll velocity)
136    // ──────────────────────────────────────────────────────────
137
138    /// Called by `PaintCtx::request_frame()` — "paint me in the next frame".
139    pub fn request_frame(&mut self) {
140        self.motion_pending = true;
141    }
142
143    /// Consumes the request from the previous frame. Called once per frame, BEFORE paint.
144    pub fn take_motion(&mut self) -> bool {
145        let was = self.motion_pending;
146        self.motion_pending = false;
147        was
148    }
149
150    // ──────────────────────────────────────────────────────────
151    // Queries
152    // ──────────────────────────────────────────────────────────
153
154    /// No pixels or animation work are pending.
155    ///
156    /// Bookkeeping-only flags (`HIT_TEST`, `DATA`) do not count as a frame.
157    pub fn is_empty(&self) -> bool {
158        !self.motion_pending && !self.flags.needs_repaint()
159    }
160
161    pub fn needs_frame(&self) -> bool {
162        !self.is_empty()
163    }
164
165    pub fn needs_layout(&self) -> bool {
166        self.flags.needs_layout()
167    }
168
169    pub fn needs_paint(&self) -> bool {
170        self.flags.needs_repaint()
171    }
172
173    pub fn flags(&self) -> Dirty {
174        self.flags
175    }
176
177    /// `Some(rect)` = repaint only this area. `None` = full repaint.
178    ///
179    /// Layout changes can move or remove children, so they always require a
180    /// full repaint; a screen-space rect alone cannot describe that damage.
181    /// The 2 px margin covers anti-aliasing; the renderer adds more headroom
182    /// for shadows before using the region as a scissor.
183    ///
184    /// This is the bounding box of [`paint_regions`](Self::paint_regions); use
185    /// the individual regions when the backend supports multiple scissors.
186    pub fn paint_region(&self) -> Option<Rect> {
187        self.paint_regions()
188            .map(|regions| regions.into_iter().fold(Rect::ZERO, Rect::union))
189    }
190
191    /// `Some(regions)` = repaint only these disjoint areas. `None` = full
192    /// repaint.
193    ///
194    /// Each region is inflated by 2 px for anti-aliasing. Regions that overlap
195    /// are merged while marks accumulate; if more than the region cap
196    /// remain, they collapse into one bounding box.
197    pub fn paint_regions(&self) -> Option<Vec<Rect>> {
198        if self.full || self.flags.needs_layout() || self.regions.is_empty() {
199            None
200        } else {
201            Some(
202                self.regions
203                    .iter()
204                    .map(|rect| rect.inflate(2.0, 2.0))
205                    .collect(),
206            )
207        }
208    }
209
210    // ──────────────────────────────────────────────────────────
211    // End of frame
212    // ──────────────────────────────────────────────────────────
213
214    /// Called after paint — resets the current frame state.
215    /// **Does NOT** touch `motion_pending`, because paint may have queued a new request.
216    pub fn after_paint(&mut self) {
217        self.flags = Dirty::empty();
218        self.regions.clear();
219        self.full = false;
220        self.frames_painted += 1;
221    }
222
223    /// Called when a frame is skipped.
224    ///
225    /// Also clears bookkeeping markers (`HIT_TEST`, `DATA`) that require no
226    /// pixels so they do not keep `needs_frame()` alive forever.
227    pub fn record_skipped(&mut self) {
228        self.flags = Dirty::empty();
229        self.regions.clear();
230        self.full = false;
231        self.frames_skipped += 1;
232    }
233
234    /// Full reset (new root, new window).
235    pub fn clear_all(&mut self) {
236        self.flags = Dirty::empty();
237        self.regions.clear();
238        self.full = false;
239        self.motion_pending = false;
240    }
241
242    // ──────────────────────────────────────────────────────────
243    // Statistics
244    // ──────────────────────────────────────────────────────────
245
246    pub fn frames_painted(&self) -> u64 {
247        self.frames_painted
248    }
249
250    pub fn frames_skipped(&self) -> u64 {
251        self.frames_skipped
252    }
253
254    /// Fraction of skipped frames (0.0 – 1.0).
255    pub fn skip_rate(&self) -> f32 {
256        let total = self.frames_painted + self.frames_skipped;
257        if total == 0 {
258            0.0
259        } else {
260            self.frames_skipped as f32 / total as f32
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use sequoia_core::Point;
269
270    fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
271        Rect::from_xywh(x, y, w, h)
272    }
273
274    #[test]
275    fn fresh_tracker_is_empty() {
276        let d = DamageTracker::new();
277        assert!(d.is_empty());
278        assert!(!d.needs_frame());
279        assert!(!d.needs_layout());
280        assert!(!d.needs_paint());
281        assert_eq!(d.paint_region(), None);
282    }
283
284    #[test]
285    fn single_rect_marks_paint_only() {
286        let mut d = DamageTracker::new();
287        d.mark_rect(r(10.0, 10.0, 4.0, 4.0));
288        assert!(!d.is_empty());
289        assert!(!d.needs_layout(), "sam mark_rect nie zmienia layoutu");
290        assert!(d.needs_paint());
291        let region = d.paint_region().unwrap();
292        assert!(region.contains(Point::new(12.0, 12.0)));
293        assert!(region.size.width >= 4.0);
294    }
295
296    #[test]
297    fn disjoint_rects_stay_separate_regions() {
298        let mut d = DamageTracker::new();
299        d.mark_rect(r(0.0, 0.0, 10.0, 10.0));
300        d.mark_rect(r(100.0, 50.0, 4.0, 4.0));
301        let regions = d.paint_regions().unwrap();
302        assert_eq!(regions.len(), 2);
303        assert!(regions[0].right() < regions[1].left());
304        assert!(regions[0].bottom() < regions[1].top());
305
306        let bbox = d.paint_region().unwrap();
307        assert!(bbox.left() <= 0.0);
308        assert!(bbox.right() >= 104.0);
309        assert!(bbox.top() <= 0.0);
310        assert!(bbox.bottom() >= 54.0);
311    }
312
313    #[test]
314    fn overlapping_rects_merge_into_one_region() {
315        let mut d = DamageTracker::new();
316        d.mark_rect(r(0.0, 0.0, 10.0, 10.0));
317        d.mark_rect(r(5.0, 5.0, 10.0, 10.0));
318        let regions = d.paint_regions().unwrap();
319        assert_eq!(regions.len(), 1);
320        assert!(regions[0].contains(Point::new(12.0, 12.0)));
321    }
322
323    #[test]
324    fn too_many_regions_collapse_to_bbox() {
325        let mut d = DamageTracker::new();
326        for i in 0..=MAX_DAMAGE_REGIONS {
327            d.mark_rect(r(i as f32 * 10.0, 0.0, 1.0, 1.0));
328        }
329        let regions = d.paint_regions().unwrap();
330        assert_eq!(regions.len(), 1, "bounded number of scissor passes");
331        assert!(regions[0].right() >= MAX_DAMAGE_REGIONS as f32 * 10.0);
332    }
333
334    #[test]
335    fn empty_rects_are_ignored() {
336        let mut d = DamageTracker::new();
337        d.mark_rect(Rect::ZERO);
338        d.mark_rect(r(5.0, 5.0, 0.0, 0.0));
339        assert!(d.is_empty(), "puste recty nie liczą się");
340    }
341
342    #[test]
343    fn full_wins_over_rects() {
344        let mut d = DamageTracker::new();
345        d.mark_rect(r(0.0, 0.0, 10.0, 10.0));
346        d.mark_full();
347        assert_eq!(d.paint_region(), None, "full = brak partial clip");
348        assert!(d.flags().contains(Dirty::SURFACE));
349    }
350
351    #[test]
352    fn layout_flag_propagates() {
353        let mut d = DamageTracker::new();
354        d.mark_layout();
355        assert!(d.needs_layout());
356        assert!(d.needs_paint(), "layout implies paint");
357        assert_eq!(d.paint_region(), None, "no rect = full paint");
358    }
359
360    #[test]
361    fn layout_with_rect_still_requires_full_paint() {
362        let mut d = DamageTracker::new();
363        d.mark_rect(r(10.0, 10.0, 20.0, 20.0));
364        d.mark_layout();
365        assert_eq!(
366            d.paint_region(),
367            None,
368            "layout changes can move or remove children"
369        );
370    }
371
372    #[test]
373    fn request_frame_forces_next_paint() {
374        let mut d = DamageTracker::new();
375        assert!(!d.needs_frame());
376        d.request_frame();
377        assert!(d.needs_frame(), "motion = ramka do namalowania");
378
379        // take_motion consumes the request
380        assert!(d.take_motion());
381        assert!(!d.take_motion(), "drugie take_motion = false");
382    }
383
384    #[test]
385    fn bookkeeping_flags_do_not_request_a_frame() {
386        let mut d = DamageTracker::new();
387        d.mark(Dirty::HIT_TEST | Dirty::DATA);
388        assert!(!d.needs_paint());
389        assert!(!d.needs_frame(), "hit-test/data alone do not paint pixels");
390
391        d.record_skipped();
392        assert_eq!(d.flags(), Dirty::empty());
393        assert!(!d.needs_frame());
394    }
395
396    #[test]
397    fn paint_without_rect_requests_full_repaint() {
398        let mut d = DamageTracker::new();
399        d.mark(Dirty::PAINT);
400        assert!(d.needs_frame());
401        assert_eq!(
402            d.paint_region(),
403            None,
404            "unknown pixel bounds require a full repaint"
405        );
406    }
407
408    #[test]
409    fn after_paint_clears_rects_but_not_motion() {
410        let mut d = DamageTracker::new();
411        d.mark_rect(r(0.0, 0.0, 10.0, 10.0));
412        d.after_paint();
413        assert!(d.is_empty(), "po klatce puste");
414
415        // paint may have set motion for the next frame
416        d.request_frame();
417        d.after_paint();
418        assert!(d.needs_frame(), "motion z paint nie ginie");
419    }
420
421    #[test]
422    fn clear_all_resets_everything() {
423        let mut d = DamageTracker::new();
424        d.mark_rect(r(0.0, 0.0, 10.0, 10.0));
425        d.mark_layout();
426        d.request_frame();
427        d.clear_all();
428        assert!(d.is_empty());
429        assert!(!d.needs_frame());
430        assert_eq!(d.paint_region(), None);
431    }
432
433    #[test]
434    fn stats_track_painted_and_skipped() {
435        let mut d = DamageTracker::new();
436        d.mark_rect(r(0.0, 0.0, 1.0, 1.0));
437        d.after_paint();
438        d.record_skipped();
439        d.record_skipped();
440        d.mark_rect(r(0.0, 0.0, 1.0, 1.0));
441        d.after_paint();
442        assert_eq!(d.frames_painted(), 2);
443        assert_eq!(d.frames_skipped(), 2);
444        assert!((d.skip_rate() - 0.5).abs() < 1e-6);
445    }
446
447    #[test]
448    fn many_marks_coalesce_into_one_frame() {
449        let mut d = DamageTracker::new();
450        for i in 0..100 {
451            d.mark_rect(r(i as f32, 0.0, 1.0, 1.0));
452        }
453        // 100 marks = one frame
454        d.after_paint();
455        assert_eq!(d.frames_painted(), 1);
456        assert!(d.is_empty());
457    }
458}