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}