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