Skip to main content

qframe/widgets/
select.rs

1//! Dropdown selection.
2
3use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::{Key, Modifiers};
6use crate::style::CellStyle;
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::IndexMessage;
12use super::cells;
13use super::placement::{self, Placement};
14use super::popup_menu::{OptionList, OptionStyles, type_ahead};
15
16/// A field showing the chosen option that opens a list of options as a layer.
17///
18/// Closed: Enter, Space, ↓ or a click opens it. Open: ↑/↓, Home/End and PgUp/PgDn move,
19/// typing a letter jumps to the next option starting with it, Enter or Space chooses, Esc or a
20/// click elsewhere closes. A click elsewhere still reaches what it landed on, so one click on
21/// another dropdown opens that one; a click on this field while open only closes it. The pointer
22/// moves the one highlight once it moves. Style keys: `select` with `hover`, `focus`, `active` (open),
23/// `disabled`; `select-placeholder`, `select-chevron`, `select-menu` (`bg`) and
24/// `select-option` with `hover`, `selected`, `checked`.
25pub struct Select<Msg> {
26    options: Vec<String>,
27    selected: Option<usize>,
28    placeholder: String,
29    disabled: bool,
30    max_visible: usize,
31    on_select: Option<IndexMessage<Msg>>,
32}
33
34#[derive(Debug, Default)]
35struct SelectMemory {
36    open: bool,
37    opened_at: std::time::Duration,
38    popup: Rect,
39    list: OptionList,
40}
41
42/// The option list's style keys.
43const OPTION_STYLES: OptionStyles = OptionStyles { menu: "select-menu", item: "select-option", check: "select-check" };
44
45impl<Msg: 'static> Select<Msg> {
46    /// A dropdown of `options`.
47    #[must_use]
48    pub fn new(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
49        Self {
50            options: options.into_iter().map(Into::into).collect(),
51            selected: None,
52            placeholder: String::new(),
53            disabled: false,
54            max_visible: 8,
55            on_select: None,
56        }
57    }
58
59    /// The chosen option.
60    #[must_use]
61    pub fn selected(mut self, index: Option<usize>) -> Self {
62        self.selected = index;
63        self
64    }
65
66    /// Faint text shown while nothing is chosen.
67    #[must_use]
68    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
69        self.placeholder = text.into();
70        self
71    }
72
73    /// Greys the field out; it cannot be opened.
74    #[must_use]
75    pub fn disabled(mut self, disabled: bool) -> Self {
76        self.disabled = disabled;
77        self
78    }
79
80    /// Rows shown before the option list scrolls; 8 by default.
81    #[must_use]
82    pub fn max_visible(mut self, rows: usize) -> Self {
83        self.max_visible = rows.max(1);
84        self
85    }
86
87    /// Message for choosing option `index`.
88    #[must_use]
89    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
90        self.on_select = Some(Box::new(message));
91        self
92    }
93
94    fn visible_rows(&self) -> usize {
95        self.options.len().min(self.max_visible)
96    }
97
98    fn open(&self, cx: &mut EventCx<'_, Msg>) {
99        let highlight = self.selected.unwrap_or(0).min(self.options.len().saturating_sub(1));
100        let list = OptionList::open(highlight, self.visible_rows(), cx.interaction.pointer);
101        let now = cx.now();
102        let memory = cx.memory::<SelectMemory>();
103        memory.open = true;
104        memory.opened_at = now;
105        memory.list = list;
106        cx.capture_keys(true);
107    }
108
109    fn close(cx: &mut EventCx<'_, Msg>) {
110        cx.memory::<SelectMemory>().open = false;
111        cx.capture_keys(false);
112    }
113
114    fn choose(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
115        Self::close(cx);
116        cx.flash();
117        if Some(index) != self.selected
118            && let Some(message) = &self.on_select
119        {
120            cx.emit(message(index));
121        }
122    }
123
124    fn move_highlight(&self, cx: &mut EventCx<'_, Msg>, target: usize) {
125        let (len, visible) = (self.options.len(), self.visible_rows());
126        cx.memory::<SelectMemory>().list.move_to(target, len, visible);
127    }
128
129    fn popup_rect(&self, cx: &PaintCx<'_>, anchor: Rect) -> (Rect, Placement) {
130        let screen = cx.clip();
131        let longest = self.options.iter().map(|option| text::width(option)).max().unwrap_or(0);
132        let width = anchor.width.max(longest.saturating_add(6));
133        let height = clamp_u16(i32::try_from(self.visible_rows()).unwrap_or(i32::MAX));
134        placement::place(anchor, Size::new(width, height), screen, Placement::Below)
135    }
136}
137
138/// Paints a closed dropdown field in `states`: the `select` surface, the pillar in its left padding
139/// while hovered, focused from the keyboard or open, and the chevron at the right. The field is
140/// not a list, so its label never slides; the options of the open list do. `label` is the chosen
141/// text; without one the placeholder shows. Shared by every dropdown field, such as [`Select`] and
142/// the date picker.
143pub(crate) fn paint_field(cx: &mut PaintCx<'_>, area: Rect, states: &[State], label: Option<&str>, placeholder: &str) {
144    let style = cx.style("select", None, states);
145    let surface = style.text();
146    cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
147    let padding = style.padding();
148    let inner = area.inset(padding);
149    let pillar = style.color("pillar").filter(|_| padding.left >= 1);
150    if let Some(color) = pillar {
151        cx.pillar(area.x, inner.y, color);
152    }
153    let chevron = cx.env().icons().glyph("chevron-down").into_owned();
154    let chevron_style = cx.style("select-chevron", None, states).text();
155    let chevron_width = text::width(&chevron);
156    cx.text(inner.right() - i32::from(chevron_width), inner.y, &chevron, chevron_style, chevron_width);
157    let budget = inner.width.saturating_sub(chevron_width + 2);
158    let (shown, text_style) = match label {
159        Some(label) => (label, CellStyle { bg: None, ..surface }),
160        None => (placeholder, cx.style("select-placeholder", None, states).text()),
161    };
162    let shown = text::truncate(shown, budget).into_owned();
163    cx.text(inner.x, inner.y, &shown, text_style, budget);
164}
165
166impl<Msg: 'static> Widget<Msg> for Select<Msg> {
167    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
168        let style = cx.env().theme().style("select", None, &[]);
169        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
170        let longest =
171            self.options.iter().map(|o| text::width(o)).chain([text::width(&self.placeholder)]).max().unwrap_or(0);
172        Size::new(cells::sum([longest, 3, horizontal.saturating_mul(2)]), vertical.saturating_mul(2).saturating_add(1))
173            .min(available)
174    }
175
176    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
177        let open = cx.memory::<SelectMemory>().open;
178        let mut states = if self.disabled { vec![State::Disabled] } else { cx.pressable_states() };
179        if open {
180            states.push(State::Active);
181        }
182        let label = self.selected.and_then(|i| self.options.get(i)).map(String::as_str);
183        paint_field(cx, area, &states, label, &self.placeholder);
184        if !self.disabled {
185            cx.register_hit(area);
186        }
187        if open && !self.disabled {
188            cx.request_overlay(area);
189        }
190    }
191
192    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
193        let (full, side) = self.popup_rect(cx, anchor);
194        // The list unfolds from the field over the theme's `motion.enter`.
195        let opened_at = cx.memory::<SelectMemory>().opened_at;
196        let enter = cx.env().theme().motion().enter;
197        let progress = cx.progress_since(opened_at, enter, crate::motion::Easing::EaseOut);
198        let popup = placement::unfold(full, side, progress);
199        let mut list = cx.memory::<SelectMemory>().list;
200        // The application may have removed options while the list was open.
201        list.clamp(self.options.len(), usize::from(full.height));
202        list.paint(cx, popup, full.height, &self.options, self.selected, OPTION_STYLES);
203        let memory = cx.memory::<SelectMemory>();
204        memory.popup = popup;
205        memory.list = list;
206    }
207
208    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
209        if self.disabled || self.options.is_empty() {
210            return false;
211        }
212        let open = cx.memory::<SelectMemory>().open;
213        match event {
214            Event::PointerOutside => {
215                Self::close(cx);
216                true
217            }
218            Event::Key(key) if !open => {
219                let opens = key.is_plain(Key::Enter) || key.is_plain(Key::Space) || key.is_plain(Key::Down);
220                if opens {
221                    self.open(cx);
222                }
223                opens
224            }
225            Event::Key(key) => {
226                let page = self.visible_rows();
227                let last = self.options.len() - 1;
228                // Events can arrive before the next frame clamps a highlight left by removed options.
229                let highlight = cx.memory::<SelectMemory>().list.highlight.min(last);
230                if key.is_plain(Key::Esc) {
231                    Self::close(cx);
232                } else if key.is_plain(Key::Tab) {
233                    Self::close(cx);
234                    return false;
235                } else if key.is_plain(Key::Up) {
236                    self.move_highlight(cx, highlight.saturating_sub(1));
237                } else if key.is_plain(Key::Down) {
238                    self.move_highlight(cx, (highlight + 1).min(last));
239                } else if key.is_plain(Key::Home) {
240                    self.move_highlight(cx, 0);
241                } else if key.is_plain(Key::End) {
242                    self.move_highlight(cx, last);
243                } else if key.is_plain(Key::PageUp) {
244                    self.move_highlight(cx, highlight.saturating_sub(page));
245                } else if key.is_plain(Key::PageDown) {
246                    self.move_highlight(cx, (highlight + page).min(last));
247                } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
248                    self.choose(cx, highlight);
249                } else if let (Some(typed), false) = (key.text, key.chord.mods.ctrl || key.chord.mods.alt) {
250                    if let Some(next) = type_ahead(&self.options, highlight, typed) {
251                        self.move_highlight(cx, next);
252                    }
253                } else if key.chord.mods != Modifiers::default() {
254                    return false;
255                }
256                true
257            }
258            Event::Mouse(mouse) => {
259                let (len, visible) = (self.options.len(), self.visible_rows());
260                let (popup, mut list) = {
261                    let memory = cx.memory::<SelectMemory>();
262                    (memory.popup, memory.list)
263                };
264                if open {
265                    let dragged = list.bar_event(cx, mouse, len, visible);
266                    cx.memory::<SelectMemory>().list = list;
267                    if dragged {
268                        return true;
269                    }
270                }
271                let in_popup = open && popup.contains(mouse.x, mouse.y);
272                match mouse.kind {
273                    MouseKind::Down(MouseButton::Left) if in_popup => {
274                        let index = list.offset + usize::try_from(mouse.y - popup.y).unwrap_or(0);
275                        if index < self.options.len() {
276                            self.choose(cx, index);
277                        }
278                        true
279                    }
280                    MouseKind::Down(MouseButton::Left) => {
281                        if open {
282                            Self::close(cx);
283                        } else {
284                            self.open(cx);
285                        }
286                        true
287                    }
288                    MouseKind::ScrollUp | MouseKind::ScrollDown if in_popup => {
289                        cx.memory::<SelectMemory>().list.scroll(mouse.kind == MouseKind::ScrollUp, len, visible);
290                        true
291                    }
292                    _ => false,
293                }
294            }
295            Event::Paste(_) => false,
296        }
297    }
298
299    fn focusable(&self) -> bool {
300        !self.disabled && !self.options.is_empty()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::runtime::{App, Command, Harness};
308    use crate::widget::{Length, View};
309    use crate::widgets::Text;
310
311    struct Demo {
312        theme: Option<usize>,
313    }
314
315    impl App for Demo {
316        type Msg = usize;
317        fn update(&mut self, index: usize) -> Command<usize> {
318            self.theme = Some(index);
319            Command::none()
320        }
321        fn view(&self, ui: &mut View<'_, usize>) {
322            ui.column(|ui| {
323                ui.add(
324                    Select::new(["Monochrome", "Iris", "Nordic", "Amber"])
325                        .selected(self.theme)
326                        .placeholder("Theme")
327                        .max_visible(3)
328                        .on_select(|i| i),
329                )
330                .width(Length::Cells(20))
331                .id("theme");
332                ui.add(Text::new("below"));
333            });
334        }
335    }
336
337    #[test]
338    fn opens_as_layer_and_chooses_by_keyboard() {
339        let mut h = Harness::new(Demo { theme: None }, 30, 6);
340        assert!(h.screen().starts_with("  Theme"), "{}", h.screen());
341        h.press("tab").press("enter");
342        let unfolding = h.screen();
343        assert!(!unfolding.contains("Nordic"), "the list unfolds over motion.enter: {unfolding}");
344        h.advance(std::time::Duration::from_millis(200));
345        let screen = h.screen();
346        assert!(screen.contains("Monochrome") && screen.contains("Nordic"), "{screen}");
347        assert!(!screen.contains("below"), "the layer covers content: {screen}");
348        h.press("down").press("down").press("enter");
349        assert_eq!(h.app().theme, Some(2));
350        assert!(h.screen().contains("below"));
351    }
352
353    #[test]
354    fn typing_jumps_and_list_scrolls() {
355        let mut h = Harness::new(Demo { theme: None }, 30, 6);
356        h.press("tab").press("space").press("a").press("enter");
357        assert_eq!(h.app().theme, Some(3));
358    }
359
360    #[test]
361    fn clicks_choose_and_outside_click_closes() {
362        let mut h = Harness::new(Demo { theme: Some(0) }, 30, 6);
363        h.click_text("Monochrome").advance(std::time::Duration::from_millis(200));
364        h.click_text("Iris");
365        assert_eq!(h.app().theme, Some(1));
366        h.click_text("Iris").advance(std::time::Duration::from_millis(200));
367        assert!(h.screen().contains("Nordic"));
368        h.click(28, 5);
369        assert!(!h.screen().contains("Nordic"));
370        assert_eq!(h.app().theme, Some(1));
371    }
372
373    /// A select whose four options fit when `max_visible` allows it.
374    struct Sized(usize);
375
376    impl App for Sized {
377        type Msg = usize;
378        fn update(&mut self, _: usize) -> Command<usize> {
379            Command::none()
380        }
381        fn view(&self, ui: &mut View<'_, usize>) {
382            ui.add(
383                Select::new(["Monochrome", "Iris", "Nordic", "Amber"])
384                    .placeholder("Theme")
385                    .max_visible(self.0)
386                    .on_select(|i| i),
387            )
388            .width(Length::Cells(20));
389        }
390    }
391
392    #[test]
393    fn hovering_the_field_raises_the_pillar_and_slides_the_label_but_not_the_chevron() {
394        let mut h = Harness::new(Sized(8), 30, 12);
395        let resting = h.screen().lines().next().unwrap_or_default().to_owned();
396        h.hover(4, 0);
397        let hovered = h.screen().lines().next().unwrap_or_default().to_owned();
398        assert_eq!(resting, "  Theme          ▾");
399        assert_eq!(hovered, "▌ Theme          ▾", "label slides, chevron stays");
400    }
401
402    #[test]
403    fn the_pointer_moves_the_one_highlight() {
404        let mut h = Harness::new(Sized(8), 30, 12);
405        h.click_text("Theme");
406        h.advance(std::time::Duration::from_millis(300));
407        let (x, y) = h.find("Nordic").expect("open list");
408        h.hover(x + 3, y);
409        let rows: String = h.screen().lines().skip(1).collect::<Vec<_>>().join("\n");
410        assert_eq!(rows.matches('▌').count(), 1, "one raised row under the open field:\n{}", h.screen());
411        assert!(h.screen().contains("▌  Nordic"), "{}", h.screen());
412    }
413
414    #[test]
415    fn keys_move_on_from_a_resting_pointer_and_a_pointer_resting_at_opening_waits() {
416        let mut h = Harness::new(Sized(8), 30, 12);
417        h.click_text("Theme").advance(std::time::Duration::from_millis(300));
418        let (x, y) = h.find("Iris").expect("open list");
419        h.hover(x, y).press("down");
420        let lit =
421            |h: &Harness<Sized>| h.screen().lines().skip(1).filter(|l| l.contains('▌')).collect::<Vec<_>>().join("|");
422        assert!(lit(&h).contains("Nordic"), "the key moves on from the hovered row: {}", h.screen());
423        h.press("esc").press("enter").advance(std::time::Duration::from_millis(300));
424        assert!(lit(&h).contains("Monochrome"), "the pointer resting on Iris does not take it: {}", h.screen());
425        h.hover(x + 1, y);
426        assert!(lit(&h).contains("Iris"), "{}", h.screen());
427    }
428
429    #[test]
430    fn scrollbar_is_decided_by_the_unfolded_height() {
431        let scrollbar = |h: &Harness<Sized>| super::super::scrollbar::column(h, 19).contains('#');
432        let mut fits = Harness::new(Sized(8), 30, 12);
433        fits.click_text("Theme");
434        let mut scrolls = Harness::new(Sized(2), 30, 12);
435        scrolls.click_text("Theme");
436        for _ in 0..8 {
437            assert!(!scrollbar(&fits), "a list that fits never shows one:\n{}", fits.screen());
438            assert!(scrollbar(&scrolls), "a list that scrolls shows one from the start:\n{}", scrolls.screen());
439            fits.advance(std::time::Duration::from_millis(20));
440            scrolls.advance(std::time::Duration::from_millis(20));
441        }
442    }
443
444    #[test]
445    fn reduced_motion_opens_at_once() {
446        let mut h = Harness::new(Demo { theme: None }, 30, 6);
447        h.set_reduced_motion(true).press("tab").press("enter");
448        assert!(h.screen().contains("Nordic"));
449    }
450
451    #[test]
452    fn escape_closes_and_tab_moves_on() {
453        let mut h = Harness::new(Demo { theme: None }, 30, 6);
454        h.press("tab").press("enter").press("esc");
455        assert!(!h.screen().contains("Iris"));
456        h.press("enter").press("tab");
457        assert!(!h.screen().contains("Iris"));
458    }
459
460    #[test]
461    fn the_scrollbar_of_the_open_list_can_be_dragged() {
462        let mut h = Harness::new(Sized(2), 30, 12);
463        h.click_text("Theme").advance(std::time::Duration::from_millis(300));
464        let (_, y) = h.find("Monochrome").expect("open list");
465        h.mouse(MouseKind::Down(MouseButton::Left), 19, y);
466        h.mouse(MouseKind::Drag(MouseButton::Left), 19, y + 5);
467        h.mouse(MouseKind::Up(MouseButton::Left), 19, y + 5);
468        let screen = h.screen();
469        assert!(screen.contains("Amber") && !screen.contains("Monochrome"), "{screen}");
470        assert!(screen.contains("Nordic"), "still open, nothing chosen: {screen}");
471    }
472
473    /// A select whose options the application can shorten while the list is open.
474    struct Shrinking {
475        options: Vec<&'static str>,
476        chosen: Vec<usize>,
477    }
478
479    impl App for Shrinking {
480        type Msg = Option<usize>;
481        fn update(&mut self, msg: Option<usize>) -> Command<Option<usize>> {
482            match msg {
483                Some(index) => self.chosen.push(index),
484                None => self.options.truncate(1),
485            }
486            Command::none()
487        }
488        fn view(&self, ui: &mut View<'_, Option<usize>>) {
489            ui.add(Select::new(self.options.clone()).on_select(Some)).width(Length::Cells(20));
490        }
491    }
492
493    #[test]
494    fn options_removed_while_the_list_is_open_are_never_chosen() {
495        let mut h = Harness::new(Shrinking { options: vec!["web", "db", "cache"], chosen: Vec::new() }, 30, 8);
496        h.set_reduced_motion(true).press("tab").press("enter").press("end");
497        h.send(None).press("enter");
498        assert_eq!(h.app().chosen, [0], "the highlight moved back onto the one option left");
499    }
500
501    /// Long option names in a narrow list.
502    struct Long;
503
504    impl App for Long {
505        type Msg = usize;
506        fn update(&mut self, _: usize) -> Command<usize> {
507            Command::none()
508        }
509        fn view(&self, ui: &mut View<'_, usize>) {
510            ui.add(Select::new(["eu-central-1 Frankfurt", "us-east-1 North Virginia"]).on_select(|i| i))
511                .width(Length::Cells(20));
512        }
513    }
514
515    #[test]
516    fn a_raised_option_is_cut_at_the_same_place_as_a_resting_one() {
517        let mut h = Harness::new(Long, 22, 6);
518        h.set_reduced_motion(true).press("tab").press("enter");
519        let label = |h: &Harness<Long>, row: usize| {
520            h.screen().lines().nth(row).unwrap_or_default().replace('▌', " ").trim().to_owned()
521        };
522        let resting = label(&h, 2);
523        h.press("down");
524        let raised = label(&h, 2);
525        assert!(resting.ends_with('…'), "{}", h.screen());
526        assert_eq!(raised, resting, "the slide moves the label, it does not cut it shorter");
527        let line = h.screen().lines().nth(2).unwrap_or_default().to_owned();
528        assert!(line.starts_with("▌  us-east"), "the raised label slid one cell: {line}");
529    }
530}