1#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum Move {
11 To(usize),
13 Activate,
15 Ignore,
17}
18
19pub fn resolve(stops: &[usize], from: Option<usize>, key: &str, wrap: bool) -> Move {
30 if stops.is_empty() {
31 return Move::Ignore;
32 }
33 let last = stops.len() as i32 - 1;
34 let here = from.and_then(|i| stops.iter().position(|s| *s == i));
35
36 let step = |delta: i32| -> Move {
37 let next = match here {
38 None if delta > 0 => 0,
39 None => last,
40 Some(pos) => {
41 let raw = pos as i32 + delta;
42 if raw < 0 {
43 if wrap {
44 last
45 } else {
46 0
47 }
48 } else if raw > last {
49 if wrap {
50 0
51 } else {
52 last
53 }
54 } else {
55 raw
56 }
57 }
58 };
59 stops
60 .get(next as usize)
61 .copied()
62 .map_or(Move::Ignore, Move::To)
63 };
64
65 match key {
66 "down" => step(1),
67 "up" => step(-1),
68 "home" => Move::To(stops[0]),
69 "end" => Move::To(stops[stops.len() - 1]),
70 "enter" | "space" => Move::Activate,
71 _ => Move::Ignore,
72 }
73}
74
75pub const TYPEAHEAD_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1000);
81
82#[derive(Clone, Debug, Default)]
87pub struct Typeahead {
88 query: String,
89 last: Option<web_time::Instant>,
90}
91
92impl Typeahead {
93 pub fn push(&mut self, key: &str, now: web_time::Instant) -> String {
99 let stale = self
100 .last
101 .is_none_or(|last| now.duration_since(last) > TYPEAHEAD_TIMEOUT);
102 if stale {
103 self.query.clear();
104 }
105 self.last = Some(now);
106 let repeat = !self.query.is_empty() && self.query.chars().all(|c| c.to_string() == key);
107 if repeat {
108 self.query = key.to_owned();
109 } else {
110 self.query.push_str(key);
111 }
112 self.query.clone()
113 }
114
115 pub fn is_repeat(&self) -> bool {
118 self.query.chars().count() == 1
119 }
120
121 pub fn query(&self) -> &str {
123 &self.query
124 }
125}
126
127pub fn is_typeahead_key(key: &str) -> bool {
133 let mut chars = key.chars();
134 match (chars.next(), chars.next()) {
135 (Some(c), None) => c.is_alphanumeric(),
136 _ => false,
137 }
138}
139
140pub fn typeahead(
146 labels: &[String],
147 stops: &[usize],
148 from: Option<usize>,
149 query: &str,
150 repeat: bool,
151) -> Option<usize> {
152 let needle = query.to_lowercase();
153 if needle.is_empty() || stops.is_empty() {
154 return None;
155 }
156 let start = match (from, repeat) {
159 (Some(at), true) => stops.iter().position(|s| *s == at).map_or(0, |p| p + 1),
160 (Some(at), false) => stops.iter().position(|s| *s == at).unwrap_or(0),
161 (None, _) => 0,
162 };
163 for step in 0..stops.len() {
164 let index = stops[(start + step) % stops.len()];
165 let label = labels.get(index).map(String::as_str).unwrap_or_default();
166 if label.to_lowercase().starts_with(&needle) {
167 return Some(index);
168 }
169 }
170 None
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn down_from_nothing_starts_at_the_top() {
179 assert_eq!(resolve(&[1, 2, 4], None, "down", false), Move::To(1));
180 }
181
182 #[test]
183 fn up_from_nothing_starts_at_the_bottom() {
184 assert_eq!(resolve(&[1, 2, 4], None, "up", false), Move::To(4));
185 }
186
187 #[test]
188 fn skips_the_rows_that_are_not_stops() {
189 assert_eq!(resolve(&[1, 2, 4], Some(2), "down", false), Move::To(4));
191 }
192
193 #[test]
194 fn the_ends_hold_without_wrap() {
195 assert_eq!(resolve(&[1, 2, 4], Some(4), "down", false), Move::To(4));
196 assert_eq!(resolve(&[1, 2, 4], Some(1), "up", false), Move::To(1));
197 }
198
199 #[test]
200 fn the_ends_join_up_with_wrap() {
201 assert_eq!(resolve(&[1, 2, 4], Some(4), "down", true), Move::To(1));
202 assert_eq!(resolve(&[1, 2, 4], Some(1), "up", true), Move::To(4));
203 }
204
205 #[test]
206 fn home_and_end_jump() {
207 assert_eq!(resolve(&[1, 2, 4], Some(2), "home", false), Move::To(1));
208 assert_eq!(resolve(&[1, 2, 4], Some(2), "end", false), Move::To(4));
209 }
210
211 #[test]
212 fn enter_and_space_activate() {
213 assert_eq!(resolve(&[1], Some(1), "enter", false), Move::Activate);
214 assert_eq!(resolve(&[1], Some(1), "space", false), Move::Activate);
215 }
216
217 #[test]
218 fn anything_else_is_ignored() {
219 assert_eq!(resolve(&[1], Some(1), "a", false), Move::Ignore);
220 assert_eq!(resolve(&[], None, "down", false), Move::Ignore);
221 }
222
223 #[test]
224 fn typeahead_finds_the_first_match_from_the_cursor() {
225 let labels: Vec<String> = ["Argentina", "Belgium", "Denmark", "Brazil"]
226 .iter()
227 .map(|s| (*s).to_owned())
228 .collect();
229 let stops = [0, 1, 2, 3];
230 assert_eq!(typeahead(&labels, &stops, None, "b", true), Some(1));
231 assert_eq!(typeahead(&labels, &stops, Some(2), "DEN", false), Some(2));
233 }
234
235 #[test]
236 fn a_repeated_letter_walks_the_matches() {
237 let labels: Vec<String> = ["Belgium", "Brazil", "Denmark"]
238 .iter()
239 .map(|s| (*s).to_owned())
240 .collect();
241 let stops = [0, 1, 2];
242 assert_eq!(typeahead(&labels, &stops, Some(0), "b", true), Some(1));
243 assert_eq!(typeahead(&labels, &stops, Some(1), "b", true), Some(0));
245 }
246
247 #[test]
248 fn typeahead_skips_rows_that_are_not_stops() {
249 let labels: Vec<String> = ["Section", "Belgium"]
250 .iter()
251 .map(|s| (*s).to_owned())
252 .collect();
253 assert_eq!(typeahead(&labels, &[1], None, "s", true), None);
255 }
256
257 #[test]
258 fn nothing_matches_nothing() {
259 let labels = vec!["Belgium".to_owned()];
260 assert_eq!(typeahead(&labels, &[0], None, "", true), None);
261 assert_eq!(typeahead(&labels, &[0], None, "z", true), None);
262 assert_eq!(typeahead(&[], &[], None, "b", true), None);
263 }
264
265 #[test]
266 fn the_buffer_clears_after_the_timeout() {
267 let mut ta = Typeahead::default();
268 let t0 = web_time::Instant::now();
269 assert_eq!(ta.push("d", t0), "d");
270 assert_eq!(
271 ta.push("e", t0 + std::time::Duration::from_millis(200)),
272 "de"
273 );
274 assert_eq!(ta.push("n", t0 + std::time::Duration::from_secs(3)), "n");
276 }
277
278 #[test]
279 fn a_repeated_letter_is_not_a_two_letter_search() {
280 let mut ta = Typeahead::default();
281 let t0 = web_time::Instant::now();
282 assert_eq!(ta.push("b", t0), "b");
283 assert_eq!(
284 ta.push("b", t0 + std::time::Duration::from_millis(100)),
285 "b"
286 );
287 assert!(ta.is_repeat());
288 assert_eq!(
289 ta.push("r", t0 + std::time::Duration::from_millis(200)),
290 "br"
291 );
292 assert!(!ta.is_repeat());
293 }
294
295 #[test]
296 fn only_single_printable_characters_are_collected() {
297 assert!(is_typeahead_key("a"));
298 assert!(is_typeahead_key("7"));
299 assert!(!is_typeahead_key("space"));
300 assert!(!is_typeahead_key("enter"));
301 assert!(!is_typeahead_key("-"));
302 assert!(!is_typeahead_key(""));
303 }
304}