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 _};
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    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::ElementExt as _;
218    use std::{
219        cell::{Cell, RefCell},
220        rc::Rc,
221        sync::{Arc, Mutex},
222    };
223
224    use gpui::{
225        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
226        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
227    };
228
229    struct Harness {
230        pressed: bool,
231        disabled: bool,
232        changes: Rc<RefCell<Vec<bool>>>,
233        keyboard_changes: Rc<Cell<usize>>,
234    }
235
236    impl Render for Harness {
237        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
238            let changes = self.changes.clone();
239            let keyboard_changes = self.keyboard_changes.clone();
240            Toggle::new("toggle")
241                .pressed(self.pressed)
242                .disabled(self.disabled)
243                .size(px(100.))
244                .on_change(move |pressed, event, _, _| {
245                    changes.borrow_mut().push(pressed);
246                    if matches!(event, ClickEvent::Keyboard(_)) {
247                        keyboard_changes.set(keyboard_changes.get() + 1);
248                    }
249                })
250        }
251    }
252
253    fn harness(
254        cx: &mut TestAppContext,
255        pressed: bool,
256        disabled: bool,
257    ) -> (
258        &mut VisualTestContext,
259        Rc<RefCell<Vec<bool>>>,
260        Rc<Cell<usize>>,
261    ) {
262        let changes = Rc::new(RefCell::new(Vec::new()));
263        let keyboard_changes = Rc::new(Cell::new(0));
264        let (_, cx) = cx.add_window_view({
265            let changes = changes.clone();
266            let keyboard_changes = keyboard_changes.clone();
267            move |_, _| Harness {
268                pressed,
269                disabled,
270                changes,
271                keyboard_changes,
272            }
273        });
274        cx.update(|window, cx| window.draw(cx).clear(cx));
275        (cx, changes, keyboard_changes)
276    }
277
278    #[gpui::test]
279    fn pointer_requests_inverse_controlled_state_once(cx: &mut TestAppContext) {
280        for (pressed, expected) in [(false, true), (true, false)] {
281            let (cx, changes, _) = harness(cx, pressed, false);
282            cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
283            assert_eq!(changes.borrow().as_slice(), &[expected]);
284        }
285    }
286
287    #[gpui::test]
288    fn enter_and_space_use_one_native_keyboard_click_each(cx: &mut TestAppContext) {
289        let (cx, changes, keyboard_changes) = harness(cx, false, false);
290        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
291        changes.borrow_mut().clear();
292        cx.update(|window, cx| window.draw(cx).clear(cx));
293
294        for key in ["enter", "space"] {
295            let keystroke = Keystroke::parse(key).unwrap();
296            cx.simulate_event(KeyDownEvent {
297                keystroke: keystroke.clone(),
298                is_held: false,
299                prefer_character_input: false,
300            });
301            cx.simulate_event(KeyUpEvent { keystroke });
302        }
303        assert_eq!(changes.borrow().as_slice(), &[true, true]);
304        assert_eq!(keyboard_changes.get(), 2);
305    }
306
307    #[gpui::test]
308    fn disabled_toggle_is_inert(cx: &mut TestAppContext) {
309        let (cx, changes, _) = harness(cx, false, true);
310        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
311        cx.simulate_keystrokes("enter space");
312        assert!(changes.borrow().is_empty());
313    }
314
315    #[gpui::test]
316    fn fixed_height_toggle_centers_ordinary_child_geometry(cx: &mut TestAppContext) {
317        type Captured = Arc<
318            Mutex<(
319                Option<gpui::Bounds<gpui::Pixels>>,
320                Option<gpui::Bounds<gpui::Pixels>>,
321            )>,
322        >;
323
324        struct AlignmentProbe(Captured);
325
326        impl Render for AlignmentProbe {
327            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
328                let root_capture = self.0.clone();
329                let child_capture = self.0.clone();
330                Toggle::new("alignment-toggle")
331                    .w(px(120.))
332                    .h(px(40.))
333                    .child(
334                        div()
335                            .w(px(48.))
336                            .h(px(12.))
337                            .on_prepaint(move |bounds, _, _| {
338                                child_capture.lock().unwrap().1 = Some(bounds);
339                            }),
340                    )
341                    .on_prepaint(move |bounds, _, _| {
342                        root_capture.lock().unwrap().0 = Some(bounds);
343                    })
344            }
345        }
346
347        let captured = Arc::new(Mutex::new((None, None)));
348        let (_, context) = cx.add_window_view({
349            let captured = captured.clone();
350            move |_, _| AlignmentProbe(captured)
351        });
352        context.update(|window, cx| window.draw(cx).clear(cx));
353
354        let (root, child) = *captured.lock().unwrap();
355        assert_eq!(
356            child.expect("child bounds").center(),
357            root.expect("toggle bounds").center()
358        );
359    }
360
361    #[test]
362    fn state_styling_and_children_are_application_owned() {
363        let _ = Toggle::new("styled")
364            .child("Label")
365            .styles(|styles| {
366                styles
367                    .pressed(|style| style.opacity(0.8))
368                    .disabled(|style| style.opacity(0.5))
369            })
370            .hover(|style| style.opacity(0.9))
371            .active(|style| style.opacity(0.8))
372            .focus_visible(|style| style.opacity(0.7));
373    }
374
375    #[test]
376    fn semantic_root_styles_follow_toggle_priority() {
377        let styled = |toggle: Toggle| {
378            toggle.styles(|styles| {
379                styles
380                    .pressed(|style| style.opacity(0.8))
381                    .disabled(|style| style.opacity(0.5))
382            })
383        };
384
385        assert_eq!(styled(Toggle::new("normal")).resolved_style().opacity, None);
386        assert_eq!(
387            styled(Toggle::new("pressed").pressed(true))
388                .resolved_style()
389                .opacity,
390            Some(0.8)
391        );
392        assert_eq!(
393            styled(Toggle::new("pressed-disabled").pressed(true).disabled(true))
394                .resolved_style()
395                .opacity,
396            Some(0.5)
397        );
398        assert_eq!(
399            styled(
400                Toggle::new("state-over-instance")
401                    .pressed(true)
402                    .disabled(true)
403                    .opacity(0.9),
404            )
405            .resolved_style()
406            .opacity,
407            Some(0.5)
408        );
409    }
410
411    #[gpui::test]
412    fn accessibility_exposes_button_role_toggled_state_and_action(cx: &mut TestAppContext) {
413        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
414        struct Probe(Captured);
415        impl Render for Probe {
416            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
417                let captured = self.0.clone();
418                canvas(
419                    move |_, window, cx| {
420                        let mut info = |toggle: Toggle| {
421                            let mut node = accesskit::Node::new(Role::Button);
422                            toggle
423                                .render(window, cx)
424                                .into_element()
425                                .write_a11y_info(&mut node);
426                            node
427                        };
428                        let enabled = info(
429                            Toggle::new("enabled")
430                                .pressed(true)
431                                .accessibility_label("Bold")
432                                .on_change(|_, _, _, _| {}),
433                        );
434                        let disabled = info(
435                            Toggle::new("disabled")
436                                .disabled(true)
437                                .on_change(|_, _, _, _| {}),
438                        );
439                        *captured.lock().unwrap() = Some((enabled, disabled));
440                    },
441                    |_, _, _, _| {},
442                )
443            }
444        }
445        let captured: Captured = Arc::new(Mutex::new(None));
446        let result = captured.clone();
447        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
448        cx.update(|window, cx| window.draw(cx).clear(cx));
449        let (enabled, disabled) = result.lock().unwrap().take().unwrap();
450        assert_eq!(enabled.role(), Role::Button);
451        assert_eq!(enabled.label(), Some("Bold"));
452        assert_eq!(enabled.toggled(), Some(Toggled::True));
453        assert!(enabled.supports_action(accesskit::Action::Click));
454        assert_eq!(disabled.toggled(), Some(Toggled::False));
455        assert!(!disabled.supports_action(accesskit::Action::Click));
456    }
457}