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        fn probe(&self, _: Coord) -> Id {
74            self.id()
75        }
76    }
77
78    impl Events for Self {
79        const REDRAW_ON_MOUSE_OVER: bool = true;
80
81        type Data = A;
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                        match cmd {
123                            cmd if cmd.is_activate() => {
124                                self.open_popup(cx, FocusSource::Key);
125                                cx.depress_with_key(&self, code);
126                            }
127                            Command::Up => self.set_active(cx, self.active.saturating_sub(1)),
128                            Command::Down => self.set_active(cx, (self.active + 1).min(last)),
129                            Command::Home => self.set_active(cx, 0),
130                            Command::End => self.set_active(cx, last),
131                            _ => return Unused,
132                        };
133                    }
134                    Used
135                }
136                Event::Scroll(delta) if !self.popup.is_open() => {
137                    if let Some(y) = delta.as_wheel_action(cx) {
138                        let index = if y > 0 {
139                            self.active.saturating_sub(y as usize)
140                        } else {
141                            self.active
142                                .saturating_add((-y) as usize)
143                                .min(self.len().saturating_sub(1))
144                        };
145                        self.set_active(cx, index);
146                        Used
147                    } else {
148                        Unused
149                    }
150                }
151                Event::PressStart(press) => {
152                    if press
153                        .id
154                        .as_ref()
155                        .map(|id| self.is_ancestor_of(id))
156                        .unwrap_or(false)
157                    {
158                        if press.is_primary() {
159                            press.grab_move(self.id()).complete(cx);
160                            cx.set_grab_depress(*press, press.id);
161                            self.opening = !self.popup.is_open();
162                        }
163                        Used
164                    } else {
165                        Unused
166                    }
167                }
168                Event::CursorMove { press } | Event::PressMove { press, .. } => {
169                    self.open_popup(cx, FocusSource::Pointer);
170                    let cond = self.popup.rect().contains(press.coord);
171                    let target = if cond { press.id } else { None };
172                    cx.set_grab_depress(press.source, target.clone());
173                    if let Some(id) = target {
174                        cx.request_nav_focus(id, FocusSource::Pointer);
175                    }
176                    Used
177                }
178                Event::PressEnd { press, success } if success => {
179                    if let Some(id) = press.id {
180                        if self.eq_id(&id) {
181                            if self.opening {
182                                self.open_popup(cx, FocusSource::Pointer);
183                                return Used;
184                            }
185                        } else if self.popup.is_open() && self.popup.is_ancestor_of(&id) {
186                            cx.send(id, Command::Activate);
187                            return Used;
188                        }
189                    }
190                    self.popup.close(cx);
191                    Used
192                }
193                _ => Unused,
194            }
195        }
196
197        fn handle_messages(&mut self, cx: &mut EventCx, _: &Self::Data) {
198            if let Some(SetIndex(index)) = cx.try_pop() {
199                self.set_active(cx, index);
200                self.popup.close(cx);
201                if let Some(ref f) = self.on_select {
202                    if let Some(msg) = cx.try_pop() {
203                        (f)(cx, msg);
204                    }
205                }
206            } else if let Some(Expand) = cx.try_pop() {
207                self.open_popup(cx, FocusSource::Synthetic);
208            } else if let Some(Collapse) = cx.try_pop() {
209                self.popup.close(cx);
210            }
211        }
212    }
213
214    impl Self {
215        fn open_popup(&mut self, cx: &mut EventCx, source: FocusSource) {
216            if self.popup.open(cx, &(), self.id(), true) {
217                if let Some(w) = self.popup.inner.inner.get_child(self.active) {
218                    cx.next_nav_focus(w.id(), false, source);
219                }
220            }
221        }
222    }
223}
224
225impl<A, V: Clone + Debug + Eq + 'static> ComboBox<A, V> {
226    /// Construct a combobox
227    ///
228    /// Constructs a combobox with labels derived from an iterator over string
229    /// types. For example:
230    /// ```
231    /// # use kas_widgets::ComboBox;
232    /// #[derive(Clone, Copy, Debug, PartialEq, Eq)]
233    /// enum Select { A, B, C }
234    ///
235    /// let combobox = ComboBox::new(
236    ///     [("A", Select::A), ("B", Select::B), ("C", Select::C)],
237    ///     |_, selection| *selection,
238    /// );
239    /// ```
240    ///
241    /// The closure `state_fn` selects the active entry from input data.
242    pub fn new<T, I>(iter: I, state_fn: impl Fn(&ConfigCx, &A) -> V + 'static) -> Self
243    where
244        T: Into<AccessString>,
245        I: IntoIterator<Item = (T, V)>,
246    {
247        let entries = iter
248            .into_iter()
249            .map(|(label, msg)| MenuEntry::new_msg(label, msg))
250            .collect();
251        Self::new_vec(entries, state_fn)
252    }
253
254    /// Construct a combobox with the given menu entries
255    ///
256    /// A combobox presents a menu with a fixed set of choices when clicked.
257    ///
258    /// The closure `state_fn` selects the active entry from input data.
259    pub fn new_vec(
260        entries: Vec<MenuEntry<V>>,
261        state_fn: impl Fn(&ConfigCx, &A) -> V + 'static,
262    ) -> Self {
263        let label = entries.first().map(|entry| entry.as_str().to_string());
264        let label = Label::new(label.unwrap_or_default()).with_class(TextClass::Button);
265        ComboBox {
266            core: Default::default(),
267            label,
268            popup: Popup::new(
269                AdaptEvents::new(Column::new(entries)).on_messages(|cx, _, _| {
270                    if let Some(_) = cx.try_peek::<V>() {
271                        if let Some(index) = cx.last_child() {
272                            cx.push(SetIndex(index));
273                        }
274                    }
275                }),
276                Direction::Down,
277            ),
278            active: 0,
279            opening: false,
280            state_fn: Box::new(state_fn),
281            on_select: None,
282        }
283    }
284
285    /// Send the message generated by `f` on selection
286    #[must_use]
287    pub fn with_msg<M: Debug + 'static>(self, f: impl Fn(V) -> M + 'static) -> Self {
288        self.with(move |cx, m| cx.push(f(m)))
289    }
290
291    /// Call the handler `f` on selection
292    ///
293    /// On selection of a new choice the closure `f` is called with the choice's
294    /// message.
295    #[must_use]
296    pub fn with<F>(mut self, f: F) -> ComboBox<A, V>
297    where
298        F: Fn(&mut EventCx, V) + 'static,
299    {
300        self.on_select = Some(Box::new(f));
301        self
302    }
303
304    /// Construct a combobox which sends a message on selection
305    ///
306    /// See [`Self::new`] and [`Self::with_msg`] for documentation.
307    pub fn new_msg<T, I, M>(
308        iter: I,
309        state_fn: impl Fn(&ConfigCx, &A) -> V + 'static,
310        msg_fn: impl Fn(V) -> M + 'static,
311    ) -> Self
312    where
313        T: Into<AccessString>,
314        I: IntoIterator<Item = (T, V)>,
315        M: Debug + 'static,
316    {
317        Self::new(iter, state_fn).with_msg(msg_fn)
318    }
319}
320
321impl<A, V: Clone + Debug + Eq + 'static> ComboBox<A, V> {
322    /// Get the index of the active choice
323    ///
324    /// This index is normally less than the number of choices (`self.len()`),
325    /// but may not be if set programmatically or there are no choices.
326    #[inline]
327    pub fn active(&self) -> usize {
328        self.active
329    }
330
331    /// Set the active choice
332    pub fn set_active(&mut self, cx: &mut EventState, index: usize) {
333        if self.active != index && index < self.popup.inner.inner.len() {
334            self.active = index;
335            let string = if index < self.len() {
336                self.popup.inner.inner[index].as_str().to_string()
337            } else {
338                "".to_string()
339            };
340            self.label.set_string(cx, string);
341        }
342    }
343
344    /// Get the number of entries
345    #[inline]
346    pub fn len(&self) -> usize {
347        self.popup.inner.inner.len()
348    }
349
350    /// True if the box contains no entries
351    #[inline]
352    pub fn is_empty(&self) -> bool {
353        self.popup.inner.inner.is_empty()
354    }
355
356    /// Remove all choices
357    pub fn clear(&mut self) {
358        self.popup.inner.inner.clear()
359    }
360
361    /// Add a choice to the combobox, in last position
362    ///
363    /// Returns the index of the new choice
364    //
365    // TODO(opt): these methods cause full-window resize. They don't need to
366    // resize at all if the menu is closed!
367    pub fn push<T: Into<AccessString>>(&mut self, cx: &mut ConfigCx, label: T, msg: V) -> usize {
368        let column = &mut self.popup.inner.inner;
369        column.push(cx, &(), MenuEntry::new_msg(label, msg))
370    }
371
372    /// Pops the last choice from the combobox
373    pub fn pop(&mut self, cx: &mut EventState) -> Option<()> {
374        self.popup.inner.inner.pop(cx).map(|_| ())
375    }
376
377    /// Add a choice at position `index`
378    ///
379    /// Panics if `index > len`.
380    pub fn insert<T: Into<AccessString>>(
381        &mut self,
382        cx: &mut ConfigCx,
383        index: usize,
384        label: T,
385        msg: V,
386    ) {
387        let column = &mut self.popup.inner.inner;
388        column.insert(cx, &(), index, MenuEntry::new_msg(label, msg));
389    }
390
391    /// Removes the choice at position `index`
392    ///
393    /// Panics if `index` is out of bounds.
394    pub fn remove(&mut self, cx: &mut EventState, index: usize) {
395        self.popup.inner.inner.remove(cx, index);
396    }
397
398    /// Replace the choice at `index`
399    ///
400    /// Panics if `index` is out of bounds.
401    pub fn replace<T: Into<AccessString>>(
402        &mut self,
403        cx: &mut ConfigCx,
404        index: usize,
405        label: T,
406        msg: V,
407    ) {
408        self.popup
409            .inner
410            .inner
411            .replace(cx, &(), index, MenuEntry::new_msg(label, msg));
412    }
413}