1use std::marker::PhantomData;
2
3use crate::prelude::*;
4
5pub struct ComboBox<
8 L: SignalGet<Vec<T>>,
9 S: SignalGet<usize>,
10 T: 'static + Clone + ToStringLocalized,
11> {
12 filter_text: Signal<String>,
14 placeholder: Signal<String>,
16 on_select: Option<Box<dyn Fn(&mut EventContext, usize)>>,
18 list: L,
20 selected: S,
22 is_open: Signal<bool>,
24 highlighted: Signal<Option<usize>>,
26
27 p: PhantomData<T>,
28}
29
30pub(crate) enum ComboBoxEvent {
31 SetOption(usize),
32 SetFilterText(String),
33}
34
35impl<L, S, T> ComboBox<L, S, T>
36where
37 L: SignalGet<Vec<T>> + Copy + 'static,
38 T: 'static + Clone + ToStringLocalized,
39 S: SignalGet<usize> + Copy + 'static,
40{
41 pub fn new(cx: &mut Context, list: L, selected: S) -> Handle<Self> {
43 let filter_text = Signal::new(String::new());
44 let placeholder = Signal::new(
45 list.get()
46 .get(selected.get())
47 .map(|item| item.to_string_local(cx))
48 .unwrap_or_else(|| String::from("Select")),
49 );
50 let is_open = Signal::new(false);
51 let highlighted = Signal::new(Some(selected.get()));
52
53 Self {
54 filter_text,
55 placeholder,
56 on_select: None,
57 list,
58 selected,
59 is_open,
60 highlighted,
61 p: PhantomData,
62 }
63 .build(cx, move |cx| {
64 cx.add_listener(move |popup: &mut Self, cx, event| {
66 let open = popup.is_open.get();
67 event.map(|window_event, meta| match window_event {
68 WindowEvent::MouseDown(_) => {
69 if open
70 && meta.origin != cx.current()
71 && !cx.hovered.is_descendant_of(cx.tree, cx.current)
72 {
73 cx.emit(TextEvent::Submit(false));
74 cx.emit_custom(
75 Event::new(TextEvent::EndEdit)
76 .target(cx.current)
77 .propagate(Propagation::Subtree),
78 );
79 meta.consume();
80 }
81 }
82
83 WindowEvent::FocusOut => {
84 if meta.target.is_descendant_of(cx.tree, cx.current)
85 && !cx.focused().is_descendant_of(cx.tree, cx.current)
86 {
87 popup.is_open.set(false);
88 }
89 }
90
91 _ => {}
92 });
93 });
94
95 let entity = cx.current();
96
97 Textbox::new(cx, filter_text)
98 .on_edit(|cx, txt| cx.emit(ComboBoxEvent::SetFilterText(txt)))
100 .on_blur(|_| {})
102 .on_cancel(|_| {})
103 .width(Stretch(1.0))
104 .height(Pixels(32.0))
105 .placeholder(placeholder)
106 .class("title")
107 .role(Role::ComboBox)
108 .expanded(is_open)
109 .active_descendant(highlighted.map(move |highlighted| {
110 highlighted
111 .map(|index| format!("{}-option-{}", entity, index))
112 .unwrap_or_default()
113 }))
114 .controls(format!("{}", entity));
115
116 Binding::new(cx, is_open, move |cx| {
117 let open = is_open.get();
118 if open {
119 Popover::new(cx, move |cx: &mut Context| {
120 Binding::new(cx, list, move |cx| {
121 Binding::new(cx, filter_text, move |cx| {
122 let query = filter_text.get().to_ascii_lowercase();
123 let values = list.get();
124 let options = values
125 .iter()
126 .enumerate()
127 .filter_map(|(index, item)| {
128 let text = item.to_string_local(cx);
129 if query.is_empty()
130 || text.to_ascii_lowercase().contains(&query)
131 {
132 Some((index, text))
133 } else {
134 None
135 }
136 })
137 .collect::<Vec<(usize, String)>>();
138
139 let options_state = Signal::new(options);
140
141 let highlighted_row = Memo::new(move |_| {
142 let highlighted_source = highlighted.get();
143 let row = highlighted_source.and_then(|source_index| {
144 options_state
145 .get()
146 .iter()
147 .position(|item| item.0 == source_index)
148 });
149
150 row.map(|idx| vec![idx]).unwrap_or_default()
151 });
152
153 List::new(cx, options_state, move |cx, _row, item| {
154 let source_index = item.get().0;
155 let option_id = format!("{}-option-{}", entity, source_index);
156 cx.style.ids.insert(cx.current(), option_id.clone());
157 cx.needs_restyle(cx.current());
158 cx.entity_identifiers.insert(option_id, cx.current());
159
160 Label::new(cx, item.map(|(_, text)| text.clone()))
161 .hoverable(false);
162 })
163 .navigable(false)
164 .width(Stretch(1.0))
165 .selectable(Selectable::Single)
166 .selection(highlighted_row)
167 .show_horizontal_scrollbar(false)
168 .on_select(move |cx, row| {
169 if let Some((source_index, _)) = options_state.get().get(row) {
170 cx.emit(ComboBoxEvent::SetOption(*source_index));
171 cx.emit(PopupEvent::Close);
172 }
173 });
174 });
175 });
176 })
177 .role(Role::ListBox)
178 .id(format!("{}", entity))
179 .should_reposition(false)
180 .arrow_size(Pixels(4.0));
181 }
182 });
183
184 Binding::new(cx, selected, move |cx| {
185 let selected_index = selected.get();
186 if let Some(selected_item) = list.get().get(selected_index).cloned() {
187 cx.environment().locale.set_or_bind(cx, move |cx, _| {
188 placeholder.set(selected_item.to_string_local(cx));
189 });
190 }
191 highlighted.set(Some(selected_index));
192 });
193 })
194 }
195}
196
197impl<L, S, T> ComboBox<L, S, T>
198where
199 L: SignalGet<Vec<T>>,
200 T: 'static + Clone + ToStringLocalized,
201 S: SignalGet<usize>,
202{
203 fn filtered_indices(&self, cx: &impl DataContext) -> Vec<usize> {
204 let query = self.filter_text.get().to_ascii_lowercase();
205 self.list
206 .get()
207 .iter()
208 .enumerate()
209 .filter_map(|(index, item)| {
210 if query.is_empty()
211 || item.to_string_local(cx).to_ascii_lowercase().contains(&query)
212 {
213 Some(index)
214 } else {
215 None
216 }
217 })
218 .collect()
219 }
220}
221
222impl<L, S, T> View for ComboBox<L, S, T>
223where
224 L: SignalGet<Vec<T>> + 'static,
225 T: 'static + Clone + ToStringLocalized,
226 S: SignalGet<usize> + 'static,
227{
228 fn element(&self) -> Option<&'static str> {
229 Some("combobox")
230 }
231
232 fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
233 event.map(|combobox_event, _| match combobox_event {
234 ComboBoxEvent::SetOption(index) => {
235 if let Some(selected_item) = self.list.get().get(*index).cloned() {
236 self.placeholder.set(selected_item.to_string_local(cx));
237 }
238 self.highlighted.set(Some(*index));
239
240 if let Some(callback) = &self.on_select {
241 (callback)(cx, *index);
242 }
243
244 self.is_open.set(false);
245 self.filter_text.set(String::new());
246
247 cx.emit_custom(
248 Event::new(TextEvent::EndEdit)
249 .target(cx.current)
250 .propagate(Propagation::Subtree),
251 );
252 }
253
254 ComboBoxEvent::SetFilterText(text) => {
255 self.placeholder.set(text.clone());
256 self.filter_text.set(text.clone());
257 self.highlighted.set(self.filtered_indices(cx).first().copied());
258
259 self.is_open.set(true);
261 }
262 });
263
264 event.map(|textbox_event, _| match textbox_event {
265 TextEvent::StartEdit => {
266 self.is_open.set(true);
267 }
268
269 TextEvent::Submit(enter) => {
270 let selected = self.selected.get();
271 if *enter {
272 } else {
274 cx.emit(ComboBoxEvent::SetOption(selected));
275 }
276 }
277
278 _ => {}
279 });
280
281 event.map(|window_event, meta| match window_event {
282 WindowEvent::KeyDown(code, _) => match code {
283 Code::ArrowDown => {
284 let filtered = self.filtered_indices(cx);
285 if self.is_open.get() {
286 if !filtered.is_empty() {
287 let current_pos = self
288 .highlighted
289 .get()
290 .and_then(|h| filtered.iter().position(|index| *index == h))
291 .unwrap_or_else(|| {
292 filtered
293 .iter()
294 .position(|index| *index == self.selected.get())
295 .unwrap_or(0)
296 });
297
298 let next_pos = (current_pos + 1) % filtered.len();
299 self.highlighted.set(Some(filtered[next_pos]));
300 meta.consume();
301 }
302 } else {
303 self.is_open.set(true);
304 self.highlighted.set(
305 self.highlighted
306 .get()
307 .filter(|h| filtered.contains(h))
308 .or_else(|| {
309 filtered
310 .iter()
311 .position(|index| *index == self.selected.get())
312 .map(|pos| filtered[pos])
313 })
314 .or_else(|| filtered.first().copied()),
315 );
316 meta.consume();
317 }
318 }
319
320 Code::ArrowUp => {
321 let filtered = self.filtered_indices(cx);
322 if self.is_open.get() {
323 if !filtered.is_empty() {
324 let current_pos = self
325 .highlighted
326 .get()
327 .and_then(|h| filtered.iter().position(|index| *index == h))
328 .unwrap_or_else(|| {
329 filtered
330 .iter()
331 .position(|index| *index == self.selected.get())
332 .unwrap_or(0)
333 });
334
335 let prev_pos =
336 if current_pos == 0 { filtered.len() - 1 } else { current_pos - 1 };
337
338 self.highlighted.set(Some(filtered[prev_pos]));
339 meta.consume();
340 }
341 } else {
342 self.is_open.set(true);
343 self.highlighted.set(
344 self.highlighted
345 .get()
346 .filter(|h| filtered.contains(h))
347 .or_else(|| {
348 filtered
349 .iter()
350 .position(|index| *index == self.selected.get())
351 .map(|pos| filtered[pos])
352 })
353 .or_else(|| filtered.last().copied()),
354 );
355 meta.consume();
356 }
357 }
358
359 Code::Enter => {
360 if self.is_open.get() {
361 if let Some(index) = self.highlighted.get() {
362 cx.emit(ComboBoxEvent::SetOption(index));
363 self.is_open.set(false);
364 meta.consume();
365 }
366 }
367 }
368
369 Code::Escape => {
370 if self.is_open.get() {
371 self.is_open.set(false);
372 } else {
373 cx.emit(TextEvent::Submit(false));
374 }
375 }
376
377 _ => {}
378 },
379
380 _ => {}
381 });
382 }
383}
384
385impl<L, S, T> Handle<'_, ComboBox<L, S, T>>
386where
387 L: SignalGet<Vec<T>> + 'static,
388 T: 'static + Clone + ToStringLocalized,
389 S: SignalGet<usize> + 'static,
390{
391 pub fn on_select<F>(self, callback: F) -> Self
393 where
394 F: 'static + Fn(&mut EventContext, usize),
395 {
396 self.modify(|combobox: &mut ComboBox<L, S, T>| {
397 combobox.on_select = Some(Box::new(callback));
398 })
399 }
400}