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#[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#[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 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 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 .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}