Skip to main content

damascene_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. Grouped rows are
6//! arrow-navigable: Left/Right move focus among the items
7//! ([`crate::tree::ArrowNav::Horizontal`]).
8//!
9//! Three flavors, three state shapes:
10//!
11//! - [`toggle`] — a single binary on/off button. State is a `bool`.
12//! - [`toggle_group`] — a row of mutually-exclusive options. State is
13//!   the value of the currently-pressed item. Looks like a panel-less
14//!   [`crate::widgets::tabs`] row; reach for that instead when each
15//!   option has associated content.
16//! - [`toggle_group_multi`] — a row of independent on/off options.
17//!   State is a set of pressed values. Use for filter chips, format
18//!   toggles, anything where multiple options can be on at once.
19//!
20//! The app owns the state; the widget is a pure visual + identity
21//! carrier — same controlled pattern used by [`crate::widgets::radio`]
22//! and [`crate::widgets::tabs`].
23//!
24//! ```ignore
25//! use damascene_core::prelude::*;
26//! use std::collections::HashSet;
27//!
28//! struct App {
29//!     wrap: bool,                 // standalone toggle
30//!     view: String,               // single-select group
31//!     filters: HashSet<String>,   // multi-select group
32//! }
33//!
34//! impl damascene_core::App for App {
35//!     fn build(&self, _cx: &BuildCx) -> El {
36//!         column([
37//!             toggle("wrap", self.wrap, "Wrap lines"),
38//!             toggle_group("view", &self.view, [
39//!                 ("list", "List"),
40//!                 ("grid", "Grid"),
41//!                 ("kanban", "Kanban"),
42//!             ]),
43//!             toggle_group_multi("filters", &self.filters, [
44//!                 ("open", "Open"),
45//!                 ("draft", "Draft"),
46//!                 ("merged", "Merged"),
47//!             ]),
48//!         ])
49//!     }
50//!
51//!     fn on_event(&mut self, event: UiEvent, _cx: &EventCx) {
52//!         toggle::apply_event_pressed(&mut self.wrap, &event, "wrap");
53//!         toggle::apply_event_single(&mut self.view, &event, "view", |s| {
54//!             Some(s.to_string())
55//!         });
56//!         toggle::apply_event_multi(&mut self.filters, &event, "filters");
57//!     }
58//! }
59//! ```
60//!
61//! # Routed keys
62//!
63//! - Standalone toggle: `{key}` — `Click` flips the bool.
64//! - Group items: `{group_key}:toggle:{value}` — `Click` selects (single)
65//!   or flips (multi) that value. Use [`toggle_option_key`] to format
66//!   and parse.
67//!
68//! Chosen to parallel [`crate::widgets::tabs`]'s `{key}:tab:{value}` and
69//! [`crate::widgets::radio`]'s `{key}:radio:{value}` so the controlled-
70//! widget vocabulary stays consistent.
71//!
72//! # Dogfood note
73//!
74//! Composes only the public widget-kit surface — `Kind::Custom`,
75//! `.focusable()` + `.paint_overflow()` for the focus ring, and
76//! `.current()` / `.ghost()` for pressed-vs-unpressed. An app crate can
77//! fork this file and produce an equivalent widget against the same
78//! public API.
79
80// Lock in full per-item documentation for this module (issue #73).
81#![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/// What a routed [`UiEvent`] means for a controlled toggle keyed `key`.
95///
96/// Returned by [`classify_event`]; the per-flavor `apply_event_*`
97/// helpers are the convenience wrappers that fold the action straight
98/// into the app's value field.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum ToggleAction<'a> {
102    /// A standalone toggle was clicked. The app flips its bool.
103    Pressed,
104    /// A toggle inside a group was clicked. The string is the raw
105    /// value token from the option key. Whether this means "set" or
106    /// "flip" is the app's call (single vs multi mode).
107    Selected(&'a str),
108}
109
110/// Classify a routed [`UiEvent`] against a controlled toggle keyed
111/// `key`. Returns `None` for events that aren't for this toggle.
112///
113/// Only `Click` / `Activate` event kinds qualify. Apps can call this
114/// unconditionally inside their event handler without filtering on
115/// `event.kind` first.
116pub 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
129/// Fold a routed [`UiEvent`] into a standalone toggle's `bool` field.
130/// Returns `true` if the event was a press for this `key`.
131pub 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
139/// Fold a routed [`UiEvent`] into a single-select toggle group's value
140/// field. Returns `true` if the event was a toggle event for this
141/// `key`.
142///
143/// `parse` converts the raw value token back to the app's value type;
144/// returning `None` from `parse` ignores the click silently. Re-clicking
145/// the already-pressed item is a no-op (the value stays set), matching
146/// shadcn's `<ToggleGroup type="single">` semantics — for "click to
147/// clear" use [`apply_event_multi`].
148pub 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
163/// Fold a routed [`UiEvent`] into a multi-select toggle group's
164/// `HashSet<String>` field, flipping the clicked value's membership.
165/// Returns `true` if the event was a toggle event for this `key`.
166pub 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
176/// Format the routed key emitted when a toggle group item is clicked.
177pub fn toggle_option_key(group_key: &str, value: &impl std::fmt::Display) -> String {
178    format!("{group_key}:toggle:{value}")
179}
180
181/// A standalone two-state button. `pressed` paints the active surface
182/// (accent fill + accent foreground + semibold), unpressed renders as
183/// ghost. Click on the routed key `key` flips the bool — fold the
184/// event back with [`apply_event_pressed`].
185#[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/// A single item inside a toggle group. Apps usually let
191/// [`toggle_group`] / [`toggle_group_multi`] build these from
192/// `(value, label)` pairs; reach for `toggle_item` directly when
193/// composing the row by hand (e.g. mixing in icons or badges per
194/// option).
195///
196/// `group_key` is the parent group's key — the routed key on the item
197/// is `{group_key}:toggle:{value}` (see [`toggle_option_key`]).
198/// `selected` paints the pressed surface.
199#[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/// A row of mutually-exclusive toggle items — pick one. `current` is
211/// the currently-pressed value, formatted via [`std::fmt::Display`]
212/// and compared against each option's `value`. `options` is an
213/// iterable of `(value, label)` pairs.
214///
215/// Per-item routed keys are `{key}:toggle:{value}`. Apps fold those
216/// back into their value field with [`apply_event_single`].
217///
218/// Use this for view-mode pickers (list / grid / kanban), text
219/// alignment (left / center / right), and similar one-of-N choices
220/// without panel content. When each option owns a panel, reach for
221/// [`crate::widgets::tabs`] instead.
222#[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/// A row of independent on/off toggle items — flip each
247/// independently. `selected` is the set of currently-pressed values
248/// (compared as strings, formatted from each option's `value`).
249/// `options` is an iterable of `(value, label)` pairs.
250///
251/// Per-item routed keys are `{key}:toggle:{value}`. Apps fold those
252/// back into their set with [`apply_event_multi`].
253///
254/// Use this for filter chips, formatting toolbars (B / I / U), and
255/// anything where multiple options coexist.
256#[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        // Surface profile so `.current()` paints the accent fill
289        // instead of taking the text-only branch (matches the
290        // tab_trigger setup that also flips between `.current()` and
291        // `.ghost()` per state).
292        .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    // The row itself is deliberately not keyed (same rationale as
316    // `tabs_list`): the space between items is visual chrome, not an
317    // interactive target. A keyed row would also make gap clicks route
318    // the bare group key, which `classify_event` reads as a standalone
319    // toggle's `Pressed` — a phantom state flip from dead space.
320    El::new(Kind::Custom("toggle_group"))
321        .at_loc(caller)
322        .axis(Axis::Row)
323        .gap(tokens::SPACE_1)
324        .align(Align::Center)
325        // ToggleGroup pattern: the row is one arrow-navigable group —
326        // Left / Right move between items (issue #63).
327        .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        // Regression for #62: a keyed group container made clicks in
347        // the gap between items route the bare group key, which
348        // classify_event reads as a standalone toggle's `Pressed`.
349        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        // Parser rejects everything → value stays "list" but the
431        // event is still consumed (returns true).
432        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        // Second click removes it.
445        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        // `.current()` paints with ACCENT fill on Custom surface kinds.
467        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        // `.ghost()` clears fill and stroke.
474        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}