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