Skip to main content

aetna_core/widgets/
toggle.rs

1//! Toggle — pressed/unpressed two-state buttons, used either standalone
2//! or grouped. Mirrors the shadcn / Radix Toggle + ToggleGroup primitives
3//! (which themselves reflect the WAI-ARIA `role="button"` with
4//! `aria-pressed` and `role="group"` patterns), so LLM authors trained
5//! on web UI find the same shape here.
6//!
7//! Three flavors, three state shapes:
8//!
9//! - [`toggle`] — a single binary on/off button. State is a `bool`.
10//! - [`toggle_group`] — a row of mutually-exclusive options. State is
11//!   the value of the currently-pressed item. Looks like a panel-less
12//!   [`crate::widgets::tabs`] row; reach for that instead when each
13//!   option has associated content.
14//! - [`toggle_group_multi`] — a row of independent on/off options.
15//!   State is a set of pressed values. Use for filter chips, format
16//!   toggles, anything where multiple options can be on at once.
17//!
18//! The app owns the state; the widget is a pure visual + identity
19//! carrier — same controlled pattern used by [`crate::widgets::radio`]
20//! and [`crate::widgets::tabs`].
21//!
22//! ```ignore
23//! use aetna_core::prelude::*;
24//! use std::collections::HashSet;
25//!
26//! struct App {
27//!     wrap: bool,                 // standalone toggle
28//!     view: String,               // single-select group
29//!     filters: HashSet<String>,   // multi-select group
30//! }
31//!
32//! impl aetna_core::App for App {
33//!     fn build(&self, _cx: &BuildCx) -> El {
34//!         column([
35//!             toggle("wrap", self.wrap, "Wrap lines"),
36//!             toggle_group("view", &self.view, [
37//!                 ("list", "List"),
38//!                 ("grid", "Grid"),
39//!                 ("kanban", "Kanban"),
40//!             ]),
41//!             toggle_group_multi("filters", &self.filters, [
42//!                 ("open", "Open"),
43//!                 ("draft", "Draft"),
44//!                 ("merged", "Merged"),
45//!             ]),
46//!         ])
47//!     }
48//!
49//!     fn on_event(&mut self, event: UiEvent) {
50//!         toggle::apply_event_pressed(&mut self.wrap, &event, "wrap");
51//!         toggle::apply_event_single(&mut self.view, &event, "view", |s| {
52//!             Some(s.to_string())
53//!         });
54//!         toggle::apply_event_multi(&mut self.filters, &event, "filters");
55//!     }
56//! }
57//! ```
58//!
59//! # Routed keys
60//!
61//! - Standalone toggle: `{key}` — `Click` flips the bool.
62//! - Group items: `{group_key}:toggle:{value}` — `Click` selects (single)
63//!   or flips (multi) that value. Use [`toggle_option_key`] to format
64//!   and parse.
65//!
66//! Chosen to parallel [`crate::widgets::tabs`]'s `{key}:tab:{value}` and
67//! [`crate::widgets::radio`]'s `{key}:radio:{value}` so the controlled-
68//! widget vocabulary stays consistent.
69//!
70//! # Dogfood note
71//!
72//! Composes only the public widget-kit surface — `Kind::Custom`,
73//! `.focusable()` + `.paint_overflow()` for the focus ring, and
74//! `.current()` / `.ghost()` for pressed-vs-unpressed. An app crate can
75//! fork this file and produce an equivalent widget against the same
76//! public API.
77
78use 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/// What a routed [`UiEvent`] means for a controlled toggle keyed `key`.
90///
91/// Returned by [`classify_event`]; the per-flavor `apply_event_*`
92/// helpers are the convenience wrappers that fold the action straight
93/// into the app's value field.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum ToggleAction<'a> {
97    /// A standalone toggle was clicked. The app flips its bool.
98    Pressed,
99    /// A toggle inside a group was clicked. The string is the raw
100    /// value token from the option key. Whether this means "set" or
101    /// "flip" is the app's call (single vs multi mode).
102    Selected(&'a str),
103}
104
105/// Classify a routed [`UiEvent`] against a controlled toggle keyed
106/// `key`. Returns `None` for events that aren't for this toggle.
107///
108/// Only `Click` / `Activate` event kinds qualify. Apps can call this
109/// unconditionally inside their event handler without filtering on
110/// `event.kind` first.
111pub 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
124/// Fold a routed [`UiEvent`] into a standalone toggle's `bool` field.
125/// Returns `true` if the event was a press for this `key`.
126pub 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
134/// Fold a routed [`UiEvent`] into a single-select toggle group's value
135/// field. Returns `true` if the event was a toggle event for this
136/// `key`.
137///
138/// `parse` converts the raw value token back to the app's value type;
139/// returning `None` from `parse` ignores the click silently. Re-clicking
140/// the already-pressed item is a no-op (the value stays set), matching
141/// shadcn's `<ToggleGroup type="single">` semantics — for "click to
142/// clear" use [`apply_event_multi`].
143pub 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
158/// Fold a routed [`UiEvent`] into a multi-select toggle group's
159/// `HashSet<String>` field, flipping the clicked value's membership.
160/// Returns `true` if the event was a toggle event for this `key`.
161pub 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
171/// Format the routed key emitted when a toggle group item is clicked.
172pub fn toggle_option_key(group_key: &str, value: &impl std::fmt::Display) -> String {
173    format!("{group_key}:toggle:{value}")
174}
175
176/// A standalone two-state button. `pressed` paints the active surface
177/// (accent fill + accent foreground + semibold), unpressed renders as
178/// ghost. Click on the routed key `key` flips the bool — fold the
179/// event back with [`apply_event_pressed`].
180#[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/// A single item inside a toggle group. Apps usually let
186/// [`toggle_group`] / [`toggle_group_multi`] build these from
187/// `(value, label)` pairs; reach for `toggle_item` directly when
188/// composing the row by hand (e.g. mixing in icons or badges per
189/// option).
190///
191/// `group_key` is the parent group's key — the routed key on the item
192/// is `{group_key}:toggle:{value}` (see [`toggle_option_key`]).
193/// `selected` paints the pressed surface.
194#[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/// A row of mutually-exclusive toggle items — pick one. `current` is
206/// the currently-pressed value, formatted via [`std::fmt::Display`]
207/// and compared against each option's `value`. `options` is an
208/// iterable of `(value, label)` pairs.
209///
210/// Per-item routed keys are `{key}:toggle:{value}`. Apps fold those
211/// back into their value field with [`apply_event_single`].
212///
213/// Use this for view-mode pickers (list / grid / kanban), text
214/// alignment (left / center / right), and similar one-of-N choices
215/// without panel content. When each option owns a panel, reach for
216/// [`crate::widgets::tabs`] instead.
217#[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/// A row of independent on/off toggle items — flip each
242/// independently. `selected` is the set of currently-pressed values
243/// (compared as strings, formatted from each option's `value`).
244/// `options` is an iterable of `(value, label)` pairs.
245///
246/// Per-item routed keys are `{key}:toggle:{value}`. Apps fold those
247/// back into their set with [`apply_event_multi`].
248///
249/// Use this for filter chips, formatting toolbars (B / I / U), and
250/// anything where multiple options coexist.
251#[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        // Surface profile so `.current()` paints the accent fill
284        // instead of taking the text-only branch (matches the
285        // tab_trigger setup that also flips between `.current()` and
286        // `.ghost()` per state).
287        .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        // Parser rejects everything → value stays "list" but the
383        // event is still consumed (returns true).
384        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        // Second click removes it.
397        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        // `.current()` paints with ACCENT fill on Custom surface kinds.
419        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        // `.ghost()` clears fill and stroke.
426        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}