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            (Lines { from: f0, to: t0 }, Lines { from: f1, to: t1 }) => Lines {
99                from: f0.min(f1),
100                to: t0.max(t1),
101            },
102        }
103    }
104
105    /// Is there anything to repaint?
106    #[must_use]
107    pub fn is_none(self) -> bool {
108        matches!(self, Damage::None)
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn join_is_widening_and_full_is_top() {
118        assert_eq!(Damage::None.join(Damage::line(3)), Damage::line(3));
119        assert_eq!(Damage::Full.join(Damage::Viewport), Damage::Full);
120        assert_eq!(Damage::Viewport.join(Damage::line(3)), Damage::Viewport);
121        assert_eq!(
122            Damage::span(2, 4).join(Damage::span(3, 9)),
123            Damage::Lines { from: 2, to: 9 },
124            "two line spans union",
125        );
126    }
127
128    #[test]
129    fn join_is_idempotent_and_commutative() {
130        let d = Damage::span(1, 5);
131        assert_eq!(d.join(d), d, "idempotent");
132        assert_eq!(
133            Damage::Viewport.join(d),
134            d.join(Damage::Viewport),
135            "commutative",
136        );
137    }
138
139    #[test]
140    fn line_count_change_runs_to_end() {
141        let d = LineDelta {
142            start: 10,
143            old_lines: 40,
144            new_lines: 41,
145        };
146        assert!(d.shifted_below());
147        assert_eq!(
148            Damage::from_delta(d),
149            Damage::Lines {
150                from: 10,
151                to: u32::MAX,
152            },
153        );
154    }
155
156    #[test]
157    fn in_place_edit_is_local() {
158        let d = LineDelta {
159            start: 10,
160            old_lines: 40,
161            new_lines: 40,
162        };
163        assert!(!d.shifted_below());
164        assert_eq!(Damage::from_delta(d), Damage::line(10));
165    }
166}