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 view keeps of a search: the matches, which one is current, and
88/// whether the view still owes a scroll to it.
89#[derive(Default)]
90pub struct Found {
91    matches: Vec<Match>,
92    current: Option<usize>,
93    reveal: bool,
94}
95
96impl Found {
97    /// Take new results. The current match stays where it was, as far as there
98    /// are as many, so that typing in a document being searched does not send
99    /// the view back to the first match.
100    pub fn set(&mut self, matches: Vec<Match>) {
101        self.matches = matches;
102        self.current = self
103            .current
104            .filter(|&at| at < self.matches.len())
105            .or_else(|| (!self.matches.is_empty()).then_some(0));
106    }
107
108    /// Forget the search.
109    pub fn clear(&mut self) {
110        *self = Self::default();
111    }
112
113    /// How many matches there are.
114    pub fn len(&self) -> usize {
115        self.matches.len()
116    }
117
118    /// Whether there are none.
119    pub fn is_empty(&self) -> bool {
120        self.matches.is_empty()
121    }
122
123    /// Which match is current, counted from zero.
124    pub fn current(&self) -> Option<usize> {
125        self.current
126    }
127
128    /// Make a match the current one and scroll to it when it is drawn.
129    pub fn show(&mut self, index: usize) -> Option<&Match> {
130        let found = self.matches.get(index)?;
131        self.current = Some(index);
132        self.reveal = true;
133        Some(found)
134    }
135
136    /// The current match.
137    pub fn current_match(&self) -> Option<&Match> {
138        self.matches.get(self.current?)
139    }
140
141    /// What a flow draws, for one scope. A scroll to the current match is
142    /// owed from [`Self::show`] until the view says it has drawn the frame it
143    /// was owed in, with [`Self::drawn`].
144    pub fn highlights(&self, scope: usize) -> Option<Highlights<'_>> {
145        if self.matches.is_empty() {
146            return None;
147        }
148        Some(Highlights {
149            matches: &self.matches,
150            current: self.current,
151            reveal: self.reveal,
152            scope,
153        })
154    }
155
156    /// The frame the highlights were drawn in is done, and any scroll they
157    /// asked for has been made.
158    pub fn drawn(&mut self) {
159        self.reveal = false;
160    }
161}
162
163/// The matches a flow draws behind its text.
164#[derive(Clone)]
165pub struct Highlights<'a> {
166    matches: &'a [Match],
167    current: Option<usize>,
168    reveal: bool,
169    scope: usize,
170}
171
172impl Highlights<'_> {
173    /// The matches in one paragraph, with whether each is the current one.
174    pub fn within(&self, paragraph: &[usize]) -> Vec<(Range<usize>, bool)> {
175        let first = self
176            .matches
177            .partition_point(|m| (m.scope, m.paragraph.as_slice()) < (self.scope, paragraph));
178        self.matches[first..]
179            .iter()
180            .enumerate()
181            .take_while(|(_, m)| m.scope == self.scope && m.paragraph == paragraph)
182            .map(|(offset, m)| (m.range.clone(), self.current == Some(first + offset)))
183            .collect()
184    }
185
186    /// Whether the view is owed a scroll to the current match.
187    pub fn reveal(&self) -> bool {
188        self.reveal
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn a_scan_finds_paragraphs_by_the_paths_a_flow_reports() {
198        let bytes = std::fs::read(
199            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
200                .join("../../corpus/libreoffice/text.odt"),
201        )
202        .expect("the corpus document");
203        let document = odox_core::doc::TextDocument::read(&bytes).expect("it reads");
204        let body = document.body().expect("it has a body");
205        let found = in_paragraphs(body, 0, "AND");
206        assert_eq!(found.len(), 6);
207        for hit in &found {
208            let paragraph = body.at(&hit.paragraph).expect("a path to a paragraph");
209            let text: Vec<char> = edit::text(paragraph).chars().collect();
210            let matched: String = text[hit.range.clone()].iter().collect();
211            assert_eq!(matched.to_lowercase(), "and");
212        }
213    }
214
215    #[test]
216    fn a_query_is_found_whatever_its_case() {
217        assert_eq!(ranges("Odox and ODOX", "odox"), vec![0..4, 9..13]);
218    }
219
220    #[test]
221    fn matches_do_not_overlap() {
222        assert_eq!(ranges("aaaa", "aa"), vec![0..2, 2..4]);
223    }
224
225    #[test]
226    fn an_empty_query_matches_nothing() {
227        assert_eq!(ranges("anything", ""), Vec::<Range<usize>>::new());
228    }
229
230    #[test]
231    fn ranges_count_characters_and_not_bytes() {
232        assert_eq!(ranges("ünï ünï", "ünï"), vec![0..3, 4..7]);
233    }
234
235    fn found_in(paragraph: &[usize], range: Range<usize>) -> Match {
236        Match {
237            scope: 0,
238            paragraph: paragraph.to_vec(),
239            range,
240        }
241    }
242
243    #[test]
244    fn a_paragraphs_highlights_are_the_matches_with_its_path() {
245        let mut found = Found::default();
246        found.set(vec![
247            found_in(&[0], 1..2),
248            found_in(&[1], 0..3),
249            found_in(&[1], 5..8),
250            found_in(&[2, 0], 0..1),
251        ]);
252        found.show(2);
253        let highlights = found.highlights(0).expect("there are matches");
254        assert_eq!(highlights.within(&[1]), vec![(0..3, false), (5..8, true)]);
255        assert_eq!(highlights.within(&[3]), Vec::<(Range<usize>, bool)>::new());
256    }
257
258    #[test]
259    fn the_current_match_survives_new_results_that_still_reach_it() {
260        let mut found = Found::default();
261        found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..1)]);
262        found.show(1);
263        found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..2)]);
264        assert_eq!(found.current(), Some(1));
265        found.set(vec![found_in(&[0], 0..1)]);
266        assert_eq!(found.current(), Some(0));
267    }
268
269    #[test]
270    fn a_scroll_is_owed_until_the_frame_is_drawn() {
271        let mut found = Found::default();
272        found.set(vec![found_in(&[0], 0..1)]);
273        found.show(0);
274        assert!(found.highlights(0).is_some_and(|h| h.reveal()));
275        assert!(found.highlights(0).is_some_and(|h| h.reveal()));
276        found.drawn();
277        assert!(found.highlights(0).is_some_and(|h| !h.reveal()));
278    }
279}