Skip to main content

rlvgl_core/
invalidation.rs

1//! Shared invalidation planner and present-plan types (LPAR-03).
2//!
3//! Implements the invalidation model ratified in
4//! `docs/concepts/LPAR-03-INVALIDATION-DISPLAY.md`: dirty rectangles are
5//! **logical**-coordinate [`Rect`]s, clipped to the logical screen bounds,
6//! collected into a bounded fixed-capacity list, and resolved into a
7//! [`PresentPlan`] of no-op / full-frame / partial rects. Capacity overflow
8//! promotes the plan to full frame rather than silently dropping rects.
9//!
10//! # Merge strategy (deterministic)
11//!
12//! [`InvalidationList::push`] uses a *first-overlap absorb-and-cascade*
13//! merge: the incoming rect (after screen clipping) repeatedly absorbs the
14//! first stored rect it overlaps — unioning it in and rescanning from the
15//! start — until no stored rect overlaps, then appends the result. Stored
16//! rects are therefore pairwise disjoint, and the final plan is a pure
17//! function of the input push sequence: the same sequence always produces
18//! the same plan. Surviving rects present in insertion order; a merged rect
19//! takes the queue position of its most recent contributing push. Because
20//! both operands of every union are already clipped to the screen, merged
21//! rects can never extend outside screen bounds (LPAR-03 §5.5).
22//!
23//! [`BufferedInvalidation`] layers the LPAR-03 §9.6 target-buffer retention
24//! rule on top: with `K` framebuffer targets, a dirty rect (or a full-frame
25//! promotion) stays in the present plan for `K` consecutive presents so
26//! every buffer in the rotation is repainted before the entry expires.
27
28use crate::widget::Rect;
29
30/// Zero-area placeholder used to initialize fixed-capacity rect storage.
31const EMPTY_RECT: Rect = Rect {
32    x: 0,
33    y: 0,
34    width: 0,
35    height: 0,
36};
37
38/// Final presentation decision for one frame (LPAR-03 §5, §10).
39///
40/// Borrowed from the planner that produced it; the rect slice lives inside
41/// the [`InvalidationList`] / [`BufferedInvalidation`] storage.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum PresentPlan<'a> {
44    /// No dirty pixels and no forced repaint. The runtime MUST NOT flush
45    /// anything for this frame (LPAR-03 §5.3).
46    None,
47    /// Repaint and flush the full logical screen. This is a first-class
48    /// plan, required on overflow or unbounded dirty sources (LPAR-03 §5.4).
49    FullFrame,
50    /// Flush exactly these logical dirty rects, in order. Rects are clipped
51    /// to screen bounds and pairwise disjoint under the default merge.
52    Rects(&'a [Rect]),
53}
54
55/// Object-safe adapter for components that report dirty regions
56/// (LPAR-03 §10): animations, scroll views, compositor restores, object
57/// mutations, and similar surfaces.
58pub trait InvalidationSource {
59    /// Report every pending dirty rect to `sink`, in a deterministic order.
60    ///
61    /// Implementations may drain internal dirty state; callers feed the
62    /// rects into an [`InvalidationList`] or [`BufferedInvalidation`].
63    fn collect_dirty(&mut self, sink: &mut dyn FnMut(Rect));
64}
65
66/// Bounded logical dirty-rect list with screen clipping, deterministic
67/// merging, and overflow-to-full-frame promotion (LPAR-03 §5, §10).
68///
69/// `N` is the fixed rect capacity; no allocation is performed. See the
70/// [module docs](self) for the merge strategy and its determinism argument.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct InvalidationList<const N: usize> {
73    screen: Rect,
74    rects: [Rect; N],
75    len: usize,
76    full_frame: bool,
77}
78
79impl<const N: usize> InvalidationList<N> {
80    /// Create an empty list clipping against the given logical screen rect.
81    pub const fn new(screen: Rect) -> Self {
82        Self {
83            screen,
84            rects: [EMPTY_RECT; N],
85            len: 0,
86            full_frame: false,
87        }
88    }
89
90    /// Create an empty list for a logical screen of `width` x `height`
91    /// pixels anchored at the origin.
92    pub const fn with_size(width: i32, height: i32) -> Self {
93        Self::new(Rect {
94            x: 0,
95            y: 0,
96            width,
97            height,
98        })
99    }
100
101    /// Logical screen rect this list clips against.
102    pub const fn screen(&self) -> Rect {
103        self.screen
104    }
105
106    /// Number of stored (clipped, merged) dirty rects.
107    ///
108    /// Zero while empty or after a full-frame promotion subsumed the rects.
109    pub const fn len(&self) -> usize {
110        self.len
111    }
112
113    /// Returns `true` when no dirty rects are stored.
114    ///
115    /// Note that a full-frame promotion empties the rect storage; check
116    /// [`is_full_frame`](Self::is_full_frame) or [`plan`](Self::plan) for
117    /// the actual frame decision.
118    pub const fn is_empty(&self) -> bool {
119        self.len == 0
120    }
121
122    /// Returns `true` once the list has been promoted to a full-frame plan.
123    pub const fn is_full_frame(&self) -> bool {
124        self.full_frame
125    }
126
127    /// Add a logical dirty rect.
128    ///
129    /// The rect is clipped to the screen bounds; degenerate (non-positive
130    /// width/height) or fully outside rects are dropped. The clipped rect
131    /// is merged via the deterministic first-overlap absorb-and-cascade
132    /// strategy (see [module docs](self)). If the list is at capacity and
133    /// the rect cannot merge, the plan is promoted to full frame — rects
134    /// are never silently dropped (LPAR-03 §5.5).
135    pub fn push(&mut self, rect: Rect) {
136        if self.full_frame {
137            return;
138        }
139        let Some(clipped) = self.screen.intersect(rect) else {
140            return;
141        };
142        let merged = absorb_overlaps(&mut self.rects, &mut self.len, clipped);
143        if self.len == N {
144            self.mark_full_frame();
145        } else {
146            self.rects[self.len] = merged;
147            self.len += 1;
148        }
149    }
150
151    /// Add an optional dirty rect; `None` is a no-op.
152    ///
153    /// Convenience for `Option<Rect>`-shaped sources such as
154    /// `ScrollView::take_dirty()` and `CommandList::dirty_union()`.
155    pub fn push_opt(&mut self, rect: Option<Rect>) {
156        if let Some(rect) = rect {
157            self.push(rect);
158        }
159    }
160
161    /// Add every rect in `rects`, in order.
162    ///
163    /// Convenience for slice-shaped sources such as
164    /// [`Animations::dirty_rects`](crate::anim::Animations::dirty_rects).
165    pub fn extend_from_slice(&mut self, rects: &[Rect]) {
166        for &rect in rects {
167            self.push(rect);
168        }
169    }
170
171    /// Drain an [`InvalidationSource`] into this list.
172    pub fn gather(&mut self, source: &mut dyn InvalidationSource) {
173        source.collect_dirty(&mut |rect| self.push(rect));
174    }
175
176    /// Promote this frame to a full-frame repaint.
177    ///
178    /// Stored rects are subsumed and discarded; subsequent pushes are
179    /// no-ops until [`clear`](Self::clear).
180    pub fn mark_full_frame(&mut self) {
181        self.full_frame = true;
182        self.len = 0;
183    }
184
185    /// Reset to an empty list, clearing any full-frame promotion.
186    ///
187    /// Call once per frame after the present plan has been consumed.
188    pub fn clear(&mut self) {
189        self.len = 0;
190        self.full_frame = false;
191    }
192
193    /// Resolve the current present plan (LPAR-03 §5.3–§5.4):
194    /// [`PresentPlan::None`] when nothing is dirty, [`PresentPlan::FullFrame`]
195    /// after promotion, otherwise [`PresentPlan::Rects`] in stable order.
196    pub fn plan(&self) -> PresentPlan<'_> {
197        if self.full_frame {
198            PresentPlan::FullFrame
199        } else if self.len == 0 {
200            PresentPlan::None
201        } else {
202            PresentPlan::Rects(&self.rects[..self.len])
203        }
204    }
205}
206
207/// Target-buffer-aware invalidation planner (LPAR-03 §9.6).
208///
209/// `N` is the rect capacity and `K` the framebuffer target count: every
210/// pushed rect — and every full-frame promotion — appears in the present
211/// plan for `K` consecutive presents so each buffer in the rotation is
212/// repainted before the entry expires. A single-buffered runtime uses
213/// `K = 1`; double buffering uses `K = 2` (a caller MAY pass `K + 1` as a
214/// safety margin, matching the compositor's multi-frame restore pattern).
215/// `K` MUST be at least 1; a `K` of 0 is treated as 1.
216///
217/// Clipping, deterministic merging, and overflow promotion follow the same
218/// rules as [`InvalidationList`] (see [module docs](self)). Merging an aged
219/// entry into a fresh push resets the merged region's retention to `K`
220/// presents, which over-invalidates but never under-invalidates.
221///
222/// Per-frame protocol: feed dirty sources via [`push`](Self::push) and
223/// friends, read [`plan`](Self::plan), flush, then call
224/// [`finish_present`](Self::finish_present) exactly once.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub struct BufferedInvalidation<const N: usize, const K: usize> {
227    screen: Rect,
228    rects: [Rect; N],
229    /// Remaining presents for each live entry; parallel to `rects`.
230    ages: [usize; N],
231    len: usize,
232    /// Remaining presents the full-frame promotion covers; `0` = inactive.
233    full_frame_age: usize,
234}
235
236impl<const N: usize, const K: usize> BufferedInvalidation<N, K> {
237    /// Retention applied to new entries and full-frame promotions.
238    const RETAIN: usize = if K == 0 { 1 } else { K };
239
240    /// Create an empty planner clipping against the given logical screen
241    /// rect.
242    pub const fn new(screen: Rect) -> Self {
243        Self {
244            screen,
245            rects: [EMPTY_RECT; N],
246            ages: [0; N],
247            len: 0,
248            full_frame_age: 0,
249        }
250    }
251
252    /// Create an empty planner for a logical screen of `width` x `height`
253    /// pixels anchored at the origin.
254    pub const fn with_size(width: i32, height: i32) -> Self {
255        Self::new(Rect {
256            x: 0,
257            y: 0,
258            width,
259            height,
260        })
261    }
262
263    /// Logical screen rect this planner clips against.
264    pub const fn screen(&self) -> Rect {
265        self.screen
266    }
267
268    /// Number of live (not yet expired) dirty-rect entries.
269    pub const fn len(&self) -> usize {
270        self.len
271    }
272
273    /// Returns `true` when no live dirty-rect entries remain.
274    ///
275    /// A live full-frame promotion empties the rect storage; check
276    /// [`is_full_frame`](Self::is_full_frame) or [`plan`](Self::plan) for
277    /// the actual frame decision.
278    pub const fn is_empty(&self) -> bool {
279        self.len == 0
280    }
281
282    /// Returns `true` while a full-frame promotion is still live.
283    pub const fn is_full_frame(&self) -> bool {
284        self.full_frame_age > 0
285    }
286
287    /// Add a logical dirty rect, retained for `K` consecutive presents.
288    ///
289    /// Same clipping, deterministic merging, and overflow rules as
290    /// [`InvalidationList::push`]; the merged entry's retention is reset to
291    /// `K` presents. Rects pushed while a full-frame promotion is live are
292    /// still recorded: each remaining full-frame present repaints them into
293    /// that buffer (aging them normally), and any retention left after the
294    /// promotion expires presents them as partial rects, so buffers the
295    /// promotion no longer covers are not left stale.
296    pub fn push(&mut self, rect: Rect) {
297        let Some(clipped) = self.screen.intersect(rect) else {
298            return;
299        };
300        let merged = self.absorb_overlaps(clipped);
301        if self.len == N {
302            self.mark_full_frame();
303        } else {
304            self.rects[self.len] = merged;
305            self.ages[self.len] = Self::RETAIN;
306            self.len += 1;
307        }
308    }
309
310    /// Add an optional dirty rect; `None` is a no-op.
311    ///
312    /// Convenience for `Option<Rect>`-shaped sources such as
313    /// `ScrollView::take_dirty()` and `CommandList::dirty_union()`.
314    pub fn push_opt(&mut self, rect: Option<Rect>) {
315        if let Some(rect) = rect {
316            self.push(rect);
317        }
318    }
319
320    /// Add every rect in `rects`, in order.
321    ///
322    /// Convenience for slice-shaped sources such as
323    /// [`Animations::dirty_rects`](crate::anim::Animations::dirty_rects).
324    pub fn extend_from_slice(&mut self, rects: &[Rect]) {
325        for &rect in rects {
326            self.push(rect);
327        }
328    }
329
330    /// Drain an [`InvalidationSource`] into this planner.
331    pub fn gather(&mut self, source: &mut dyn InvalidationSource) {
332        source.collect_dirty(&mut |rect| self.push(rect));
333    }
334
335    /// Promote to a full-frame repaint retained for `K` consecutive
336    /// presents, so every framebuffer target receives the repaint
337    /// (LPAR-03 §9.6).
338    ///
339    /// Live rect entries are subsumed and discarded: each had at most `K`
340    /// presents remaining, and the next `K` presents are all full frame.
341    /// (Rects pushed *after* this promotion are recorded, not subsumed —
342    /// see [`push`](Self::push).)
343    pub fn mark_full_frame(&mut self) {
344        self.full_frame_age = Self::RETAIN;
345        self.len = 0;
346    }
347
348    /// Reset to an empty planner, dropping pending retention state.
349    ///
350    /// Unlike [`InvalidationList::clear`], this discards entries that other
351    /// framebuffer targets still needed; use it only when every buffer is
352    /// known clean (for example, after an out-of-band full repaint of all
353    /// targets). The normal per-frame step is
354    /// [`finish_present`](Self::finish_present).
355    pub fn clear(&mut self) {
356        self.len = 0;
357        self.full_frame_age = 0;
358    }
359
360    /// Resolve the present plan for the current present:
361    /// [`PresentPlan::FullFrame`] while a promotion is live,
362    /// [`PresentPlan::None`] when nothing is dirty, otherwise every live
363    /// entry as [`PresentPlan::Rects`] in stable order.
364    pub fn plan(&self) -> PresentPlan<'_> {
365        if self.full_frame_age > 0 {
366            PresentPlan::FullFrame
367        } else if self.len == 0 {
368            PresentPlan::None
369        } else {
370            PresentPlan::Rects(&self.rects[..self.len])
371        }
372    }
373
374    /// Record that one present (one buffer flip/flush pass) completed.
375    ///
376    /// Decrements every live entry's remaining-presents age and the
377    /// full-frame age, dropping entries that have now been presented to all
378    /// `K` targets. Call exactly once per present, after consuming
379    /// [`plan`](Self::plan).
380    pub fn finish_present(&mut self) {
381        if self.full_frame_age > 0 {
382            self.full_frame_age -= 1;
383        }
384        let mut keep = 0;
385        for i in 0..self.len {
386            let age = self.ages[i] - 1;
387            if age > 0 {
388                self.rects[keep] = self.rects[i];
389                self.ages[keep] = age;
390                keep += 1;
391            }
392        }
393        self.len = keep;
394    }
395
396    /// First-overlap absorb-and-cascade merge over the live entries; the
397    /// absorbed entries' (shorter or equal) retention is covered by the
398    /// caller assigning the merged entry a fresh `K`-present age.
399    fn absorb_overlaps(&mut self, mut rect: Rect) -> Rect {
400        let mut i = 0;
401        while i < self.len {
402            if self.rects[i].intersect(rect).is_some() {
403                rect = rect.union(self.rects[i]);
404                self.rects.copy_within(i + 1..self.len, i);
405                self.ages.copy_within(i + 1..self.len, i);
406                self.len -= 1;
407                i = 0;
408            } else {
409                i += 1;
410            }
411        }
412        rect
413    }
414}
415
416/// First-overlap absorb-and-cascade merge used by [`InvalidationList`]:
417/// `rect` absorbs (unions and removes) the first stored rect it overlaps,
418/// rescanning from the start until no overlap remains, and returns the
419/// grown rect for the caller to append. Deterministic for a given input
420/// sequence; keeps stored rects pairwise disjoint.
421fn absorb_overlaps<const N: usize>(rects: &mut [Rect; N], len: &mut usize, mut rect: Rect) -> Rect {
422    let mut i = 0;
423    while i < *len {
424        if rects[i].intersect(rect).is_some() {
425            rect = rect.union(rects[i]);
426            rects.copy_within(i + 1..*len, i);
427            *len -= 1;
428            i = 0;
429        } else {
430            i += 1;
431        }
432    }
433    rect
434}
435
436#[cfg(test)]
437mod tests {
438    use alloc::boxed::Box;
439    use alloc::vec::Vec;
440
441    use super::*;
442    use crate::anim::{Animations, Tween};
443
444    fn rect(x: i32, y: i32, width: i32, height: i32) -> Rect {
445        Rect {
446            x,
447            y,
448            width,
449            height,
450        }
451    }
452
453    #[test]
454    fn push_clips_to_screen_bounds() {
455        let mut list = InvalidationList::<4>::with_size(100, 50);
456        list.push(rect(-10, -10, 30, 30));
457        list.push(rect(90, 40, 30, 30));
458
459        assert_eq!(
460            list.plan(),
461            PresentPlan::Rects(&[rect(0, 0, 20, 20), rect(90, 40, 10, 10)])
462        );
463    }
464
465    #[test]
466    fn push_drops_degenerate_and_outside_rects() {
467        let mut list = InvalidationList::<4>::with_size(100, 50);
468        list.push(rect(10, 10, 0, 20)); // zero width
469        list.push(rect(10, 10, 20, -5)); // negative height
470        list.push(rect(200, 200, 10, 10)); // fully outside
471        list.push(rect(-50, 0, 50, 50)); // touches edge, zero overlap
472
473        assert_eq!(list.plan(), PresentPlan::None);
474        assert!(list.is_empty());
475    }
476
477    #[test]
478    fn empty_list_plans_none() {
479        let list = InvalidationList::<4>::with_size(100, 100);
480        assert_eq!(list.plan(), PresentPlan::None);
481    }
482
483    #[test]
484    fn overlapping_rects_merge_and_cascade() {
485        let mut list = InvalidationList::<4>::with_size(100, 100);
486        // Two disjoint rects, then a bridge overlapping both.
487        list.push(rect(0, 0, 10, 10));
488        list.push(rect(30, 0, 10, 10));
489        list.push(rect(5, 0, 30, 10));
490
491        assert_eq!(list.plan(), PresentPlan::Rects(&[rect(0, 0, 40, 10)]));
492    }
493
494    #[test]
495    fn merge_is_deterministic_for_same_input_sequence() {
496        let sequence = [
497            rect(0, 0, 10, 10),
498            rect(50, 50, 10, 10),
499            rect(5, 5, 10, 10),
500            rect(80, 0, 40, 10),
501            rect(-5, -5, 8, 8),
502        ];
503        let mut a = InvalidationList::<8>::with_size(100, 100);
504        let mut b = InvalidationList::<8>::with_size(100, 100);
505        for &r in &sequence {
506            a.push(r);
507        }
508        for &r in &sequence {
509            b.push(r);
510        }
511        assert_eq!(a.plan(), b.plan());
512        assert!(matches!(a.plan(), PresentPlan::Rects(_)));
513    }
514
515    #[test]
516    fn capacity_overflow_promotes_to_full_frame() {
517        let mut list = InvalidationList::<2>::with_size(100, 100);
518        list.push(rect(0, 0, 10, 10));
519        list.push(rect(20, 0, 10, 10));
520        assert_eq!(list.len(), 2);
521
522        // Disjoint third rect cannot merge: must promote, never drop.
523        list.push(rect(40, 0, 10, 10));
524        assert_eq!(list.plan(), PresentPlan::FullFrame);
525        assert!(list.is_full_frame());
526
527        // Further pushes stay full frame; clear resets.
528        list.push(rect(0, 0, 5, 5));
529        assert_eq!(list.plan(), PresentPlan::FullFrame);
530        list.clear();
531        assert_eq!(list.plan(), PresentPlan::None);
532    }
533
534    #[test]
535    fn multi_rect_present_order_is_stable() {
536        let mut list = InvalidationList::<4>::with_size(200, 200);
537        list.push(rect(100, 100, 10, 10));
538        list.push(rect(0, 0, 10, 10));
539        list.push(rect(50, 50, 10, 10));
540
541        assert_eq!(
542            list.plan(),
543            PresentPlan::Rects(&[
544                rect(100, 100, 10, 10),
545                rect(0, 0, 10, 10),
546                rect(50, 50, 10, 10),
547            ])
548        );
549    }
550
551    #[test]
552    fn mark_full_frame_subsumes_rects() {
553        let mut list = InvalidationList::<4>::with_size(100, 100);
554        list.push(rect(0, 0, 10, 10));
555        list.mark_full_frame();
556        assert_eq!(list.plan(), PresentPlan::FullFrame);
557        assert!(list.is_empty());
558    }
559
560    #[test]
561    fn push_opt_none_is_noop() {
562        let mut list = InvalidationList::<4>::with_size(100, 100);
563        list.push_opt(None);
564        assert_eq!(list.plan(), PresentPlan::None);
565
566        list.push_opt(Some(rect(0, 0, 10, 10)));
567        assert_eq!(list.plan(), PresentPlan::Rects(&[rect(0, 0, 10, 10)]));
568    }
569
570    #[test]
571    fn extend_from_slice_ingests_animation_dirty_rects() {
572        let mut anims = Animations::new();
573        anims.register(
574            Tween::new(0, 8, 4),
575            Box::new(|value| {
576                Some(Rect {
577                    x: value,
578                    y: 0,
579                    width: 10,
580                    height: 10,
581                })
582            }),
583        );
584        anims.tick();
585        let reported: Vec<Rect> = anims.dirty_rects().to_vec();
586        assert_eq!(reported.len(), 1);
587
588        let mut list = InvalidationList::<4>::with_size(100, 100);
589        list.extend_from_slice(anims.dirty_rects());
590        assert_eq!(list.plan(), PresentPlan::Rects(&reported));
591    }
592
593    #[test]
594    fn invalidation_source_gathers_into_list() {
595        struct TwoRects;
596        impl InvalidationSource for TwoRects {
597            fn collect_dirty(&mut self, sink: &mut dyn FnMut(Rect)) {
598                sink(Rect {
599                    x: 0,
600                    y: 0,
601                    width: 10,
602                    height: 10,
603                });
604                sink(Rect {
605                    x: 20,
606                    y: 0,
607                    width: 10,
608                    height: 10,
609                });
610            }
611        }
612
613        let mut list = InvalidationList::<4>::with_size(100, 100);
614        list.gather(&mut TwoRects);
615        assert_eq!(
616            list.plan(),
617            PresentPlan::Rects(&[rect(0, 0, 10, 10), rect(20, 0, 10, 10)])
618        );
619    }
620
621    #[test]
622    fn buffered_rect_persists_exactly_k_presents() {
623        let mut buf = BufferedInvalidation::<4, 2>::with_size(100, 100);
624        buf.push(rect(10, 10, 5, 5));
625
626        // Present 1 (front buffer).
627        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(10, 10, 5, 5)]));
628        buf.finish_present();
629        // Present 2 (back buffer still stale: rect must reappear).
630        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(10, 10, 5, 5)]));
631        buf.finish_present();
632        // Both buffers repainted: entry expired.
633        assert_eq!(buf.plan(), PresentPlan::None);
634    }
635
636    #[test]
637    fn buffered_full_frame_persists_k_presents() {
638        let mut buf = BufferedInvalidation::<4, 2>::with_size(100, 100);
639        buf.mark_full_frame();
640
641        assert_eq!(buf.plan(), PresentPlan::FullFrame);
642        buf.finish_present();
643        // Second buffer also needs the full repaint.
644        assert_eq!(buf.plan(), PresentPlan::FullFrame);
645        buf.finish_present();
646        assert_eq!(buf.plan(), PresentPlan::None);
647    }
648
649    #[test]
650    fn buffered_overflow_promotes_full_frame_for_k_presents() {
651        let mut buf = BufferedInvalidation::<2, 2>::with_size(100, 100);
652        buf.push(rect(0, 0, 10, 10));
653        buf.push(rect(20, 0, 10, 10));
654        buf.push(rect(40, 0, 10, 10)); // overflow
655
656        assert_eq!(buf.plan(), PresentPlan::FullFrame);
657        buf.finish_present();
658        assert_eq!(buf.plan(), PresentPlan::FullFrame);
659        buf.finish_present();
660        assert_eq!(buf.plan(), PresentPlan::None);
661    }
662
663    #[test]
664    fn buffered_push_during_live_full_frame_reaches_all_buffers() {
665        let mut buf = BufferedInvalidation::<4, 2>::with_size(100, 100);
666        buf.mark_full_frame(); // covers presents 1 and 2
667        buf.finish_present(); // buffer A repainted; promotion has 1 left
668
669        // Dirty rect arrives mid-promotion: only buffer B's full-frame
670        // present will paint it; buffer A must get it as a partial rect.
671        buf.push(rect(0, 0, 10, 10));
672        assert_eq!(buf.plan(), PresentPlan::FullFrame);
673        buf.finish_present(); // buffer B repainted; promotion expires
674
675        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(0, 0, 10, 10)]));
676        buf.finish_present(); // buffer A gets the rect
677
678        assert_eq!(buf.plan(), PresentPlan::None);
679    }
680
681    #[test]
682    fn buffered_interleaved_pushes_across_presents() {
683        let mut buf = BufferedInvalidation::<4, 2>::with_size(100, 100);
684        buf.push(rect(0, 0, 10, 10)); // entry A, 2 presents remaining
685
686        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(0, 0, 10, 10)]));
687        buf.finish_present(); // A: 1 remaining
688
689        buf.push(rect(50, 0, 10, 10)); // entry B, 2 presents remaining
690        assert_eq!(
691            buf.plan(),
692            PresentPlan::Rects(&[rect(0, 0, 10, 10), rect(50, 0, 10, 10)])
693        );
694        buf.finish_present(); // A expires, B: 1 remaining
695
696        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(50, 0, 10, 10)]));
697        buf.finish_present(); // B expires
698
699        assert_eq!(buf.plan(), PresentPlan::None);
700    }
701
702    #[test]
703    fn buffered_merge_resets_retention_to_k() {
704        let mut buf = BufferedInvalidation::<4, 2>::with_size(100, 100);
705        buf.push(rect(0, 0, 10, 10));
706        buf.finish_present(); // entry now has 1 present remaining
707
708        // Overlapping push merges and refreshes retention to K = 2.
709        buf.push(rect(5, 0, 10, 10));
710        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(0, 0, 15, 10)]));
711        buf.finish_present();
712        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(0, 0, 15, 10)]));
713        buf.finish_present();
714        assert_eq!(buf.plan(), PresentPlan::None);
715    }
716
717    #[test]
718    fn buffered_clips_and_drops_like_unbuffered() {
719        let mut buf = BufferedInvalidation::<4, 1>::with_size(100, 50);
720        buf.push(rect(-10, -10, 30, 30)); // clipped
721        buf.push(rect(200, 200, 10, 10)); // dropped
722        buf.push_opt(None); // no-op
723
724        assert_eq!(buf.plan(), PresentPlan::Rects(&[rect(0, 0, 20, 20)]));
725        buf.finish_present();
726        assert_eq!(buf.plan(), PresentPlan::None);
727    }
728}