use std::panic::Location;
use crate::event::{UiEvent, UiEventKind};
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::popover::{Anchor, menu_item, popover, popover_panel};
use crate::{icon, text};
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SelectAction {
Toggle,
Dismiss,
Pick(String),
}
pub fn classify_event(event: &UiEvent, key: &str) -> Option<SelectAction> {
if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
return None;
}
let routed = event.route()?;
if routed == key {
return Some(SelectAction::Toggle);
}
let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
if rest == "dismiss" {
return Some(SelectAction::Dismiss);
}
if let Some(value) = rest.strip_prefix("option:") {
return Some(SelectAction::Pick(value.to_string()));
}
None
}
pub fn apply_event<V>(
value: &mut V,
open: &mut bool,
event: &UiEvent,
key: &str,
parse: impl FnOnce(String) -> Option<V>,
) -> bool {
let Some(action) = classify_event(event, key) else {
return false;
};
match action {
SelectAction::Toggle => *open = !*open,
SelectAction::Dismiss => *open = false,
SelectAction::Pick(s) => {
if let Some(v) = parse(s) {
*value = v;
*open = false;
}
}
}
true
}
pub fn select_option_key(key: &str, value: &impl std::fmt::Display) -> String {
format!("{key}:option:{value}")
}
#[track_caller]
pub fn select_trigger(key: impl Into<String>, current_label: impl Into<String>) -> El {
let label = text(current_label)
.label()
.ellipsis()
.width(Size::Fill(1.0));
let chevron = icon("chevron-down")
.icon_size(tokens::ICON_SM)
.text_color(tokens::MUTED_FOREGROUND);
El::new(Kind::Custom("select_trigger"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::Input)
.surface_role(SurfaceRole::Input)
.focusable()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
.key(key)
.axis(Axis::Row)
.default_gap(tokens::SPACE_2)
.align(Align::Center)
.child(label)
.child(chevron)
.fill(tokens::MUTED)
.stroke(tokens::BORDER)
.text_color(tokens::FOREGROUND)
.default_radius(tokens::RADIUS_MD)
.default_width(Size::Fill(1.0))
.default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
.default_padding(Sides::xy(tokens::SPACE_3, 0.0))
}
#[track_caller]
pub fn select_menu<I, V, L>(key: impl Into<String>, options: I) -> El
where
I: IntoIterator<Item = (V, L)>,
V: std::fmt::Display,
L: Into<String>,
{
let caller = Location::caller();
let key = key.into();
let items: Vec<El> = options
.into_iter()
.map(|(value, label)| {
menu_item(label)
.at_loc(caller)
.key(select_option_key(&key, &value))
})
.collect();
popover(key.clone(), Anchor::below_key(key), popover_panel(items))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn select_trigger_keys_root_and_carries_chevron() {
let t = select_trigger("color", "Red");
assert_eq!(t.key.as_deref(), Some("color"));
let chevron = t.children.last().expect("trigger has chevron child");
assert_eq!(
chevron.icon,
Some(crate::IconSource::Builtin(IconName::ChevronDown))
);
assert!(t.focusable, "select_trigger must be focusable");
}
#[test]
fn select_menu_routes_dismiss_and_option_keys() {
let menu = select_menu("color", [("red", "Red"), ("blue", "Blue")]);
let scrim = &menu.children[0];
assert_eq!(scrim.kind, Kind::Scrim);
assert_eq!(scrim.key.as_deref(), Some("color:dismiss"));
let layer = &menu.children[1];
let panel = &layer.children[0];
assert_eq!(panel.children.len(), 2);
assert_eq!(panel.children[0].key.as_deref(), Some("color:option:red"));
assert_eq!(panel.children[1].key.as_deref(), Some("color:option:blue"));
}
#[test]
fn select_option_key_matches_widget_format() {
assert_eq!(select_option_key("color", &"red"), "color:option:red");
assert_eq!(
select_option_key("profile:7", &42u32),
"profile:7:option:42"
);
}
fn click_event(key: &str) -> UiEvent {
UiEvent {
path: None,
kind: UiEventKind::Click,
key: Some(key.to_string()),
target: None,
pointer: None,
key_press: None,
text: None,
selection: None,
modifiers: Default::default(),
click_count: 1,
}
}
#[test]
fn classify_event_routes_trigger_dismiss_and_option() {
assert_eq!(
classify_event(&click_event("color"), "color"),
Some(SelectAction::Toggle),
);
assert_eq!(
classify_event(&click_event("color:dismiss"), "color"),
Some(SelectAction::Dismiss),
);
assert_eq!(
classify_event(&click_event("color:option:red"), "color"),
Some(SelectAction::Pick("red".to_string())),
);
assert_eq!(
classify_event(&click_event("profile:7"), "profile:7"),
Some(SelectAction::Toggle),
);
assert_eq!(
classify_event(&click_event("profile:7:dismiss"), "profile:7"),
Some(SelectAction::Dismiss),
);
assert_eq!(
classify_event(&click_event("profile:7:option:42"), "profile:7"),
Some(SelectAction::Pick("42".to_string())),
);
assert_eq!(classify_event(&click_event("mute:7"), "profile:7"), None);
assert_eq!(
classify_event(&click_event("profile:7-other"), "profile:7"),
None,
);
assert_eq!(
classify_event(&click_event("profile:7:option"), "profile:7"),
None,
);
}
#[test]
fn classify_event_ignores_non_activating_kinds() {
let mut ev = click_event("color");
ev.kind = UiEventKind::PointerDown;
assert_eq!(classify_event(&ev, "color"), None);
ev.kind = UiEventKind::Drag;
assert_eq!(classify_event(&ev, "color"), None);
ev.kind = UiEventKind::Activate;
assert_eq!(
classify_event(&ev, "color"),
Some(SelectAction::Toggle),
"keyboard activation should toggle like a click",
);
}
#[test]
fn apply_event_folds_actions_into_value_and_open() {
let mut value = String::from("red");
let mut open = false;
assert!(apply_event(
&mut value,
&mut open,
&click_event("color"),
"color",
Some,
));
assert!(open);
assert_eq!(value, "red");
assert!(apply_event(
&mut value,
&mut open,
&click_event("color:option:blue"),
"color",
Some,
));
assert_eq!(value, "blue");
assert!(!open);
apply_event(&mut value, &mut open, &click_event("color"), "color", Some);
assert!(open);
assert!(apply_event(
&mut value,
&mut open,
&click_event("color:dismiss"),
"color",
Some,
));
assert!(!open);
assert_eq!(value, "blue", "dismiss must not alter the value");
let mut value = String::from("v");
let mut open = true;
assert!(!apply_event(
&mut value,
&mut open,
&click_event("unrelated"),
"color",
Some,
));
assert_eq!((value.as_str(), open), ("v", true));
}
#[test]
fn apply_event_silently_ignores_unparseable_picks() {
let mut value: u32 = 3;
let mut open = true;
assert!(apply_event(
&mut value,
&mut open,
&click_event("profile:7:option:not-a-number"),
"profile:7",
|s| s.parse::<u32>().ok(),
));
assert_eq!(value, 3, "value preserved when parse returns None");
assert!(open, "open preserved when parse returns None");
}
#[test]
fn select_menu_anchors_below_trigger_key() {
use crate::layout::layout;
use crate::state::UiState;
use crate::tree::stack;
let trigger = select_trigger("sel", "A");
let menu = select_menu("sel", [("a", "A"), ("b", "B")]);
let mut tree = stack([trigger, menu]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 300.0));
let trig_rect = state
.rect_of_key(&tree, "sel")
.expect("trigger key resolves");
let layer = &tree.children[1].children[1];
let panel = &layer.children[0];
let panel_rect = state.rect(&panel.computed_id);
assert!(
panel_rect.y >= trig_rect.bottom(),
"panel should sit below trigger; trig.bottom={}, panel.y={}",
trig_rect.bottom(),
panel_rect.y,
);
}
}