Skip to main content

teksilo_widgets/rich_text/
caret_highlight.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! An ambient band behind the sentence — or paragraph — the caret is in.
5//!
6//! `CaretHighlightSession` owns a **range session** on a [`TextDocument`] (see
7//! `add_range_session_with_priority`) holding at most one range: the extent of whatever the
8//! caret is currently inside. It is the writing-comfort counterpart of
9//! [`FindSession`](super::find_session::FindSession) — same registry, same staleness discipline,
10//! opposite intent. Find answers "where is this text"; this answers "where am I".
11//!
12//! ## Only the view being written in bands
13//!
14//! Two panes over one document each own a session, and an **inactive view clears its range**.
15//! So the union of what the document carries is exactly one band, at the caret of the pane
16//! being written in, and neither view needs a [`HighlightMask`] to say so. That is also what
17//! makes the band vanish the moment focus leaves the editor, which is what you want: the band
18//! marks where you are *writing*, not where a caret happens to rest.
19//!
20//! A **selection** makes a view inactive for the same reason. The band answers "where am I
21//! writing"; a selection answers it better and more precisely, so while one is up the band is
22//! redundant. It was also actively wrong: the range is resolved from the caret, which during a
23//! selection is its *moving end*, so the band skipped from sentence to sentence underneath a
24//! growing selection.
25//!
26//! ## Priority, not registration order
27//!
28//! The session registers at [`CARET_HIGHLIGHT_PRIORITY`], below every other layer, so a find
29//! match or a spell squiggle always paints over the band. Registration order could not express
30//! that: a view's session is registered when the view appears, so a split pane opened *after*
31//! the find banner would outrank it in that pane and not in its sibling.
32//!
33//! ## Staleness on edit
34//!
35//! The range is an absolute char offset frozen at push time, and text-document does not
36//! re-anchor a range session the way it re-anchors carets. Like `FindSession` this one
37//! subscribes to its document and marks itself stale on any content edit; the host calls
38//! `CaretHighlightSession::refresh` each frame with the live caret, which is
39//! cheap — a caret that has not moved into a different sentence pushes nothing at all.
40//!
41//! [`HighlightMask`]: teksilo_text::text_document::HighlightMask
42
43use std::cell::{Cell, RefCell};
44use std::sync::Arc;
45use std::sync::atomic::{AtomicBool, Ordering};
46
47use teksilo_text::text_document::{
48    DocumentEvent, HighlightFormat, RangeHighlight, SessionId, Subscription, TextDocument,
49};
50
51/// Where the caret-highlight session sits in the document's merge order.
52///
53/// Far below the default `0` every other layer takes, so anything meaningful — a find match, a
54/// spell squiggle, a syntax colour — wins the overlap. The band is ambient; it must never hide
55/// something the writer asked to see.
56pub const CARET_HIGHLIGHT_PRIORITY: i32 = -1000;
57
58/// How much text around the caret the band covers.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CaretHighlightScope {
61    /// The sentence the caret is in, in [`CaretHighlight::content_locale`]'s language.
62    Sentence,
63    /// The whole paragraph (block) the caret is in. Needs no language.
64    Paragraph,
65}
66
67/// The band an editor should draw, or the absence of one.
68///
69/// Handed over whole rather than field by field, so a host pushes the same way whichever of
70/// its inputs changed — the shape [`EditorTypographyDefaults`](teksilo_text::EditorTypographyDefaults)
71/// already uses.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct CaretHighlight {
74    pub scope: CaretHighlightScope,
75    /// How the band paints. Should be **paint-only** — a background colour — so it stays a
76    /// recolor rather than a reshape, and stays out of the accessibility tree.
77    pub format: HighlightFormat,
78    /// BCP-47-ish tag naming the language of the text, which selects the sentence tailoring
79    /// (abbreviations, French spaced guillemets, the Greek question mark). Ignored by
80    /// [`CaretHighlightScope::Paragraph`], which needs no language to find a block.
81    pub content_locale: Option<String>,
82}
83
84/// The band layer for one view of one document.
85pub(crate) struct CaretHighlightSession {
86    doc: TextDocument,
87    session: SessionId,
88    /// What to draw, or `None` while the feature is off. Kept even while inactive, so becoming
89    /// active again needs no re-push from the host.
90    config: RefCell<Option<CaretHighlight>>,
91    /// Whether this view should be showing a band at all — see [`set_active`](Self::set_active).
92    active: Cell<bool>,
93    /// The range last pushed, so an unchanged recompute skips the push — and so a format-only
94    /// change (a theme switch) can re-push the same extent without re-deriving it.
95    last: Cell<Option<(usize, usize)>>,
96    /// Set by the document subscription on any offset-moving edit; drained by
97    /// [`refresh`](Self::refresh).
98    dirty: Arc<AtomicBool>,
99    /// Kept alive so the subscription lives as long as the session.
100    _sub: Subscription,
101}
102
103impl CaretHighlightSession {
104    /// Attach an idle band session to `doc`. Draws nothing until
105    /// [`set_config`](Self::set_config) and [`set_active`](Self::set_active) both say so.
106    pub(crate) fn new(doc: &TextDocument) -> Self {
107        let session = doc.add_range_session_with_priority(CARET_HIGHLIGHT_PRIORITY);
108        let dirty = Arc::new(AtomicBool::new(false));
109        let sub = {
110            let dirty = dirty.clone();
111            doc.on_change(move |event| {
112                // Only edits that MOVE char offsets stale the cached range. Format- and
113                // highlight-only events leave the text where it was — and reacting to
114                // `HighlightPaintChanged` here would loop, since this session's own
115                // `set_session_ranges` emits exactly that. Same filter `FindSession` uses.
116                if matches!(
117                    event,
118                    DocumentEvent::ContentsChanged { .. }
119                        | DocumentEvent::DocumentReset
120                        | DocumentEvent::BlockCountChanged(_)
121                        | DocumentEvent::FlowElementsInserted { .. }
122                        | DocumentEvent::FlowElementsRemoved { .. }
123                ) {
124                    dirty.store(true, Ordering::Relaxed);
125                }
126            })
127        };
128        Self {
129            doc: doc.clone(),
130            session,
131            config: RefCell::new(None),
132            active: Cell::new(false),
133            last: Cell::new(None),
134            dirty,
135            _sub: sub,
136        }
137    }
138
139    /// The document session this layer owns — for a view's
140    /// [`HighlightMask`](teksilo_text::text_document::HighlightMask), should it name one.
141    #[allow(dead_code)]
142    pub(crate) fn session_id(&self) -> SessionId {
143        self.session
144    }
145
146    /// What this session is currently configured to draw.
147    pub(crate) fn config(&self) -> Option<CaretHighlight> {
148        self.config.borrow().clone()
149    }
150
151    /// Set (or clear) what to draw. Returns `true` if a repaint is owed.
152    ///
153    /// A change to the **format alone** — what a light/dark switch does — re-pushes the extent
154    /// already resolved rather than recomputing it, so a theme toggle costs one range write per
155    /// open editor and no segmentation at all.
156    pub(crate) fn set_config(&self, config: Option<CaretHighlight>) -> bool {
157        let previous = self.config.replace(config.clone());
158        if previous == config {
159            return false;
160        }
161        match (&previous, &config) {
162            (None, _) | (_, None) => {
163                // Turned on or off: `refresh` resolves it (or `clear` empties it) next.
164                if config.is_none() {
165                    return self.clear();
166                }
167                self.last.set(None);
168                true
169            }
170            (Some(before), Some(after))
171                if before.scope == after.scope && before.content_locale == after.content_locale =>
172            {
173                // Format-only: repaint the same extent in the new colour.
174                match self.last.get() {
175                    Some(range) => self.push(Some(range), after),
176                    None => true,
177                }
178            }
179            _ => {
180                // The scope or the language changed: the extent has to be re-derived.
181                self.last.set(None);
182                true
183            }
184        }
185    }
186
187    /// Tell the session whether its view should be banding right now: focused, and not in the
188    /// middle of a selection. An inactive view draws no band — see the module docs for why that
189    /// is what makes both split panes and selections behave.
190    ///
191    /// Returns `true` if a repaint is owed.
192    pub(crate) fn set_active(&self, active: bool) -> bool {
193        if self.active.replace(active) == active {
194            return false;
195        }
196        if active { true } else { self.clear() }
197    }
198
199    /// Re-resolve the band for `caret` and push it if it moved. Returns `true` if the pushed
200    /// range changed, so the caller can pump a frame.
201    ///
202    /// Cheap to call every frame: an inactive or unconfigured session returns immediately, and
203    /// a caret that stayed inside the same sentence pushes nothing.
204    pub(crate) fn refresh(&self, caret: usize) -> bool {
205        let stale = self.dirty.swap(false, Ordering::Relaxed);
206        let config = self.config.borrow().clone();
207        let Some(config) = config else {
208            return false;
209        };
210        if !self.active.get() {
211            return false;
212        }
213        let range = self.resolve(caret, &config);
214        // An edit can leave the extent numerically identical while the text under it changed
215        // (typing inside a sentence that already ran to the block's end), so a staled session
216        // pushes even when the range matches.
217        if range == self.last.get() && !stale {
218            return false;
219        }
220        self.push(range, &config)
221    }
222
223    /// The extent the caret is inside, per the scope.
224    fn resolve(&self, caret: usize, config: &CaretHighlight) -> Option<(usize, usize)> {
225        match config.scope {
226            CaretHighlightScope::Sentence => self
227                .doc
228                .sentence_at(caret, config.content_locale.as_deref()),
229            CaretHighlightScope::Paragraph => {
230                // `block_at_caret`, not `block_at`: the latter reads its argument as a
231                // character index, where the inter-block separator belongs to the block
232                // *after* it. A caret at the end of a paragraph sits on exactly that index,
233                // so the band lit the next paragraph the moment you finished typing one.
234                let block = self.doc.block_at_caret(caret).ok()?;
235                let (start, len) = (block.start, block.length);
236                (len > 0).then_some((start, start + len))
237            }
238        }
239    }
240
241    /// Write `range` to the document as this session's only highlight.
242    fn push(&self, range: Option<(usize, usize)>, config: &CaretHighlight) -> bool {
243        let ranges = match range {
244            Some((start, end)) if end > start => vec![RangeHighlight {
245                start,
246                length: end - start,
247                format: config.format.clone(),
248            }],
249            _ => Vec::new(),
250        };
251        self.doc.set_session_ranges(self.session, ranges);
252        self.last.set(range);
253        true
254    }
255
256    /// Drop the band without forgetting the configuration. Returns `true` if anything went away.
257    fn clear(&self) -> bool {
258        if self.last.get().is_none() {
259            return false;
260        }
261        self.doc.set_session_ranges(self.session, Vec::new());
262        self.last.set(None);
263        true
264    }
265}
266
267impl Drop for CaretHighlightSession {
268    /// Retire the session, so a closed editor leaves no band behind on a document its siblings
269    /// are still showing. The `Subscription`'s own drop stops callbacks but does **not** remove
270    /// the highlight layer — exactly as `FindSession` documents.
271    fn drop(&mut self) {
272        self.doc.remove_session(self.session);
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use teksilo_text::text_document::{Color, FlowElementSnapshot, HighlightMask};
280
281    const BAND: Color = Color {
282        red: 255,
283        green: 254,
284        blue: 235,
285        alpha: 255,
286    };
287    const OTHER: Color = Color {
288        red: 255,
289        green: 140,
290        blue: 0,
291        alpha: 255,
292    };
293
294    fn doc(text: &str) -> TextDocument {
295        let d = TextDocument::new();
296        d.set_plain_text(text).unwrap();
297        d
298    }
299
300    fn band(scope: CaretHighlightScope) -> CaretHighlight {
301        CaretHighlight {
302            scope,
303            format: HighlightFormat {
304                background_color: Some(BAND),
305                ..Default::default()
306            },
307            content_locale: Some("en".into()),
308        }
309    }
310
311    /// The band's paint spans on a block, as `(start, length)`.
312    fn spans(doc: &TextDocument, block: usize) -> Vec<(usize, usize)> {
313        match &doc.snapshot_flow_masked(&HighlightMask::all()).elements[block] {
314            FlowElementSnapshot::Block(b) => b
315                .paint_highlights
316                .iter()
317                .filter(|s| s.background_color == Some(BAND))
318                .map(|s| (s.start, s.length))
319                .collect(),
320            _ => panic!("block"),
321        }
322    }
323
324    /// A focused, configured session ready to band.
325    fn live(d: &TextDocument, scope: CaretHighlightScope) -> CaretHighlightSession {
326        let s = CaretHighlightSession::new(d);
327        s.set_config(Some(band(scope)));
328        s.set_active(true);
329        s
330    }
331
332    #[test]
333    fn each_scope_bands_its_own_extent() {
334        let d = doc("One is first. Two is second.");
335
336        let s = live(&d, CaretHighlightScope::Sentence);
337        s.refresh(16);
338        assert_eq!(spans(&d, 0), [(14, 14)], "just \"Two is second.\"");
339
340        let p = live(&d, CaretHighlightScope::Paragraph);
341        // Two sessions now band the same document; look at the paragraph one's own extent by
342        // dropping the sentence one first.
343        drop(s);
344        p.refresh(16);
345        assert_eq!(spans(&d, 0), [(0, 28)], "the whole block");
346    }
347
348    #[test]
349    fn the_band_follows_the_caret_between_sentences() {
350        let d = doc("One is first. Two is second.");
351        let s = live(&d, CaretHighlightScope::Sentence);
352
353        s.refresh(2);
354        assert_eq!(spans(&d, 0), [(0, 13)]);
355        assert!(s.refresh(16), "moving to another sentence re-pushes");
356        assert_eq!(spans(&d, 0), [(14, 14)]);
357    }
358
359    /// The cheap path: a caret moving *within* one sentence changes nothing, so a burst of
360    /// keystrokes costs one push and not one per frame.
361    #[test]
362    fn a_caret_move_inside_the_same_sentence_pushes_nothing() {
363        let d = doc("One is first. Two is second.");
364        let s = live(&d, CaretHighlightScope::Sentence);
365        assert!(s.refresh(2), "the first resolve always pushes");
366        assert!(!s.refresh(3), "same sentence: no push");
367        assert!(!s.refresh(10), "still the same sentence");
368    }
369
370    #[test]
371    fn an_inactive_view_draws_no_band() {
372        let d = doc("One is first. Two is second.");
373        let s = live(&d, CaretHighlightScope::Sentence);
374        s.refresh(2);
375        assert!(!spans(&d, 0).is_empty());
376
377        assert!(s.set_active(false), "going inactive is a repaint");
378        assert!(spans(&d, 0).is_empty(), "the band goes away");
379        assert!(!s.refresh(2), "and stays away while inactive");
380        assert!(spans(&d, 0).is_empty());
381
382        s.set_active(true);
383        s.refresh(2);
384        assert!(!spans(&d, 0).is_empty(), "focus brings it back");
385    }
386
387    #[test]
388    fn clearing_the_config_clears_the_band() {
389        let d = doc("One is first.");
390        let s = live(&d, CaretHighlightScope::Sentence);
391        s.refresh(2);
392        assert!(!spans(&d, 0).is_empty());
393
394        assert!(s.set_config(None));
395        assert!(spans(&d, 0).is_empty());
396        assert!(!s.refresh(2), "nothing to draw");
397    }
398
399    /// A theme switch changes only the colour, and must not need the extent re-derived.
400    #[test]
401    fn a_format_only_change_repaints_the_same_extent() {
402        let d = doc("One is first. Two is second.");
403        let s = live(&d, CaretHighlightScope::Sentence);
404        s.refresh(16);
405        let before = spans(&d, 0);
406
407        let mut recoloured = band(CaretHighlightScope::Sentence);
408        recoloured.format.background_color = Some(OTHER);
409        assert!(s.set_config(Some(recoloured)));
410
411        // Same extent, new colour — without any call to `refresh`.
412        let after = match &d.snapshot_flow_masked(&HighlightMask::all()).elements[0] {
413            FlowElementSnapshot::Block(b) => b.paint_highlights.clone(),
414            _ => panic!("block"),
415        };
416        assert_eq!(after.len(), 1);
417        assert_eq!((after[0].start, after[0].length), before[0]);
418        assert_eq!(after[0].background_color, Some(OTHER));
419    }
420
421    #[test]
422    fn changing_scope_re_resolves_the_extent() {
423        let d = doc("One is first. Two is second.");
424        let s = live(&d, CaretHighlightScope::Sentence);
425        s.refresh(16);
426        assert_eq!(spans(&d, 0), [(14, 14)]);
427
428        s.set_config(Some(band(CaretHighlightScope::Paragraph)));
429        s.refresh(16);
430        assert_eq!(spans(&d, 0), [(0, 28)]);
431    }
432
433    /// Offsets are frozen at push time, so an edit ahead of the band must re-derive it — the
434    /// same discipline `FindSession` follows.
435    #[test]
436    fn an_edit_stales_the_band_and_refresh_re_derives_it() {
437        let d = doc("One is first. Two is second.");
438        let s = live(&d, CaretHighlightScope::Sentence);
439        s.refresh(16);
440        assert_eq!(spans(&d, 0), [(14, 14)]);
441
442        d.set_plain_text("XXXX. One is first. Two is second.")
443            .unwrap();
444        assert!(s.refresh(22), "the edit staled it");
445        assert_eq!(spans(&d, 0), [(20, 14)], "the band followed its text");
446    }
447
448    #[test]
449    fn dropping_the_session_removes_the_layer() {
450        let d = doc("One is first.");
451        {
452            let s = live(&d, CaretHighlightScope::Sentence);
453            s.refresh(2);
454            assert!(!spans(&d, 0).is_empty());
455        }
456        assert!(
457            spans(&d, 0).is_empty(),
458            "the band must not outlive its editor"
459        );
460    }
461
462    /// The band is ambient and must lose every overlap, whichever layer was registered first.
463    #[test]
464    fn another_layer_paints_over_the_band() {
465        for band_first in [true, false] {
466            let d = doc("One is first.");
467            let (s, other) = if band_first {
468                let s = live(&d, CaretHighlightScope::Sentence);
469                (s, d.add_range_session())
470            } else {
471                let o = d.add_range_session();
472                (live(&d, CaretHighlightScope::Sentence), o)
473            };
474            s.refresh(2);
475            d.set_session_ranges(
476                other,
477                vec![RangeHighlight {
478                    start: 0,
479                    length: 3,
480                    format: HighlightFormat {
481                        background_color: Some(OTHER),
482                        ..Default::default()
483                    },
484                }],
485            );
486
487            let painted = match &d.snapshot_flow_masked(&HighlightMask::all()).elements[0] {
488                FlowElementSnapshot::Block(b) => b.paint_highlights.clone(),
489                _ => panic!("block"),
490            };
491            let at_zero = painted
492                .iter()
493                .rfind(|s| s.start == 0 && 0 < s.start + s.length)
494                .and_then(|s| s.background_color);
495            assert_eq!(
496                at_zero,
497                Some(OTHER),
498                "the other layer must win (band registered first: {band_first})"
499            );
500        }
501    }
502
503    /// A caret at the end of a paragraph is still writing in *that* paragraph.
504    ///
505    /// The caret then sits on the character index of the inter-block separator, which
506    /// `block_at` assigns to the following block — correct for a character query, wrong for a
507    /// cursor. Both scopes read that answer, so pressing End (or just typing to the end of a
508    /// paragraph) threw the band forward onto the next one.
509    #[test]
510    fn a_caret_at_the_end_of_a_paragraph_bands_that_paragraph() {
511        let d = doc("One is first.\nTwo is second.");
512        let end_of_first = "One is first.".chars().count();
513
514        let p = live(&d, CaretHighlightScope::Paragraph);
515        p.refresh(end_of_first);
516        assert_eq!(
517            spans(&d, 0),
518            [(0, end_of_first)],
519            "the band belongs to the paragraph the caret is finishing"
520        );
521        assert!(spans(&d, 1).is_empty(), "and not to the one after it");
522        drop(p);
523
524        let s = live(&d, CaretHighlightScope::Sentence);
525        s.refresh(end_of_first);
526        assert_eq!(spans(&d, 0), [(0, end_of_first)]);
527        assert!(spans(&d, 1).is_empty());
528    }
529
530    /// The step past that boundary must still cross: one character further along is the start
531    /// of the next block and belongs to it.
532    #[test]
533    fn the_band_does_cross_once_the_caret_enters_the_next_paragraph() {
534        let d = doc("One is first.\nTwo is second.");
535        let start_of_second = "One is first.\n".chars().count();
536
537        let p = live(&d, CaretHighlightScope::Paragraph);
538        p.refresh(start_of_second);
539        assert!(spans(&d, 0).is_empty(), "left the first paragraph");
540        assert_eq!(spans(&d, 1), [(0, "Two is second.".chars().count())]);
541    }
542
543    /// An empty block has no sentence and no paragraph extent, and must not push a zero-length
544    /// range — which would be a highlight nobody can see but everybody has to merge.
545    #[test]
546    fn an_empty_block_bands_nothing() {
547        let d = doc("Text.\n\nMore.");
548        let s = live(&d, CaretHighlightScope::Paragraph);
549        let blank = "Text.\n".chars().count();
550        s.refresh(blank);
551        assert!(spans(&d, 1).is_empty());
552    }
553}