Skip to main content

rotulus_layout/
search.rs

1//! In-buffer search: find every occurrence of a needle, and a cursor
2//! over the results.
3//!
4//! Net-new rather than a port of xtext's `gtk_xtext_search`, which was
5//! built on GRegex plus a `search_found` list threaded through the entry
6//! chain. Here a match is a `(message, source, byte range)` over the
7//! structured message model and needs no parallel bookkeeping on the
8//! buffer at all.
9//!
10//! Matching is literal, not regex. The needle is what the user typed;
11//! there is no metacharacter vocabulary to explain and no pathological
12//! backtracking to defend against.
13
14use crate::message::MessageId;
15use crate::wrap::LineSource;
16
17/// One occurrence, in the same coordinate system carets use: a byte
18/// range within one source of one message.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Match {
21    pub message: MessageId,
22    pub source: LineSource,
23    pub start: usize,
24    pub end: usize,
25}
26
27impl Match {
28    /// Does `pos` (a byte offset in the same source) fall inside?
29    pub fn contains(&self, pos: usize) -> bool {
30        pos >= self.start && pos < self.end
31    }
32}
33
34/// A search and its results.
35///
36/// The cursor is an index into `matches`, so stepping is trivial and
37/// wraps. `None` means "no match is current yet" — the state right
38/// after a query changes, before the caller has picked a starting point.
39#[derive(Debug, Default, Clone)]
40pub struct SearchState {
41    needle: String,
42    case_sensitive: bool,
43    matches: Vec<Match>,
44    current: Option<usize>,
45}
46
47impl SearchState {
48    pub fn new() -> SearchState {
49        SearchState::default()
50    }
51
52    pub fn needle(&self) -> &str {
53        &self.needle
54    }
55
56    pub fn case_sensitive(&self) -> bool {
57        self.case_sensitive
58    }
59
60    pub fn is_active(&self) -> bool {
61        !self.needle.is_empty()
62    }
63
64    pub fn matches(&self) -> &[Match] {
65        &self.matches
66    }
67
68    pub fn len(&self) -> usize {
69        self.matches.len()
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.matches.is_empty()
74    }
75
76    /// 1-based position of the current match, for a "3 of 17" readout.
77    pub fn ordinal(&self) -> Option<usize> {
78        self.current.map(|i| i + 1)
79    }
80
81    pub fn current(&self) -> Option<Match> {
82        self.current.and_then(|i| self.matches.get(i)).copied()
83    }
84
85    pub fn current_index(&self) -> Option<usize> {
86        self.current
87    }
88
89    /// Is this exact occurrence the current one?
90    ///
91    /// Compared by value rather than by index so the renderer doesn't
92    /// need to know where in the list it is.
93    pub fn is_current(&self, m: &Match) -> bool {
94        self.current() == Some(*m)
95    }
96
97    pub fn clear(&mut self) {
98        self.needle.clear();
99        self.matches.clear();
100        self.current = None;
101    }
102
103    /// Install a fresh result set for `needle`.
104    pub fn set_results(&mut self, needle: &str, case_sensitive: bool, matches: Vec<Match>) {
105        self.needle = needle.to_string();
106        self.case_sensitive = case_sensitive;
107        self.matches = matches;
108        self.current = None;
109    }
110
111    /// Point the cursor at the first match at or after `message`, so
112    /// opening the find bar starts from what is on screen rather than
113    /// from the top of a long scrollback.
114    ///
115    /// Falls back to the last match when everything is above the
116    /// viewport, which is the useful direction: scrollback grows
117    /// downward and the interesting end is the recent one.
118    pub fn seek_from(&mut self, order: impl Fn(MessageId) -> Option<usize>, from_row: usize) {
119        if self.matches.is_empty() {
120            self.current = None;
121            return;
122        }
123        let at = self
124            .matches
125            .iter()
126            .position(|m| order(m.message).is_some_and(|r| r >= from_row));
127        self.current = Some(at.unwrap_or(self.matches.len() - 1));
128    }
129
130    /// Step the cursor. `dir > 0` forward, `dir < 0` back; both wrap.
131    ///
132    /// With no current match, a forward step selects the first and a
133    /// backward step the last, so both keys do something useful the
134    /// first time they are pressed.
135    pub fn step(&mut self, dir: i32) -> Option<Match> {
136        if self.matches.is_empty() {
137            self.current = None;
138            return None;
139        }
140        let n = self.matches.len();
141        self.current = Some(match (self.current, dir >= 0) {
142            (None, true) => 0,
143            (None, false) => n - 1,
144            (Some(i), true) => (i + 1) % n,
145            (Some(i), false) => (i + n - 1) % n,
146        });
147        self.current()
148    }
149}
150
151/// Every occurrence of `needle` in `haystack`, as byte ranges.
152///
153/// Occurrences do not overlap: after a match the scan resumes at its
154/// end, so searching "aa" in "aaaa" finds two, not three. That is what
155/// every find bar does and what makes the match count match what the
156/// user can step through.
157///
158/// **Case-insensitive matching is per-character.** The obvious
159/// implementation — lowercase both sides and search that — is wrong
160/// here, because lowercasing can change a string's byte length (`İ`
161/// U+0130 lowercases to two chars) and every offset it returns would
162/// then be an offset into a string the caller does not have. Walking the
163/// original preserves the offsets by construction. The cost is that
164/// case-folds which change *character* count (`ß` vs `ss`) don't match;
165/// that is a real limitation and a deliberate one.
166pub fn find_all(haystack: &str, needle: &str, case_sensitive: bool) -> Vec<(usize, usize)> {
167    let mut out = Vec::new();
168    if needle.is_empty() || haystack.is_empty() {
169        return out;
170    }
171
172    if case_sensitive {
173        let mut base = 0usize;
174        while let Some(rel) = haystack[base..].find(needle) {
175            let start = base + rel;
176            let end = start + needle.len();
177            out.push((start, end));
178            base = end;
179        }
180        return out;
181    }
182
183    let mut start = 0usize;
184    while start < haystack.len() {
185        if !haystack.is_char_boundary(start) {
186            start += 1;
187            continue;
188        }
189        match match_at(&haystack[start..], needle) {
190            Some(len) => {
191                out.push((start, start + len));
192                // Zero-length can't happen (needle is non-empty) but
193                // guard anyway: a zero-width advance here is an infinite
194                // loop, and this runs on every keystroke.
195                start += len.max(1);
196            }
197            None => {
198                start += haystack[start..]
199                    .chars()
200                    .next()
201                    .map(char::len_utf8)
202                    .unwrap_or(1);
203            }
204        }
205    }
206    out
207}
208
209/// If `needle` matches a prefix of `hay` case-insensitively, the length
210/// of that prefix **in `hay`'s bytes** — which is not necessarily the
211/// needle's own length.
212fn match_at(hay: &str, needle: &str) -> Option<usize> {
213    let mut h = hay.chars();
214    let mut n = needle.chars();
215    let mut used = 0usize;
216    loop {
217        let Some(nc) = n.next() else {
218            return Some(used);
219        };
220        let hc = h.next()?;
221        if !eq_fold(hc, nc) {
222            return None;
223        }
224        used += hc.len_utf8();
225    }
226}
227
228/// Case-insensitive single-character comparison.
229///
230/// `to_lowercase` yields an iterator because one char can fold to
231/// several; comparing only single-char folds is what limits this to
232/// same-length case pairs, which covers every alphabet a Hotline server
233/// is going to send.
234fn eq_fold(a: char, b: char) -> bool {
235    if a == b {
236        return true;
237    }
238    let mut al = a.to_lowercase();
239    let mut bl = b.to_lowercase();
240    match (al.next(), bl.next()) {
241        (Some(x), Some(y)) => x == y && al.next().is_none() && bl.next().is_none(),
242        _ => false,
243    }
244}