lattice_mode/pending_synthetic_highlights.rs
1//! Pending synthetic-buffer highlights mechanism (MG.2).
2//!
3//! A shared service that decouples async refresh tasks (e.g. magit status
4//! buffer rebuild) from the Editor's tick drain. The async task:
5//!
6//! 1. Computes per-line `StyledSpan` vectors.
7//! 2. Stores them in `map` keyed by `BufferId`.
8//! 3. Fires `waker` (the Editor's `async_landed` Notify).
9//!
10//! On the next tick, `Editor::drain_pending_synthetic_highlights` drains
11//! the map into each buffer's `ExtraHighlights` BufferLocal.
12//!
13//! Uses only `tokio` for the waker; `lattice-cells` / `lattice-core` for
14//! the span and buffer-id types. No host or mode dependencies.
15
16use std::collections::HashMap;
17use std::sync::{Arc, Mutex};
18
19use lattice_cells::{RefineSpan, StyledSpan};
20use lattice_core::BufferId;
21
22/// Entry in the pending highlights map: a full replacement, or a
23/// splice (insert or remove) that shifts every subsequent line's
24/// spans to stay aligned with a text edit that inserted/removed
25/// lines at the same position.
26#[derive(Debug, Clone)]
27pub enum HighlightsOp {
28 /// Replace the buffer's whole highlight vector: one entry per line.
29 Replace(Vec<Vec<StyledSpan>>),
30 /// Splice `spans` in at `start_line`, shifting later lines down.
31 InsertAt {
32 /// Zero-based line the first new entry lands on.
33 start_line: u32,
34 /// One entry per inserted line.
35 spans: Vec<Vec<StyledSpan>>,
36 },
37 /// Remove `count` lines of highlights at `start_line`, shifting later
38 /// lines up.
39 RemoveAt {
40 /// Zero-based first removed line.
41 start_line: u32,
42 /// Number of lines removed.
43 count: usize,
44 },
45}
46
47/// One op's worth of published highlighting —
48/// foreground spans plus, optionally, intra-line diff refinement (DR.3, 2026-08-12).
49///
50/// Refinement rides the SAME update rather than a parallel channel,
51/// and that is deliberate. The drain's own comment states the rule for
52/// diff signs: *"deriving rather than carrying signs on a parallel
53/// channel is what makes the tint impossible to desynchronise from the
54/// text — an inline diff expansion shifts spans and signs by
55/// construction, because there is only one thing being shifted."*
56/// A second channel for refinement would reintroduce exactly that
57/// hazard: a `=` expansion inserts lines, and two lists spliced by two
58/// code paths can disagree. One update, one splice.
59///
60/// `refine` is empty for every producer that has none, which is all of
61/// them except magit's diff views.
62#[derive(Debug, Clone)]
63pub struct HighlightsUpdate {
64 /// The foreground-span change.
65 pub op: HighlightsOp,
66 /// Intra-line refinement, aligned line-for-line with `op`'s spans;
67 /// empty when the producer has none.
68 pub refine: Vec<Vec<RefineSpan>>,
69}
70
71/// Shared state between async refresh tasks and the Editor's tick drain.
72///
73/// The host registers the **bare type**, so reach it as
74/// `ctx.service::<PendingSyntheticHighlights>()` — which already returns an
75/// `Arc`, i.e. a [`PendingSyntheticHighlightsHandle`] to keep. Looking it up
76/// *as* the handle type misses (the `ServiceRegistry` `TypeId` rule). Every
77/// `*_and_wake` method fires the editor's `async_landed` notify, so the
78/// spans reach the screen without a keystroke (the inbound-wake rule).
79///
80/// **One pending update per buffer.** The map holds the latest undrained
81/// update; a second store for the same buffer before the drain runs
82/// replaces the first. Two splices in quick succession therefore need a
83/// drain between them, or a `Replace` instead.
84///
85/// # Examples
86///
87/// ```
88/// use std::sync::Arc;
89/// use lattice_core::BufferId;
90/// use lattice_mode::{HighlightsOp, PendingSyntheticHighlights};
91///
92/// let pending = PendingSyntheticHighlights::new();
93/// let wake = Arc::new(tokio::sync::Notify::new());
94/// *pending.waker.lock().unwrap() = Some(wake.clone()); // the host does this at boot
95///
96/// pending.remove_at_and_wake(BufferId(3), 10, 2);
97/// let update = pending.map.lock().unwrap().remove(&BufferId(3)).unwrap();
98/// assert!(matches!(update.op, HighlightsOp::RemoveAt { start_line: 10, count: 2 }));
99/// ```
100pub struct PendingSyntheticHighlights {
101 /// Undrained updates by buffer; the host's tick drain empties it.
102 pub map: Arc<Mutex<HashMap<BufferId, HighlightsUpdate>>>,
103 /// The editor's `async_landed` notify, installed by the host at boot.
104 /// `None` (a test harness) means stores land but nothing wakes.
105 pub waker: Arc<Mutex<Option<Arc<tokio::sync::Notify>>>>,
106}
107
108impl PendingSyntheticHighlights {
109 /// Empty map, no waker installed.
110 pub fn new() -> Self {
111 Self {
112 map: Arc::new(Mutex::new(HashMap::new())),
113 waker: Arc::new(Mutex::new(None)),
114 }
115 }
116
117 /// Store per-line spans for `buffer_id` and fire the waker so the
118 /// Editor drains them on the next tick. Replaces any existing highlights
119 /// for the buffer.
120 pub fn store_and_wake(&self, buffer_id: BufferId, spans: Vec<Vec<StyledSpan>>) {
121 self.store_refined_and_wake(buffer_id, spans, Vec::new());
122 }
123
124 /// As [`Self::store_and_wake`], carrying intra-line
125 /// refinement alongside the spans so both shift together (DR.3).
126 pub fn store_refined_and_wake(
127 &self,
128 buffer_id: BufferId,
129 spans: Vec<Vec<StyledSpan>>,
130 refine: Vec<Vec<RefineSpan>>,
131 ) {
132 if let Ok(mut map) = self.map.lock() {
133 map.insert(
134 buffer_id,
135 HighlightsUpdate {
136 op: HighlightsOp::Replace(spans),
137 refine,
138 },
139 );
140 }
141 self.fire_waker();
142 }
143
144 /// Store per-line spans to be SPLICED IN to existing highlights at
145 /// a given line offset — lines before `start_line` keep their
146 /// spans; `spans` becomes the new content at `start_line`; every
147 /// line that was already at or after `start_line` shifts DOWN by
148 /// `spans.len()`. Use when the underlying text edit INSERTED
149 /// `spans.len()` new lines at `start_line` (e.g. toggle-diff
150 /// expanding inline content) — the highlight vector must grow and
151 /// shift in lockstep with the text, or every line after the
152 /// insertion point ends up painted with the wrong span.
153 pub fn insert_at_and_wake(
154 &self,
155 buffer_id: BufferId,
156 start_line: u32,
157 spans: Vec<Vec<StyledSpan>>,
158 ) {
159 self.insert_at_refined_and_wake(buffer_id, start_line, spans, Vec::new())
160 }
161
162 /// Splice spans AND refinement at the same offset (DR.3).
163 ///
164 /// The `=` toggle inserts an expansion's lines mid-buffer; both
165 /// lists must shift by the same amount or the refinement ends up
166 /// over the wrong rows. Carrying them in one update and splicing
167 /// them with one implementation is what makes that impossible.
168 pub fn insert_at_refined_and_wake(
169 &self,
170 buffer_id: BufferId,
171 start_line: u32,
172 spans: Vec<Vec<StyledSpan>>,
173 refine: Vec<Vec<RefineSpan>>,
174 ) {
175 if let Ok(mut map) = self.map.lock() {
176 map.insert(
177 buffer_id,
178 HighlightsUpdate {
179 op: HighlightsOp::InsertAt { start_line, spans },
180 refine,
181 },
182 );
183 }
184 self.fire_waker();
185 }
186
187 /// Remove `count` lines of highlights starting at `start_line`,
188 /// shifting everything after them UP by `count`. The exact
189 /// inverse of [`Self::insert_at_and_wake`] — use when the
190 /// underlying text edit DELETED `count` lines at `start_line`
191 /// (e.g. toggle-diff collapsing inline content back down).
192 pub fn remove_at_and_wake(&self, buffer_id: BufferId, start_line: u32, count: usize) {
193 if let Ok(mut map) = self.map.lock() {
194 map.insert(
195 buffer_id,
196 HighlightsUpdate {
197 op: HighlightsOp::RemoveAt { start_line, count },
198 refine: Default::default(),
199 },
200 );
201 }
202 self.fire_waker();
203 }
204
205 /// Fire the waker without storing anything. Use when the buffer was
206 /// modified by a non-refresh action (e.g. toggle-diff) and the existing
207 /// ExtraHighlights are still valid — the Editor needs to repaint.
208 pub fn wake(&self) {
209 self.fire_waker();
210 }
211
212 fn fire_waker(&self) {
213 if let Ok(waker_guard) = self.waker.lock()
214 && let Some(waker) = waker_guard.as_ref()
215 {
216 waker.notify_one();
217 }
218 }
219}
220
221impl Default for PendingSyntheticHighlights {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227/// The shared handle a producer keeps (e.g. in its Guard or a spawned task).
228///
229/// Unlike `BufferStoreHandle`, this alias is **not** the registration key:
230/// the host registers `PendingSyntheticHighlights` itself, and
231/// `ServiceRegistry::get::<PendingSyntheticHighlights>()` yields this
232/// `Arc`. A `get::<PendingSyntheticHighlightsHandle>()` returns `None`
233/// against the production registration.
234pub type PendingSyntheticHighlightsHandle = Arc<PendingSyntheticHighlights>;
235
236/// Splice `spans` into `base` at `start_line`, shifting everything at
237/// or after `start_line` down by `spans.len()`. Pulled out as a pure
238/// function (rather than inlined at the drain call site) so the
239/// line-offset arithmetic — the exact thing that regressed into an
240/// in-place overwrite once already — has its own unit tests.
241/// DR.3: generic over the span type so foreground spans and
242/// intra-line refinement are shifted by ONE implementation. Two copies
243/// of this arithmetic is precisely how the two lists drift apart.
244pub fn splice_insert<T>(base: &mut Vec<Vec<T>>, start_line: u32, spans: Vec<Vec<T>>) {
245 let at = (start_line as usize).min(base.len());
246 base.splice(at..at, spans);
247}
248
249/// Remove `count` lines from `base` starting at `start_line`,
250/// shifting everything after them up by `count`. Exact inverse of
251/// [`splice_insert`].
252pub fn splice_remove<T>(base: &mut Vec<Vec<T>>, start_line: u32, count: usize) {
253 let start = (start_line as usize).min(base.len());
254 let end = (start + count).min(base.len());
255 base.drain(start..end);
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn line(len: usize) -> Vec<StyledSpan> {
263 vec![StyledSpan {
264 start: 0,
265 end: len,
266 style: lattice_cells::Style::Default,
267 }]
268 }
269
270 fn labels(spans: &[Vec<StyledSpan>]) -> Vec<usize> {
271 spans.iter().map(|v| v[0].end).collect()
272 }
273
274 #[test]
275 fn insert_in_the_middle_shifts_the_tail_down() {
276 // Base has 3 "lines" (lengths 1/2/3 standing in for identity).
277 let mut base = vec![line(1), line(2), line(3)];
278 splice_insert(&mut base, 1, vec![line(4), line(5)]);
279 // Line 0 untouched, new lines land at 1..3, old line 1/2 now at 3/4.
280 assert_eq!(labels(&base), vec![1, 4, 5, 2, 3]);
281 }
282
283 #[test]
284 fn insert_past_the_end_clamps_instead_of_panicking() {
285 let mut base = vec![line(1)];
286 splice_insert(&mut base, 50, vec![line(2)]);
287 assert_eq!(labels(&base), vec![1, 2]);
288 }
289
290 #[test]
291 fn remove_in_the_middle_shifts_the_tail_up() {
292 // 5 lines; remove the 2 that were inserted at offset 1.
293 let mut base = vec![line(1), line(4), line(5), line(2), line(3)];
294 splice_remove(&mut base, 1, 2);
295 assert_eq!(labels(&base), vec![1, 2, 3]);
296 }
297
298 #[test]
299 fn remove_past_the_end_clamps_instead_of_panicking() {
300 let mut base = vec![line(1), line(2)];
301 splice_remove(&mut base, 1, 50);
302 assert_eq!(labels(&base), vec![1]);
303 }
304
305 #[test]
306 fn insert_then_remove_round_trips_to_the_original() {
307 let original = vec![line(1), line(2), line(3)];
308 let mut base = original.clone();
309 splice_insert(&mut base, 1, vec![line(4), line(5)]);
310 splice_remove(&mut base, 1, 2);
311 assert_eq!(labels(&base), labels(&original));
312 }
313}