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}