Skip to main content

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}