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}