Skip to main content

aetna_core/widgets/
select.rs

1//! Select / dropdown menu — a trigger surface that displays the
2//! currently chosen value paired with a dropdown popover of options.
3//! Authored as two compositional pieces (trigger + menu) so apps place
4//! the trigger inline in their layout and compose the menu at the root
5//! of the El tree (the popover paradigm — see `widgets/popover.rs`).
6//!
7//! This is the **value picker** sibling of
8//! [`crate::widgets::dropdown_menu`]: items here carry a value the app
9//! binds via [`apply_event`] (`(value, open)` state shape, same as
10//! `tabs` / `text_input` / `switch`). Reach for `dropdown_menu` when
11//! items perform side-effects instead of selecting a value.
12//!
13//! # Shape
14//!
15//! ```ignore
16//! use aetna_core::prelude::*;
17//!
18//! struct Picker {
19//!     color: String,
20//!     color_open: bool,
21//! }
22//!
23//! impl App for Picker {
24//!     fn build(&self, _cx: &BuildCx) -> El {
25//!         let trigger = select_trigger("color", &self.color);
26//!         let main = column([row([text("Color"), trigger])]);
27//!
28//!         let mut layers: Vec<El> = vec![main];
29//!         if self.color_open {
30//!             layers.push(select_menu("color", [
31//!                 ("red", "Red"),
32//!                 ("blue", "Blue"),
33//!                 ("green", "Green"),
34//!             ]));
35//!         }
36//!         stack(layers)
37//!     }
38//!
39//!     fn on_event(&mut self, event: UiEvent) {
40//!         if event.is_click_or_activate("color") {
41//!             self.color_open = !self.color_open;
42//!         } else if event.is_click_or_activate("color:dismiss") {
43//!             self.color_open = false;
44//!         } else if let Some(value) = event.route().and_then(|r| r.strip_prefix("color:option:")) {
45//!             self.color = value.to_string();
46//!             self.color_open = false;
47//!         }
48//!     }
49//! }
50//! ```
51//!
52//! # Routed keys
53//!
54//! - `{key}` — `Click` on the trigger; the app toggles its open flag.
55//! - `{key}:dismiss` — `Click` outside the menu (the popover scrim);
56//!   the app clears its open flag.
57//! - `{key}:option:{value}` — `Click` on an option; the app sets the
58//!   selected value and clears its open flag.
59//!
60//! Apps that share one open slot across several selects can match the
61//! `:option:` and `:dismiss` suffixes back to the active select's key.
62//!
63//! # Dogfood note
64//!
65//! Composes only the public widget-kit surface — `Kind::Custom` for
66//! the inspector tag, `.focusable()` + `.paint_overflow()` for the
67//! focus ring, `.key()` for hit-test routing, and the existing
68//! [`crate::widgets::popover`] composition for the dropdown body. An
69//! app crate can write an equivalent select against the same public
70//! API. See `widget_kit.md`.
71
72use std::panic::Location;
73
74use crate::event::{UiEvent, UiEventKind};
75use crate::metrics::MetricsRole;
76use crate::style::StyleProfile;
77use crate::tokens;
78use crate::tree::*;
79use crate::widgets::popover::{Anchor, menu_item, popover, popover_panel};
80use crate::{icon, text};
81
82/// What a routed [`UiEvent`] means for a controlled select keyed `key`.
83///
84/// Returned by [`classify_event`]; [`apply_event`] is the convenience
85/// wrapper that folds the action straight into `(value, open)` state.
86///
87/// The action variants cover the three routed keys [`select_trigger`]
88/// + [`select_menu`] emit:
89///
90/// - `{key}` — toggle (trigger click / activate).
91/// - `{key}:dismiss` — dismiss (scrim click).
92/// - `{key}:option:{value}` — pick an option; the carried `String` is
93///   the same `{value}` token passed to [`select_option_key`]. Apps
94///   move it into their value type (identity for `String`, `s.parse()`
95///   for numbers, a lookup for enums, …).
96#[derive(Clone, Debug, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum SelectAction {
99    /// The trigger was clicked or activated. Toggle the open flag.
100    Toggle,
101    /// The dismiss scrim was clicked. Close the menu.
102    Dismiss,
103    /// An option was picked. The string is the raw value token from
104    /// the option key.
105    Pick(String),
106}
107
108/// Classify a routed [`UiEvent`] against a controlled select keyed
109/// `key`. Returns `None` for events that aren't for this select.
110///
111/// Only `Click` / `Activate` event kinds qualify — pointer-move,
112/// hover, and other non-activating events return `None` even when
113/// they target a select sub-key. That means an app can call
114/// [`classify_event`] unconditionally inside its event handler
115/// without filtering on `event.kind` first.
116pub fn classify_event(event: &UiEvent, key: &str) -> Option<SelectAction> {
117    if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
118        return None;
119    }
120    let routed = event.route()?;
121    if routed == key {
122        return Some(SelectAction::Toggle);
123    }
124    let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
125    if rest == "dismiss" {
126        return Some(SelectAction::Dismiss);
127    }
128    if let Some(value) = rest.strip_prefix("option:") {
129        return Some(SelectAction::Pick(value.to_string()));
130    }
131    None
132}
133
134/// Fold a routed [`UiEvent`] into `(value, open)` state for a
135/// controlled select keyed `key`. Returns `true` if the event was a
136/// select event for this `key` (so the caller can short-circuit
137/// further dispatch), `false` otherwise.
138///
139/// `parse` converts the raw option-value token back to the app's
140/// value type, taking ownership of the picked `String`. Returning
141/// `None` ignores the option pick silently (useful when the option
142/// list and the value type can drift — e.g. a stale event arriving
143/// after the underlying data changed).
144///
145/// For a `String` value field, pass `Some` directly — the picked
146/// string moves straight into the destination. For typed values use
147/// `s.parse().ok()` or a lookup closure.
148///
149/// ```ignore
150/// use aetna_core::prelude::*;
151///
152/// // App owns (value, open) per select.
153/// struct Picker { color: String, color_open: bool }
154///
155/// impl App for Picker {
156///     fn on_event(&mut self, event: UiEvent) {
157///         widgets::select::apply_event(
158///             &mut self.color,
159///             &mut self.color_open,
160///             &event,
161///             "color",
162///             Some,
163///         );
164///     }
165///     // ...
166/// }
167/// ```
168pub fn apply_event<V>(
169    value: &mut V,
170    open: &mut bool,
171    event: &UiEvent,
172    key: &str,
173    parse: impl FnOnce(String) -> Option<V>,
174) -> bool {
175    let Some(action) = classify_event(event, key) else {
176        return false;
177    };
178    match action {
179        SelectAction::Toggle => *open = !*open,
180        SelectAction::Dismiss => *open = false,
181        SelectAction::Pick(s) => {
182            if let Some(v) = parse(s) {
183                *value = v;
184                *open = false;
185            }
186        }
187    }
188    true
189}
190
191/// Format the routed key emitted when an option is clicked. Apps that
192/// match against the `:option:` suffix can use this helper to produce
193/// the same string the widget produces, but the convention is also
194/// stable enough to format inline.
195pub fn select_option_key(key: &str, value: &impl std::fmt::Display) -> String {
196    format!("{key}:option:{value}")
197}
198
199/// The trigger surface for a `select`. Visually a button-shaped row
200/// of `[ current_label ▼ ]` keyed by `key`. Click emits `Click` on
201/// `key`; the app toggles its open flag in `on_event`.
202///
203/// Default height is [`tokens::CONTROL_HEIGHT`] — use that constant
204/// when sizing a parent row that has to fit the trigger.
205///
206/// The trigger is also the anchor key for [`select_menu`] — keep them
207/// identical so the menu drops below the trigger.
208#[track_caller]
209pub fn select_trigger(key: impl Into<String>, current_label: impl Into<String>) -> El {
210    let label = text(current_label)
211        .label()
212        .ellipsis()
213        .width(Size::Fill(1.0));
214    let chevron = icon("chevron-down")
215        .icon_size(tokens::ICON_SM)
216        .text_color(tokens::MUTED_FOREGROUND);
217    El::new(Kind::Custom("select_trigger"))
218        .at_loc(Location::caller())
219        .style_profile(StyleProfile::Surface)
220        .metrics_role(MetricsRole::Input)
221        .surface_role(SurfaceRole::Input)
222        .focusable()
223        .paint_overflow(Sides::all(tokens::RING_WIDTH))
224        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
225        .key(key)
226        .axis(Axis::Row)
227        .default_gap(tokens::SPACE_2)
228        .align(Align::Center)
229        .child(label)
230        .child(chevron)
231        .fill(tokens::MUTED)
232        .stroke(tokens::BORDER)
233        .text_color(tokens::FOREGROUND)
234        .default_radius(tokens::RADIUS_MD)
235        .default_width(Size::Fill(1.0))
236        .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
237        .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
238}
239
240/// The dropdown popover for a `select`. Render this only while the
241/// menu is open; place it at the root of the El tree (e.g. inside a
242/// `stack`) so it paints over content and intercepts clicks above
243/// siblings.
244///
245/// `options` is an iterable of `(value, label)` pairs. Each becomes a
246/// [`menu_item`] keyed `{key}:option:{value}`. The dismiss scrim
247/// emits `{key}:dismiss` (per the popover convention) on click
248/// outside.
249///
250/// The menu anchors below the trigger keyed `key`; if that placement
251/// would clip the viewport bottom the popover flips above
252/// automatically (see [`crate::anchor_rect`]).
253#[track_caller]
254pub fn select_menu<I, V, L>(key: impl Into<String>, options: I) -> El
255where
256    I: IntoIterator<Item = (V, L)>,
257    V: std::fmt::Display,
258    L: Into<String>,
259{
260    // Capture once so the user's call site flows through to each
261    // `menu_item`. `#[track_caller]` doesn't propagate through
262    // `.map(...)` closures, so the items would otherwise record the
263    // closure's source — see `tabs_list` for the same pattern and
264    // motivation.
265    let caller = Location::caller();
266    let key = key.into();
267    let items: Vec<El> = options
268        .into_iter()
269        .map(|(value, label)| {
270            menu_item(label)
271                .at_loc(caller)
272                .key(select_option_key(&key, &value))
273        })
274        .collect();
275    popover(key.clone(), Anchor::below_key(key), popover_panel(items))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn select_trigger_keys_root_and_carries_chevron() {
284        let t = select_trigger("color", "Red");
285        assert_eq!(t.key.as_deref(), Some("color"));
286        // Trigger is a row of [label, chevron]. The chevron is the
287        // last child and carries the chevron-down icon name so visual
288        // affordance is unambiguous.
289        let chevron = t.children.last().expect("trigger has chevron child");
290        assert_eq!(
291            chevron.icon,
292            Some(crate::IconSource::Builtin(IconName::ChevronDown))
293        );
294        // Trigger opts into focus + ring overhead so keyboard users
295        // can tab through selects like any other interactive surface.
296        assert!(t.focusable, "select_trigger must be focusable");
297    }
298
299    #[test]
300    fn select_menu_routes_dismiss_and_option_keys() {
301        let menu = select_menu("color", [("red", "Red"), ("blue", "Blue")]);
302        // Dismiss scrim follows the popover convention: `{key}:dismiss`.
303        let scrim = &menu.children[0];
304        assert_eq!(scrim.kind, Kind::Scrim);
305        assert_eq!(scrim.key.as_deref(), Some("color:dismiss"));
306        // Layer wraps the panel; panel children are the menu_items
307        // keyed `{key}:option:{value}`.
308        let layer = &menu.children[1];
309        let panel = &layer.children[0];
310        assert_eq!(panel.children.len(), 2);
311        assert_eq!(panel.children[0].key.as_deref(), Some("color:option:red"));
312        assert_eq!(panel.children[1].key.as_deref(), Some("color:option:blue"));
313    }
314
315    #[test]
316    fn select_option_key_matches_widget_format() {
317        // Apps decoding routed events should use the same helper to
318        // avoid format drift.
319        assert_eq!(select_option_key("color", &"red"), "color:option:red");
320        assert_eq!(
321            select_option_key("profile:7", &42u32),
322            "profile:7:option:42"
323        );
324    }
325
326    fn click_event(key: &str) -> UiEvent {
327        UiEvent {
328            path: None,
329            kind: UiEventKind::Click,
330            key: Some(key.to_string()),
331            target: None,
332            pointer: None,
333            key_press: None,
334            text: None,
335            selection: None,
336            modifiers: Default::default(),
337            click_count: 1,
338        }
339    }
340
341    #[test]
342    fn classify_event_routes_trigger_dismiss_and_option() {
343        // The same three keys `parse_profile_event` used to decode in
344        // the volume app. classify_event collapses that boilerplate.
345        assert_eq!(
346            classify_event(&click_event("color"), "color"),
347            Some(SelectAction::Toggle),
348        );
349        assert_eq!(
350            classify_event(&click_event("color:dismiss"), "color"),
351            Some(SelectAction::Dismiss),
352        );
353        assert_eq!(
354            classify_event(&click_event("color:option:red"), "color"),
355            Some(SelectAction::Pick("red".to_string())),
356        );
357
358        // Compound keys (the volume app uses `profile:{card_id}` as the
359        // select key) work the same way — the helper compares against
360        // the full select key, not just a prefix.
361        assert_eq!(
362            classify_event(&click_event("profile:7"), "profile:7"),
363            Some(SelectAction::Toggle),
364        );
365        assert_eq!(
366            classify_event(&click_event("profile:7:dismiss"), "profile:7"),
367            Some(SelectAction::Dismiss),
368        );
369        assert_eq!(
370            classify_event(&click_event("profile:7:option:42"), "profile:7"),
371            Some(SelectAction::Pick("42".to_string())),
372        );
373
374        // Non-matching keys fall through.
375        assert_eq!(classify_event(&click_event("mute:7"), "profile:7"), None);
376        // Even when a key shares a prefix with the select key, the
377        // separator-after-prefix check rejects events that aren't this
378        // select's own children.
379        assert_eq!(
380            classify_event(&click_event("profile:7-other"), "profile:7"),
381            None,
382        );
383        // Malformed option suffix isn't a Pick.
384        assert_eq!(
385            classify_event(&click_event("profile:7:option"), "profile:7"),
386            None,
387        );
388    }
389
390    #[test]
391    fn classify_event_ignores_non_activating_kinds() {
392        // Pointer-down / drag / hotkey events that target the same key
393        // shouldn't toggle the menu — only Click and Activate qualify.
394        let mut ev = click_event("color");
395        ev.kind = UiEventKind::PointerDown;
396        assert_eq!(classify_event(&ev, "color"), None);
397        ev.kind = UiEventKind::Drag;
398        assert_eq!(classify_event(&ev, "color"), None);
399        ev.kind = UiEventKind::Activate;
400        assert_eq!(
401            classify_event(&ev, "color"),
402            Some(SelectAction::Toggle),
403            "keyboard activation should toggle like a click",
404        );
405    }
406
407    #[test]
408    fn apply_event_folds_actions_into_value_and_open() {
409        let mut value = String::from("red");
410        let mut open = false;
411
412        // Trigger click flips open.
413        assert!(apply_event(
414            &mut value,
415            &mut open,
416            &click_event("color"),
417            "color",
418            Some,
419        ));
420        assert!(open);
421        assert_eq!(value, "red");
422
423        // Pick replaces value and closes the menu.
424        assert!(apply_event(
425            &mut value,
426            &mut open,
427            &click_event("color:option:blue"),
428            "color",
429            Some,
430        ));
431        assert_eq!(value, "blue");
432        assert!(!open);
433
434        // Reopen, then dismiss.
435        apply_event(&mut value, &mut open, &click_event("color"), "color", Some);
436        assert!(open);
437        assert!(apply_event(
438            &mut value,
439            &mut open,
440            &click_event("color:dismiss"),
441            "color",
442            Some,
443        ));
444        assert!(!open);
445        assert_eq!(value, "blue", "dismiss must not alter the value");
446
447        // Non-select event returns false; state unchanged.
448        let mut value = String::from("v");
449        let mut open = true;
450        assert!(!apply_event(
451            &mut value,
452            &mut open,
453            &click_event("unrelated"),
454            "color",
455            Some,
456        ));
457        assert_eq!((value.as_str(), open), ("v", true));
458    }
459
460    #[test]
461    fn apply_event_silently_ignores_unparseable_picks() {
462        // The volume app uses u32 profile indices; a stale option key
463        // that doesn't parse should leave state untouched rather than
464        // panic.
465        let mut value: u32 = 3;
466        let mut open = true;
467        assert!(apply_event(
468            &mut value,
469            &mut open,
470            &click_event("profile:7:option:not-a-number"),
471            "profile:7",
472            |s| s.parse::<u32>().ok(),
473        ));
474        assert_eq!(value, 3, "value preserved when parse returns None");
475        assert!(open, "open preserved when parse returns None");
476    }
477
478    #[test]
479    fn select_menu_anchors_below_trigger_key() {
480        // End-to-end layout regression: the menu must look up the
481        // trigger's rect via `rect_of_key(key)`, so when the trigger
482        // is laid out at (x, y, w, h), the panel lands directly below.
483        use crate::layout::layout;
484        use crate::state::UiState;
485        use crate::tree::stack;
486        let trigger = select_trigger("sel", "A");
487        let menu = select_menu("sel", [("a", "A"), ("b", "B")]);
488        let mut tree = stack([trigger, menu]);
489        let mut state = UiState::new();
490        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 300.0));
491        // Trigger laid out by stack at parent origin, height 36.
492        let trig_rect = state
493            .rect_of_key(&tree, "sel")
494            .expect("trigger key resolves");
495        // The popover panel sits below the trigger with the standard
496        // anchor gap. It's the popover layer's first child.
497        let layer = &tree.children[1].children[1];
498        let panel = &layer.children[0];
499        let panel_rect = state.rect(&panel.computed_id);
500        assert!(
501            panel_rect.y >= trig_rect.bottom(),
502            "panel should sit below trigger; trig.bottom={}, panel.y={}",
503            trig_rect.bottom(),
504            panel_rect.y,
505        );
506    }
507}