Skip to main content

gpui_base/
toggle.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
5    IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString,
6    Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div,
7    prelude::FluentBuilder as _, relative,
8};
9use smallvec::SmallVec;
10
11use crate::{StateStyle, StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
12
13type ChangeHandler = Rc<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
14
15/// An unstyled, controlled toggle button.
16///
17/// This primitive owns activation, focus, and accessibility behavior. The
18/// application owns all layout, visual states, sizes, and variants.
19#[derive(IntoElement)]
20pub struct Toggle {
21    id: ElementId,
22    base: Stateful<Div>,
23    style: StyleRefinement,
24    semantic_styles: ToggleStyles,
25    pressed: bool,
26    disabled: bool,
27    children: SmallVec<[AnyElement; 2]>,
28    on_change: Option<ChangeHandler>,
29    accessibility_label: Option<SharedString>,
30    tab_index: isize,
31    tab_stop: bool,
32    tracked_focus: Option<FocusHandle>,
33}
34
35/// Semantic root styles supported by [`Toggle`].
36#[derive(Default)]
37pub struct ToggleStyles {
38    pressed: StyleRefinement,
39    disabled: StyleRefinement,
40}
41
42impl ToggleStyles {
43    pub fn pressed(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
44        self.pressed
45            .refine(&build(StateStyle::default()).into_refinement());
46        self
47    }
48
49    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
50        self.disabled
51            .refine(&build(StateStyle::default()).into_refinement());
52        self
53    }
54}
55
56impl Toggle {
57    pub fn new(id: impl Into<ElementId>) -> Self {
58        let id = id.into();
59        Self {
60            base: div().id(id.clone()),
61            id,
62            style: StyleRefinement::default(),
63            semantic_styles: ToggleStyles::default(),
64            pressed: false,
65            disabled: false,
66            children: SmallVec::new(),
67            on_change: None,
68            accessibility_label: None,
69            tab_index: 0,
70            tab_stop: true,
71            tracked_focus: None,
72        }
73    }
74
75    pub fn pressed(mut self, pressed: bool) -> Self {
76        self.pressed = pressed;
77        self
78    }
79
80    pub fn disabled(mut self, disabled: bool) -> Self {
81        self.disabled = disabled;
82        self
83    }
84
85    /// Configures application-owned styles for the toggle's semantic states.
86    pub fn styles(mut self, build: impl FnOnce(ToggleStyles) -> ToggleStyles) -> Self {
87        self.semantic_styles = build(self.semantic_styles);
88        self
89    }
90
91    fn resolved_style(&self) -> StyleRefinement {
92        crate::state_style::resolve_style(
93            &self.style,
94            [
95                self.pressed.then_some(&self.semantic_styles.pressed),
96                self.disabled.then_some(&self.semantic_styles.disabled),
97            ]
98            .into_iter()
99            .flatten(),
100        )
101    }
102
103    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
104        self.accessibility_label = Some(label.into());
105        self
106    }
107
108    /// Handles a request to change the controlled pressed state.
109    pub fn on_change(
110        mut self,
111        handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
112    ) -> Self {
113        self.on_change = Some(Rc::new(handler));
114        self
115    }
116
117    pub fn tab_index(mut self, tab_index: isize) -> Self {
118        self.tab_index = tab_index;
119        self
120    }
121
122    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
123        self.tab_stop = tab_stop;
124        self
125    }
126
127    pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
128        self.tracked_focus = Some(focus_handle.clone());
129        self
130    }
131
132    fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle {
133        window
134            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
135            .read(cx)
136            .clone()
137    }
138}
139
140impl Styled for Toggle {
141    fn style(&mut self) -> &mut StyleRefinement {
142        &mut self.style
143    }
144}
145
146impl ParentElement for Toggle {
147    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
148        self.children.extend(elements);
149    }
150}
151
152impl InteractiveElement for Toggle {
153    fn interactivity(&mut self) -> &mut Interactivity {
154        self.base.interactivity()
155    }
156}
157
158impl StatefulInteractiveElement for Toggle {}
159
160impl RenderOnce for Toggle {
161    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
162        let focus_handle = self
163            .tracked_focus
164            .clone()
165            .unwrap_or_else(|| self.focus_handle(window, cx));
166        let pressed = self.pressed;
167        let disabled = self.disabled;
168        let style = self.resolved_style();
169        let on_change = self.on_change;
170
171        self.base
172            .test_support()
173            .role(Role::Button)
174            // Match Button's neutral control geometry: a fixed-size toggle
175            // centers ordinary content, while callers still own its size,
176            // spacing and visual treatment.
177            .flex()
178            .items_center()
179            .justify_center()
180            .line_height(relative(1.))
181            .aria_toggled(if pressed {
182                Toggled::True
183            } else {
184                Toggled::False
185            })
186            .when_some(self.accessibility_label, |this, label| {
187                this.aria_label(label)
188            })
189            .when(!disabled, |this| {
190                this.track_focus(
191                    &focus_handle
192                        .tab_index(self.tab_index)
193                        .tab_stop(self.tab_stop),
194                )
195            })
196            .when(disabled, |this| {
197                this.on_mouse_down(MouseButton::Left, |_, _, cx| {
198                    cx.stop_propagation();
199                })
200            })
201            .when_some(
202                (!disabled).then_some(on_change).flatten(),
203                |this, on_change| {
204                    this.on_click(move |event, window, cx| {
205                        on_change(!pressed, event, window, cx);
206                    })
207                },
208            )
209            .children(self.children)
210            .refine_style(&style)
211            .a11y_disabled(disabled)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::ElementExt as _;
219    use std::{
220        cell::{Cell, RefCell},
221        rc::Rc,
222        sync::{Arc, Mutex},
223    };
224
225    use gpui::{
226        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
227        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
228    };
229
230    struct Harness {
231        pressed: bool,
232        disabled: bool,
233        changes: Rc<RefCell<Vec<bool>>>,
234        keyboard_changes: Rc<Cell<usize>>,
235    }
236
237    impl Render for Harness {
238        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
239            let changes = self.changes.clone();
240            let keyboard_changes = self.keyboard_changes.clone();
241            Toggle::new("toggle")
242                .pressed(self.pressed)
243                .disabled(self.disabled)
244                .size(px(100.))
245                .on_change(move |pressed, event, _, _| {
246                    changes.borrow_mut().push(pressed);
247                    if matches!(event, ClickEvent::Keyboard(_)) {
248                        keyboard_changes.set(keyboard_changes.get() + 1);
249                    }
250                })
251        }
252    }
253
254    fn harness(
255        cx: &mut TestAppContext,
256        pressed: bool,
257        disabled: bool,
258    ) -> (
259        &mut VisualTestContext,
260        Rc<RefCell<Vec<bool>>>,
261        Rc<Cell<usize>>,
262    ) {
263        let changes = Rc::new(RefCell::new(Vec::new()));
264        let keyboard_changes = Rc::new(Cell::new(0));
265        let (_, cx) = cx.add_window_view({
266            let changes = changes.clone();
267            let keyboard_changes = keyboard_changes.clone();
268            move |_, _| Harness {
269                pressed,
270                disabled,
271                changes,
272                keyboard_changes,
273            }
274        });
275        cx.update(|window, cx| window.draw(cx).clear(cx));
276        (cx, changes, keyboard_changes)
277    }
278
279    #[gpui::test]
280    fn pointer_requests_inverse_controlled_state_once(cx: &mut TestAppContext) {
281        for (pressed, expected) in [(false, true), (true, false)] {
282            let (cx, changes, _) = harness(cx, pressed, false);
283            cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
284            assert_eq!(changes.borrow().as_slice(), &[expected]);
285        }
286    }
287
288    #[gpui::test]
289    fn enter_and_space_use_one_native_keyboard_click_each(cx: &mut TestAppContext) {
290        let (cx, changes, keyboard_changes) = harness(cx, false, false);
291        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
292        changes.borrow_mut().clear();
293        cx.update(|window, cx| window.draw(cx).clear(cx));
294
295        for key in ["enter", "space"] {
296            let keystroke = Keystroke::parse(key).unwrap();
297            cx.simulate_event(KeyDownEvent {
298                keystroke: keystroke.clone(),
299                is_held: false,
300                prefer_character_input: false,
301            });
302            cx.simulate_event(KeyUpEvent { keystroke });
303        }
304        assert_eq!(changes.borrow().as_slice(), &[true, true]);
305        assert_eq!(keyboard_changes.get(), 2);
306    }
307
308    #[gpui::test]
309    fn disabled_toggle_is_inert(cx: &mut TestAppContext) {
310        let (cx, changes, _) = harness(cx, false, true);
311        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
312        cx.simulate_keystrokes("enter space");
313        assert!(changes.borrow().is_empty());
314    }
315
316    #[gpui::test]
317    fn fixed_height_toggle_centers_ordinary_child_geometry(cx: &mut TestAppContext) {
318        type Captured = Arc<
319            Mutex<(
320                Option<gpui::Bounds<gpui::Pixels>>,
321                Option<gpui::Bounds<gpui::Pixels>>,
322            )>,
323        >;
324
325        struct AlignmentProbe(Captured);
326
327        impl Render for AlignmentProbe {
328            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
329                let root_capture = self.0.clone();
330                let child_capture = self.0.clone();
331                Toggle::new("alignment-toggle")
332                    .w(px(120.))
333                    .h(px(40.))
334                    .child(
335                        div()
336                            .w(px(48.))
337                            .h(px(12.))
338                            .on_prepaint(move |bounds, _, _| {
339                                child_capture.lock().unwrap().1 = Some(bounds);
340                            }),
341                    )
342                    .on_prepaint(move |bounds, _, _| {
343                        root_capture.lock().unwrap().0 = Some(bounds);
344                    })
345            }
346        }
347
348        let captured = Arc::new(Mutex::new((None, None)));
349        let (_, context) = cx.add_window_view({
350            let captured = captured.clone();
351            move |_, _| AlignmentProbe(captured)
352        });
353        context.update(|window, cx| window.draw(cx).clear(cx));
354
355        let (root, child) = *captured.lock().unwrap();
356        assert_eq!(
357            child.expect("child bounds").center(),
358            root.expect("toggle bounds").center()
359        );
360    }
361
362    #[test]
363    fn state_styling_and_children_are_application_owned() {
364        let _ = Toggle::new("styled")
365            .child("Label")
366            .styles(|styles| {
367                styles
368                    .pressed(|style| style.opacity(0.8))
369                    .disabled(|style| style.opacity(0.5))
370            })
371            .hover(|style| style.opacity(0.9))
372            .active(|style| style.opacity(0.8))
373            .focus_visible(|style| style.opacity(0.7));
374    }
375
376    #[test]
377    fn semantic_root_styles_follow_toggle_priority() {
378        let styled = |toggle: Toggle| {
379            toggle.styles(|styles| {
380                styles
381                    .pressed(|style| style.opacity(0.8))
382                    .disabled(|style| style.opacity(0.5))
383            })
384        };
385
386        assert_eq!(styled(Toggle::new("normal")).resolved_style().opacity, None);
387        assert_eq!(
388            styled(Toggle::new("pressed").pressed(true))
389                .resolved_style()
390                .opacity,
391            Some(0.8)
392        );
393        assert_eq!(
394            styled(Toggle::new("pressed-disabled").pressed(true).disabled(true))
395                .resolved_style()
396                .opacity,
397            Some(0.5)
398        );
399        assert_eq!(
400            styled(
401                Toggle::new("state-over-instance")
402                    .pressed(true)
403                    .disabled(true)
404                    .opacity(0.9),
405            )
406            .resolved_style()
407            .opacity,
408            Some(0.5)
409        );
410    }
411
412    #[gpui::test]
413    fn accessibility_exposes_button_role_toggled_state_and_action(cx: &mut TestAppContext) {
414        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
415        struct Probe(Captured);
416        impl Render for Probe {
417            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
418                let captured = self.0.clone();
419                canvas(
420                    move |_, window, cx| {
421                        let mut info = |toggle: Toggle| {
422                            let mut node = accesskit::Node::new(Role::Button);
423                            toggle
424                                .render(window, cx)
425                                .into_element()
426                                .write_a11y_info(&mut node);
427                            node
428                        };
429                        let enabled = info(
430                            Toggle::new("enabled")
431                                .pressed(true)
432                                .accessibility_label("Bold")
433                                .on_change(|_, _, _, _| {}),
434                        );
435                        let disabled = info(
436                            Toggle::new("disabled")
437                                .disabled(true)
438                                .on_change(|_, _, _, _| {}),
439                        );
440                        *captured.lock().unwrap() = Some((enabled, disabled));
441                    },
442                    |_, _, _, _| {},
443                )
444            }
445        }
446        let captured: Captured = Arc::new(Mutex::new(None));
447        let result = captured.clone();
448        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
449        cx.update(|window, cx| window.draw(cx).clear(cx));
450        let (enabled, disabled) = result.lock().unwrap().take().unwrap();
451        assert_eq!(enabled.role(), Role::Button);
452        assert_eq!(enabled.label(), Some("Bold"));
453        assert_eq!(enabled.toggled(), Some(Toggled::True));
454        assert!(enabled.supports_action(accesskit::Action::Click));
455        assert_eq!(disabled.toggled(), Some(Toggled::False));
456        assert!(!disabled.supports_action(accesskit::Action::Click));
457    }
458}