Skip to main content

termesh_editor/
decoration.rs

1//! Styled overlays on buffer text (ARCHITECTURE.md §10).
2//!
3//! Three classes exist here from the first commit — syntax, diagnostics, and **agent
4//! proposal hunks** — even though only hunks are wired up in Phase 03. That is deliberate
5//! and §10 is explicit about it: designing the decoration system with agent hunks in mind
6//! is a Phase-03 requirement, not a Phase-07 afterthought. A layer built for syntax
7//! highlighting alone acquires assumptions (spans always exist in the buffer; spans are
8//! always recomputable from the text) that agent hunks then violate.
9//!
10//! Decorations are stored as **char offsets**, like everything else in this crate. The
11//! conversion to screen cells happens once, at the render boundary — see `ui::text`.
12
13use termesh_core::ProposalId;
14
15use crate::change::{Assoc, ChangeSet, RangeEffect};
16use crate::{ConflictReason, HunkState};
17
18/// Severity of a language-server diagnostic. Rendered in Phase 07; the class exists now
19/// so the layer is not shaped around a single consumer.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Severity {
22    Error,
23    Warning,
24    Info,
25    Hint,
26}
27
28/// A syntax token class. Tree-sitter fills these in during the phase's last slice.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SyntaxKind {
31    Keyword,
32    StringLit,
33    Comment,
34    Number,
35    Type,
36    Function,
37}
38
39/// Which side of a proposed change a hunk decoration marks.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum HunkSide {
42    /// Text the proposal would remove or replace. Present in the buffer, so it has a real
43    /// range and can be struck through in place.
44    Removed,
45    /// Text the proposal would add. **Not in the buffer**, so its range is zero-width —
46    /// an anchor saying "new text goes here". The content lives on the proposal and is
47    /// rendered as a preview line rather than as a span over existing text.
48    ///
49    /// This is the case a syntax-only decoration model cannot express, and the reason
50    /// this layer carries a side at all.
51    Added,
52}
53
54/// What a decoration is for.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum DecorationClass {
57    Syntax(SyntaxKind),
58    Diagnostic(Severity),
59    Hunk {
60        proposal: ProposalId,
61        side: HunkSide,
62        state: HunkState,
63    },
64    /// A find/replace hit. `current` is the one the cursor is on.
65    Match {
66        current: bool,
67    },
68}
69
70impl DecorationClass {
71    /// Whether this decoration is *derived* data its producer can regenerate.
72    ///
73    /// Syntax and diagnostics are recomputed from the text after every edit, so a stale
74    /// one is discarded rather than repaired. A hunk is not derived — it is a pending
75    /// proposal, and losing it silently would lose the human's review.
76    fn is_derived(&self) -> bool {
77        matches!(
78            self,
79            DecorationClass::Syntax(_)
80                | DecorationClass::Diagnostic(_)
81                | DecorationClass::Match { .. }
82        )
83    }
84}
85
86/// A styled span over a char range of the buffer.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct Decoration {
89    pub start: usize,
90    pub end: usize,
91    pub class: DecorationClass,
92}
93
94impl Decoration {
95    pub fn new(start: usize, end: usize, class: DecorationClass) -> Self {
96        Self { start, end, class }
97    }
98
99    pub fn is_empty(&self) -> bool {
100        self.start == self.end
101    }
102}
103
104/// A decoration clipped to one line, with offsets relative to that line's start.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct LineDecoration {
107    pub start: usize,
108    pub end: usize,
109    pub class: DecorationClass,
110}
111
112/// The decorations attached to a buffer.
113#[derive(Debug, Default, Clone)]
114pub struct DecorationSet {
115    items: Vec<Decoration>,
116}
117
118impl DecorationSet {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    pub fn push(&mut self, decoration: Decoration) {
124        self.items.push(decoration);
125    }
126
127    pub fn iter(&self) -> impl Iterator<Item = &Decoration> {
128        self.items.iter()
129    }
130
131    pub fn len(&self) -> usize {
132        self.items.len()
133    }
134
135    pub fn is_empty(&self) -> bool {
136        self.items.is_empty()
137    }
138
139    /// Drop every decoration a producer is about to replace.
140    ///
141    /// Per class, not all-derived-at-once: a re-parse must not wipe the find results, and
142    /// a new search must not wipe the highlighting. Each producer clears its own.
143    pub fn clear_syntax(&mut self) {
144        self.items.retain(|d| !matches!(d.class, DecorationClass::Syntax(_)));
145    }
146
147    pub fn clear_matches(&mut self) {
148        self.items.retain(|d| !matches!(d.class, DecorationClass::Match { .. }));
149    }
150
151    pub fn clear_diagnostics(&mut self) {
152        self.items.retain(|d| !matches!(d.class, DecorationClass::Diagnostic(_)));
153    }
154
155    /// Drop every derived decoration, leaving pending agent hunks alone.
156    pub fn clear_derived(&mut self) {
157        self.items.retain(|d| !d.class.is_derived());
158    }
159
160    /// Drop every hunk belonging to a proposal — it was accepted, rejected, or withdrawn.
161    pub fn remove_proposal(&mut self, proposal: ProposalId) {
162        self.items.retain(
163            |d| !matches!(d.class, DecorationClass::Hunk { proposal: p, .. } if p == proposal),
164        );
165    }
166
167    /// Carry every decoration through an applied change.
168    ///
169    /// Positions map as usual, but *disturbance* is handled per class, and this is where
170    /// ADR-0006 §4's policy reaches the screen:
171    ///
172    /// - **Derived** decorations (syntax, diagnostics) whose range was disturbed are
173    ///   dropped. Their producer recomputes them; a half-mapped highlight is worse than
174    ///   none, because it colours the wrong text.
175    /// - **Hunks** are kept and marked [`HunkState::Conflicted`]. A pending proposal must
176    ///   never vanish silently — the human is mid-review, and "your edit collided with
177    ///   this change" is information they need.
178    pub fn map(&mut self, changes: &ChangeSet) {
179        self.items.retain_mut(|d| {
180            let effect = changes.touches(d.start, d.end);
181
182            if let DecorationClass::Hunk { state, .. } = &mut d.class {
183                if let Some(reason) = ConflictReason::from_effect(effect) {
184                    *state = HunkState::Conflicted(reason);
185                }
186            } else if effect != RangeEffect::Untouched {
187                return false;
188            }
189
190            // A zero-width anchor uses `After` on both ends so an insertion at exactly
191            // that point leaves the anchor after the new text, matching how pending
192            // proposal anchors move (ADR-0006 §2).
193            d.start = changes.map_pos(d.start, Assoc::After);
194            d.end = changes.map_pos(d.end, Assoc::After);
195            true
196        });
197    }
198
199    /// The decorations overlapping `line_start..line_end`, clipped to it and rebased to
200    /// offsets relative to `line_start`.
201    ///
202    /// Sorted by start so the renderer can walk them in order. Zero-width anchors are
203    /// kept — they are exactly the "text goes here" markers of [`HunkSide::Added`].
204    pub fn for_line(&self, line_start: usize, line_end: usize) -> Vec<LineDecoration> {
205        let mut out: Vec<LineDecoration> = self
206            .items
207            .iter()
208            .filter(|d| overlaps(d, line_start, line_end))
209            .map(|d| LineDecoration {
210                start: d.start.clamp(line_start, line_end) - line_start,
211                end: d.end.clamp(line_start, line_end) - line_start,
212                class: d.class,
213            })
214            .collect();
215        out.sort_by_key(|d| (d.start, d.end));
216        out
217    }
218}
219
220/// Whether a decoration touches a line's char range.
221///
222/// A zero-width anchor counts when it sits anywhere in the line *including* its end, so
223/// an insertion at the end of a line is drawn on that line rather than disappearing.
224fn overlaps(d: &Decoration, line_start: usize, line_end: usize) -> bool {
225    if d.is_empty() {
226        return d.start >= line_start && d.start <= line_end;
227    }
228    d.start < line_end && d.end > line_start
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn hunk(start: usize, end: usize, side: HunkSide) -> Decoration {
236        Decoration::new(
237            start,
238            end,
239            DecorationClass::Hunk { proposal: ProposalId::new(1), side, state: HunkState::Clean },
240        )
241    }
242
243    fn syntax(start: usize, end: usize) -> Decoration {
244        Decoration::new(start, end, DecorationClass::Syntax(SyntaxKind::Keyword))
245    }
246
247    fn state_of(set: &DecorationSet) -> Option<HunkState> {
248        set.iter().find_map(|d| match d.class {
249            DecorationClass::Hunk { state, .. } => Some(state),
250            _ => None,
251        })
252    }
253
254    // --- carrying decorations through edits ---------------------------------------
255
256    #[test]
257    fn an_edit_before_a_decoration_shifts_it() {
258        let mut set = DecorationSet::new();
259        set.push(syntax(10, 20));
260        set.map(&ChangeSet::replace(40, 0, 0, "abc"));
261
262        let d = set.iter().next().unwrap();
263        assert_eq!((d.start, d.end), (13, 23));
264    }
265
266    #[test]
267    fn an_edit_after_a_decoration_leaves_it_alone() {
268        let mut set = DecorationSet::new();
269        set.push(syntax(10, 20));
270        set.map(&ChangeSet::replace(40, 30, 30, "abc"));
271
272        let d = set.iter().next().unwrap();
273        assert_eq!((d.start, d.end), (10, 20));
274    }
275
276    /// Derived decorations are regenerated by their producer, so a disturbed one is
277    /// dropped rather than repaired — a half-mapped highlight colours the wrong text.
278    #[test]
279    fn a_disturbed_syntax_span_is_dropped() {
280        let mut set = DecorationSet::new();
281        set.push(syntax(10, 20));
282        set.map(&ChangeSet::replace(40, 12, 18, "x"));
283        assert!(set.is_empty(), "stale highlighting is worse than none");
284    }
285
286    /// A pending hunk is not derived: losing it silently would lose the human's review.
287    #[test]
288    fn a_disturbed_hunk_is_kept_and_marked_conflicted() {
289        let mut set = DecorationSet::new();
290        set.push(hunk(10, 20, HunkSide::Removed));
291        set.map(&ChangeSet::replace(40, 12, 18, "mine"));
292
293        assert_eq!(set.len(), 1, "the human is mid-review; it must not vanish");
294        assert_eq!(state_of(&set), Some(HunkState::Conflicted(ConflictReason::AnchorDeleted)));
295    }
296
297    #[test]
298    fn typing_inside_a_hunk_conflicts_it_by_the_right_reason() {
299        let mut set = DecorationSet::new();
300        set.push(hunk(10, 20, HunkSide::Removed));
301        set.map(&ChangeSet::replace(40, 15, 15, "mine"));
302
303        assert_eq!(state_of(&set), Some(HunkState::Conflicted(ConflictReason::EditedInsideRange)));
304    }
305
306    #[test]
307    fn an_untouched_hunk_stays_clean_and_rides_forward() {
308        let mut set = DecorationSet::new();
309        set.push(hunk(10, 20, HunkSide::Removed));
310        set.map(&ChangeSet::replace(40, 0, 0, "xx"));
311
312        assert_eq!(state_of(&set), Some(HunkState::Clean));
313        let d = set.iter().next().unwrap();
314        assert_eq!((d.start, d.end), (12, 22));
315    }
316
317    #[test]
318    fn a_zero_width_insertion_anchor_rides_forward_too() {
319        let mut set = DecorationSet::new();
320        set.push(hunk(10, 10, HunkSide::Added));
321        set.map(&ChangeSet::replace(40, 0, 0, "abc"));
322
323        let d = set.iter().next().unwrap();
324        assert_eq!((d.start, d.end), (13, 13), "still zero-width, just moved");
325    }
326
327    #[test]
328    fn a_conflicted_hunk_does_not_silently_go_clean_again() {
329        let mut set = DecorationSet::new();
330        set.push(hunk(10, 20, HunkSide::Removed));
331        set.map(&ChangeSet::replace(40, 15, 15, "mine")); // conflict
332        set.map(&ChangeSet::replace(44, 0, 0, "x")); // an unrelated later edit
333
334        assert!(matches!(state_of(&set), Some(HunkState::Conflicted(_))));
335    }
336
337    // --- housekeeping --------------------------------------------------------------
338
339    #[test]
340    fn each_producer_clears_only_its_own_class() {
341        // A re-parse must not wipe the find results, and a new search must not wipe the
342        // highlighting.
343        let mut set = DecorationSet::new();
344        set.push(syntax(0, 5));
345        set.push(Decoration::new(6, 8, DecorationClass::Match { current: true }));
346        set.push(hunk(10, 20, HunkSide::Removed));
347
348        set.clear_syntax();
349        assert_eq!(set.len(), 2, "the match and the hunk survive a re-parse");
350
351        set.clear_matches();
352        assert_eq!(set.len(), 1, "the hunk survives a new search");
353        assert!(matches!(set.iter().next().unwrap().class, DecorationClass::Hunk { .. }));
354    }
355
356    #[test]
357    fn clearing_derived_decorations_leaves_pending_hunks_alone() {
358        let mut set = DecorationSet::new();
359        set.push(syntax(0, 5));
360        set.push(Decoration::new(6, 8, DecorationClass::Diagnostic(Severity::Error)));
361        set.push(hunk(10, 20, HunkSide::Removed));
362
363        set.clear_derived();
364        assert_eq!(set.len(), 1);
365        assert!(matches!(set.iter().next().unwrap().class, DecorationClass::Hunk { .. }));
366    }
367
368    #[test]
369    fn a_resolved_proposal_takes_only_its_own_hunks() {
370        let mut set = DecorationSet::new();
371        set.push(hunk(0, 5, HunkSide::Removed));
372        set.push(Decoration::new(
373            10,
374            15,
375            DecorationClass::Hunk {
376                proposal: ProposalId::new(2),
377                side: HunkSide::Removed,
378                state: HunkState::Clean,
379            },
380        ));
381
382        set.remove_proposal(ProposalId::new(1));
383        assert_eq!(set.len(), 1, "the other proposal's review is untouched");
384    }
385
386    // --- clipping to a line ---------------------------------------------------------
387
388    #[test]
389    fn decorations_are_clipped_and_rebased_onto_their_line() {
390        // Line spanning chars 10..20.
391        let mut set = DecorationSet::new();
392        set.push(syntax(5, 14)); // starts before the line
393        set.push(syntax(16, 30)); // runs past its end
394
395        let spans = set.for_line(10, 20);
396        assert_eq!(spans.len(), 2);
397        assert_eq!((spans[0].start, spans[0].end), (0, 4));
398        assert_eq!((spans[1].start, spans[1].end), (6, 10));
399    }
400
401    #[test]
402    fn decorations_on_other_lines_are_excluded() {
403        let mut set = DecorationSet::new();
404        set.push(syntax(0, 5));
405        set.push(syntax(30, 35));
406        assert!(set.for_line(10, 20).is_empty());
407    }
408
409    #[test]
410    fn spans_come_back_in_order_so_the_renderer_can_walk_them() {
411        let mut set = DecorationSet::new();
412        set.push(syntax(18, 20));
413        set.push(syntax(10, 12));
414        set.push(syntax(14, 16));
415
416        let starts: Vec<usize> = set.for_line(10, 20).iter().map(|d| d.start).collect();
417        assert_eq!(starts, [0, 4, 8]);
418    }
419
420    #[test]
421    fn an_insertion_anchor_at_the_end_of_a_line_is_drawn_on_that_line() {
422        // Otherwise "add a line after this one" would render nowhere at all.
423        let mut set = DecorationSet::new();
424        set.push(hunk(20, 20, HunkSide::Added));
425
426        assert_eq!(set.for_line(10, 20).len(), 1, "belongs to the line it ends");
427    }
428
429    #[test]
430    fn a_decoration_touching_only_a_boundary_does_not_bleed_onto_the_next_line() {
431        let mut set = DecorationSet::new();
432        set.push(syntax(5, 10)); // ends exactly where the line starts
433        assert!(set.for_line(10, 20).is_empty());
434    }
435}