Skip to main content

odox_ui/
find.rs

1//! Searching a document's text: where the matches are, which one is current,
2//! and what a flow needs to draw them.
3//!
4//! A match is a range of characters in one paragraph's flat text, the string
5//! [`odox_core::edit::text`] answers and the page editor's offsets count in, so
6//! the same range lights up the same characters whether the paragraph is being
7//! read or edited. What a match is found in is the view's business; this module
8//! knows only how to look in a string and how to keep the results. Case is not
9//! significant, and the query is not a pattern. DESIGN.md §11.
10//
11// Author: David M. Anderson
12// Built with AI assistance (Claude, Anthropic)
13
14use std::ops::Range;
15
16use odox_core::Element;
17use odox_core::edit;
18
19use crate::flow_model::paragraph_paths;
20
21/// One place a query was found.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Match {
24    /// Which part of the document it is in, where a document has parts a view
25    /// shows one at a time: a slide, a sheet. Zero where it has none.
26    pub scope: usize,
27    /// The paragraph's path under the root the view draws, as a flow reports
28    /// it, or a cell's row and column in a sheet.
29    pub paragraph: Vec<usize>,
30    /// The characters matched, counted in the paragraph's flat text.
31    pub range: Range<usize>,
32}
33
34/// A character with its case taken away, one for one, so that a range found in
35/// the folded text is the same range in the original.
36fn fold(c: char) -> char {
37    c.to_lowercase().next().unwrap_or(c)
38}
39
40fn folded(text: &str) -> Vec<char> {
41    text.chars().map(fold).collect()
42}
43
44/// Where a query occurs in a text, as character ranges that do not overlap.
45/// An empty query occurs nowhere.
46pub fn ranges(text: &str, query: &str) -> Vec<Range<usize>> {
47    let needle = folded(query);
48    if needle.is_empty() {
49        return Vec::new();
50    }
51    let hay = folded(text);
52    let mut found = Vec::new();
53    let mut at = 0;
54    while at + needle.len() <= hay.len() {
55        if hay[at..at + needle.len()] == needle[..] {
56            found.push(at..at + needle.len());
57            at += needle.len();
58        } else {
59            at += 1;
60        }
61    }
62    found
63}
64
65/// Every match of a query in the paragraphs under a root, in the order the
66/// flow draws them, tagged with a scope.
67pub fn in_paragraphs(root: &Element, scope: usize, query: &str) -> Vec<Match> {
68    let mut found = Vec::new();
69    if query.is_empty() {
70        return found;
71    }
72    for path in paragraph_paths(root) {
73        let Some(paragraph) = root.at(&path) else {
74            continue;
75        };
76        for range in ranges(&edit::text(paragraph), query) {
77            found.push(Match {
78                scope,
79                paragraph: path.clone(),
80                range,
81            });
82        }
83    }
84    found
85}
86
87/// What a replace did: how many matches were replaced, and how many were left
88/// as they were because what holds them cannot take text.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub struct Replaced {
91    /// Matches that now say something else.
92    pub replaced: usize,
93    /// Matches left alone: a formula, or a cell that holds a number, a date or
94    /// a boolean and not text.
95    pub skipped: usize,
96}
97
98/// A text with some ranges of it, counted in characters and not overlapping,
99/// replaced by another text.
100pub fn replace_ranges(text: &str, ranges: &[Range<usize>], with: &str) -> String {
101    let mut out = String::new();
102    let mut from = 0;
103    for range in ranges {
104        out.extend(
105            text.chars()
106                .skip(from)
107                .take(range.start.saturating_sub(from)),
108        );
109        out.push_str(with);
110        from = range.end;
111    }
112    out.extend(text.chars().skip(from));
113    out
114}
115
116/// What a view keeps of a search: the matches, which one is current, and
117/// whether the view still owes a scroll to it.
118#[derive(Default)]
119pub struct Found {
120    matches: Vec<Match>,
121    current: Option<usize>,
122    reveal: bool,
123}
124
125impl Found {
126    /// Take new results. The current match stays where it was, as far as there
127    /// are as many, so that typing in a document being searched does not send
128    /// the view back to the first match.
129    pub fn set(&mut self, matches: Vec<Match>) {
130        self.matches = matches;
131        self.current = self
132            .current
133            .filter(|&at| at < self.matches.len())
134            .or_else(|| (!self.matches.is_empty()).then_some(0));
135    }
136
137    /// Forget the search.
138    pub fn clear(&mut self) {
139        *self = Self::default();
140    }
141
142    /// How many matches there are.
143    pub fn len(&self) -> usize {
144        self.matches.len()
145    }
146
147    /// Whether there are none.
148    pub fn is_empty(&self) -> bool {
149        self.matches.is_empty()
150    }
151
152    /// Every match, in document order.
153    pub fn all(&self) -> &[Match] {
154        &self.matches
155    }
156
157    /// Which match is current, counted from zero.
158    pub fn current(&self) -> Option<usize> {
159        self.current
160    }
161
162    /// Make a match the current one and scroll to it when it is drawn.
163    pub fn show(&mut self, index: usize) -> Option<&Match> {
164        let found = self.matches.get(index)?;
165        self.current = Some(index);
166        self.reveal = true;
167        Some(found)
168    }
169
170    /// The current match.
171    pub fn current_match(&self) -> Option<&Match> {
172        self.matches.get(self.current?)
173    }
174
175    /// What a flow draws, for one scope. A scroll to the current match is
176    /// owed from [`Self::show`] until the view says it has drawn the frame it
177    /// was owed in, with [`Self::drawn`].
178    pub fn highlights(&self, scope: usize) -> Option<Highlights<'_>> {
179        if self.matches.is_empty() {
180            return None;
181        }
182        Some(Highlights {
183            matches: &self.matches,
184            current: self.current,
185            reveal: self.reveal,
186            scope,
187        })
188    }
189
190    /// The frame the highlights were drawn in is done, and any scroll they
191    /// asked for has been made.
192    pub fn drawn(&mut self) {
193        self.reveal = false;
194    }
195}
196
197/// The matches a flow draws behind its text.
198#[derive(Clone)]
199pub struct Highlights<'a> {
200    matches: &'a [Match],
201    current: Option<usize>,
202    reveal: bool,
203    scope: usize,
204}
205
206impl Highlights<'_> {
207    /// The matches in one paragraph, with whether each is the current one.
208    pub fn within(&self, paragraph: &[usize]) -> Vec<(Range<usize>, bool)> {
209        let first = self
210            .matches
211            .partition_point(|m| (m.scope, m.paragraph.as_slice()) < (self.scope, paragraph));
212        self.matches[first..]
213            .iter()
214            .enumerate()
215            .take_while(|(_, m)| m.scope == self.scope && m.paragraph == paragraph)
216            .map(|(offset, m)| (m.range.clone(), self.current == Some(first + offset)))
217            .collect()
218    }
219
220    /// Whether the view is owed a scroll to the current match.
221    pub fn reveal(&self) -> bool {
222        self.reveal
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn a_scan_finds_paragraphs_by_the_paths_a_flow_reports() {
232        let bytes = std::fs::read(
233            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
234                .join("../../corpus/libreoffice/text.odt"),
235        )
236        .expect("the corpus document");
237        let document = odox_core::doc::TextDocument::read(&bytes).expect("it reads");
238        let body = document.body().expect("it has a body");
239        let found = in_paragraphs(body, 0, "AND");
240        assert_eq!(found.len(), 6);
241        for hit in &found {
242            let paragraph = body.at(&hit.paragraph).expect("a path to a paragraph");
243            let text: Vec<char> = edit::text(paragraph).chars().collect();
244            let matched: String = text[hit.range.clone()].iter().collect();
245            assert_eq!(matched.to_lowercase(), "and");
246        }
247    }
248
249    #[test]
250    fn ranges_are_replaced_in_place_and_counted_in_characters() {
251        assert_eq!(replace_ranges("ünï ünï x", &[0..3, 4..7], "a"), "a a x");
252        assert_eq!(
253            replace_ranges("abc", std::slice::from_ref(&(1..2)), ""),
254            "ac"
255        );
256        assert_eq!(replace_ranges("abc", &[], "z"), "abc");
257    }
258
259    #[test]
260    fn a_query_is_found_whatever_its_case() {
261        assert_eq!(ranges("Odox and ODOX", "odox"), vec![0..4, 9..13]);
262    }
263
264    #[test]
265    fn matches_do_not_overlap() {
266        assert_eq!(ranges("aaaa", "aa"), vec![0..2, 2..4]);
267    }
268
269    #[test]
270    fn an_empty_query_matches_nothing() {
271        assert_eq!(ranges("anything", ""), Vec::<Range<usize>>::new());
272    }
273
274    #[test]
275    fn ranges_count_characters_and_not_bytes() {
276        assert_eq!(ranges("ünï ünï", "ünï"), vec![0..3, 4..7]);
277    }
278
279    fn found_in(paragraph: &[usize], range: Range<usize>) -> Match {
280        Match {
281            scope: 0,
282            paragraph: paragraph.to_vec(),
283            range,
284        }
285    }
286
287    #[test]
288    fn a_paragraphs_highlights_are_the_matches_with_its_path() {
289        let mut found = Found::default();
290        found.set(vec![
291            found_in(&[0], 1..2),
292            found_in(&[1], 0..3),
293            found_in(&[1], 5..8),
294            found_in(&[2, 0], 0..1),
295        ]);
296        found.show(2);
297        let highlights = found.highlights(0).expect("there are matches");
298        assert_eq!(highlights.within(&[1]), vec![(0..3, false), (5..8, true)]);
299        assert_eq!(highlights.within(&[3]), Vec::<(Range<usize>, bool)>::new());
300    }
301
302    #[test]
303    fn the_current_match_survives_new_results_that_still_reach_it() {
304        let mut found = Found::default();
305        found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..1)]);
306        found.show(1);
307        found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..2)]);
308        assert_eq!(found.current(), Some(1));
309        found.set(vec![found_in(&[0], 0..1)]);
310        assert_eq!(found.current(), Some(0));
311    }
312
313    #[test]
314    fn a_scroll_is_owed_until_the_frame_is_drawn() {
315        let mut found = Found::default();
316        found.set(vec![found_in(&[0], 0..1)]);
317        found.show(0);
318        assert!(found.highlights(0).is_some_and(|h| h.reveal()));
319        assert!(found.highlights(0).is_some_and(|h| h.reveal()));
320        found.drawn();
321        assert!(found.highlights(0).is_some_and(|h| !h.reveal()));
322    }
323}