Skip to main content

escriba_core/
damage.rs

1//! [`Damage`] + [`LineDelta`] — the typed dirty-region boundary.
2//!
3//! The second node of escriba's sealed refresh tree (`theory/ESCRIBA.md` §X),
4//! below [`EditGen`](crate::EditGen): where `EditGen` answers *did anything
5//! change?*, `Damage` answers *what region changed?* so the renderer can scope
6//! its work (re-shape / scissored present) to that region instead of the whole
7//! document.
8//!
9//! The invariant it seals (S3): **`Damage ⊇ the actually-changed region`**.
10//! The type only ever *widens* (the [`join`](Damage::join) is a
11//! join-semilattice with `Full` as top), so the producer can be conservative
12//! and a consumer that repaints `Damage` never misses a changed cell. An
13//! under-approximation — a changed line the renderer skips — has no
14//! constructible path: there is no operation that *narrows* `Damage`.
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19/// How the line count changed under one edit — the scope signal a buffer
20/// mutation reports up to the refresh tree. `start` is the first line the edit
21/// touched; the counts let a consumer tell an in-place edit (`old == new`,
22/// damage is local) from one that shifted every line below (`old != new`,
23/// damage runs to the end of the document).
24#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize, JsonSchema)]
25pub struct LineDelta {
26    pub start: u32,
27    pub old_lines: u32,
28    pub new_lines: u32,
29}
30
31impl LineDelta {
32    /// Did this edit change the number of lines? If so, every line below
33    /// `start` shifted and the damage runs to the end of the document.
34    #[must_use]
35    pub fn shifted_below(self) -> bool {
36        self.old_lines != self.new_lines
37    }
38}
39
40/// The dirty region of the document to refresh. A join-semilattice ordered
41/// `None ⊑ Lines ⊑ Viewport ⊑ Full`, so combining two damages always yields
42/// one that covers both (never less). Line ranges are inclusive `[from, to]`;
43/// `to == u32::MAX` means "to the end of the document" (used when an edit
44/// shifted every line below it).
45#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize, JsonSchema)]
46pub enum Damage {
47    /// Nothing changed — an idle frame reuses everything.
48    #[default]
49    None,
50    /// The inclusive line range `[from, to]` changed.
51    Lines { from: u32, to: u32 },
52    /// The whole visible region must repaint (scroll / resize), but the
53    /// document's shaped content is unchanged.
54    Viewport,
55    /// Everything must be recomputed (buffer swap, theme change, full reflow).
56    Full,
57}
58
59impl Damage {
60    /// A single changed line.
61    #[must_use]
62    pub fn line(n: u32) -> Self {
63        Damage::Lines { from: n, to: n }
64    }
65
66    /// An inclusive changed line span (order-normalized).
67    #[must_use]
68    pub fn span(a: u32, b: u32) -> Self {
69        Damage::Lines {
70            from: a.min(b),
71            to: a.max(b),
72        }
73    }
74
75    /// The changed span implied by a [`LineDelta`]: local if the line count
76    /// held, otherwise to end-of-document (every line below shifted).
77    #[must_use]
78    pub fn from_delta(d: LineDelta) -> Self {
79        if d.shifted_below() {
80            Damage::Lines {
81                from: d.start,
82                to: u32::MAX,
83            }
84        } else {
85            Damage::line(d.start)
86        }
87    }
88
89    /// The lattice join: the smallest [`Damage`] covering both `self` and
90    /// `other`. Widening only — the seal that keeps `Damage ⊇ changed`.
91    #[must_use]
92    pub fn join(self, other: Damage) -> Damage {
93        use Damage::{Full, Lines, None, Viewport};
94        match (self, other) {
95            (Full, _) | (_, Full) => Full,
96            (Viewport, _) | (_, Viewport) => Viewport,
97            (None, x) | (x, None) => x,
98            (
99                Lines {
100                    from: f0,
101                    to: t0,
102                },
103                Lines {
104                    from: f1,
105                    to: t1,
106                },
107            ) => Lines {
108                from: f0.min(f1),
109                to: t0.max(t1),
110            },
111        }
112    }
113
114    /// Is there anything to repaint?
115    #[must_use]
116    pub fn is_none(self) -> bool {
117        matches!(self, Damage::None)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn join_is_widening_and_full_is_top() {
127        assert_eq!(Damage::None.join(Damage::line(3)), Damage::line(3));
128        assert_eq!(Damage::Full.join(Damage::Viewport), Damage::Full);
129        assert_eq!(Damage::Viewport.join(Damage::line(3)), Damage::Viewport);
130        assert_eq!(
131            Damage::span(2, 4).join(Damage::span(3, 9)),
132            Damage::Lines { from: 2, to: 9 },
133            "two line spans union",
134        );
135    }
136
137    #[test]
138    fn join_is_idempotent_and_commutative() {
139        let d = Damage::span(1, 5);
140        assert_eq!(d.join(d), d, "idempotent");
141        assert_eq!(
142            Damage::Viewport.join(d),
143            d.join(Damage::Viewport),
144            "commutative",
145        );
146    }
147
148    #[test]
149    fn line_count_change_runs_to_end() {
150        let d = LineDelta {
151            start: 10,
152            old_lines: 40,
153            new_lines: 41,
154        };
155        assert!(d.shifted_below());
156        assert_eq!(
157            Damage::from_delta(d),
158            Damage::Lines {
159                from: 10,
160                to: u32::MAX,
161            },
162        );
163    }
164
165    #[test]
166    fn in_place_edit_is_local() {
167        let d = LineDelta {
168            start: 10,
169            old_lines: 40,
170            new_lines: 40,
171        };
172        assert!(!d.shifted_below());
173        assert_eq!(Damage::from_delta(d), Damage::line(10));
174    }
175}