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}