Skip to main content

frus_widgets/
autocomplete.rs

1//! [`Autocomplete`]: a text field with a floating **suggestion list**. Controlled:
2//! the application supplies the value **and** the suggestions (already filtered);
3//! the list only floats when it is non-empty. Adjustable width
4//! ([`width`](Autocomplete::width)); the suggestions take keyboard focus.
5//!
6//! Each suggestion **brings out** the portion matching the query (in the `primary`
7//! color), and the **active** suggestion ([`active`](Autocomplete::active), stepped
8//! through from the keyboard) is highlighted — like a `DropdownButton`'s menu.
9
10use 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;
26/// The vertical gap between suggestions.
27const ROW_GAP: f32 = 2.0;
28
29/// The style the suggestions are drawn in: what the caller said, else what the theme says,
30/// else the reference's — the reference's own suggestion list is built out of list tiles,
31/// whose title is `bodyLarge`.
32///
33/// **Resolved once**, so that the number the box is measured with is the number the glyphs
34/// are drawn at. Resolving is the single place the reader's font setting is applied
35/// (milestone 403); a size that never passes through it is a size the reader cannot change.
36fn 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
42/// The height of one suggestion — the floor, or the line if the type asks for more.
43fn row_height(style: &ResolvedTextStyle) -> f32 {
44    frus_text::line_box(ROW_H, style, 0.0)
45}
46
47/// The portion of the label (in **character** indices) matching the query
48/// (a case-insensitive search). `None` if the query is empty or absent.
49fn 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
63/// A clickable suggestion. The portion matching `query` is brought out (in the
64/// `primary` color); the **active** one (stepped to from the keyboard) is highlighted.
65struct Suggestion<Msg> {
66    label: String,
67    /// The current query, used to highlight the matching portion.
68    query: String,
69    width: f32,
70    /// The **active** suggestion (the one that would be picked): a tinted background.
71    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        // A menu panel is a distinct area within the surface, the `surface_container`
102        // role (`menu_anchor.dart:4240`). The active suggestion: a `primary`-tinted
103        // background; hover on top.
104        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        // Segments [before | match | after]: the match in `primary`.
120        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
145/// A text field with suggestions.
146pub struct Autocomplete<Msg = crate::callback::Callback> {
147    value: String,
148    width: f32,
149    /// The **active** suggestion (stepped to from the keyboard, highlighted), if any.
150    active: Option<usize>,
151    /// The maximum number of visible suggestions; beyond that the list **scrolls**.
152    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    /// `[field]` or `[field, list]`.
158    children: Vec<Box<dyn Widget<Msg>>>,
159}
160
161impl<Msg: Clone + 'static> Autocomplete<Msg> {
162    /// Creates a field: the current value, `on_input(text)` on typing, and
163    /// `on_pick(suggestion)` when a suggestion is chosen.
164    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    /// The suggestions' type, over the theme's and the reference's.
185    #[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    /// The width of the field and the suggestions, in logical pixels (260 by default).
193    pub fn width(mut self, width: f32) -> Self {
194        self.width = width;
195        self.rebuild();
196        self
197    }
198
199    /// The index of the **active** suggestion (highlighted; chosen from the keyboard).
200    /// The app moves it along (arrows) and commits it (Enter) — the state stays with it.
201    pub fn active(mut self, index: usize) -> Self {
202        self.active = Some(index);
203        self.rebuild();
204        self
205    }
206
207    /// Limits the number of **visible** suggestions; beyond that the floating list
208    /// **scrolls** (a viewport bounded to `n` rows) instead of stretching forever.
209    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    /// Adds a suggestion to the floating list.
216    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        // The field: rebuilt on every setting (width, value). The shared `on_input`
224        // callback (an Rc) is captured by the field.
225        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            // Past the threshold, the list scrolls in a viewport bounded to `n` rows.
244            match self.max_visible {
245                Some(n) if self.labels.len() > n => {
246                    // The rows' own height, not the floor: a viewport counted at `ROW_H`
247                    // while the rows are taller shows `n` rows minus a sliver of each —
248                    // which is what the reader's font setting does to them, and it is
249                    // applied here because `resolved()` applies it.
250                    //
251                    // `None` for the theme, and it is the one thing a builder cannot have:
252                    // this list is built before any theme exists. So an application that
253                    // retypesets suggestions through `widgets.autocomplete` and *also*
254                    // caps them with `max_visible` should say it on the widget instead,
255                    // where the same number reaches both the rows and their viewport.
256                    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        // The list holds both suggestions; the 1st emits Pick("apple").
321        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        // The query "ap" on "apricot" → segments "ap" (brought out) + "ricot".
340        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        // The overlay is a SingleChildScrollView bounded to 2 rows (viewport = 2*ROW_H + 1 gap).
401        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        // It does scroll over all 4 suggestions.
407        assert_eq!(overlay.children()[0].children().len(), 4);
408    }
409
410    #[test]
411    fn short_list_is_not_wrapped_in_scroll() {
412        // Below the threshold: a bare list, with no bounded viewport.
413        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        // A direct list: its children are the 2 suggestions, not a SingleChildScrollView one level down.
419        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        // Keyboard descent goes through the focus system: the field, then the
429        // suggestions, enter the Tab cycle (the down arrow from the single-line field
430        // moves the focus to the 1st suggestion).
431        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}