vizia_core/views/
combobox.rs

1use std::marker::PhantomData;
2
3use crate::prelude::*;
4
5/// A ComboBox view which combines a textbox with a picklist, allowing users to filter to only the options matching a query.
6#[derive(Lens)]
7pub struct ComboBox<
8    L1: Lens<Target = Vec<T>>,
9    L2: Lens<Target = usize>,
10    T: 'static + Data + ToString,
11> {
12    // Text to filter the list.
13    filter_text: String,
14    // Text to display when the combobox is unfocused.
15    placeholder: String,
16    // Callback triggered when an item is selected.
17    on_select: Option<Box<dyn Fn(&mut EventContext, usize)>>,
18    // Lens to a list of values.
19    list_lens: L1,
20    // Lens to the selected value.
21    selected: L2,
22    // Whether the popup list is visible.
23    is_open: bool,
24
25    p: PhantomData<T>,
26}
27
28pub(crate) enum ComboBoxEvent {
29    SetOption(usize),
30    SetFilterText(String),
31}
32
33impl<L1, L2, T> ComboBox<L1, L2, T>
34where
35    L1: Copy + Lens<Target = Vec<T>>,
36    T: 'static + Data + ToString,
37    L2: Copy + Lens<Target = usize>,
38{
39    /// Creates a new [ComboBox] view.
40    pub fn new(cx: &mut Context, list_lens: L1, selected: L2) -> Handle<Self> {
41        Self {
42            filter_text: String::from(""),
43            on_select: None,
44            list_lens,
45            selected,
46            p: PhantomData,
47            is_open: false,
48            placeholder: String::from("One"),
49        }
50        .build(cx, |cx| {
51            // Add listener to defocus when mouse is pressed outside the combobox.
52            cx.add_listener(move |popup: &mut Self, cx, event| {
53                let flag: bool = popup.is_open;
54                event.map(|window_event, meta| match window_event {
55                    WindowEvent::MouseDown(_) => {
56                        if flag && meta.origin != cx.current() {
57                            // Check if the mouse was pressed outside of any descendants.
58                            // TODO: Replace with a check to is_over when that works correctly.
59                            if !cx.hovered.is_descendant_of(cx.tree, cx.current) {
60                                cx.emit(TextEvent::Submit(false));
61                                cx.emit_custom(
62                                    Event::new(TextEvent::EndEdit)
63                                        .target(cx.current)
64                                        .propagate(Propagation::Subtree),
65                                );
66                                meta.consume();
67                            }
68                        }
69                    }
70
71                    _ => {}
72                });
73            });
74
75            Textbox::new(cx, Self::filter_text)
76                .on_edit(|cx, txt| cx.emit(ComboBoxEvent::SetFilterText(txt)))
77                // Prevent the textbox from losing focus on blur. We control that with the listener instead.
78                .on_blur(|_| {})
79                // Prevent the textbox from losing focus on cancel (escape key press).
80                .on_cancel(|_| {})
81                .width(Stretch(1.0))
82                .height(Pixels(32.0))
83                .placeholder(Self::placeholder)
84                .class("title");
85
86            Binding::new(cx, Self::is_open, move |cx, is_open| {
87                if is_open.get(cx) {
88                    Popup::new(cx, move |cx: &mut Context| {
89                        // Binding to the filter text.
90                        Binding::new(cx, Self::filter_text, move |cx, filter_text| {
91                            let f = filter_text.get(cx);
92                            List::new_filtered(
93                                cx,
94                                list_lens,
95                                move |item| {
96                                    if f.is_empty() {
97                                        true
98                                    } else {
99                                        item.to_string()
100                                            .to_ascii_lowercase()
101                                            .contains(&f.to_ascii_lowercase())
102                                    }
103                                },
104                                |cx, _, item| {
105                                    Label::new(cx, item);
106                                },
107                            )
108                            .selectable(Selectable::Single)
109                            .selected(selected.map(|s| vec![*s]))
110                            .on_select(|cx, index| {
111                                cx.emit(ComboBoxEvent::SetOption(index));
112                                cx.emit(PopupEvent::Close);
113                            });
114                        });
115                    })
116                    .should_reposition(false)
117                    .arrow_size(Pixels(4.0));
118                }
119            });
120        })
121        .bind(selected, move |handle, selected| {
122            let selected_item = list_lens.idx(selected.get(&handle)).get(&handle);
123            handle.modify(|combobox| combobox.placeholder = selected_item.to_string());
124        })
125    }
126}
127
128impl<L1, L2, T> View for ComboBox<L1, L2, T>
129where
130    L1: Lens<Target = Vec<T>>,
131    T: 'static + Data + ToString,
132    L2: Lens<Target = usize>,
133{
134    fn element(&self) -> Option<&'static str> {
135        Some("combobox")
136    }
137
138    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
139        event.map(|combobox_event, _| match combobox_event {
140            ComboBoxEvent::SetOption(index) => {
141                // Set the placeholder text to the selected item.
142                let selected_item = self.list_lens.idx(*index).get(cx);
143                self.placeholder = selected_item.to_string();
144
145                // Call the on_select callback.
146                if let Some(callback) = &self.on_select {
147                    (callback)(cx, *index);
148                }
149
150                // Close the popup.
151                self.is_open = false;
152
153                // Reset the filter text.
154                self.filter_text = String::new();
155
156                // Set the textbox to non-edit state.
157                // TODO: Add a modifier to textbox and bind to some state in combobox.
158                cx.emit_custom(
159                    Event::new(TextEvent::EndEdit)
160                        .target(cx.current)
161                        .propagate(Propagation::Subtree),
162                );
163            }
164
165            ComboBoxEvent::SetFilterText(text) => {
166                self.placeholder.clone_from(text);
167                self.filter_text.clone_from(text);
168
169                // Reopen the popup in case it was closed with the ESC key.
170                self.is_open = true;
171            }
172        });
173
174        event.map(|textbox_event, _| match textbox_event {
175            // User pressed on the textbox or focused it.
176            TextEvent::StartEdit => {
177                self.is_open = true;
178            }
179
180            TextEvent::Submit(enter) => {
181                let selected = self.selected.get(cx);
182                if *enter {
183                    // User pressed the enter key.
184                    //cx.emit(ComboBoxEvent::SetOption(self.hovered));
185                } else {
186                    // User clicked outside the textbox.
187                    cx.emit(ComboBoxEvent::SetOption(selected));
188                }
189            }
190
191            _ => {}
192        });
193
194        event.map(|window_event, meta| match window_event {
195            WindowEvent::KeyDown(code, _) => match code {
196                Code::ArrowDown => {
197                    // Forward events to list
198                    if meta.origin != cx.current() {
199                        cx.emit_custom(
200                            Event::new(window_event.clone())
201                                .origin(cx.current())
202                                .target(Entity::root())
203                                .propagate(Propagation::Subtree),
204                        );
205                    }
206                }
207
208                Code::ArrowUp => {
209                    // Forward events to list
210                    if meta.origin != cx.current() {
211                        cx.emit_custom(
212                            Event::new(window_event.clone())
213                                .origin(cx.current())
214                                .target(Entity::root())
215                                .propagate(Propagation::Subtree),
216                        );
217                    }
218                }
219
220                Code::Enter => {
221                    if meta.origin != cx.current() {
222                        cx.emit_custom(
223                            Event::new(window_event.clone())
224                                .origin(cx.current())
225                                .target(Entity::root())
226                                .propagate(Propagation::Subtree),
227                        );
228                    }
229                }
230
231                Code::Escape => {
232                    if self.is_open {
233                        self.is_open = false;
234                    } else {
235                        cx.emit(TextEvent::Submit(false));
236                    }
237                }
238
239                _ => {}
240            },
241
242            _ => {}
243        });
244    }
245}
246
247impl<L1, L2, T> Handle<'_, ComboBox<L1, L2, T>>
248where
249    L1: Lens<Target = Vec<T>>,
250    T: 'static + Data + ToString,
251    L2: Lens<Target = usize>,
252{
253    /// Set the callback triggered when an item is selected from the [ComboBox] dropdown list.
254    pub fn on_select<F>(self, callback: F) -> Self
255    where
256        F: 'static + Fn(&mut EventContext, usize),
257    {
258        self.modify(|combobox: &mut ComboBox<L1, L2, T>| {
259            combobox.on_select = Some(Box::new(callback))
260        })
261    }
262}