Skip to main content

denise_ui/widgets/
select.rs

1//! The closed half of a dropdown, and the four lines that open it.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use denise::Pen;
7use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role};
8use denise_text::TextStyle;
9
10use crate::widget::{
11    Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
12};
13use crate::widgets::describe::{
14    Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
15};
16use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair, muted};
17
18/// A control showing one chosen option, which asks to be opened.
19///
20/// ```
21/// # use denise_ui::Select;
22/// enum Message { Open, Chose(usize) }
23/// Select::new(["Auto", "Manuell", "Av"], Message::Open).with_placeholder("Velg modus");
24/// ```
25///
26/// # It does not open its own list, and nothing here can
27///
28/// To open a list a widget would have to create nodes from inside `on_event`.
29/// [`EventCtx`] can emit a message, ask for focus, ask for frames and ask to be
30/// revealed — it cannot add widgets, and giving it that power would let any
31/// widget restructure the tree from an event handler.
32///
33/// That is the same line already drawn three times: [`Tabs`](super::Tabs) owns
34/// the selected index and not the pages, [`List`](super::List) owns the
35/// selection and not the viewport, and
36/// [`Ui::push_popup`](crate::Ui::push_popup) places a container the caller
37/// fills. A select that owned its list would be the first widget to own nodes,
38/// and the exception would be permanent.
39///
40/// So this widget emits an *open* message and the application opens the list —
41/// which [`open_select`] does in one call:
42///
43/// ```
44/// # use denise::{Size, theme};
45/// # use denise_ui::{Select, Ui, widgets};
46/// # #[derive(Clone, Debug)] enum Message { Open, Chose(usize) }
47/// # fn demo(message: Message, select: denise_ui::NodeId) {
48/// # let mut ui: Ui<Message> = Ui::new(Size::new(1920, 1080), theme::DARK);
49/// match message {
50///     Message::Open => { widgets::open_select(&mut ui, select, Message::Chose); }
51///     Message::Chose(index) => {
52///         ui.close_popup();
53///         ui.widget_mut::<Select<Message>>(select).unwrap().set_selected(Some(index));
54///     }
55/// }
56/// # }
57/// ```
58///
59/// Everything the open list needs is already there: the popup flips near a
60/// screen edge, closes on Escape or a press outside — swallowing that press —
61/// and returns focus here when it goes.
62///
63/// # Keyboard
64///
65/// `Enter`, `Space` and `ArrowDown` open it. Left and Right deliberately do
66/// **not** cycle the value: a select whose value changes as somebody tabs past
67/// it is the classic accidental-edit bug, and a closed control that quietly
68/// edits itself is worse than one that needs a second keystroke.
69#[derive(Clone, Debug)]
70pub struct Select<M> {
71    options: Vec<String>,
72    selected: Option<usize>,
73    placeholder: String,
74    message: Option<M>,
75    role: Role,
76    style: TextStyle,
77}
78
79impl<M> Select<M> {
80    /// A select with nothing chosen, emitting `message` when it wants opening.
81    pub fn new(options: impl IntoIterator<Item = impl Into<String>>, message: M) -> Self {
82        Self {
83            options: options.into_iter().map(Into::into).collect(),
84            selected: None,
85            placeholder: String::from("—"),
86            message: Some(message),
87            role: Role::Base100,
88            style: TextStyle::built_in(16),
89        }
90    }
91
92    /// A dropdown that emits nothing, for a chosen value the application reads
93    /// rather than one it is told about.
94    ///
95    /// The message a `Select` carries is its request to be **opened** — the
96    /// popup is a scene the application pushes, since a widget cannot own other
97    /// nodes — so one without a message is a closed control showing
98    /// [`selected`](Select::selected) and nothing else. That is the honest
99    /// meaning of an inert select, and it is what a form file wants for a value
100    /// it displays.
101    ///
102    /// A form that wants the list to open names `on-change`, and the engine
103    /// wires it; see `docs/forms.md`.
104    pub fn inert(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
105        Self {
106            options: options.into_iter().map(Into::into).collect(),
107            selected: None,
108            placeholder: String::from("—"),
109            message: None,
110            role: Role::Base100,
111            style: TextStyle::built_in(16),
112        }
113    }
114
115    /// Sets the text shown when nothing is chosen.
116    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
117        self.placeholder = placeholder.into();
118        self
119    }
120
121    /// Sets the initially chosen option. Out of range chooses nothing.
122    pub fn with_selected(mut self, index: Option<usize>) -> Self {
123        self.set_selected(index);
124        self
125    }
126
127    /// Sets the control's surface role.
128    pub fn with_role(mut self, role: Role) -> Self {
129        self.role = role;
130        self
131    }
132
133    /// Sets the font and size.
134    pub fn with_style(mut self, style: TextStyle) -> Self {
135        self.style = style;
136        self
137    }
138
139    /// The chosen index, if any.
140    #[inline]
141    pub const fn selected(&self) -> Option<usize> {
142        self.selected
143    }
144
145    /// The chosen option's text, or `None` when nothing is chosen.
146    #[inline]
147    pub fn selected_option(&self) -> Option<&str> {
148        self.options.get(self.selected?).map(String::as_str)
149    }
150
151    /// Chooses an option **without emitting anything**. Out of range chooses
152    /// nothing, as [`List::set_selected`](super::List::set_selected) does and
153    /// for the same reason: nothing chosen is a state this control can show.
154    pub fn set_selected(&mut self, index: Option<usize>) {
155        self.selected = index.filter(|index| *index < self.options.len());
156    }
157
158    /// The options, in order.
159    #[inline]
160    pub fn options(&self) -> &[String] {
161        &self.options
162    }
163
164    /// Replaces the options, dropping a selection that no longer exists.
165    pub fn set_options(&mut self, options: impl IntoIterator<Item = impl Into<String>>) {
166        self.options = options.into_iter().map(Into::into).collect();
167        self.set_selected(self.selected);
168    }
169
170    /// Replaces the font and size.
171    pub fn set_style(&mut self, style: TextStyle) {
172        self.style = style;
173    }
174
175    /// The font and size the text draws in.
176    #[inline]
177    pub const fn style(&self) -> TextStyle {
178        self.style
179    }
180
181    /// What the control currently reads.
182    fn shown(&self) -> &str {
183        self.selected_option().unwrap_or(&self.placeholder)
184    }
185}
186
187/// Space between the text and the control's edge.
188#[inline]
189const fn padding(size_px: u16) -> i32 {
190    let half = size_px as i32 / 2;
191    if half < 4 { 4 } else { half }
192}
193
194/// The chevron's box: a square at the trailing edge, inset by the padding.
195fn chevron_box(bounds: Rect, pad: i32) -> Rect {
196    let side = (bounds.height / 3).clamp(1, bounds.width.max(1));
197    Rect::new(
198        bounds.right() - pad - side,
199        bounds.y + (bounds.height - side / 2) / 2,
200        side,
201        side / 2,
202    )
203}
204
205/// Draws a downward chevron inside `box_of`, as two strokes.
206///
207/// Two lines rather than a glyph: the built-in font has no arrow, and a control
208/// whose affordance depended on which font was loaded would lose it on the
209/// panel that ships with none.
210fn draw_chevron(canvas: &mut Pen<'_>, box_of: Rect, thickness: i32, color: denise::Color) {
211    if box_of.is_empty() {
212        return;
213    }
214    let tip = Point::new(box_of.x + box_of.width / 2, box_of.bottom());
215    let left = Point::new(box_of.x, box_of.y);
216    let right = Point::new(box_of.right(), box_of.y);
217    for offset in 0..thickness.max(1) {
218        let dy = offset;
219        canvas.draw_line(
220            Point::new(left.x, left.y + dy),
221            Point::new(tip.x, tip.y + dy),
222            color,
223        );
224        canvas.draw_line(
225            Point::new(tip.x, tip.y + dy),
226            Point::new(right.x, right.y + dy),
227            color,
228        );
229    }
230}
231
232impl<M: Clone + 'static> Widget<M> for Select<M> {
233    fn describe(&self) -> Option<&dyn DynDescribe> {
234        Some(self)
235    }
236
237    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
238        Some(self)
239    }
240    fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
241        // As wide as its longest option, so a dropdown does not clip the thing
242        // it exists to show. The chevron and both paddings go on top.
243        let pad = padding(self.style.size_px);
244        // A plain loop rather than an iterator chain: `measure_line` takes the
245        // engine mutably, so a closure over it would hold it for the whole walk.
246        let mut widest = ctx.text.measure_line(self.style, &self.placeholder);
247        for option in self.options() {
248            widest = widest.max(ctx.text.measure_line(self.style, option));
249        }
250        Measured::both(widest + pad * 4, ctx.theme.metrics.size_field.max(1))
251    }
252
253    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
254        let bounds = ctx.bounds;
255        if bounds.is_empty() {
256            return;
257        }
258        let radius = ctx.theme.radius(Radius::Field);
259        let (surface, content) = interactive_pair(ctx.theme, self.role, ctx.state);
260        canvas.fill_rounded_rect(bounds, radius, surface);
261        canvas.stroke_rounded_rect(
262            bounds,
263            radius,
264            ctx.theme.metrics.border,
265            ctx.theme.color(Role::Base300),
266        );
267        if ctx.state.contains(VisualState::FOCUSED) {
268            focus_ring(ctx.theme, bounds, radius, canvas);
269        }
270
271        let pad = padding(self.style.size_px);
272        let chevron = chevron_box(bounds, pad);
273        draw_chevron(canvas, chevron, ctx.theme.metrics.border, content);
274
275        // A placeholder is de-emphasised, a chosen value is not — the same
276        // `muted` every de-emphasised label here goes through, so a pair with
277        // no contrast to spare is returned unchanged rather than made
278        // unreadable.
279        let colour = if self.selected.is_some() {
280            content
281        } else {
282            muted(surface, content)
283        };
284        let text = Rect::from_edges(
285            bounds.x + pad,
286            bounds.y,
287            (chevron.x - pad).max(bounds.x + pad),
288            bounds.bottom(),
289        );
290        if !text.is_empty() {
291            draw_aligned(
292                canvas,
293                ctx.text,
294                self.style,
295                text,
296                (Align::Start, Align::Center),
297                self.shown(),
298                colour,
299            );
300        }
301    }
302
303    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
304        let opened = match event {
305            Event::Input(InputEvent::PointerButton {
306                state: ElementState::Up,
307                position,
308                ..
309            })
310            | Event::Input(InputEvent::TouchUp {
311                position,
312                cancelled: false,
313                ..
314            }) => ctx.bounds.contains(*position),
315            // Enter, Space and Down open. Left and Right are deliberately not
316            // handled: a closed select that edited its own value as somebody
317            // tabbed past it is the classic accidental-edit bug.
318            Event::Input(InputEvent::Key {
319                code: KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space | KeyCode::ArrowDown,
320                state: ElementState::Down,
321                repeat: false,
322                ..
323            }) => ctx.state.contains(VisualState::FOCUSED),
324            _ => return Handled::No,
325        };
326        if !opened || self.options.is_empty() {
327            return Handled::No;
328        }
329        if let Some(message) = self.message.clone() {
330            ctx.emit(message);
331        }
332        Handled::Yes
333    }
334
335    fn accepts_pointer(&self) -> bool {
336        true
337    }
338
339    /// A select with no options is not a tab stop: there is nothing to open.
340    fn focusable(&self) -> bool {
341        !self.options.is_empty()
342    }
343}
344
345impl<M> Describe for Select<M> {
346    const KIND: &'static str = "select";
347    const DOC: &'static str = "One choice out of many, picked from a dropdown list.";
348    const GROUP: Group = Group::Input;
349    const ICON: &'static denise::icon::Icon = &super::icons::SELECT;
350
351    const PROPERTIES: &'static [Property] = &[
352        Property::new(
353            "option",
354            PropertyKind::List,
355            "The choices, as `option` child nodes. A dropdown's are usually the real ones.",
356        ),
357        Property::new(
358            "selected",
359            PropertyKind::Int {
360                min: 0,
361                max: i32::MAX,
362            },
363            "The chosen option. Without one, nothing is chosen and the placeholder shows.",
364        ),
365        Property::new(
366            "placeholder",
367            PropertyKind::Text,
368            "Shown while nothing is chosen.",
369        ),
370        Property::new(
371            "on-change",
372            // The exception to the payload table: a `Select` holds one message
373            // and the application reads `selected()` when it arrives, because a
374            // dropdown's choice outlives the event that made it.
375            PropertyKind::Message(Payload::None),
376            "Emitted when a choice is made; the application reads `selected` afterwards.",
377        ),
378        Property::new(
379            "role",
380            PropertyKind::Enum(ROLES),
381            "Colour role of the control's own surface.",
382        ),
383        Property::new(
384            "size",
385            PropertyKind::Int { min: 6, max: 96 },
386            "Text size in logical pixels.",
387        )
388        .in_pixels(),
389    ];
390
391    fn get(&self, name: &str) -> Option<Value> {
392        Some(match name {
393            // Nothing chosen reports nothing, which is the state the format
394            // spells by leaving `selected` out.
395            "selected" => Value::Int(i32::try_from(self.selected?).unwrap_or(i32::MAX)),
396            "placeholder" => Value::text(self.placeholder.as_str()),
397            "role" => Value::role(self.role),
398            "size" => Value::Int(i32::from(self.style.size_px)),
399            _ => return None,
400        })
401    }
402
403    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
404        match name {
405            // Through the setter, so an option that is not there chooses
406            // nothing rather than leaving a dangling index behind.
407            "selected" => self.set_selected(Some(value.as_index()?)),
408            "placeholder" => self.placeholder = value.as_text()?,
409            // The engine builds these from the child nodes, and an
410            // inspector edits them where they live. See
411            // `PropertyKind::List`.
412            "on-change" | "option" => return Err(Mismatch::Supplied),
413            "role" => self.role = value.as_role()?,
414            "size" => self.style.size_px = value.as_size()?,
415            _ => return Err(Mismatch::Unknown),
416        }
417        Ok(())
418    }
419}
420
421/// Opens a [`Select`]'s option list as a popup below it.
422///
423/// The four lines an application would otherwise write, and the ones several
424/// panels would each get subtly wrong: sizing the popup to the widest option,
425/// matching its width to the control so the open list lines up with the closed
426/// one, and seeding the list's selection from the select's.
427///
428/// The popup is an ordinary one — [`Ui::push_popup`](crate::Ui::push_popup) —
429/// so it flips near a screen edge, closes on Escape or a press outside
430/// (swallowing that press), and returns focus to the select. Its contents are
431/// ordinary nodes: a caller who wants a different open list writes those four
432/// lines instead of calling this.
433///
434/// message` is emitted when a row is **chosen** — by `Enter` or by a tap — and
435/// not while the arrow keys move the highlight through the list. That is the
436/// distinction [`List`](super::List) draws between selecting and activating,
437/// and a dropdown wants only the second: a list that reported every row the
438/// keyboard passed over would have an application applying three values on the
439/// way to the fourth.
440///
441/// The application closes the popup and applies the choice; this does not,
442/// because choosing is the application's business and a helper that closed the
443/// popup would be deciding when a multi-select was finished.
444///
445/// Returns the popup's container, or `None` if `select` is not a live node
446/// holding a `Select`.
447pub fn open_select<M: Clone + 'static>(
448    ui: &mut crate::Ui<M>,
449    select: crate::NodeId,
450    message: fn(usize) -> M,
451) -> Option<crate::NodeId> {
452    let widget = ui.widget::<Select<M>>(select)?;
453    let options: Vec<String> = widget.options().to_vec();
454    if options.is_empty() {
455        return None;
456    }
457    let style = widget.style();
458    let chosen = widget.selected();
459
460    let anchor = ui.bounds(select)?;
461    let row = ui.theme().metrics.size_field;
462    let widest = options
463        .iter()
464        .map(|option| ui.text_mut().measure_line(style, option))
465        .max()
466        .unwrap_or(0);
467
468    // As wide as the control, or as wide as the options need — whichever is
469    // more. A list narrower than the thing it drops out of looks detached.
470    let pad = padding(style.size_px);
471    let width = anchor.width.max(widest + pad * 2);
472    let content = row * options.len() as i32;
473    // No taller than the room on the roomier side of the control, in whole
474    // rows. A list of a hundred keyboard layouts sized to its content runs off
475    // the surface, where no wheel or arrow key can reach the rows it hides.
476    let surface = ui.bounds(ui.root())?;
477    let room = (surface.bottom() - anchor.bottom()).max(anchor.y - surface.y) - POPUP_MARGIN;
478    let height = content.min((room / row).max(1) * row);
479
480    let container = ui.push_popup(
481        select,
482        denise::Size::new(width as u32, height as u32),
483        crate::Side::Below,
484    )?;
485    // The panel is the viewport and the list inside it is as tall as its rows:
486    // the tree scrolls it for the wheel and the page keys, and the list's arrow
487    // keys pull it along.
488    let viewport = ui.add(
489        container,
490        super::Panel::default(),
491        Rect::new(0, 0, width, height),
492    )?;
493    ui.set_scrollable(viewport, true);
494    // Inert for selection, wired for activation: the arrows move the highlight
495    // silently and only Enter or a tap reports a choice. `activate_on_click`
496    // makes one tap do both, which is what a dropdown row is — a command, not
497    // an option to be pondered.
498    let list = super::List::inert(options)
499        .on_activate(message)
500        .with_row_height(row)
501        .with_style(style)
502        .activate_on_click()
503        .with_selected(chosen);
504    let list = ui.add(viewport, list, Rect::new(0, 0, width, content))?;
505    // So the keyboard works the moment it opens, and Escape has somewhere to
506    // return focus from.
507    ui.focus(Some(list));
508    // Open on the current choice, in the middle where there is room, rather
509    // than on the first rows with the choice somewhere out of sight below.
510    if let Some(chosen) = chosen {
511        let y = row * chosen as i32 - (height - row) / 2;
512        ui.set_scroll(viewport, Point::new(0, y));
513    }
514    Some(container)
515}
516
517/// Space kept between an open list and the surface's edge, beyond the room
518/// the popup leaves between itself and its control.
519const POPUP_MARGIN: i32 = 8;
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    fn select() -> Select<u8> {
526        Select::new(["Auto", "Manuell", "Av"], 1u8)
527    }
528
529    /// An inert select is a closed control showing what is chosen.
530    ///
531    /// Its message is the request to be *opened*, so one without a message
532    /// cannot be — which is the honest meaning of inert here, and different from
533    /// a checkbox's, which still changes its own value. Everything else about it
534    /// works: the options are there, the selection is readable and settable, and
535    /// it still takes focus, because a focused one is still readable.
536    #[test]
537    fn an_inert_select_shows_a_choice_and_cannot_be_opened() {
538        let mut inert: Select<u8> = Select::inert(["Auto", "Manuell", "Av"]);
539        assert_eq!(inert.options().len(), 3);
540        assert_eq!(inert.selected(), None);
541
542        inert.set_selected(Some(2));
543        assert_eq!(inert.selected(), Some(2));
544        assert!(inert.focusable(), "an inert select is still readable");
545
546        // The one built with a message wants opening; this one has nothing to
547        // ask with.
548        assert!(select().message.is_some());
549        assert!(inert.message.is_none());
550    }
551
552    /// Nothing chosen shows the placeholder; a choice shows the option.
553    #[test]
554    fn it_shows_the_placeholder_until_something_is_chosen() {
555        let mut select = select().with_placeholder("Velg modus");
556        assert_eq!(select.shown(), "Velg modus");
557        assert_eq!(select.selected(), None);
558
559        select.set_selected(Some(1));
560        assert_eq!(select.shown(), "Manuell");
561        assert_eq!(select.selected_option(), Some("Manuell"));
562    }
563
564    /// Out of range chooses nothing rather than the nearest option — nothing
565    /// chosen is a state this control can show, so it is the honest answer.
566    #[test]
567    fn an_index_that_does_not_exist_chooses_nothing() {
568        let mut select = select();
569        select.set_selected(Some(9));
570        assert_eq!(select.selected(), None);
571
572        select.set_selected(Some(2));
573        select.set_options(["Bare én"]);
574        assert_eq!(select.selected(), None, "a shorter list drops it");
575    }
576
577    /// A select with no options is not a tab stop: there is nothing to open.
578    #[test]
579    fn an_empty_select_is_not_a_tab_stop() {
580        let empty: Select<u8> = Select::new(Vec::<String>::new(), 1u8);
581        assert!(!Widget::<u8>::focusable(&empty));
582        assert!(Widget::<u8>::focusable(&select()));
583    }
584
585    /// The chevron stays inside the control at every size, and never inverts.
586    #[test]
587    fn the_chevron_stays_inside_the_control() {
588        for bounds in [
589            Rect::new(0, 0, 200, 36),
590            Rect::new(10, 10, 40, 20),
591            Rect::new(0, 0, 8, 8),
592            Rect::new(0, 0, 1, 1),
593        ] {
594            let box_of = chevron_box(bounds, 8);
595            assert!(box_of.width >= 0 && box_of.height >= 0, "{bounds:?}");
596            assert!(
597                box_of.right() <= bounds.right(),
598                "{bounds:?}: chevron {box_of:?} escaped right"
599            );
600            assert!(
601                box_of.y >= bounds.y && box_of.bottom() <= bounds.bottom(),
602                "{bounds:?}: chevron {box_of:?} escaped vertically"
603            );
604        }
605    }
606
607    /// The text column stops before the chevron, so a long option is clipped
608    /// rather than drawn through the affordance.
609    #[test]
610    fn the_text_column_stops_before_the_chevron() {
611        let bounds = Rect::new(0, 0, 200, 36);
612        let pad = padding(16);
613        let chevron = chevron_box(bounds, pad);
614        let text = Rect::from_edges(
615            bounds.x + pad,
616            bounds.y,
617            (chevron.x - pad).max(bounds.x + pad),
618            bounds.bottom(),
619        );
620        assert!(text.width > 0);
621        assert!(
622            text.right() <= chevron.x,
623            "the text runs into the chevron: {text:?} {chevron:?}"
624        );
625    }
626
627    /// The placeholder is de-emphasised and a value is not — and both stay
628    /// readable in every theme, through the shared `muted`.
629    #[test]
630    fn the_placeholder_is_muted_but_still_readable() {
631        use denise::Theme;
632        use denise::theme::{AA_LARGE, contrast_x100};
633
634        for theme in Theme::BUILT_IN {
635            for state in [VisualState::NONE, VisualState::DISABLED] {
636                let (surface, content) = interactive_pair(&theme, Role::Base100, state);
637                let placeholder = muted(surface, content);
638                let ratio = contrast_x100(surface, placeholder);
639                assert!(
640                    ratio >= AA_LARGE,
641                    "{} {state:?}: placeholder is {ratio}, floor is {AA_LARGE}",
642                    theme.name
643                );
644            }
645            // Enabled, it is visibly quieter than a chosen value.
646            let (surface, content) = interactive_pair(&theme, Role::Base100, VisualState::NONE);
647            assert_ne!(muted(surface, content), content, "{}", theme.name);
648        }
649    }
650}