use std::collections::HashSet;
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::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ToggleAction<'a> {
Pressed,
Selected(&'a str),
}
pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<ToggleAction<'a>> {
if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
return None;
}
let routed = event.route()?;
if routed == key {
return Some(ToggleAction::Pressed);
}
let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
let value = rest.strip_prefix("toggle:")?;
Some(ToggleAction::Selected(value))
}
pub fn apply_event_pressed(pressed: &mut bool, event: &UiEvent, key: &str) -> bool {
let Some(ToggleAction::Pressed) = classify_event(event, key) else {
return false;
};
*pressed = !*pressed;
true
}
pub fn apply_event_single<V>(
value: &mut V,
event: &UiEvent,
key: &str,
parse: impl FnOnce(&str) -> Option<V>,
) -> bool {
let Some(ToggleAction::Selected(raw)) = classify_event(event, key) else {
return false;
};
if let Some(v) = parse(raw) {
*value = v;
}
true
}
pub fn apply_event_multi(set: &mut HashSet<String>, event: &UiEvent, key: &str) -> bool {
let Some(ToggleAction::Selected(raw)) = classify_event(event, key) else {
return false;
};
if !set.remove(raw) {
set.insert(raw.to_string());
}
true
}
pub fn toggle_option_key(group_key: &str, value: &impl std::fmt::Display) -> String {
format!("{group_key}:toggle:{value}")
}
#[track_caller]
pub fn toggle(key: impl Into<String>, pressed: bool, label: impl Into<String>) -> El {
toggle_button(Location::caller(), key.into(), pressed, label)
}
#[track_caller]
pub fn toggle_item(
group_key: &str,
value: impl std::fmt::Display,
label: impl Into<String>,
selected: bool,
) -> El {
let routed_key = toggle_option_key(group_key, &value);
toggle_button(Location::caller(), routed_key, selected, label)
}
#[track_caller]
pub fn toggle_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;
toggle_item(&key, value, label, selected).at_loc(caller)
})
.collect();
toggle_group_row(caller, key, items)
}
#[track_caller]
pub fn toggle_group_multi<I, V, L>(
key: impl Into<String>,
selected: &HashSet<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)| {
let value_str = value.to_string();
let pressed = selected.contains(&value_str);
toggle_item(&key, value, label, pressed).at_loc(caller)
})
.collect();
toggle_group_row(caller, key, items)
}
fn toggle_button(
caller: &'static Location<'static>,
routed_key: String,
pressed: bool,
label: impl Into<String>,
) -> El {
let base = El::new(Kind::Custom("toggle"))
.at_loc(caller)
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::Button)
.focusable()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
.cursor(Cursor::Pointer)
.key(routed_key)
.text(label)
.text_align(TextAlign::Center)
.text_role(TextRole::Label)
.default_radius(tokens::RADIUS_MD)
.default_width(Size::Hug)
.default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
.default_padding(Sides::xy(tokens::SPACE_3, 0.0));
let styled = if pressed {
base.current()
} else {
base.ghost()
};
styled.animate(Timing::SPRING_QUICK)
}
fn toggle_group_row(caller: &'static Location<'static>, key: String, items: Vec<El>) -> El {
El::new(Kind::Custom("toggle_group"))
.at_loc(caller)
.key(key)
.axis(Axis::Row)
.gap(tokens::SPACE_1)
.align(Align::Center)
.children(items)
.width(Size::Hug)
.height(Size::Hug)
}
#[cfg(test)]
mod tests {
use super::*;
fn click(key: &str) -> UiEvent {
UiEvent::synthetic_click(key)
}
#[test]
fn classify_standalone_returns_pressed() {
let event = click("wrap");
assert_eq!(classify_event(&event, "wrap"), Some(ToggleAction::Pressed),);
}
#[test]
fn classify_group_returns_selected_with_value() {
let event = click("view:toggle:grid");
assert_eq!(
classify_event(&event, "view"),
Some(ToggleAction::Selected("grid")),
);
}
#[test]
fn classify_unrelated_event_is_none() {
let event = click("other");
assert!(classify_event(&event, "view").is_none());
}
#[test]
fn apply_pressed_flips_bool() {
let mut wrap = false;
let event = click("wrap");
assert!(apply_event_pressed(&mut wrap, &event, "wrap"));
assert!(wrap);
assert!(apply_event_pressed(&mut wrap, &event, "wrap"));
assert!(!wrap);
}
#[test]
fn apply_pressed_ignores_other_keys() {
let mut wrap = false;
let event = click("other");
assert!(!apply_event_pressed(&mut wrap, &event, "wrap"));
assert!(!wrap);
}
#[test]
fn apply_single_sets_value_via_parser() {
let mut view = String::from("list");
let event = click("view:toggle:grid");
assert!(apply_event_single(&mut view, &event, "view", |s| {
Some(s.to_string())
}));
assert_eq!(view, "grid");
}
#[test]
fn apply_single_ignores_unparseable_value() {
let mut view = String::from("list");
let event = click("view:toggle:grid");
assert!(apply_event_single(&mut view, &event, "view", |_| {
None::<String>
}));
assert_eq!(view, "list");
}
#[test]
fn apply_multi_flips_membership() {
let mut set: HashSet<String> = HashSet::new();
let event = click("filters:toggle:open");
assert!(apply_event_multi(&mut set, &event, "filters"));
assert!(set.contains("open"));
assert!(apply_event_multi(&mut set, &event, "filters"));
assert!(!set.contains("open"));
}
#[test]
fn standalone_toggle_routes_via_its_key() {
let t = toggle("wrap", false, "Wrap");
assert_eq!(t.key.as_deref(), Some("wrap"));
assert!(t.focusable);
assert_eq!(t.cursor, Some(Cursor::Pointer));
}
#[test]
fn toggle_option_key_matches_widget_format() {
assert_eq!(toggle_option_key("view", &"grid"), "view:toggle:grid");
assert_eq!(toggle_option_key("page:7", &42u32), "page:7:toggle:42");
}
#[test]
fn standalone_toggle_pressed_renders_current_surface() {
let pressed = toggle("wrap", true, "Wrap");
assert_eq!(pressed.fill, Some(tokens::ACCENT));
}
#[test]
fn standalone_toggle_unpressed_is_ghost() {
let unpressed = toggle("wrap", false, "Wrap");
assert!(unpressed.fill.is_none());
assert!(unpressed.stroke.is_none());
}
#[test]
fn group_marks_only_current_value_as_pressed() {
let group = toggle_group("view", &"grid", [("list", "List"), ("grid", "Grid")]);
let [list_item, grid_item] = [&group.children[0], &group.children[1]];
assert!(list_item.fill.is_none(), "non-current item is ghost");
assert_eq!(
grid_item.fill,
Some(tokens::ACCENT),
"current item paints accent",
);
assert_eq!(list_item.key.as_deref(), Some("view:toggle:list"));
assert_eq!(grid_item.key.as_deref(), Some("view:toggle:grid"));
}
#[test]
fn group_multi_marks_each_pressed_value() {
let mut selected = HashSet::new();
selected.insert("open".to_string());
selected.insert("draft".to_string());
let group = toggle_group_multi(
"filters",
&selected,
[("open", "Open"), ("draft", "Draft"), ("merged", "Merged")],
);
let [open, draft, merged] = [&group.children[0], &group.children[1], &group.children[2]];
assert_eq!(open.fill, Some(tokens::ACCENT));
assert_eq!(draft.fill, Some(tokens::ACCENT));
assert!(merged.fill.is_none(), "unpressed multi item is ghost");
}
}