Skip to main content

vizia_core/views/
combobox.rs

1use std::marker::PhantomData;
2
3use crate::prelude::*;
4
5/// A ComboBox view which combines a textbox with a list popup, allowing users to
6/// filter options by typing.
7pub struct ComboBox<L: SignalGet<Vec<T>>, S: SignalGet<usize>, T: 'static + Clone + ToString> {
8    // Text used to filter the list.
9    filter_text: Signal<String>,
10    // Text displayed when the textbox is not actively editing.
11    placeholder: Signal<String>,
12    // Callback triggered when an item is selected.
13    on_select: Option<Box<dyn Fn(&mut EventContext, usize)>>,
14    // Source list of values.
15    list: L,
16    // Selected item index in the source list.
17    selected: S,
18    // Whether the popup list is visible.
19    is_open: Signal<bool>,
20    // Highlighted source index in the filtered popup list.
21    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    /// Creates a new [ComboBox] view.
39    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            // Defocus when clicking outside the combobox while the popup is open.
62            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                // Prevent blur/cancel from ending edit; this view handles it explicitly.
98                .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                // Reopen in case it was closed with Escape.
266                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                    // Enter behavior can be enhanced to select current focused popup row.
281                } 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    /// Sets the callback triggered when an item is selected from the [ComboBox] list.
399    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}