Skip to main content

stipple_core/
diff.rs

1//! Frame reconciliation: diff two retained [`LayoutNode`] trees to find what
2//! visually changed, so the platform can re-present only the changed region
3//! instead of the whole window.
4//!
5//! The diff is **paint-oriented**: it compares the things that produce pixels —
6//! each node's `bounds`, `decoration`, and text `content` — and ignores the
7//! routing handles (`action`/`focus`/`drag`), which never draw. A node whose
8//! visuals differ contributes the union of its old and new bounds; a node whose
9//! child *count* differs contributes its whole subtree (covering inserts and
10//! removals). Matching children are compared pairwise.
11//!
12//! Damage is always **conservative**: over-reporting a region is merely a
13//! larger repaint, while under-reporting would leave stale pixels on screen, so
14//! every ambiguous case widens the region rather than narrowing it.
15
16use crate::runtime::LayoutNode;
17use stipple_geometry::{Rect, ScaleFactor};
18
19/// What changed between two frames, in **logical** pixels.
20#[derive(Clone, Debug, PartialEq)]
21pub enum Damage {
22    /// Nothing changed; the previous frame is still valid (skip present).
23    None,
24    /// Only these rectangles changed.
25    Regions(Vec<Rect>),
26    /// Everything must be repainted (first frame, resize, or an overlay change
27    /// the tree diff can't localize).
28    Full,
29}
30
31impl Damage {
32    /// `true` if nothing changed.
33    pub fn is_empty(&self) -> bool {
34        matches!(self, Damage::None)
35    }
36
37    /// The single rectangle covering all damage, or `None` for [`Damage::None`].
38    /// [`Damage::Full`] has no finite bound, so it also returns `None`.
39    pub fn bounding(&self) -> Option<Rect> {
40        match self {
41            Damage::Regions(rs) => rs.iter().copied().reduce(|a, b| a.union(b)),
42            _ => None,
43        }
44    }
45
46    /// Convert to physical-pixel rectangles for [`Surface::present`], clamped to
47    /// a `bounds` (the surface size) and rounded outward to whole pixels.
48    ///
49    /// [`Damage::None`] and [`Damage::Full`] both yield an empty list — which the
50    /// [`Surface`] contract reads as "assume everything changed". Callers that
51    /// want to skip a no-op present should check [`Damage::is_empty`] first.
52    ///
53    /// [`Surface`]: stipple_render::Surface
54    /// [`Surface::present`]: stipple_render::Surface::present
55    pub fn to_physical(&self, scale: ScaleFactor, bounds: Rect) -> Vec<Rect> {
56        let Damage::Regions(regions) = self else {
57            return Vec::new();
58        };
59        let s = scale.get();
60        regions
61            .iter()
62            .filter_map(|r| {
63                let phys = Rect::from_xywh(
64                    (r.min_x() * s).floor(),
65                    (r.min_y() * s).floor(),
66                    (r.width() * s).ceil(),
67                    (r.height() * s).ceil(),
68                );
69                // Snap the far edge outward too, then clip to the surface.
70                let snapped = Rect::from_points(
71                    stipple_geometry::Point::new(phys.min_x(), phys.min_y()),
72                    stipple_geometry::Point::new((r.max_x() * s).ceil(), (r.max_y() * s).ceil()),
73                );
74                snapped.intersection(bounds).filter(|c| !c.is_empty())
75            })
76            .collect()
77    }
78}
79
80/// Diff a previously-presented tree against the freshly built one, returning the
81/// damaged region. Pass trees laid out at the same root size; a size change
82/// should be treated as [`Damage::Full`] by the caller.
83pub fn diff_trees(old: &LayoutNode, new: &LayoutNode) -> Damage {
84    let mut regions = Vec::new();
85    diff_node(old, new, &mut regions);
86    if regions.is_empty() {
87        Damage::None
88    } else {
89        Damage::Regions(coalesce(regions))
90    }
91}
92
93/// `true` if two nodes paint the same pixels for themselves (ignoring children).
94///
95/// `caret` and `selection` are included because the focus overlay paints them,
96/// so moving the caret or changing the selection (text otherwise unchanged) must
97/// still damage the node.
98fn visuals_equal(a: &LayoutNode, b: &LayoutNode) -> bool {
99    a.bounds == b.bounds
100        && a.decoration == b.decoration
101        && a.content == b.content
102        && a.caret == b.caret
103        && a.selection == b.selection
104}
105
106fn diff_node(old: &LayoutNode, new: &LayoutNode, out: &mut Vec<Rect>) {
107    if !visuals_equal(old, new) {
108        out.push(old.bounds.union(new.bounds));
109    }
110    if old.children.len() != new.children.len() {
111        // Structural change: a child was inserted or removed. We can't align
112        // the lists cheaply, so repaint the whole subtree (both extents).
113        out.push(old.bounds.union(new.bounds));
114        return;
115    }
116    for (o, n) in old.children.iter().zip(&new.children) {
117        diff_node(o, n, out);
118    }
119}
120
121/// Merge rectangles that overlap or touch into fewer, larger ones. A small
122/// fixed-point loop keeps the region list short without a full rectangle-set
123/// algorithm — over-merging only enlarges the repaint, which is always safe.
124fn coalesce(mut rects: Vec<Rect>) -> Vec<Rect> {
125    let mut merged = true;
126    while merged {
127        merged = false;
128        let mut i = 0;
129        while i < rects.len() {
130            let mut j = i + 1;
131            while j < rects.len() {
132                if overlaps_or_touches(rects[i], rects[j]) {
133                    rects[i] = rects[i].union(rects[j]);
134                    rects.swap_remove(j);
135                    merged = true;
136                } else {
137                    j += 1;
138                }
139            }
140            i += 1;
141        }
142    }
143    rects
144}
145
146/// `true` if the rectangles share area or abut (so their union wastes no space
147/// worth keeping them separate for).
148fn overlaps_or_touches(a: Rect, b: Rect) -> bool {
149    a.min_x() <= b.max_x()
150        && b.min_x() <= a.max_x()
151        && a.min_y() <= b.max_y()
152        && b.min_y() <= a.max_y()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::element::BoxStyle;
159    use crate::runtime::NodeContent;
160    use stipple_render::Color;
161
162    fn node(bounds: Rect, fill: Option<Color>, children: Vec<LayoutNode>) -> LayoutNode {
163        LayoutNode {
164            bounds,
165            decoration: BoxStyle {
166                fill,
167                radius: 0.0,
168                border: None,
169            },
170            content: NodeContent::None,
171            action: None,
172            focus: None,
173            drag: None,
174            context: None,
175            caret: None,
176            selection: None,
177            text_pos: None,
178            wrap: false,
179            scroll: None,
180            clip: false,
181            children,
182        }
183    }
184
185    fn text_node(bounds: Rect, text: &str) -> LayoutNode {
186        LayoutNode {
187            bounds,
188            decoration: BoxStyle::default(),
189            content: NodeContent::Text {
190                text: text.into(),
191                size: 14.0,
192                color: Color::BLACK,
193            },
194            action: None,
195            focus: None,
196            drag: None,
197            context: None,
198            caret: None,
199            selection: None,
200            text_pos: None,
201            wrap: false,
202            scroll: None,
203            clip: false,
204            children: Vec::new(),
205        }
206    }
207
208    fn root(children: Vec<LayoutNode>) -> LayoutNode {
209        node(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), None, children)
210    }
211
212    #[test]
213    fn identical_trees_have_no_damage() {
214        let a = root(vec![text_node(
215            Rect::from_xywh(10.0, 10.0, 40.0, 20.0),
216            "hi",
217        )]);
218        let b = a.clone();
219        assert_eq!(diff_trees(&a, &b), Damage::None);
220        assert!(diff_trees(&a, &b).is_empty());
221    }
222
223    #[test]
224    fn changed_text_damages_only_that_node() {
225        let a = root(vec![
226            text_node(Rect::from_xywh(10.0, 10.0, 40.0, 20.0), "0"),
227            text_node(Rect::from_xywh(10.0, 40.0, 40.0, 20.0), "stable"),
228        ]);
229        let mut b = a.clone();
230        b.children[0] = text_node(Rect::from_xywh(10.0, 10.0, 40.0, 20.0), "1");
231
232        let dmg = diff_trees(&a, &b);
233        // Exactly the first child's box, not the whole 200x100 root.
234        assert_eq!(
235            dmg,
236            Damage::Regions(vec![Rect::from_xywh(10.0, 10.0, 40.0, 20.0)])
237        );
238        let bound = dmg.bounding().unwrap();
239        assert!(bound.width() < 200.0 && bound.height() < 100.0);
240    }
241
242    #[test]
243    fn caret_move_alone_is_damage() {
244        // Text unchanged, only the caret index moves — must still repaint so the
245        // focus overlay's caret bar redraws at the new position.
246        let mut a = root(vec![text_node(Rect::from_xywh(0.0, 0.0, 40.0, 20.0), "ab")]);
247        a.children[0].caret = Some(2);
248        let mut b = a.clone();
249        b.children[0].caret = Some(1);
250        assert_eq!(
251            diff_trees(&a, &b),
252            Damage::Regions(vec![Rect::from_xywh(0.0, 0.0, 40.0, 20.0)])
253        );
254    }
255
256    #[test]
257    fn selection_change_alone_is_damage() {
258        // Text + caret unchanged, only the selection range differs — must
259        // repaint so the highlight redraws.
260        let mut a = root(vec![text_node(
261            Rect::from_xywh(0.0, 0.0, 40.0, 20.0),
262            "abc",
263        )]);
264        a.children[0].selection = Some((0, 1));
265        let mut b = a.clone();
266        b.children[0].selection = Some((0, 3));
267        assert_eq!(
268            diff_trees(&a, &b),
269            Damage::Regions(vec![Rect::from_xywh(0.0, 0.0, 40.0, 20.0)])
270        );
271    }
272
273    #[test]
274    fn fill_change_is_detected() {
275        let a = root(vec![node(
276            Rect::from_xywh(0.0, 0.0, 50.0, 50.0),
277            Some(Color::rgb(10, 10, 10)),
278            vec![],
279        )]);
280        let mut b = a.clone();
281        b.children[0].decoration.fill = Some(Color::rgb(200, 0, 0));
282        assert_eq!(
283            diff_trees(&a, &b),
284            Damage::Regions(vec![Rect::from_xywh(0.0, 0.0, 50.0, 50.0)])
285        );
286    }
287
288    #[test]
289    fn moved_node_damages_old_and_new_position() {
290        let a = root(vec![node(
291            Rect::from_xywh(0.0, 0.0, 20.0, 20.0),
292            Some(Color::BLACK),
293            vec![],
294        )]);
295        let mut b = a.clone();
296        b.children[0].bounds = Rect::from_xywh(80.0, 0.0, 20.0, 20.0);
297        // Union spans from x=0 to x=100.
298        let dmg = diff_trees(&a, &b);
299        assert_eq!(
300            dmg,
301            Damage::Regions(vec![Rect::from_xywh(0.0, 0.0, 100.0, 20.0)])
302        );
303    }
304
305    #[test]
306    fn added_child_repaints_subtree() {
307        let a = root(vec![text_node(Rect::from_xywh(0.0, 0.0, 20.0, 20.0), "a")]);
308        let b = root(vec![
309            text_node(Rect::from_xywh(0.0, 0.0, 20.0, 20.0), "a"),
310            text_node(Rect::from_xywh(0.0, 30.0, 20.0, 20.0), "b"),
311        ]);
312        // Child count differs at the root → repaint the root's extent.
313        assert_eq!(
314            diff_trees(&a, &b),
315            Damage::Regions(vec![Rect::from_xywh(0.0, 0.0, 200.0, 100.0)])
316        );
317    }
318
319    #[test]
320    fn coalesce_merges_touching_regions() {
321        let a = root(vec![
322            node(
323                Rect::from_xywh(0.0, 0.0, 50.0, 20.0),
324                Some(Color::BLACK),
325                vec![],
326            ),
327            node(
328                Rect::from_xywh(50.0, 0.0, 50.0, 20.0),
329                Some(Color::BLACK),
330                vec![],
331            ),
332        ]);
333        let mut b = a.clone();
334        b.children[0].decoration.fill = Some(Color::rgb(1, 2, 3));
335        b.children[1].decoration.fill = Some(Color::rgb(1, 2, 3));
336        // Two abutting damaged boxes coalesce into one.
337        assert_eq!(
338            diff_trees(&a, &b),
339            Damage::Regions(vec![Rect::from_xywh(0.0, 0.0, 100.0, 20.0)])
340        );
341    }
342
343    #[test]
344    fn to_physical_scales_and_clips() {
345        let dmg = Damage::Regions(vec![Rect::from_xywh(10.0, 10.0, 40.0, 20.0)]);
346        let phys = dmg.to_physical(
347            ScaleFactor::new(2.0),
348            Rect::from_xywh(0.0, 0.0, 400.0, 400.0),
349        );
350        assert_eq!(phys, vec![Rect::from_xywh(20.0, 20.0, 80.0, 40.0)]);
351
352        // Full and None carry no rects (caller treats empty as "whole surface").
353        assert!(
354            Damage::Full
355                .to_physical(ScaleFactor::IDENTITY, Rect::from_xywh(0.0, 0.0, 10.0, 10.0))
356                .is_empty()
357        );
358        assert!(
359            Damage::None
360                .to_physical(ScaleFactor::IDENTITY, Rect::from_xywh(0.0, 0.0, 10.0, 10.0))
361                .is_empty()
362        );
363    }
364}