1#![warn(missing_docs)]
82
83use std::collections::HashSet;
84use std::panic::Location;
85
86use crate::anim::Timing;
87use crate::cursor::Cursor;
88use crate::event::{UiEvent, UiEventKind};
89use crate::metrics::MetricsRole;
90use crate::style::StyleProfile;
91use crate::tokens;
92use crate::tree::*;
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum ToggleAction<'a> {
102 Pressed,
104 Selected(&'a str),
108}
109
110pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<ToggleAction<'a>> {
117 if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
118 return None;
119 }
120 let routed = event.route()?;
121 if routed == key {
122 return Some(ToggleAction::Pressed);
123 }
124 let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
125 let value = rest.strip_prefix("toggle:")?;
126 Some(ToggleAction::Selected(value))
127}
128
129pub fn apply_event_pressed(pressed: &mut bool, event: &UiEvent, key: &str) -> bool {
132 let Some(ToggleAction::Pressed) = classify_event(event, key) else {
133 return false;
134 };
135 *pressed = !*pressed;
136 true
137}
138
139pub fn apply_event_single<V>(
149 value: &mut V,
150 event: &UiEvent,
151 key: &str,
152 parse: impl FnOnce(&str) -> Option<V>,
153) -> bool {
154 let Some(ToggleAction::Selected(raw)) = classify_event(event, key) else {
155 return false;
156 };
157 if let Some(v) = parse(raw) {
158 *value = v;
159 }
160 true
161}
162
163pub fn apply_event_multi(set: &mut HashSet<String>, event: &UiEvent, key: &str) -> bool {
167 let Some(ToggleAction::Selected(raw)) = classify_event(event, key) else {
168 return false;
169 };
170 if !set.remove(raw) {
171 set.insert(raw.to_string());
172 }
173 true
174}
175
176pub fn toggle_option_key(group_key: &str, value: &impl std::fmt::Display) -> String {
178 format!("{group_key}:toggle:{value}")
179}
180
181#[track_caller]
186pub fn toggle(key: impl Into<String>, pressed: bool, label: impl Into<String>) -> El {
187 toggle_button(Location::caller(), key.into(), pressed, label)
188}
189
190#[track_caller]
200pub fn toggle_item(
201 group_key: &str,
202 value: impl std::fmt::Display,
203 label: impl Into<String>,
204 selected: bool,
205) -> El {
206 let routed_key = toggle_option_key(group_key, &value);
207 toggle_button(Location::caller(), routed_key, selected, label)
208}
209
210#[track_caller]
223pub fn toggle_group<I, V, L>(
224 key: impl Into<String>,
225 current: &impl std::fmt::Display,
226 options: I,
227) -> El
228where
229 I: IntoIterator<Item = (V, L)>,
230 V: std::fmt::Display,
231 L: Into<String>,
232{
233 let caller = Location::caller();
234 let key = key.into();
235 let current_str = current.to_string();
236 let items: Vec<El> = options
237 .into_iter()
238 .map(|(value, label)| {
239 let selected = value.to_string() == current_str;
240 toggle_item(&key, value, label, selected).at_loc(caller)
241 })
242 .collect();
243 toggle_group_row(caller, items)
244}
245
246#[track_caller]
257pub fn toggle_group_multi<I, V, L>(
258 key: impl Into<String>,
259 selected: &HashSet<String>,
260 options: I,
261) -> El
262where
263 I: IntoIterator<Item = (V, L)>,
264 V: std::fmt::Display,
265 L: Into<String>,
266{
267 let caller = Location::caller();
268 let key = key.into();
269 let items: Vec<El> = options
270 .into_iter()
271 .map(|(value, label)| {
272 let value_str = value.to_string();
273 let pressed = selected.contains(&value_str);
274 toggle_item(&key, value, label, pressed).at_loc(caller)
275 })
276 .collect();
277 toggle_group_row(caller, items)
278}
279
280fn toggle_button(
281 caller: &'static Location<'static>,
282 routed_key: String,
283 pressed: bool,
284 label: impl Into<String>,
285) -> El {
286 let base = El::new(Kind::Custom("toggle"))
287 .at_loc(caller)
288 .style_profile(StyleProfile::Surface)
293 .metrics_role(MetricsRole::Button)
294 .focusable()
295 .paint_overflow(Sides::all(tokens::RING_WIDTH))
296 .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
297 .cursor(Cursor::Pointer)
298 .key(routed_key)
299 .text(label)
300 .text_align(TextAlign::Center)
301 .text_role(TextRole::Label)
302 .default_radius(tokens::RADIUS_MD)
303 .default_width(Size::Hug)
304 .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
305 .default_padding(Sides::xy(tokens::SPACE_3, 0.0));
306 let styled = if pressed {
307 base.current()
308 } else {
309 base.ghost()
310 };
311 styled.animate(Timing::SPRING_QUICK)
312}
313
314fn toggle_group_row(caller: &'static Location<'static>, items: Vec<El>) -> El {
315 El::new(Kind::Custom("toggle_group"))
321 .at_loc(caller)
322 .axis(Axis::Row)
323 .gap(tokens::SPACE_1)
324 .align(Align::Center)
325 .arrow_nav(crate::tree::ArrowNav::Horizontal)
328 .children(items)
329 .width(Size::Hug)
330 .height(Size::Hug)
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::hit_test::hit_test_target;
337 use crate::layout::layout;
338 use crate::state::UiState;
339
340 fn click(key: &str) -> UiEvent {
341 UiEvent::synthetic_click(key)
342 }
343
344 #[test]
345 fn toggle_group_gap_click_is_not_a_pressed_action() {
346 let mut group = toggle_group("view", &"list", [("list", "List"), ("grid", "Grid")]);
350 let mut state = UiState::new();
351 layout(&mut group, &mut state, Rect::new(0.0, 0.0, 240.0, 60.0));
352
353 let first = group.children[0].computed_rect;
354 let second = group.children[1].computed_rect;
355 assert!(
356 second.x > first.x + first.w,
357 "test requires the group's configured gap to be present"
358 );
359
360 let item_target = hit_test_target(
361 &group,
362 &state,
363 (first.x + first.w / 2.0, first.y + first.h / 2.0),
364 )
365 .expect("toggle item should still be interactive");
366 assert_eq!(item_target.key, "view:toggle:list");
367
368 let gap_x = (first.x + first.w + second.x) / 2.0;
369 let gap_y = first.y + first.h / 2.0;
370 assert_eq!(
371 hit_test_target(&group, &state, (gap_x, gap_y)),
372 None,
373 "the gap between toggles must not route the bare group key"
374 );
375 }
376
377 #[test]
378 fn classify_standalone_returns_pressed() {
379 let event = click("wrap");
380 assert_eq!(classify_event(&event, "wrap"), Some(ToggleAction::Pressed),);
381 }
382
383 #[test]
384 fn classify_group_returns_selected_with_value() {
385 let event = click("view:toggle:grid");
386 assert_eq!(
387 classify_event(&event, "view"),
388 Some(ToggleAction::Selected("grid")),
389 );
390 }
391
392 #[test]
393 fn classify_unrelated_event_is_none() {
394 let event = click("other");
395 assert!(classify_event(&event, "view").is_none());
396 }
397
398 #[test]
399 fn apply_pressed_flips_bool() {
400 let mut wrap = false;
401 let event = click("wrap");
402 assert!(apply_event_pressed(&mut wrap, &event, "wrap"));
403 assert!(wrap);
404 assert!(apply_event_pressed(&mut wrap, &event, "wrap"));
405 assert!(!wrap);
406 }
407
408 #[test]
409 fn apply_pressed_ignores_other_keys() {
410 let mut wrap = false;
411 let event = click("other");
412 assert!(!apply_event_pressed(&mut wrap, &event, "wrap"));
413 assert!(!wrap);
414 }
415
416 #[test]
417 fn apply_single_sets_value_via_parser() {
418 let mut view = String::from("list");
419 let event = click("view:toggle:grid");
420 assert!(apply_event_single(&mut view, &event, "view", |s| {
421 Some(s.to_string())
422 }));
423 assert_eq!(view, "grid");
424 }
425
426 #[test]
427 fn apply_single_ignores_unparseable_value() {
428 let mut view = String::from("list");
429 let event = click("view:toggle:grid");
430 assert!(apply_event_single(&mut view, &event, "view", |_| {
433 None::<String>
434 }));
435 assert_eq!(view, "list");
436 }
437
438 #[test]
439 fn apply_multi_flips_membership() {
440 let mut set: HashSet<String> = HashSet::new();
441 let event = click("filters:toggle:open");
442 assert!(apply_event_multi(&mut set, &event, "filters"));
443 assert!(set.contains("open"));
444 assert!(apply_event_multi(&mut set, &event, "filters"));
446 assert!(!set.contains("open"));
447 }
448
449 #[test]
450 fn standalone_toggle_routes_via_its_key() {
451 let t = toggle("wrap", false, "Wrap");
452 assert_eq!(t.key.as_deref(), Some("wrap"));
453 assert!(t.focusable);
454 assert_eq!(t.cursor, Some(Cursor::Pointer));
455 }
456
457 #[test]
458 fn toggle_option_key_matches_widget_format() {
459 assert_eq!(toggle_option_key("view", &"grid"), "view:toggle:grid");
460 assert_eq!(toggle_option_key("page:7", &42u32), "page:7:toggle:42");
461 }
462
463 #[test]
464 fn standalone_toggle_pressed_renders_current_surface() {
465 let pressed = toggle("wrap", true, "Wrap");
466 assert_eq!(pressed.fill, Some(tokens::ACCENT));
468 }
469
470 #[test]
471 fn standalone_toggle_unpressed_is_ghost() {
472 let unpressed = toggle("wrap", false, "Wrap");
473 assert!(unpressed.fill.is_none());
475 assert!(unpressed.stroke.is_none());
476 }
477
478 #[test]
479 fn group_marks_only_current_value_as_pressed() {
480 let group = toggle_group("view", &"grid", [("list", "List"), ("grid", "Grid")]);
481 let [list_item, grid_item] = [&group.children[0], &group.children[1]];
482 assert!(list_item.fill.is_none(), "non-current item is ghost");
483 assert_eq!(
484 grid_item.fill,
485 Some(tokens::ACCENT),
486 "current item paints accent",
487 );
488 assert_eq!(list_item.key.as_deref(), Some("view:toggle:list"));
489 assert_eq!(grid_item.key.as_deref(), Some("view:toggle:grid"));
490 }
491
492 #[test]
493 fn group_multi_marks_each_pressed_value() {
494 let mut selected = HashSet::new();
495 selected.insert("open".to_string());
496 selected.insert("draft".to_string());
497 let group = toggle_group_multi(
498 "filters",
499 &selected,
500 [("open", "Open"), ("draft", "Draft"), ("merged", "Merged")],
501 );
502 let [open, draft, merged] = [&group.children[0], &group.children[1], &group.children[2]];
503 assert_eq!(open.fill, Some(tokens::ACCENT));
504 assert_eq!(draft.fill, Some(tokens::ACCENT));
505 assert!(merged.fill.is_none(), "unpressed multi item is ghost");
506 }
507}