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<
8    L: SignalGet<Vec<T>>,
9    S: SignalGet<usize>,
10    T: 'static + Clone + ToStringLocalized,
11> {
12    // Text used to filter the list.
13    filter_text: Signal<String>,
14    // Text displayed when the textbox is not actively editing.
15    placeholder: Signal<String>,
16    // Callback triggered when an item is selected.
17    on_select: Option<Box<dyn Fn(&mut EventContext, usize)>>,
18    // Source list of values.
19    list: L,
20    // Selected item index in the source list.
21    selected: S,
22    // Whether the popup list is visible.
23    is_open: Signal<bool>,
24    // Highlighted source index in the filtered popup list.
25    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    /// Creates a new [ComboBox] view.
42    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            // Defocus when clicking outside the combobox while the popup is open.
65            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_press(|cx| cx.emit(ComboBoxEvent::OpenPopup))
99                .on_edit(|cx, txt| cx.emit(ComboBoxEvent::SetFilterText(txt)))
100                // Prevent blur/cancel from ending edit; this view handles it explicitly.
101                .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                // Reopen in case it was closed with Escape.
260                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                    // Enter behavior can be enhanced to select current focused popup row.
273                } 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    /// Sets the callback triggered when an item is selected from the [ComboBox] list.
392    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}