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}