1use std::collections::HashSet;
79use std::panic::Location;
80
81use crate::anim::Timing;
82use crate::cursor::Cursor;
83use crate::event::{UiEvent, UiEventKind};
84use crate::metrics::MetricsRole;
85use crate::style::StyleProfile;
86use crate::tokens;
87use crate::tree::*;
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum ToggleAction<'a> {
97 Pressed,
99 Selected(&'a str),
103}
104
105pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<ToggleAction<'a>> {
112 if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
113 return None;
114 }
115 let routed = event.route()?;
116 if routed == key {
117 return Some(ToggleAction::Pressed);
118 }
119 let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
120 let value = rest.strip_prefix("toggle:")?;
121 Some(ToggleAction::Selected(value))
122}
123
124pub fn apply_event_pressed(pressed: &mut bool, event: &UiEvent, key: &str) -> bool {
127 let Some(ToggleAction::Pressed) = classify_event(event, key) else {
128 return false;
129 };
130 *pressed = !*pressed;
131 true
132}
133
134pub fn apply_event_single<V>(
144 value: &mut V,
145 event: &UiEvent,
146 key: &str,
147 parse: impl FnOnce(&str) -> Option<V>,
148) -> bool {
149 let Some(ToggleAction::Selected(raw)) = classify_event(event, key) else {
150 return false;
151 };
152 if let Some(v) = parse(raw) {
153 *value = v;
154 }
155 true
156}
157
158pub fn apply_event_multi(set: &mut HashSet<String>, event: &UiEvent, key: &str) -> bool {
162 let Some(ToggleAction::Selected(raw)) = classify_event(event, key) else {
163 return false;
164 };
165 if !set.remove(raw) {
166 set.insert(raw.to_string());
167 }
168 true
169}
170
171pub fn toggle_option_key(group_key: &str, value: &impl std::fmt::Display) -> String {
173 format!("{group_key}:toggle:{value}")
174}
175
176#[track_caller]
181pub fn toggle(key: impl Into<String>, pressed: bool, label: impl Into<String>) -> El {
182 toggle_button(Location::caller(), key.into(), pressed, label)
183}
184
185#[track_caller]
195pub fn toggle_item(
196 group_key: &str,
197 value: impl std::fmt::Display,
198 label: impl Into<String>,
199 selected: bool,
200) -> El {
201 let routed_key = toggle_option_key(group_key, &value);
202 toggle_button(Location::caller(), routed_key, selected, label)
203}
204
205#[track_caller]
218pub fn toggle_group<I, V, L>(
219 key: impl Into<String>,
220 current: &impl std::fmt::Display,
221 options: I,
222) -> El
223where
224 I: IntoIterator<Item = (V, L)>,
225 V: std::fmt::Display,
226 L: Into<String>,
227{
228 let caller = Location::caller();
229 let key = key.into();
230 let current_str = current.to_string();
231 let items: Vec<El> = options
232 .into_iter()
233 .map(|(value, label)| {
234 let selected = value.to_string() == current_str;
235 toggle_item(&key, value, label, selected).at_loc(caller)
236 })
237 .collect();
238 toggle_group_row(caller, key, items)
239}
240
241#[track_caller]
252pub fn toggle_group_multi<I, V, L>(
253 key: impl Into<String>,
254 selected: &HashSet<String>,
255 options: I,
256) -> El
257where
258 I: IntoIterator<Item = (V, L)>,
259 V: std::fmt::Display,
260 L: Into<String>,
261{
262 let caller = Location::caller();
263 let key = key.into();
264 let items: Vec<El> = options
265 .into_iter()
266 .map(|(value, label)| {
267 let value_str = value.to_string();
268 let pressed = selected.contains(&value_str);
269 toggle_item(&key, value, label, pressed).at_loc(caller)
270 })
271 .collect();
272 toggle_group_row(caller, key, items)
273}
274
275fn toggle_button(
276 caller: &'static Location<'static>,
277 routed_key: String,
278 pressed: bool,
279 label: impl Into<String>,
280) -> El {
281 let base = El::new(Kind::Custom("toggle"))
282 .at_loc(caller)
283 .style_profile(StyleProfile::Surface)
288 .metrics_role(MetricsRole::Button)
289 .focusable()
290 .paint_overflow(Sides::all(tokens::RING_WIDTH))
291 .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
292 .cursor(Cursor::Pointer)
293 .key(routed_key)
294 .text(label)
295 .text_align(TextAlign::Center)
296 .text_role(TextRole::Label)
297 .default_radius(tokens::RADIUS_MD)
298 .default_width(Size::Hug)
299 .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
300 .default_padding(Sides::xy(tokens::SPACE_3, 0.0));
301 let styled = if pressed {
302 base.current()
303 } else {
304 base.ghost()
305 };
306 styled.animate(Timing::SPRING_QUICK)
307}
308
309fn toggle_group_row(caller: &'static Location<'static>, key: String, items: Vec<El>) -> El {
310 El::new(Kind::Custom("toggle_group"))
311 .at_loc(caller)
312 .key(key)
313 .axis(Axis::Row)
314 .gap(tokens::SPACE_1)
315 .align(Align::Center)
316 .children(items)
317 .width(Size::Hug)
318 .height(Size::Hug)
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 fn click(key: &str) -> UiEvent {
326 UiEvent::synthetic_click(key)
327 }
328
329 #[test]
330 fn classify_standalone_returns_pressed() {
331 let event = click("wrap");
332 assert_eq!(classify_event(&event, "wrap"), Some(ToggleAction::Pressed),);
333 }
334
335 #[test]
336 fn classify_group_returns_selected_with_value() {
337 let event = click("view:toggle:grid");
338 assert_eq!(
339 classify_event(&event, "view"),
340 Some(ToggleAction::Selected("grid")),
341 );
342 }
343
344 #[test]
345 fn classify_unrelated_event_is_none() {
346 let event = click("other");
347 assert!(classify_event(&event, "view").is_none());
348 }
349
350 #[test]
351 fn apply_pressed_flips_bool() {
352 let mut wrap = false;
353 let event = click("wrap");
354 assert!(apply_event_pressed(&mut wrap, &event, "wrap"));
355 assert!(wrap);
356 assert!(apply_event_pressed(&mut wrap, &event, "wrap"));
357 assert!(!wrap);
358 }
359
360 #[test]
361 fn apply_pressed_ignores_other_keys() {
362 let mut wrap = false;
363 let event = click("other");
364 assert!(!apply_event_pressed(&mut wrap, &event, "wrap"));
365 assert!(!wrap);
366 }
367
368 #[test]
369 fn apply_single_sets_value_via_parser() {
370 let mut view = String::from("list");
371 let event = click("view:toggle:grid");
372 assert!(apply_event_single(&mut view, &event, "view", |s| {
373 Some(s.to_string())
374 }));
375 assert_eq!(view, "grid");
376 }
377
378 #[test]
379 fn apply_single_ignores_unparseable_value() {
380 let mut view = String::from("list");
381 let event = click("view:toggle:grid");
382 assert!(apply_event_single(&mut view, &event, "view", |_| {
385 None::<String>
386 }));
387 assert_eq!(view, "list");
388 }
389
390 #[test]
391 fn apply_multi_flips_membership() {
392 let mut set: HashSet<String> = HashSet::new();
393 let event = click("filters:toggle:open");
394 assert!(apply_event_multi(&mut set, &event, "filters"));
395 assert!(set.contains("open"));
396 assert!(apply_event_multi(&mut set, &event, "filters"));
398 assert!(!set.contains("open"));
399 }
400
401 #[test]
402 fn standalone_toggle_routes_via_its_key() {
403 let t = toggle("wrap", false, "Wrap");
404 assert_eq!(t.key.as_deref(), Some("wrap"));
405 assert!(t.focusable);
406 assert_eq!(t.cursor, Some(Cursor::Pointer));
407 }
408
409 #[test]
410 fn toggle_option_key_matches_widget_format() {
411 assert_eq!(toggle_option_key("view", &"grid"), "view:toggle:grid");
412 assert_eq!(toggle_option_key("page:7", &42u32), "page:7:toggle:42");
413 }
414
415 #[test]
416 fn standalone_toggle_pressed_renders_current_surface() {
417 let pressed = toggle("wrap", true, "Wrap");
418 assert_eq!(pressed.fill, Some(tokens::ACCENT));
420 }
421
422 #[test]
423 fn standalone_toggle_unpressed_is_ghost() {
424 let unpressed = toggle("wrap", false, "Wrap");
425 assert!(unpressed.fill.is_none());
427 assert!(unpressed.stroke.is_none());
428 }
429
430 #[test]
431 fn group_marks_only_current_value_as_pressed() {
432 let group = toggle_group("view", &"grid", [("list", "List"), ("grid", "Grid")]);
433 let [list_item, grid_item] = [&group.children[0], &group.children[1]];
434 assert!(list_item.fill.is_none(), "non-current item is ghost");
435 assert_eq!(
436 grid_item.fill,
437 Some(tokens::ACCENT),
438 "current item paints accent",
439 );
440 assert_eq!(list_item.key.as_deref(), Some("view:toggle:list"));
441 assert_eq!(grid_item.key.as_deref(), Some("view:toggle:grid"));
442 }
443
444 #[test]
445 fn group_multi_marks_each_pressed_value() {
446 let mut selected = HashSet::new();
447 selected.insert("open".to_string());
448 selected.insert("draft".to_string());
449 let group = toggle_group_multi(
450 "filters",
451 &selected,
452 [("open", "Open"), ("draft", "Draft"), ("merged", "Merged")],
453 );
454 let [open, draft, merged] = [&group.children[0], &group.children[1], &group.children[2]];
455 assert_eq!(open.fill, Some(tokens::ACCENT));
456 assert_eq!(draft.fill, Some(tokens::ACCENT));
457 assert!(merged.fill.is_none(), "unpressed multi item is ghost");
458 }
459}