teksilo_widgets/rich_text/find_session.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! A find-highlight layer over one document.
5//!
6//! [`FindSession`] owns a **range session** on a [`TextDocument`] (see
7//! `add_range_session`) and keeps its ranges in step with the matches of a query — the
8//! *current* match formatted one way, the others another. It is the search side of the
9//! highlight registry: the document holds the layer, a per-view [`HighlightMask`] decides
10//! which panes render it, and this drives what it contains.
11//!
12//! It is deliberately **pure over the document** — no widget, no theme. The colours come in as
13//! two [`HighlightFormat`]s (the caller resolves them from semantic theme roles), and the
14//! matcher is text-document's own, so a project-wide search and this in-editor find can never
15//! disagree about what a match is. A view wires it up by including
16//! [`session_id`](FindSession::session_id) in its mask (the default `all()` already shows it)
17//! and calling `select_range` / `reveal_range` on the current match.
18//!
19//! ## A document with matches and no current one
20//!
21//! [`set_current`](FindSession::set_current) takes an `Option`, and the `None` is not a
22//! convenience: a find that spans **several** documents — a page of editors, one per
23//! scene — has exactly one current match across the whole page, so every document except
24//! the one the reader is standing in holds matches that are all "other". Without that
25//! state each document would style one of its own matches as current, and a reader
26//! walking a fifty-scene chapter would see fifty current matches at once.
27//!
28//! ## Staleness on edit
29//!
30//! Matches are **absolute char offsets**, frozen at [`set_query`](FindSession::set_query). An
31//! edit anywhere before a match shifts the text underneath those offsets, and text-document
32//! does **not** re-anchor a range session the way it re-anchors carets — so the boxes would
33//! drift onto the wrong characters. To make that impossible to forget, a `FindSession`
34//! subscribes to its document and marks itself stale on any content edit; the host calls
35//! [`refresh_if_stale`](FindSession::refresh_if_stale) (e.g. once per frame) to re-derive. The
36//! matcher is cheap over a single open document, and re-deriving is the same discipline the
37//! rest of search follows: never carry an offset across an edit.
38//!
39//! [`HighlightMask`]: teksilo_text::text_document::HighlightMask
40
41use std::sync::Arc;
42use std::sync::atomic::{AtomicBool, Ordering};
43
44use teksilo_text::text_document::{
45 DocumentEvent, FindMatch, FindOptions, HighlightFormat, RangeHighlight, SessionId,
46 Subscription, TextDocument,
47};
48
49/// A search-highlight layer: the matches of a query, as a document range session, with the
50/// current match distinguished.
51pub struct FindSession {
52 doc: TextDocument,
53 session: SessionId,
54 matches: Vec<FindMatch>,
55 /// Index into `matches` of the current match — the one the reader is standing on.
56 ///
57 /// `None` when there is no such match: either nothing matched, or this document is
58 /// one of several being searched and the reader is standing in another of them. See
59 /// the module note.
60 current: Option<usize>,
61 current_format: HighlightFormat,
62 other_format: HighlightFormat,
63 /// The last query + options, kept so [`refresh_if_stale`](Self::refresh_if_stale) can
64 /// re-run them after an edit without the caller re-passing them.
65 query: String,
66 options: FindOptions,
67 /// Set by the document subscription on any offset-moving edit; drained by
68 /// [`refresh_if_stale`](Self::refresh_if_stale). `Arc` because the `on_change` callback is
69 /// `Send + Sync`.
70 dirty: Arc<AtomicBool>,
71 /// Kept alive so the subscription lives as long as the session (dropping it unsubscribes).
72 _sub: Subscription,
73}
74
75impl FindSession {
76 /// Attach a fresh, empty find session to `doc`. `current_format` styles the current match
77 /// (e.g. the editor selection colour); `other_format` styles the rest (e.g. a subtle
78 /// accent). Both should be **paint-only** (background / underline) so the highlight stays
79 /// out of the accessibility tree — a screen-reader user navigates matches by count, not by
80 /// colour.
81 pub fn new(
82 doc: &TextDocument,
83 current_format: HighlightFormat,
84 other_format: HighlightFormat,
85 ) -> Self {
86 let session = doc.add_range_session();
87 let dirty = Arc::new(AtomicBool::new(false));
88 let sub = {
89 let dirty = dirty.clone();
90 doc.on_change(move |event| {
91 // Only edits that MOVE char offsets stale the cached matches. Format- and
92 // highlight-only events leave positions where they were — and reacting to
93 // `HighlightPaintChanged` here would loop, since this session's own
94 // `set_session_ranges` emits exactly that.
95 if matches!(
96 event,
97 DocumentEvent::ContentsChanged { .. }
98 | DocumentEvent::DocumentReset
99 | DocumentEvent::BlockCountChanged(_)
100 | DocumentEvent::FlowElementsInserted { .. }
101 | DocumentEvent::FlowElementsRemoved { .. }
102 ) {
103 dirty.store(true, Ordering::Relaxed);
104 }
105 })
106 };
107 Self {
108 doc: doc.clone(),
109 session,
110 matches: Vec::new(),
111 current: None,
112 current_format,
113 other_format,
114 query: String::new(),
115 options: FindOptions::default(),
116 dirty,
117 _sub: sub,
118 }
119 }
120
121 /// The document session this layer owns — put it in a view's
122 /// [`HighlightMask`](teksilo_text::text_document::HighlightMask) to render it there.
123 pub fn session_id(&self) -> SessionId {
124 self.session
125 }
126
127 /// Re-run `query` and highlight every match; the first becomes current. An empty query
128 /// clears the highlighting.
129 pub fn set_query(&mut self, query: &str, options: &FindOptions) {
130 self.query = query.to_string();
131 self.options = options.clone();
132 self.rerun();
133 self.current = (!self.matches.is_empty()).then_some(0);
134 self.apply();
135 }
136
137 /// Say which of this document's matches the reader is standing on, or `None` for
138 /// "none of them" — the state a find spanning several documents needs for every
139 /// document but the one holding the cursor. Returns the new current match.
140 ///
141 /// An index past the end is clamped to the last match, and any index at all on an
142 /// empty match set is `None`: a caller stepping into a document it has just re-run
143 /// cannot be asked to know how many matches it turned out to have.
144 pub fn set_current(&mut self, index: Option<usize>) -> Option<FindMatch> {
145 self.current = match index {
146 Some(_) if self.matches.is_empty() => None,
147 Some(i) => Some(i.min(self.matches.len() - 1)),
148 None => None,
149 };
150 self.apply();
151 self.current_match()
152 }
153
154 /// Re-derive the matches for the stored query **if** an edit has staled them since the last
155 /// run. Returns `true` if it re-derived (so the caller can request a repaint). Cheap to
156 /// call every frame: a no-op when nothing has changed.
157 ///
158 /// The current-match index is clamped, not reset — an edit should not throw away where the
159 /// writer was in the match list, only re-locate the matches. A document that had no
160 /// current match still has none: the reader is standing somewhere else.
161 pub fn refresh_if_stale(&mut self) -> bool {
162 if !self.dirty.swap(false, Ordering::Relaxed) {
163 return false;
164 }
165 self.rerun();
166 if let Some(i) = self.current
167 && i >= self.matches.len()
168 {
169 self.current = self.matches.len().checked_sub(1);
170 }
171 self.apply();
172 true
173 }
174
175 /// Run the stored query against the document now, into `self.matches`, and clear the dirty
176 /// flag. Does not touch `current` or push ranges — callers do that.
177 fn rerun(&mut self) {
178 self.matches = if self.query.is_empty() {
179 Vec::new()
180 } else {
181 // Best-effort: a search that errors (e.g. a malformed regex) simply highlights
182 // nothing, rather than propagating into a banner that just wanted to draw boxes.
183 self.doc
184 .find_all(&self.query, &self.options)
185 .unwrap_or_default()
186 };
187 self.dirty.store(false, Ordering::Relaxed);
188 }
189
190 /// How many matches the last query found.
191 pub fn match_count(&self) -> usize {
192 self.matches.len()
193 }
194
195 /// The current match's 0-based index (`0` when the reader is not standing on one).
196 pub fn current_index(&self) -> usize {
197 self.current.unwrap_or(0)
198 }
199
200 /// The current match, if the reader is standing on one of this document's.
201 pub fn current_match(&self) -> Option<FindMatch> {
202 self.matches.get(self.current?).cloned()
203 }
204
205 /// Advance to the next match, wrapping past the end, and return it. `None` if there are no
206 /// matches. From "no current match" it lands on the **first** — which is what stepping
207 /// into this document from the one above it means.
208 pub fn next_match(&mut self) -> Option<FindMatch> {
209 if self.matches.is_empty() {
210 return None;
211 }
212 self.current = Some(match self.current {
213 Some(i) => (i + 1) % self.matches.len(),
214 None => 0,
215 });
216 self.apply();
217 self.current_match()
218 }
219
220 /// Step to the previous match, wrapping past the start, and return it. From "no current
221 /// match" it lands on the **last**, the mirror of [`next_match`](Self::next_match):
222 /// stepping backwards into a document arrives at its end.
223 pub fn prev_match(&mut self) -> Option<FindMatch> {
224 if self.matches.is_empty() {
225 return None;
226 }
227 self.current = Some(match self.current {
228 Some(i) => (i + self.matches.len() - 1) % self.matches.len(),
229 None => self.matches.len() - 1,
230 });
231 self.apply();
232 self.current_match()
233 }
234
235 /// Clear all highlighting (the query went away, or the banner closed).
236 pub fn clear(&mut self) {
237 self.query.clear();
238 self.matches.clear();
239 self.current = None;
240 self.dirty.store(false, Ordering::Relaxed);
241 self.apply();
242 }
243
244 /// Push the current match set to the document as range highlights — current match in one
245 /// format, the rest in the other. The current range is emitted **last**, so where matches
246 /// abut, its format wins the registry's last-writer-per-field merge.
247 fn apply(&self) {
248 let mut ranges: Vec<RangeHighlight> = Vec::with_capacity(self.matches.len());
249 for (i, m) in self.matches.iter().enumerate() {
250 if Some(i) == self.current {
251 continue;
252 }
253 ranges.push(RangeHighlight {
254 start: m.position,
255 length: m.length,
256 format: self.other_format.clone(),
257 });
258 }
259 if let Some(cur) = self.current.and_then(|i| self.matches.get(i)) {
260 ranges.push(RangeHighlight {
261 start: cur.position,
262 length: cur.length,
263 format: self.current_format.clone(),
264 });
265 }
266 self.doc.set_session_ranges(self.session, ranges);
267 }
268}
269
270impl Drop for FindSession {
271 /// Retire the session so a closed find banner leaves no highlight layer behind on the
272 /// shared document.
273 fn drop(&mut self) {
274 self.doc.remove_session(self.session);
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use teksilo_text::text_document::{Color, FlowElementSnapshot, HighlightMask};
282
283 fn bg(color: Color) -> HighlightFormat {
284 HighlightFormat {
285 background_color: Some(color),
286 ..Default::default()
287 }
288 }
289
290 const CURRENT: Color = Color {
291 red: 0,
292 green: 120,
293 blue: 255,
294 alpha: 255,
295 };
296 const OTHER: Color = Color {
297 red: 255,
298 green: 214,
299 blue: 0,
300 alpha: 150,
301 };
302
303 fn doc(text: &str) -> TextDocument {
304 let d = TextDocument::new();
305 d.set_plain_text(text).unwrap();
306 d
307 }
308
309 fn paint_spans(doc: &TextDocument) -> Vec<teksilo_text::text_document::PaintHighlightSpan> {
310 match &doc.snapshot_flow_masked(&HighlightMask::all()).elements[0] {
311 FlowElementSnapshot::Block(b) => b.paint_highlights.clone(),
312 _ => panic!("block"),
313 }
314 }
315
316 #[test]
317 fn a_query_highlights_every_match_with_the_current_distinguished() {
318 let d = doc("elena and Elena and ELENA");
319 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
320 fs.set_query("elena", &FindOptions::default());
321
322 assert_eq!(fs.match_count(), 3, "case-folded: all three");
323 let spans = paint_spans(&d);
324 // Three highlighted ranges; exactly one carries the current colour.
325 let current: Vec<_> = spans
326 .iter()
327 .filter(|s| s.background_color == Some(CURRENT))
328 .collect();
329 let others: Vec<_> = spans
330 .iter()
331 .filter(|s| s.background_color == Some(OTHER))
332 .collect();
333 assert_eq!(current.len(), 1, "one current match");
334 assert_eq!(others.len(), 2, "two other matches");
335 // The current match is the first occurrence.
336 assert_eq!(current[0].start, 0);
337 }
338
339 #[test]
340 fn next_and_prev_move_the_current_match_and_wrap() {
341 let d = doc("a x a x a");
342 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
343 fs.set_query("a", &FindOptions::default());
344 assert_eq!(fs.match_count(), 3);
345 assert_eq!(fs.current_index(), 0);
346
347 assert_eq!(fs.next_match().unwrap().position, 4); // second "a" at char 4
348 assert_eq!(fs.current_index(), 1);
349 fs.next_match();
350 assert_eq!(fs.current_index(), 2);
351 fs.next_match(); // wraps
352 assert_eq!(fs.current_index(), 0);
353 fs.prev_match(); // wraps back to the end
354 assert_eq!(fs.current_index(), 2);
355 }
356
357 #[test]
358 fn an_empty_query_clears_the_highlighting() {
359 let d = doc("hello hello");
360 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
361 fs.set_query("hello", &FindOptions::default());
362 assert!(!paint_spans(&d).is_empty());
363 fs.set_query("", &FindOptions::default());
364 assert!(paint_spans(&d).is_empty(), "cleared");
365 assert_eq!(fs.match_count(), 0);
366 }
367
368 /// **The staleness fix.** An edit that shifts the text must not leave the highlights on the
369 /// old offsets — `refresh_if_stale` re-derives against the edited document.
370 #[test]
371 fn an_edit_stales_the_matches_and_refresh_re_derives_them() {
372 let d = doc("the cat sat");
373 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
374 fs.set_query("cat", &FindOptions::default());
375 let before = fs.current_match().unwrap();
376 assert_eq!(before.position, 4, "`cat` starts at char 4");
377
378 // Insert four chars at the very front: "cat" shifts to char 8.
379 d.set_plain_text("XXXXthe cat sat").unwrap();
380
381 // The old match is now stale. A refresh re-locates it.
382 assert!(
383 fs.refresh_if_stale(),
384 "the edit must have marked the session stale"
385 );
386 let after = fs.current_match().unwrap();
387 assert_eq!(after.position, 8, "the match followed the text it names");
388
389 // …and a second refresh with no edit is a cheap no-op.
390 assert!(!fs.refresh_if_stale());
391 }
392
393 /// A refresh whose re-run drops the match the writer was on clamps the index rather than
394 /// panicking or resetting to the top.
395 #[test]
396 fn refresh_clamps_the_current_index_when_matches_shrink() {
397 let d = doc("a a a");
398 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
399 fs.set_query("a", &FindOptions::default());
400 fs.next_match();
401 fs.next_match(); // current = 2 (the last)
402 assert_eq!(fs.current_index(), 2);
403
404 d.set_plain_text("a").unwrap(); // only one match now
405 assert!(fs.refresh_if_stale());
406 assert_eq!(fs.match_count(), 1);
407 assert_eq!(fs.current_index(), 0, "clamped to the one remaining match");
408 }
409
410 /// **A document nobody is standing in still shows its matches** — all of them as
411 /// "other", none as current. This is what a find spanning a page of editors needs:
412 /// one current match across the whole page, not one per document.
413 #[test]
414 fn clearing_the_current_match_leaves_every_match_an_other() {
415 let d = doc("elena and Elena and ELENA");
416 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
417 fs.set_query("elena", &FindOptions::default());
418 assert!(fs.current_match().is_some(), "set_query lands on the first");
419
420 assert!(
421 fs.set_current(None).is_none(),
422 "no current match to report back"
423 );
424 let spans = paint_spans(&d);
425 assert_eq!(spans.len(), 3, "all three still highlighted");
426 assert!(
427 spans.iter().all(|s| s.background_color == Some(OTHER)),
428 "and every one of them as an `other`"
429 );
430 }
431
432 /// Stepping into a document from the one above lands on its **first** match; stepping
433 /// in backwards lands on its **last**. That is the whole of how a multi-document walk
434 /// crosses a boundary, so it is asserted here rather than in the caller.
435 #[test]
436 fn stepping_into_a_document_with_no_current_match_enters_from_the_right_end() {
437 let d = doc("a x a x a");
438 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
439 fs.set_query("a", &FindOptions::default());
440
441 fs.set_current(None);
442 assert_eq!(fs.next_match().unwrap().position, 0, "forwards: the first");
443
444 fs.set_current(None);
445 assert_eq!(fs.prev_match().unwrap().position, 8, "backwards: the last");
446 }
447
448 /// `set_current` is handed an index by a caller that has not counted this document's
449 /// matches — walking backwards into it asks for "the last one" as a number it guessed.
450 /// Out of range clamps; an empty document answers `None` rather than panicking.
451 #[test]
452 fn setting_a_current_match_out_of_range_clamps_instead_of_panicking() {
453 let d = doc("a x a");
454 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
455 fs.set_query("a", &FindOptions::default());
456 assert_eq!(
457 fs.set_current(Some(99)).unwrap().position,
458 4,
459 "the last one"
460 );
461
462 fs.set_query("zzz", &FindOptions::default());
463 assert!(fs.set_current(Some(0)).is_none(), "nothing to stand on");
464 assert_eq!(fs.current_index(), 0, "and the index reads as zero");
465 }
466
467 /// An edit that removes the matches under a document nobody is standing in must not
468 /// hand it a current match on the way past — `refresh_if_stale` clamps `Some`, it does
469 /// not invent one.
470 #[test]
471 fn a_refresh_does_not_give_a_currentless_document_a_current_match() {
472 let d = doc("a a a");
473 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
474 fs.set_query("a", &FindOptions::default());
475 fs.set_current(None);
476
477 d.set_plain_text("a").unwrap();
478 assert!(fs.refresh_if_stale());
479 assert!(
480 fs.current_match().is_none(),
481 "the reader is still standing somewhere else"
482 );
483 }
484
485 #[test]
486 fn dropping_the_session_removes_the_layer() {
487 let d = doc("hello hello");
488 {
489 let mut fs = FindSession::new(&d, bg(CURRENT), bg(OTHER));
490 fs.set_query("hello", &FindOptions::default());
491 assert!(!paint_spans(&d).is_empty());
492 } // fs dropped here
493 assert!(
494 paint_spans(&d).is_empty(),
495 "the find session's layer must not outlive it"
496 );
497 }
498}