rosace_widgets/tree/
autocomplete.rs1use 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
22pub 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 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 pub fn max_visible(mut self, n: usize) -> Self { self.max_visible = n.max(1); self }
57 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 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 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 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 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}