use std::panic::Location;
use crate::anim::Timing;
use crate::cursor::Cursor;
use crate::event::{UiEvent, UiEventKind};
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::text::text;
const INDICATOR_OUTER: f32 = 16.0;
const INDICATOR_DOT: f32 = 8.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum RadioAction<'a> {
Select(&'a str),
}
pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<RadioAction<'a>> {
if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
return None;
}
let routed = event.route()?;
let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
let value = rest.strip_prefix("radio:")?;
Some(RadioAction::Select(value))
}
pub fn apply_event<V>(
value: &mut V,
event: &UiEvent,
key: &str,
parse: impl FnOnce(&str) -> Option<V>,
) -> bool {
let Some(RadioAction::Select(raw)) = classify_event(event, key) else {
return false;
};
if let Some(v) = parse(raw) {
*value = v;
}
true
}
pub fn radio_option_key(key: &str, value: &impl std::fmt::Display) -> String {
format!("{key}:radio:{value}")
}
#[track_caller]
pub fn radio_item(
group_key: &str,
value: impl std::fmt::Display,
label: impl Into<String>,
selected: bool,
) -> El {
let routed_key = radio_option_key(group_key, &value);
let stroke = if selected {
tokens::PRIMARY
} else {
tokens::INPUT
};
let dot_opacity = if selected { 1.0 } else { 0.0 };
let dot_scale = if selected { 1.0 } else { 0.4 };
let indicator = El::new(Kind::Custom("radio-indicator"))
.metrics_role(MetricsRole::ChoiceControl)
.axis(Axis::Overlay)
.align(Align::Center)
.justify(Justify::Center)
.default_width(Size::Fixed(INDICATOR_OUTER))
.default_height(Size::Fixed(INDICATOR_OUTER))
.radius(tokens::RADIUS_PILL)
.fill(tokens::CARD)
.stroke(stroke)
.animate(Timing::SPRING_STANDARD)
.child(
El::new(Kind::Custom("radio-dot"))
.width(Size::Fixed(INDICATOR_DOT))
.height(Size::Fixed(INDICATOR_DOT))
.radius(tokens::RADIUS_PILL)
.fill(tokens::PRIMARY)
.opacity(dot_opacity)
.scale(dot_scale)
.animate(Timing::SPRING_STANDARD),
);
El::new(Kind::Custom("radio_item"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::ChoiceItem)
.focusable()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.cursor(Cursor::Pointer)
.key(routed_key)
.axis(Axis::Row)
.default_gap(tokens::SPACE_2)
.align(Align::Center)
.child(indicator)
.child(text(label).label())
.default_padding(Sides::xy(0.0, tokens::SPACE_1))
.width(Size::Fill(1.0))
.height(Size::Hug)
.default_radius(tokens::RADIUS_SM)
}
#[track_caller]
pub fn radio_group<I, V, L>(
key: impl Into<String>,
current: &impl std::fmt::Display,
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 current_str = current.to_string();
let items: Vec<El> = options
.into_iter()
.map(|(value, label)| {
let selected = value.to_string() == current_str;
radio_item(&key, value, label, selected).at_loc(caller)
})
.collect();
El::new(Kind::Custom("radio_group"))
.at_loc(caller)
.key(key)
.axis(Axis::Column)
.gap(tokens::SPACE_1)
.align(Align::Stretch)
.children(items)
.width(Size::Fill(1.0))
.height(Size::Hug)
}
#[cfg(test)]
mod tests {
use super::*;
fn click(key: &str) -> UiEvent {
UiEvent::synthetic_click(key)
}
#[test]
fn radio_option_key_matches_widget_format() {
assert_eq!(radio_option_key("theme", &"dark"), "theme:radio:dark");
assert_eq!(radio_option_key("page:7", &42u32), "page:7:radio:42");
}
#[test]
fn radio_item_routes_via_radio_option_key() {
let item = radio_item("theme", "dark", "Dark", false);
assert_eq!(item.key.as_deref(), Some("theme:radio:dark"));
assert!(item.focusable);
}
#[test]
fn radio_item_declares_pointer_cursor() {
let item = radio_item("theme", "dark", "Dark", false);
assert_eq!(item.cursor, Some(Cursor::Pointer));
}
#[test]
fn unselected_indicator_has_invisible_dot() {
let item = radio_item("theme", "dark", "Dark", false);
let indicator = &item.children[0];
assert_eq!(indicator.children.len(), 1, "dot stays in the tree");
assert_eq!(indicator.children[0].opacity, 0.0);
assert_eq!(indicator.stroke, Some(tokens::INPUT));
}
#[test]
fn selected_indicator_has_visible_dot_and_primary_stroke() {
let item = radio_item("theme", "dark", "Dark", true);
let indicator = &item.children[0];
assert_eq!(indicator.children.len(), 1);
let dot = &indicator.children[0];
assert_eq!(dot.fill, Some(tokens::PRIMARY));
assert_eq!(dot.opacity, 1.0);
assert_eq!(indicator.stroke, Some(tokens::PRIMARY));
}
#[test]
fn indicator_and_dot_animate_so_selection_changes_ease() {
let item = radio_item("theme", "dark", "Dark", false);
let indicator = &item.children[0];
assert!(indicator.animate.is_some(), "ring eases stroke");
assert!(
indicator.children[0].animate.is_some(),
"dot eases opacity/scale"
);
}
#[test]
fn radio_group_marks_only_current_value_visibly_selected() {
let g = radio_group(
"theme",
&"dark",
[
("system", "Match system"),
("light", "Light"),
("dark", "Dark"),
],
);
assert_eq!(g.key.as_deref(), Some("theme"));
assert_eq!(g.children.len(), 3);
let [system, light, dark] = [&g.children[0], &g.children[1], &g.children[2]];
assert_eq!(system.children[0].children[0].opacity, 0.0);
assert_eq!(light.children[0].children[0].opacity, 0.0);
assert_eq!(dark.children[0].children[0].opacity, 1.0);
}
#[test]
fn radio_group_compares_via_display_so_typed_values_work() {
let g = radio_group(
"page",
&7u32,
[(0u32, "Zero"), (7u32, "Seven"), (42u32, "Forty-two")],
);
let [zero, seven, fortytwo] = [&g.children[0], &g.children[1], &g.children[2]];
assert_eq!(zero.children[0].children[0].opacity, 0.0);
assert_eq!(seven.children[0].children[0].opacity, 1.0);
assert_eq!(fortytwo.children[0].children[0].opacity, 0.0);
}
#[test]
fn classify_event_selects_only_on_matching_route() {
assert_eq!(
classify_event(&click("theme:radio:dark"), "theme"),
Some(RadioAction::Select("dark")),
);
assert_eq!(
classify_event(&click("page:7:radio:42"), "page:7"),
Some(RadioAction::Select("42")),
);
assert_eq!(classify_event(&click("theme"), "theme"), None);
assert_eq!(classify_event(&click("other:radio:x"), "theme"), None);
assert_eq!(classify_event(&click("theme-other:radio:x"), "theme"), None);
assert_eq!(classify_event(&click("theme:option:x"), "theme"), None);
}
#[test]
fn classify_event_ignores_non_activating_kinds() {
let mut ev = click("theme:radio:dark");
ev.kind = UiEventKind::PointerDown;
assert_eq!(classify_event(&ev, "theme"), None);
ev.kind = UiEventKind::Activate;
assert_eq!(
classify_event(&ev, "theme"),
Some(RadioAction::Select("dark")),
);
}
#[test]
fn apply_event_folds_actions_into_value() {
let mut theme = String::from("system");
assert!(apply_event(
&mut theme,
&click("theme:radio:dark"),
"theme",
|s| Some(s.to_string()),
));
assert_eq!(theme, "dark");
assert!(!apply_event(&mut theme, &click("save"), "theme", |s| Some(
s.to_string()
),));
assert_eq!(theme, "dark");
}
#[test]
fn apply_event_silently_ignores_unparseable_values() {
let mut page: u32 = 1;
assert!(apply_event(
&mut page,
&click("page:radio:not-a-number"),
"page",
|s| s.parse::<u32>().ok(),
));
assert_eq!(page, 1);
}
}