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