Skip to main content

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}