Skip to main content

damascene_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 damascene_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, _cx: &EventCx) {
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
72// Lock in full per-item documentation for this module (issue #73).
73#![warn(missing_docs)]
74
75use std::panic::Location;
76
77use crate::event::{UiEvent, UiEventKind};
78use crate::metrics::MetricsRole;
79use crate::style::StyleProfile;
80use crate::tokens;
81use crate::tree::*;
82use crate::widgets::popover::{
83    Anchor, MenuDensity, apply_menu_density, menu_item, popover, popover_panel,
84};
85use crate::{icon, text};
86
87/// What a routed [`UiEvent`] means for a controlled select keyed `key`.
88///
89/// Returned by [`classify_event`]; [`apply_event`] is the convenience
90/// wrapper that folds the action straight into `(value, open)` state.
91///
92/// The action variants cover the three routed keys [`select_trigger`]
93/// + [`select_menu`] emit:
94///
95/// - `{key}` — toggle (trigger click / activate).
96/// - `{key}:dismiss` — dismiss (scrim click).
97/// - `{key}:option:{value}` — pick an option; the carried `String` is
98///   the same `{value}` token passed to [`select_option_key`]. Apps
99///   move it into their value type (identity for `String`, `s.parse()`
100///   for numbers, a lookup for enums, …).
101#[derive(Clone, Debug, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum SelectAction {
104    /// The trigger was clicked or activated. Toggle the open flag.
105    Toggle,
106    /// The dismiss scrim was clicked. Close the menu.
107    Dismiss,
108    /// An option was picked. The string is the raw value token from
109    /// the option key.
110    Pick(String),
111}
112
113/// Classify a routed [`UiEvent`] against a controlled select keyed
114/// `key`. Returns `None` for events that aren't for this select.
115///
116/// Only `Click` / `Activate` event kinds qualify — pointer-move,
117/// hover, and other non-activating events return `None` even when
118/// they target a select sub-key. That means an app can call
119/// [`classify_event`] unconditionally inside its event handler
120/// without filtering on `event.kind` first.
121pub fn classify_event(event: &UiEvent, key: &str) -> Option<SelectAction> {
122    if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
123        return None;
124    }
125    let routed = event.route()?;
126    if routed == key {
127        return Some(SelectAction::Toggle);
128    }
129    let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
130    if rest == "dismiss" {
131        return Some(SelectAction::Dismiss);
132    }
133    if let Some(value) = rest.strip_prefix("option:") {
134        return Some(SelectAction::Pick(value.to_string()));
135    }
136    None
137}
138
139/// Fold a routed [`UiEvent`] into `(value, open)` state for a
140/// controlled select keyed `key`. Returns `true` if the event was a
141/// select event for this `key` (so the caller can short-circuit
142/// further dispatch), `false` otherwise.
143///
144/// `parse` converts the raw option-value token back to the app's
145/// value type, taking ownership of the picked `String`. Returning
146/// `None` ignores the option pick silently (useful when the option
147/// list and the value type can drift — e.g. a stale event arriving
148/// after the underlying data changed).
149///
150/// For a `String` value field, pass `Some` directly — the picked
151/// string moves straight into the destination. For typed values use
152/// `s.parse().ok()` or a lookup closure.
153///
154/// ```ignore
155/// use damascene_core::prelude::*;
156///
157/// // App owns (value, open) per select.
158/// struct Picker { color: String, color_open: bool }
159///
160/// impl App for Picker {
161///     fn on_event(&mut self, event: UiEvent, _cx: &EventCx) {
162///         widgets::select::apply_event(
163///             &mut self.color,
164///             &mut self.color_open,
165///             &event,
166///             "color",
167///             Some,
168///         );
169///     }
170///     // ...
171/// }
172/// ```
173pub fn apply_event<V>(
174    value: &mut V,
175    open: &mut bool,
176    event: &UiEvent,
177    key: &str,
178    parse: impl FnOnce(String) -> Option<V>,
179) -> bool {
180    let Some(action) = classify_event(event, key) else {
181        return false;
182    };
183    match action {
184        SelectAction::Toggle => *open = !*open,
185        SelectAction::Dismiss => *open = false,
186        SelectAction::Pick(s) => {
187            if let Some(v) = parse(s) {
188                *value = v;
189                *open = false;
190            }
191        }
192    }
193    true
194}
195
196/// Format the routed key emitted when an option is clicked. Apps that
197/// match against the `:option:` suffix can use this helper to produce
198/// the same string the widget produces, but the convention is also
199/// stable enough to format inline.
200pub fn select_option_key(key: &str, value: &impl std::fmt::Display) -> String {
201    format!("{key}:option:{value}")
202}
203
204/// The trigger surface for a `select`. Visually a button-shaped row
205/// of `[ current_label ▼ ]` keyed by `key`. Click emits `Click` on
206/// `key`; the app toggles its open flag in `on_event`.
207///
208/// Default height is [`tokens::CONTROL_HEIGHT`] — use that constant
209/// when sizing a parent row that has to fit the trigger.
210///
211/// The trigger is also the anchor key for [`select_menu`] — keep them
212/// identical so the menu drops below the trigger.
213#[track_caller]
214pub fn select_trigger(key: impl Into<String>, current_label: impl Into<String>) -> El {
215    let label = text(current_label)
216        .label()
217        .ellipsis()
218        .width(Size::Fill(1.0));
219    let chevron = icon("chevron-down")
220        .icon_size(tokens::ICON_SM)
221        .text_color(tokens::MUTED_FOREGROUND);
222    El::new(Kind::Custom("select_trigger"))
223        .at_loc(Location::caller())
224        .cursor(crate::cursor::Cursor::Pointer)
225        .style_profile(StyleProfile::Surface)
226        .metrics_role(MetricsRole::Input)
227        .surface_role(SurfaceRole::Input)
228        .focusable()
229        .paint_overflow(Sides::all(tokens::RING_WIDTH))
230        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
231        .key(key)
232        .axis(Axis::Row)
233        .default_gap(tokens::SPACE_2)
234        .align(Align::Center)
235        .child(label)
236        .child(chevron)
237        .fill(tokens::MUTED)
238        .stroke(tokens::BORDER)
239        .text_color(tokens::FOREGROUND)
240        .default_radius(tokens::RADIUS_MD)
241        .default_width(Size::Fill(1.0))
242        .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
243        .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
244}
245
246/// The dropdown popover for a `select`. Render this only while the
247/// menu is open; place it at the root of the El tree (e.g. inside a
248/// `stack`) so it paints over content and intercepts clicks above
249/// siblings.
250///
251/// `options` is an iterable of `(value, label)` pairs. Each becomes a
252/// [`menu_item`] keyed `{key}:option:{value}`. The dismiss scrim
253/// emits `{key}:dismiss` (per the popover convention) on click
254/// outside.
255///
256/// The menu anchors below the trigger keyed `key`; if that placement
257/// would clip the viewport bottom the popover flips above
258/// automatically (see [`crate::anchor_rect`]).
259#[track_caller]
260pub fn select_menu<I, V, L>(key: impl Into<String>, options: I) -> El
261where
262    I: IntoIterator<Item = (V, L)>,
263    V: std::fmt::Display,
264    L: Into<String>,
265{
266    select_menu_with_density(key, options, MenuDensity::Compact).at_loc(Location::caller())
267}
268
269/// Density-aware variant of [`select_menu`].
270///
271/// Use [`MenuDensity::from_event`] with the event that opened the
272/// trigger when a touch-originated select should use larger option
273/// rows.
274#[track_caller]
275pub fn select_menu_with_density<I, V, L>(
276    key: impl Into<String>,
277    options: I,
278    density: MenuDensity,
279) -> El
280where
281    I: IntoIterator<Item = (V, L)>,
282    V: std::fmt::Display,
283    L: Into<String>,
284{
285    // Capture once so the user's call site flows through to each
286    // `menu_item`. `#[track_caller]` doesn't propagate through
287    // `.map(...)` closures, so the items would otherwise record the
288    // closure's source — see `tabs_list` for the same pattern and
289    // motivation.
290    let caller = Location::caller();
291    let key = key.into();
292    let items: Vec<El> = options
293        .into_iter()
294        .map(|(value, label)| {
295            menu_item(label)
296                .at_loc(caller)
297                .key(select_option_key(&key, &value))
298        })
299        .map(|item| apply_menu_density(item, density))
300        .collect();
301    popover(key.clone(), Anchor::below_key(key), popover_panel(items))
302}
303
304/// [`select_menu`] that also knows the currently-selected value and
305/// marks it with a trailing check glyph — the shadcn `SelectItem`
306/// affordance (every row reserves the right-side slot so labels align;
307/// only the selected row shows the check). Prefer this over the bare
308/// [`select_menu`] whenever the current value is at hand: without the
309/// indicator an open select gives no confirmation of the active
310/// choice.
311#[track_caller]
312pub fn select_menu_selected<I, V, L>(key: impl Into<String>, options: I, selected: &V) -> El
313where
314    I: IntoIterator<Item = (V, L)>,
315    V: std::fmt::Display,
316    L: Into<String>,
317{
318    select_menu_selected_with_density(key, options, selected, MenuDensity::Compact)
319        .at_loc(Location::caller())
320}
321
322/// Density-aware variant of [`select_menu_selected`].
323#[track_caller]
324pub fn select_menu_selected_with_density<I, V, L>(
325    key: impl Into<String>,
326    options: I,
327    selected: &V,
328    density: MenuDensity,
329) -> El
330where
331    I: IntoIterator<Item = (V, L)>,
332    V: std::fmt::Display,
333    L: Into<String>,
334{
335    let caller = Location::caller();
336    let key = key.into();
337    let selected = selected.to_string();
338    let items: Vec<El> = options
339        .into_iter()
340        .map(|(value, label)| {
341            let value = value.to_string();
342            crate::widgets::popover::menu_item_checked(label, value == selected)
343                .at_loc(caller)
344                .key(select_option_key(&key, &value))
345        })
346        .map(|item| apply_menu_density(item, density))
347        .collect();
348    popover(key.clone(), Anchor::below_key(key), popover_panel(items))
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn select_trigger_keys_root_and_carries_chevron() {
357        let t = select_trigger("color", "Red");
358        assert_eq!(t.key.as_deref(), Some("color"));
359        // Trigger is a row of [label, chevron]. The chevron is the
360        // last child and carries the chevron-down icon name so visual
361        // affordance is unambiguous.
362        let chevron = t.children.last().expect("trigger has chevron child");
363        assert_eq!(
364            chevron.icon,
365            Some(crate::IconSource::Builtin(IconName::ChevronDown))
366        );
367        // Trigger opts into focus + ring overhead so keyboard users
368        // can tab through selects like any other interactive surface.
369        assert!(t.focusable, "select_trigger must be focusable");
370    }
371
372    #[test]
373    fn select_menu_routes_dismiss_and_option_keys() {
374        let menu = select_menu("color", [("red", "Red"), ("blue", "Blue")]);
375        // Dismiss scrim follows the popover convention: `{key}:dismiss`.
376        let scrim = &menu.children[0];
377        assert_eq!(scrim.kind, Kind::Scrim);
378        assert_eq!(scrim.key.as_deref(), Some("color:dismiss"));
379        // Layer wraps the panel; panel children are the menu_items
380        // keyed `{key}:option:{value}`.
381        let layer = &menu.children[1];
382        let panel = &layer.children[0];
383        assert_eq!(panel.children.len(), 2);
384        assert_eq!(panel.children[0].key.as_deref(), Some("color:option:red"));
385        assert_eq!(panel.children[1].key.as_deref(), Some("color:option:blue"));
386    }
387
388    #[test]
389    fn select_menu_with_touch_density_expands_options() {
390        let menu = select_menu_with_density(
391            "color",
392            [("red", "Red"), ("blue", "Blue")],
393            MenuDensity::Touch,
394        );
395        let panel = &menu.children[1].children[0];
396
397        assert_eq!(
398            panel.children[0].height,
399            Size::Fixed(crate::widgets::popover::TOUCH_MENU_ITEM_HEIGHT)
400        );
401        assert_eq!(
402            panel.children[1].height,
403            Size::Fixed(crate::widgets::popover::TOUCH_MENU_ITEM_HEIGHT)
404        );
405    }
406
407    #[test]
408    fn select_option_key_matches_widget_format() {
409        // Apps decoding routed events should use the same helper to
410        // avoid format drift.
411        assert_eq!(select_option_key("color", &"red"), "color:option:red");
412        assert_eq!(
413            select_option_key("profile:7", &42u32),
414            "profile:7:option:42"
415        );
416    }
417
418    fn click_event(key: &str) -> UiEvent {
419        UiEvent {
420            path: None,
421            kind: UiEventKind::Click,
422            key: Some(key.to_string()),
423            target: None,
424            pointer: None,
425            key_press: None,
426            text: None,
427            selection: None,
428            modifiers: Default::default(),
429            click_count: 1,
430            pointer_kind: None,
431            wheel_delta: None,
432        }
433    }
434
435    #[test]
436    fn classify_event_routes_trigger_dismiss_and_option() {
437        // The same three keys `parse_profile_event` used to decode in
438        // the volume app. classify_event collapses that boilerplate.
439        assert_eq!(
440            classify_event(&click_event("color"), "color"),
441            Some(SelectAction::Toggle),
442        );
443        assert_eq!(
444            classify_event(&click_event("color:dismiss"), "color"),
445            Some(SelectAction::Dismiss),
446        );
447        assert_eq!(
448            classify_event(&click_event("color:option:red"), "color"),
449            Some(SelectAction::Pick("red".to_string())),
450        );
451
452        // Compound keys (the volume app uses `profile:{card_id}` as the
453        // select key) work the same way — the helper compares against
454        // the full select key, not just a prefix.
455        assert_eq!(
456            classify_event(&click_event("profile:7"), "profile:7"),
457            Some(SelectAction::Toggle),
458        );
459        assert_eq!(
460            classify_event(&click_event("profile:7:dismiss"), "profile:7"),
461            Some(SelectAction::Dismiss),
462        );
463        assert_eq!(
464            classify_event(&click_event("profile:7:option:42"), "profile:7"),
465            Some(SelectAction::Pick("42".to_string())),
466        );
467
468        // Non-matching keys fall through.
469        assert_eq!(classify_event(&click_event("mute:7"), "profile:7"), None);
470        // Even when a key shares a prefix with the select key, the
471        // separator-after-prefix check rejects events that aren't this
472        // select's own children.
473        assert_eq!(
474            classify_event(&click_event("profile:7-other"), "profile:7"),
475            None,
476        );
477        // Malformed option suffix isn't a Pick.
478        assert_eq!(
479            classify_event(&click_event("profile:7:option"), "profile:7"),
480            None,
481        );
482    }
483
484    #[test]
485    fn classify_event_ignores_non_activating_kinds() {
486        // Pointer-down / drag / hotkey events that target the same key
487        // shouldn't toggle the menu — only Click and Activate qualify.
488        let mut ev = click_event("color");
489        ev.kind = UiEventKind::PointerDown;
490        assert_eq!(classify_event(&ev, "color"), None);
491        ev.kind = UiEventKind::Drag;
492        assert_eq!(classify_event(&ev, "color"), None);
493        ev.kind = UiEventKind::Activate;
494        assert_eq!(
495            classify_event(&ev, "color"),
496            Some(SelectAction::Toggle),
497            "keyboard activation should toggle like a click",
498        );
499    }
500
501    #[test]
502    fn apply_event_folds_actions_into_value_and_open() {
503        let mut value = String::from("red");
504        let mut open = false;
505
506        // Trigger click flips open.
507        assert!(apply_event(
508            &mut value,
509            &mut open,
510            &click_event("color"),
511            "color",
512            Some,
513        ));
514        assert!(open);
515        assert_eq!(value, "red");
516
517        // Pick replaces value and closes the menu.
518        assert!(apply_event(
519            &mut value,
520            &mut open,
521            &click_event("color:option:blue"),
522            "color",
523            Some,
524        ));
525        assert_eq!(value, "blue");
526        assert!(!open);
527
528        // Reopen, then dismiss.
529        apply_event(&mut value, &mut open, &click_event("color"), "color", Some);
530        assert!(open);
531        assert!(apply_event(
532            &mut value,
533            &mut open,
534            &click_event("color:dismiss"),
535            "color",
536            Some,
537        ));
538        assert!(!open);
539        assert_eq!(value, "blue", "dismiss must not alter the value");
540
541        // Non-select event returns false; state unchanged.
542        let mut value = String::from("v");
543        let mut open = true;
544        assert!(!apply_event(
545            &mut value,
546            &mut open,
547            &click_event("unrelated"),
548            "color",
549            Some,
550        ));
551        assert_eq!((value.as_str(), open), ("v", true));
552    }
553
554    #[test]
555    fn apply_event_silently_ignores_unparseable_picks() {
556        // The volume app uses u32 profile indices; a stale option key
557        // that doesn't parse should leave state untouched rather than
558        // panic.
559        let mut value: u32 = 3;
560        let mut open = true;
561        assert!(apply_event(
562            &mut value,
563            &mut open,
564            &click_event("profile:7:option:not-a-number"),
565            "profile:7",
566            |s| s.parse::<u32>().ok(),
567        ));
568        assert_eq!(value, 3, "value preserved when parse returns None");
569        assert!(open, "open preserved when parse returns None");
570    }
571
572    #[test]
573    fn select_menu_anchors_below_trigger_key() {
574        // End-to-end layout regression: the menu must look up the
575        // trigger's rect via `rect_of_key(key)`, so when the trigger
576        // is laid out at (x, y, w, h), the panel lands directly below.
577        use crate::layout::layout;
578        use crate::state::UiState;
579        use crate::tree::stack;
580        let trigger = select_trigger("sel", "A");
581        let menu = select_menu("sel", [("a", "A"), ("b", "B")]);
582        let mut tree = stack([trigger, menu]);
583        let mut state = UiState::new();
584        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 300.0));
585        // Trigger laid out by stack at parent origin, height 36.
586        let trig_rect = state.rect_of_key("sel").expect("trigger key resolves");
587        // The popover panel sits below the trigger with the standard
588        // anchor gap. It's the popover layer's first child.
589        let layer = &tree.children[1].children[1];
590        let panel = &layer.children[0];
591        let panel_rect = panel.computed_rect;
592        assert!(
593            panel_rect.y >= trig_rect.bottom(),
594            "panel should sit below trigger; trig.bottom={}, panel.y={}",
595            trig_rect.bottom(),
596            panel_rect.y,
597        );
598    }
599}