Skip to main content

kas_widgets/
combobox.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License in the LICENSE-APACHE file or at:
4//     https://www.apache.org/licenses/LICENSE-2.0
5
6//! Combobox
7
8use crate::adapt::AdaptEvents;
9use crate::{Column, Label, Mark, menu::MenuEntry};
10use kas::event::FocusSource;
11use kas::messages::{Collapse, Expand, SetIndex};
12use kas::prelude::*;
13use kas::theme::FrameStyle;
14use kas::theme::{MarkStyle, TextClass};
15use kas::window::Popup;
16use std::fmt::Debug;
17
18#[impl_self]
19mod ComboBox {
20    /// A pop-up multiple choice menu
21    ///
22    /// # Messages
23    ///
24    /// A combobox presents a menu with a fixed set of choices when clicked.
25    /// Each choice has an associated value of type `V`.
26    ///
27    /// If no selection handler exists, then the choice's message is emitted
28    /// when selected. If a handler is specified via [`Self::with`] or
29    /// [`Self::with_msg`] then this message is passed to the handler and not emitted.
30    ///
31    /// # Messages
32    ///
33    /// [`kas::messages::SetIndex`] may be used to set the selected entry.
34    ///
35    /// [`kas::messages::Expand`] and [`kas::messages::Collapse`] may be used to
36    /// open and close the menu.
37    #[widget]
38    #[layout(
39        frame!(row! [self.label, Mark::new(MarkStyle::Chevron(Direction::Down), "Expand")])
40            .with_style(FrameStyle::Button)
41            .align(AlignHints::CENTER)
42    )]
43    pub struct ComboBox<A, V: Clone + Debug + Eq + 'static> {
44        core: widget_core!(),
45        #[widget(&())]
46        label: Label<String>,
47        #[widget(&())]
48        popup: Popup<AdaptEvents<Column<Vec<MenuEntry<V>>>>>,
49        active: usize,
50        opening: bool,
51        state_fn: Box<dyn Fn(&ConfigCx, &A) -> V>,
52        on_select: Option<Box<dyn Fn(&mut EventCx, V)>>,
53    }
54
55    impl Tile for Self {
56        fn navigable(&self) -> bool {
57            true
58        }
59
60        fn role(&self, _: &mut dyn RoleCx) -> Role<'_> {
61            Role::ComboBox {
62                active: self.active,
63                text: self.label.as_str(),
64                expanded: self.popup.is_open(),
65            }
66        }
67
68        fn nav_next(&self, _: bool, _: Option<usize>) -> Option<usize> {
69            // We have no child within our rect
70            None
71        }
72    }
73
74    impl Events for Self {
75        const REDRAW_ON_MOUSE_OVER: bool = true;
76
77        type Data = A;
78
79        fn probe(&self, _: Coord) -> Id {
80            self.id()
81        }
82
83        fn update(&mut self, cx: &mut ConfigCx, data: &A) {
84            let msg = (self.state_fn)(cx, data);
85            let opt_index = self
86                .popup
87                .inner
88                .inner
89                .iter()
90                .enumerate()
91                .find_map(|(i, w)| (*w == msg).then_some(i));
92            if let Some(index) = opt_index {
93                self.set_active(cx, index);
94            } else {
95                log::warn!("ComboBox::update: unknown entry {msg:?}");
96            };
97        }
98
99        fn handle_event(&mut self, cx: &mut EventCx, _: &A, event: Event) -> IsUsed {
100            match event {
101                Event::Command(cmd, code) => {
102                    if self.popup.is_open() {
103                        let next = |cx: &mut EventCx, clr, rev| {
104                            if clr {
105                                cx.clear_nav_focus();
106                            }
107                            cx.next_nav_focus(None, rev, FocusSource::Key);
108                        };
109                        match cmd {
110                            cmd if cmd.is_activate() => {
111                                self.popup.close(cx);
112                                cx.depress_with_key(&self, code);
113                            }
114                            Command::Up => next(cx, false, true),
115                            Command::Down => next(cx, false, false),
116                            Command::Home => next(cx, true, false),
117                            Command::End => next(cx, true, true),
118                            _ => return Unused,
119                        }
120                    } else {
121                        let last = self.len().saturating_sub(1);
122                        if cmd.is_activate() {
123                            self.open_popup(cx, FocusSource::Key);
124                            cx.depress_with_key(&self, code);
125                        } else {
126                            let index = match cmd {
127                                Command::Up => self.active.saturating_sub(1),
128                                Command::Down => (self.active + 1).min(last),
129                                Command::Home => 0,
130                                Command::End => last,
131                                _ => return Unused,
132                            };
133                            self.set_active(cx, index);
134                        }
135                    }
136                    Used
137                }
138                Event::Scroll(delta) if !self.popup.is_open() => {
139                    if let Some(y) = delta.as_wheel_action(cx) {
140                        let index = if y > 0 {
141                            self.active.saturating_sub(y as usize)
142                        } else {
143                            self.active
144                                .saturating_add((-y) as usize)
145                                .min(self.len().saturating_sub(1))
146                        };
147                        self.set_active(cx, index);
148                        Used
149                    } else {
150                        Unused
151                    }
152                }
153                Event::PressStart(press) => {
154                    if press
155                        .id
156                        .as_ref()
157                        .map(|id| self.is_ancestor_of(id))
158                        .unwrap_or(false)
159                    {
160                        if press.is_primary() {
161                            press.grab_move(self.id()).complete(cx);
162                            cx.set_grab_depress(*press, press.id);
163                            self.opening = !self.popup.is_open();
164                        }
165                        Used
166                    } else {
167                        Unused
168                    }
169                }
170                Event::PointerMove { press } | Event::PressMove { press, .. } => {
171                    self.open_popup(cx, FocusSource::Pointer);
172                    let cond = self.popup.rect().contains(press.coord);
173                    let target = if cond { press.id } else { None };
174                    cx.set_grab_depress(press.source, target.clone());
175                    if let Some(id) = target {
176                        cx.request_nav_focus(id, FocusSource::Pointer);
177                    }
178                    Used
179                }
180                Event::PressEnd { press, success } if success => {
181                    if let Some(id) = press.id {
182                        if self.eq_id(&id) {
183                            if self.opening {
184                                self.open_popup(cx, FocusSource::Pointer);
185                                return Used;
186                            }
187                        } else if self.popup.is_open() && self.popup.is_ancestor_of(&id) {
188                            cx.send(id, Command::Activate);
189                            return Used;
190                        }
191                    }
192                    self.popup.close(cx);
193                    Used
194                }
195                _ => Unused,
196            }
197        }
198
199        fn handle_messages(&mut self, cx: &mut EventCx, _: &Self::Data) {
200            if let Some(SetIndex(index)) = cx.try_pop() {
201                self.set_active(cx, index);
202                self.popup.close(cx);
203                if let Some(ref f) = self.on_select {
204                    if let Some(msg) = cx.try_pop() {
205                        (f)(cx, msg);
206                    }
207                }
208            } else if let Some(Expand) = cx.try_pop() {
209                self.open_popup(cx, FocusSource::Synthetic);
210            } else if let Some(Collapse) = cx.try_pop() {
211                self.popup.close(cx);
212            }
213        }
214    }
215
216    impl Self {
217        fn open_popup(&mut self, cx: &mut EventCx, source: FocusSource) {
218            if self.popup.open(cx, &(), self.id(), true) {
219                if let Some(w) = self.popup.inner.inner.get_child(self.active) {
220                    cx.next_nav_focus(w.id(), false, source);
221                }
222            }
223        }
224    }
225}
226
227impl<A, V: Clone + Debug + Eq + 'static> ComboBox<A, V> {
228    /// Construct a combobox
229    ///
230    /// Constructs a combobox with labels derived from an iterator over string
231    /// types. For example:
232    /// ```
233    /// # use kas_widgets::ComboBox;
234    /// #[derive(Clone, Copy, Debug, PartialEq, Eq)]
235    /// enum Select { A, B, C }
236    ///
237    /// let combobox = ComboBox::new(
238    ///     [("A", Select::A), ("B", Select::B), ("C", Select::C)],
239    ///     |_, selection| *selection,
240    /// );
241    /// ```
242    ///
243    /// The closure `state_fn` selects the active entry from input data.
244    pub fn new<T, I>(iter: I, state_fn: impl Fn(&ConfigCx, &A) -> V + 'static) -> Self
245    where
246        T: Into<AccessString>,
247        I: IntoIterator<Item = (T, V)>,
248    {
249        let entries = iter
250            .into_iter()
251            .map(|(label, msg)| MenuEntry::new_msg(label, msg))
252            .collect();
253        Self::new_vec(entries, state_fn)
254    }
255
256    /// Construct a combobox with the given menu entries
257    ///
258    /// A combobox presents a menu with a fixed set of choices when clicked.
259    ///
260    /// The closure `state_fn` selects the active entry from input data.
261    pub fn new_vec(
262        entries: Vec<MenuEntry<V>>,
263        state_fn: impl Fn(&ConfigCx, &A) -> V + 'static,
264    ) -> Self {
265        let label = entries.first().map(|entry| entry.as_str().to_string());
266        let label = Label::new(label.unwrap_or_default()).with_class(TextClass::Label);
267        ComboBox {
268            core: Default::default(),
269            label,
270            popup: Popup::new(
271                AdaptEvents::new(Column::new(entries)).on_messages(|cx, _, _| {
272                    if let Some(_) = cx.try_peek::<V>() {
273                        if let Some(index) = cx.last_child() {
274                            cx.push(SetIndex(index));
275                        }
276                    }
277                }),
278                Direction::Down,
279            ),
280            active: 0,
281            opening: false,
282            state_fn: Box::new(state_fn),
283            on_select: None,
284        }
285    }
286
287    /// Send the message generated by `f` on selection
288    #[must_use]
289    pub fn with_msg<M: Debug + 'static>(self, f: impl Fn(V) -> M + 'static) -> Self {
290        self.with(move |cx, m| cx.push(f(m)))
291    }
292
293    /// Call the handler `f` on selection
294    ///
295    /// On selection of a new choice the closure `f` is called with the choice's
296    /// message.
297    #[must_use]
298    pub fn with<F>(mut self, f: F) -> ComboBox<A, V>
299    where
300        F: Fn(&mut EventCx, V) + 'static,
301    {
302        self.on_select = Some(Box::new(f));
303        self
304    }
305
306    /// Construct a combobox which sends a message on selection
307    ///
308    /// See [`Self::new`] and [`Self::with_msg`] for documentation.
309    pub fn new_msg<T, I, M>(
310        iter: I,
311        state_fn: impl Fn(&ConfigCx, &A) -> V + 'static,
312        msg_fn: impl Fn(V) -> M + 'static,
313    ) -> Self
314    where
315        T: Into<AccessString>,
316        I: IntoIterator<Item = (T, V)>,
317        M: Debug + 'static,
318    {
319        Self::new(iter, state_fn).with_msg(msg_fn)
320    }
321}
322
323impl<A, V: Clone + Debug + Eq + 'static> ComboBox<A, V> {
324    /// Get the index of the active choice
325    ///
326    /// This index is normally less than the number of choices (`self.len()`),
327    /// but may not be if set programmatically or there are no choices.
328    #[inline]
329    pub fn active(&self) -> usize {
330        self.active
331    }
332
333    /// Set the active choice
334    pub fn set_active(&mut self, cx: &mut ConfigCx, index: usize) {
335        if self.active != index && index < self.popup.inner.inner.len() {
336            self.active = index;
337            let string = if index < self.len() {
338                self.popup.inner.inner[index].as_str().to_string()
339            } else {
340                "".to_string()
341            };
342            self.label.set_string(cx, string);
343        }
344    }
345
346    /// Get the number of entries
347    #[inline]
348    pub fn len(&self) -> usize {
349        self.popup.inner.inner.len()
350    }
351
352    /// True if the box contains no entries
353    #[inline]
354    pub fn is_empty(&self) -> bool {
355        self.popup.inner.inner.is_empty()
356    }
357
358    /// Remove all choices
359    pub fn clear(&mut self) {
360        self.popup.inner.inner.clear()
361    }
362
363    /// Add a choice to the combobox, in last position
364    ///
365    /// Returns the index of the new choice
366    //
367    // TODO(opt): these methods cause full-window resize. They don't need to
368    // resize at all if the menu is closed!
369    pub fn push<T: Into<AccessString>>(&mut self, cx: &mut ConfigCx, label: T, msg: V) -> usize {
370        let column = &mut self.popup.inner.inner;
371        column.push(cx, &(), MenuEntry::new_msg(label, msg))
372    }
373
374    /// Pops the last choice from the combobox
375    pub fn pop(&mut self, cx: &mut ConfigCx) -> Option<()> {
376        self.popup.inner.inner.pop(cx).map(|_| ())
377    }
378
379    /// Add a choice at position `index`
380    ///
381    /// Panics if `index > len`.
382    pub fn insert<T: Into<AccessString>>(
383        &mut self,
384        cx: &mut ConfigCx,
385        index: usize,
386        label: T,
387        msg: V,
388    ) {
389        let column = &mut self.popup.inner.inner;
390        column.insert(cx, &(), index, MenuEntry::new_msg(label, msg));
391    }
392
393    /// Removes the choice at position `index`
394    ///
395    /// Panics if `index` is out of bounds.
396    pub fn remove(&mut self, cx: &mut ConfigCx, index: usize) {
397        self.popup.inner.inner.remove(cx, index);
398    }
399
400    /// Replace the choice at `index`
401    ///
402    /// Panics if `index` is out of bounds.
403    pub fn replace<T: Into<AccessString>>(
404        &mut self,
405        cx: &mut ConfigCx,
406        index: usize,
407        label: T,
408        msg: V,
409    ) {
410        self.popup
411            .inner
412            .inner
413            .replace(cx, &(), index, MenuEntry::new_msg(label, msg));
414    }
415}