Skip to main content

rosace_widgets/tree/
autocomplete.rs

1//! `Autocomplete` — a text field with a filtered suggestion dropdown, the
2//! typeahead/combobox pattern `SearchBar` and `Dropdown` don't individually
3//! cover (one is a plain text field, the other has no live-typed query).
4//!
5//! Built the same way `SearchBar` is (a preset over [`super::TextInput`])
6//! plus the same overlay mechanism [`super::Dropdown`] uses for its option
7//! list ([`super::overlay::push_overlay`]) — no new framework primitive,
8//! just composing the two existing patterns. `open` auto-manages itself
9//! from typing (non-empty query → open) via a wrapped `on_change`, so
10//! callers only need to supply an initially-`false` atom, same as
11//! `Dropdown` — not a fully separate state contract to learn.
12
13use std::sync::Arc;
14use rosace_core::types::{Point, Size};
15use rosace_render::Color;
16use rosace_state::Atom;
17
18use super::menu::Menu;
19use super::overlay::{push_overlay, FocusBehavior, InputBehavior, LayerPosition, OverlayEntry, ScrimConfig};
20use super::{Children, LayoutCtx, PaintCtx, Widget};
21
22/// A text field with a live-filtered suggestion dropdown below it.
23pub struct Autocomplete {
24    value: String,
25    placeholder: String,
26    options: Vec<String>,
27    open: Atom<bool>,
28    width: Option<f32>,
29    height: f32,
30    max_visible: usize,
31    on_change: Option<Arc<dyn Fn(String) + Send + Sync>>,
32    on_select: Option<Arc<dyn Fn(String) + Send + Sync>>,
33}
34
35impl Autocomplete {
36    /// `options` is the full candidate list to filter against; `open` is an
37    /// initially-`false` atom this widget manages as the user types.
38    pub fn new(options: Vec<impl Into<String>>, open: Atom<bool>) -> Self {
39        Self {
40            value: String::new(),
41            placeholder: "Search\u{2026}".to_string(),
42            options: options.into_iter().map(Into::into).collect(),
43            open,
44            width: None,
45            height: 36.0,
46            max_visible: 6,
47            on_change: None,
48            on_select: None,
49        }
50    }
51    pub fn value(mut self, v: impl Into<String>) -> Self { self.value = v.into(); self }
52    pub fn placeholder(mut self, p: impl Into<String>) -> Self { self.placeholder = p.into(); self }
53    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
54    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
55    /// Cap on how many matches show at once (default 6).
56    pub fn max_visible(mut self, n: usize) -> Self { self.max_visible = n.max(1); self }
57    /// Fired on every keystroke with the raw field text.
58    pub fn on_change(mut self, f: impl Fn(String) + Send + Sync + 'static) -> Self {
59        self.on_change = Some(Arc::new(f)); self
60    }
61    /// Fired once when a suggestion is tapped, with the chosen option.
62    pub fn on_select(mut self, f: impl Fn(String) + Send + Sync + 'static) -> Self {
63        self.on_select = Some(Arc::new(f)); self
64    }
65
66    /// Matches for the current value — case-insensitive substring, capped
67    /// at `max_visible`. Standalone so it's unit-testable without a paint
68    /// context.
69    fn matches(&self) -> Vec<&String> {
70        let q = self.value.trim().to_lowercase();
71        if q.is_empty() {
72            return Vec::new();
73        }
74        self.options
75            .iter()
76            .filter(|o| o.to_lowercase().contains(&q))
77            .take(self.max_visible)
78            .collect()
79    }
80
81    /// The underlying field. Built fresh per call (like `Dropdown` rebuilds
82    /// its `Menu` every paint) rather than cached — `on_change` needs to
83    /// close over this call's `self.open`/`self.on_change`, which change
84    /// across rebuilds.
85    fn input(&self) -> super::TextInput {
86        let mut input = super::TextInput::new()
87            .value(self.value.clone())
88            .placeholder(self.placeholder.clone())
89            .height(self.height)
90            .leading(super::Icon::new(super::IconKind::Search).size(18.0));
91        if let Some(w) = self.width {
92            input = input.width(w);
93        }
94        let open = self.open.clone();
95        let user_on_change = self.on_change.clone();
96        input = input.on_change(move |v| {
97            open.set(!v.trim().is_empty());
98            if let Some(f) = &user_on_change {
99                f(v);
100            }
101        });
102        input
103    }
104}
105
106impl Widget for Autocomplete {
107    fn children(&self) -> Children<'_> { Children::None }
108
109    fn layout(&self, ctx: &LayoutCtx) -> Size {
110        self.input().layout(ctx)
111    }
112
113    fn paint(&self, ctx: &mut PaintCtx) {
114        let r = ctx.rect;
115        self.input().paint(ctx);
116
117        let filtered = self.matches();
118        if self.open.get() && !filtered.is_empty() {
119            let pos = Point { x: r.origin.x, y: r.origin.y + r.size.height + 4.0 };
120            let mut menu = Menu::new().min_width(self.width.unwrap_or(r.size.width));
121            for opt in filtered {
122                let chosen = opt.clone();
123                let open = self.open.clone();
124                let cb = self.on_select.clone();
125                menu = menu.item(opt.clone(), move || {
126                    open.set(false);
127                    if let Some(cb) = &cb {
128                        cb(chosen.clone());
129                    }
130                });
131            }
132            let open2 = self.open.clone();
133            push_overlay(
134                OverlayEntry::new(LayerPosition::Absolute(pos), menu)
135                    .input(InputBehavior::PassThrough)
136                    .focus(FocusBehavior::PassThrough)
137                    .scrim(ScrimConfig {
138                        color: Color::TRANSPARENT,
139                        on_tap: Some(Arc::new(move || open2.set(false))),
140                        // Own field's rect — typing/clicking there is
141                        // handled by the field itself, not outside-tap
142                        // dismiss (same reasoning as `Dropdown`'s trigger).
143                        exclude_rect: Some(r),
144                    }),
145            );
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use rosace_layout::Constraints;
154
155    fn open_atom() -> Atom<bool> {
156        Atom::new(rosace_state::next_atom_id(), false)
157    }
158
159    #[test]
160    fn matches_filters_case_insensitively() {
161        let ac = Autocomplete::new(vec!["Apple", "Banana", "apricot"], open_atom())
162            .value("ap");
163        let m: Vec<&str> = ac.matches().iter().map(|s| s.as_str()).collect();
164        assert_eq!(m, vec!["Apple", "apricot"]);
165    }
166
167    #[test]
168    fn empty_query_has_no_matches() {
169        let ac = Autocomplete::new(vec!["Apple", "Banana"], open_atom());
170        assert!(ac.matches().is_empty());
171    }
172
173    #[test]
174    fn respects_max_visible() {
175        let ac = Autocomplete::new(vec!["a1", "a2", "a3", "a4"], open_atom())
176            .value("a")
177            .max_visible(2);
178        assert_eq!(ac.matches().len(), 2);
179    }
180
181    #[test]
182    fn layout_matches_the_underlying_field() {
183        let font = rosace_render::FontCache::embedded();
184        let theme = rosace_theme::built_in::dark_theme();
185        let ctx = LayoutCtx::new(Constraints::loose(500.0, 60.0), &font, &theme);
186        let ac = Autocomplete::new(vec!["A", "B"], open_atom()).width(240.0);
187        assert_eq!(ac.layout(&ctx).width, 240.0);
188    }
189}