1use std::rc::Rc;
11
12use frus_core::{Point, Rect, ResolvedTextStyle, Scene, TextStyle};
13use frus_layout::{Dimension, FlexDirection, Style};
14
15use crate::flex::Flex;
16use crate::interaction::Status;
17use crate::portal::Placement;
18use crate::scroll::SingleChildScrollView;
19use crate::textinput::TextField;
20use crate::theme::Theme;
21use crate::widget::Widget;
22
23const DEFAULT_WIDTH: f32 = 260.0;
24const ROW_H: f32 = 32.0;
25const PAD_X: f32 = 10.0;
26const ROW_GAP: f32 = 2.0;
28
29fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
37 over.or(theme.and_then(|t| t.widgets.autocomplete.text_style))
38 .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
39 .resolved()
40}
41
42fn row_height(style: &ResolvedTextStyle) -> f32 {
44 frus_text::line_box(ROW_H, style, 0.0)
45}
46
47fn match_range(label: &str, query: &str) -> Option<(usize, usize)> {
50 if query.trim().is_empty() {
51 return None;
52 }
53 let ll: Vec<char> = label.to_lowercase().chars().collect();
54 let ql: Vec<char> = query.to_lowercase().chars().collect();
55 if ql.is_empty() || ql.len() > ll.len() {
56 return None;
57 }
58 (0..=ll.len() - ql.len())
59 .find(|&i| ll[i..i + ql.len()] == ql[..])
60 .map(|i| (i, i + ql.len()))
61}
62
63struct Suggestion<Msg> {
66 label: String,
67 query: String,
69 width: f32,
70 active: bool,
72 text_style: Option<TextStyle>,
73 message: Msg,
74}
75
76impl<Msg> Suggestion<Msg> {
77 fn sizing(&self, theme: Option<&Theme>) -> Style {
78 Style {
79 width: Dimension::Length(self.width),
80 height: Dimension::Length(row_height(&label_style(self.text_style, theme))),
81 ..Default::default()
82 }
83 }
84}
85
86impl<Msg: Clone> Widget<Msg> for Suggestion<Msg> {
87 fn style(&self) -> Style {
88 self.sizing(None)
89 }
90
91 fn style_themed(&self, theme: &Theme) -> Style {
92 self.sizing(Some(theme))
93 }
94
95 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
96 &[]
97 }
98
99 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
100 let o = status.opacity;
101 let panel = theme.scheme.surface_container;
105 let base = if self.active {
106 panel.lerp(theme.primary, 0.14)
107 } else {
108 panel
109 };
110 let bg = theme.state_layer(base, theme.on_surface, &status);
111 scene.draw_rect(bounds, bg.fade(o), theme.radius, 1.0, theme.border.fade(o));
112
113 let style = label_style(self.text_style, Some(theme));
114 let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
115 let chars: Vec<char> = self.label.chars().collect();
116 let normal = theme.on_surface.fade(o);
117 let hilite = theme.primary.fade(o);
118 let mut x = bounds.x + PAD_X;
119 let segments: [(std::ops::Range<usize>, frus_core::Color); 3] =
121 match match_range(&self.label, &self.query) {
122 Some((i, j)) => [(0..i, normal), (i..j, hilite), (j..chars.len(), normal)],
123 None => [(0..chars.len(), normal), (0..0, normal), (0..0, normal)],
124 };
125 for (range, color) in segments {
126 if range.is_empty() {
127 continue;
128 }
129 let text: String = chars[range].iter().collect();
130 let width = frus_text::measure_resolved(&text, &style).width;
131 scene.text(Point::new(x, ty), text, &style, color);
132 x += width;
133 }
134 }
135
136 fn on_click(&self) -> Option<Msg> {
137 Some(self.message.clone())
138 }
139
140 fn focusable(&self) -> bool {
141 true
142 }
143}
144
145pub struct Autocomplete<Msg = crate::callback::Callback> {
147 value: String,
148 width: f32,
149 active: Option<usize>,
151 max_visible: Option<usize>,
153 on_input: Rc<dyn Fn(String) -> Msg>,
154 on_pick: Rc<dyn Fn(String) -> Msg>,
155 labels: Vec<String>,
156 text_style: Option<TextStyle>,
157 children: Vec<Box<dyn Widget<Msg>>>,
159}
160
161impl<Msg: Clone + 'static> Autocomplete<Msg> {
162 pub fn new(
165 value: impl Into<String>,
166 on_input: impl Fn(String) -> Msg + 'static,
167 on_pick: impl Fn(String) -> Msg + 'static,
168 ) -> Self {
169 let mut ac = Self {
170 value: value.into(),
171 width: DEFAULT_WIDTH,
172 active: None,
173 max_visible: None,
174 on_input: Rc::new(on_input),
175 on_pick: Rc::new(on_pick),
176 labels: Vec::new(),
177 text_style: None,
178 children: Vec::new(),
179 };
180 ac.rebuild();
181 ac
182 }
183
184 #[must_use]
186 pub fn text_style(mut self, style: TextStyle) -> Self {
187 self.text_style = Some(style);
188 self.rebuild();
189 self
190 }
191
192 pub fn width(mut self, width: f32) -> Self {
194 self.width = width;
195 self.rebuild();
196 self
197 }
198
199 pub fn active(mut self, index: usize) -> Self {
202 self.active = Some(index);
203 self.rebuild();
204 self
205 }
206
207 pub fn max_visible(mut self, rows: usize) -> Self {
210 self.max_visible = Some(rows.max(1));
211 self.rebuild();
212 self
213 }
214
215 pub fn suggestion(mut self, label: impl Into<String>) -> Self {
217 self.labels.push(label.into());
218 self.rebuild();
219 self
220 }
221
222 fn rebuild(&mut self) {
223 let on_input = self.on_input.clone();
226 let input = TextField::new(self.value.clone())
227 .width(self.width)
228 .on_input(move |text| on_input(text));
229 self.children = vec![Box::new(input)];
230
231 if !self.labels.is_empty() {
232 let mut list = Flex::column().gap(ROW_GAP);
233 for (index, label) in self.labels.iter().enumerate() {
234 list = list.child(Suggestion {
235 label: label.clone(),
236 query: self.value.clone(),
237 width: self.width,
238 active: self.active == Some(index),
239 text_style: self.text_style,
240 message: (self.on_pick)(label.clone()),
241 });
242 }
243 match self.max_visible {
245 Some(n) if self.labels.len() > n => {
246 let row = row_height(&label_style(self.text_style, None));
257 let viewport = n as f32 * row + (n as f32 - 1.0) * ROW_GAP;
258 self.children.push(Box::new(
259 SingleChildScrollView::new()
260 .width(self.width)
261 .height(viewport)
262 .child(list),
263 ));
264 }
265 _ => self.children.push(Box::new(list)),
266 }
267 }
268 }
269}
270
271impl<Msg: Clone> Widget<Msg> for Autocomplete<Msg> {
272 fn style(&self) -> Style {
273 Style {
274 flex_direction: FlexDirection::Column,
275 ..Default::default()
276 }
277 }
278
279 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
280 &self.children
281 }
282
283 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
284
285 fn on_click(&self) -> Option<Msg> {
286 None
287 }
288
289 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
290 self.children
291 .get(1)
292 .map(|list| (list.as_ref(), Placement::Below))
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::{build_ui, Runtime, Size};
300 use frus_core::Primitive;
301
302 #[derive(Clone, Debug, PartialEq)]
303 enum Msg {
304 Input(String),
305 Pick(String),
306 }
307
308 #[test]
309 fn no_suggestions_no_overlay() {
310 let ac = Autocomplete::new("a", Msg::Input, Msg::Pick);
311 assert!(Widget::<Msg>::overlay(&ac).is_none());
312 }
313
314 #[test]
315 fn suggestions_float_and_pick() {
316 let ac = Autocomplete::new("a", Msg::Input, Msg::Pick)
317 .suggestion("apple")
318 .suggestion("apricot");
319 assert!(Widget::<Msg>::overlay(&ac).is_some());
320 let list = &Widget::<Msg>::children(&ac)[1];
322 assert_eq!(list.children().len(), 2);
323 assert_eq!(
324 list.children()[0].on_click(),
325 Some(Msg::Pick("apple".to_string()))
326 );
327 }
328
329 #[test]
330 fn match_range_is_case_insensitive_substring() {
331 assert_eq!(match_range("Apricot", "ap"), Some((0, 2)));
332 assert_eq!(match_range("pineapple", "APPLE"), Some((4, 9)));
333 assert_eq!(match_range("apple", ""), None);
334 assert_eq!(match_range("apple", "xyz"), None);
335 }
336
337 #[test]
338 fn matched_portion_is_drawn_as_its_own_segment() {
339 let ac = Autocomplete::new("ap", Msg::Input, Msg::Pick).suggestion("apricot");
341 let (list, _) = Widget::<Msg>::overlay(&ac).unwrap();
342 let ui = build_ui(
343 list,
344 Size::new(280.0, 80.0),
345 &Runtime::default(),
346 &Theme::default(),
347 );
348 let texts: Vec<String> = ui
349 .scene()
350 .primitives()
351 .iter()
352 .filter_map(|p| match p {
353 Primitive::Text { text, .. } => Some(text.clone()),
354 _ => None,
355 })
356 .collect();
357 assert!(
358 texts.contains(&"ap".to_string()),
359 "the matching portion is isolated: {texts:?}"
360 );
361 assert!(
362 texts.contains(&"ricot".to_string()),
363 "the remainder is isolated: {texts:?}"
364 );
365 }
366
367 #[test]
368 fn active_suggestion_is_highlighted() {
369 let ac = Autocomplete::new("ap", Msg::Input, Msg::Pick)
370 .suggestion("apple")
371 .suggestion("apricot")
372 .active(1);
373 let (list, _) = Widget::<Msg>::overlay(&ac).unwrap();
374 let ui = build_ui(
375 list,
376 Size::new(280.0, 120.0),
377 &Runtime::default(),
378 &Theme::default(),
379 );
380 let theme = Theme::default();
381 let tint = theme.scheme.surface_container.lerp(theme.primary, 0.14);
382 let has_tint = ui.scene().primitives().iter().any(|p| {
383 matches!(
384 p,
385 Primitive::Rect { color, .. } if color.fade(1.0) == tint.fade(1.0)
386 )
387 });
388 assert!(has_tint, "the active suggestion is highlighted");
389 }
390
391 #[test]
392 fn long_list_scrolls_when_capped() {
393 let ac = Autocomplete::new("a", Msg::Input, Msg::Pick)
394 .max_visible(2)
395 .suggestion("a1")
396 .suggestion("a2")
397 .suggestion("a3")
398 .suggestion("a4");
399 let (overlay, _) = Widget::<Msg>::overlay(&ac).unwrap();
400 let expected = 2.0 * ROW_H + ROW_GAP;
402 assert!(
403 matches!(Widget::<Msg>::style(overlay).height, Dimension::Length(v) if (v - expected).abs() < 0.5),
404 "viewport bounded to 2 rows",
405 );
406 assert_eq!(overlay.children()[0].children().len(), 4);
408 }
409
410 #[test]
411 fn short_list_is_not_wrapped_in_scroll() {
412 let ac = Autocomplete::new("a", Msg::Input, Msg::Pick)
414 .max_visible(5)
415 .suggestion("a1")
416 .suggestion("a2");
417 let (overlay, _) = Widget::<Msg>::overlay(&ac).unwrap();
418 assert_eq!(overlay.children().len(), 2);
420 assert_eq!(
421 overlay.children()[0].on_click(),
422 Some(Msg::Pick("a1".to_string()))
423 );
424 }
425
426 #[test]
427 fn field_and_suggestions_are_keyboard_reachable() {
428 let ac = Autocomplete::new("ap", Msg::Input, Msg::Pick)
432 .suggestion("apple")
433 .suggestion("apricot");
434 let ui = build_ui(
435 &ac,
436 Size::new(280.0, 200.0),
437 &Runtime::default(),
438 &Theme::default(),
439 );
440 let first = ui.focus_next(None, true);
441 assert!(first.is_some(), "the field is focusable");
442 let second = ui.focus_next(first, true);
443 assert!(
444 second.is_some() && second != first,
445 "a suggestion follows the field in the cycle"
446 );
447 }
448}