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