1use std::{cell::Cell, rc::Rc, time::Duration};
2
3use gpui::{
4 AnyElement, App, Axis, Corners, Edges, ElementId, InteractiveElement, IntoElement,
5 ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
6 Window, prelude::FluentBuilder as _, relative,
7};
8use gpui_base::{Toggle as BaseToggle, ToggleGroup as BaseToggleGroup};
9use smallvec::{SmallVec, smallvec};
10
11use crate::{ActiveTheme, Disableable, Icon, Sizable, Size, StyledExt, tooltip::ComponentTooltip};
12
13#[derive(Default, Copy, Debug, Clone, PartialEq, Eq, Hash)]
14pub enum ToggleVariant {
15 #[default]
16 Ghost,
17 Outline,
18}
19
20pub trait ToggleVariants: Sized {
21 fn with_variant(self, variant: ToggleVariant) -> Self;
23 fn ghost(self) -> Self {
25 self.with_variant(ToggleVariant::Ghost)
26 }
27 fn outline(self) -> Self {
29 self.with_variant(ToggleVariant::Outline)
30 }
31}
32
33#[derive(IntoElement)]
34pub struct Toggle {
35 id: ElementId,
36 style: StyleRefinement,
37 checked: bool,
38 size: Size,
39 variant: ToggleVariant,
40 disabled: bool,
41 border_corners: Corners<bool>,
42 border_edges: Edges<bool>,
43 children: SmallVec<[AnyElement; 1]>,
44 on_click: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
45 tooltip: ComponentTooltip,
46}
47
48impl Toggle {
49 pub fn new(id: impl Into<ElementId>) -> Self {
51 Self {
52 id: id.into(),
53 style: StyleRefinement::default(),
54 checked: false,
55 size: Size::default(),
56 variant: ToggleVariant::default(),
57 disabled: false,
58 border_corners: Corners {
59 top_left: true,
60 top_right: true,
61 bottom_left: true,
62 bottom_right: true,
63 },
64 border_edges: Edges::all(true),
65 children: smallvec![],
66 on_click: None,
67 tooltip: ComponentTooltip::default(),
68 }
69 }
70
71 pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
73 self.tooltip.text = Some((tooltip.into(), None));
74 self
75 }
76
77 pub fn tooltip_show_delay(mut self, delay: Duration) -> Self {
81 self.tooltip.show_delay = Some(delay);
82 self
83 }
84
85 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
87 let label: SharedString = label.into();
88 self.children.push(label.into_any_element());
89 self
90 }
91
92 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
94 let icon: Icon = icon.into();
95 self.children.push(icon.into());
96 self
97 }
98
99 pub fn checked(mut self, checked: bool) -> Self {
101 self.checked = checked;
102 self
103 }
104
105 pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
109 self.on_click = Some(Box::new(handler));
110 self
111 }
112
113 pub(crate) fn border_corners(mut self, corners: impl Into<Corners<bool>>) -> Self {
114 self.border_corners = corners.into();
115 self
116 }
117
118 pub(crate) fn border_edges(mut self, edges: impl Into<Edges<bool>>) -> Self {
119 self.border_edges = edges.into();
120 self
121 }
122}
123
124impl ToggleVariants for Toggle {
125 fn with_variant(mut self, variant: ToggleVariant) -> Self {
126 self.variant = variant;
127 self
128 }
129}
130
131impl ParentElement for Toggle {
132 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
133 self.children.extend(elements);
134 }
135}
136
137impl Disableable for Toggle {
138 fn disabled(mut self, disabled: bool) -> Self {
139 self.disabled = disabled;
140 self
141 }
142}
143
144impl Sizable for Toggle {
145 fn with_size(mut self, size: impl Into<Size>) -> Self {
146 self.size = size.into();
147 self
148 }
149}
150
151impl Styled for Toggle {
152 fn style(&mut self) -> &mut StyleRefinement {
153 &mut self.style
154 }
155}
156
157impl RenderOnce for Toggle {
158 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
159 let checked = self.checked;
160 let disabled = self.disabled;
161 let hoverable = !disabled && !checked;
162 let rounding = cx.theme().radius;
163 let pressed_background = cx.theme().tokens.accent;
164 let pressed_foreground = cx.theme().accent_foreground;
165 let instance_style = self.style.clone();
166
167 BaseToggle::new(self.id)
168 .pressed(checked)
169 .disabled(disabled)
170 .when_some(
171 self.tooltip.text.as_ref().map(|(text, _)| text.clone()),
172 |this, label| this.accessibility_label(label),
173 )
174 .when_some(self.on_click, |this, on_click| {
175 this.on_change(move |next, _, window, cx| on_click(&next, window, cx))
176 })
177 .flex()
178 .flex_row()
179 .line_height(relative(1.25))
180 .items_center()
181 .justify_center()
182 .map(|this| match self.size {
183 Size::XSmall => this.min_w_5().h_5().px_0p5().text_xs(),
184 Size::Small => this.min_w_6().h_6().px_1().text_sm(),
185 Size::Large => this.min_w_9().h_9().px_3().text_base(),
186 _ => this.min_w_8().h_8().px_2().text_sm(),
187 })
188 .when(self.border_corners.top_left, |this| {
189 this.rounded_tl(rounding)
190 })
191 .when(self.border_corners.top_right, |this| {
192 this.rounded_tr(rounding)
193 })
194 .when(self.border_corners.bottom_left, |this| {
195 this.rounded_bl(rounding)
196 })
197 .when(self.border_corners.bottom_right, |this| {
198 this.rounded_br(rounding)
199 })
200 .when(self.variant == ToggleVariant::Outline, |this| {
201 this.when(self.border_edges.left, |this| this.border_l_1())
202 .when(self.border_edges.right, |this| this.border_r_1())
203 .when(self.border_edges.top, |this| this.border_t_1())
204 .when(self.border_edges.bottom, |this| this.border_b_1())
205 .border_color(cx.theme().border)
206 .bg(cx.theme().tokens.background)
207 })
208 .when(hoverable, |this| {
209 this.hover(|this| {
210 this.bg(cx.theme().tokens.accent)
211 .text_color(cx.theme().accent_foreground)
212 })
213 })
214 .styles(|styles| {
215 styles.pressed(|style| {
216 style
217 .bg(pressed_background)
218 .text_color(pressed_foreground)
219 .refine_style(&instance_style)
220 })
221 })
222 .refine_style(&self.style)
223 .children(self.children)
224 .map(|this| self.tooltip.apply(this))
225 }
226}
227
228#[derive(IntoElement)]
230pub struct ToggleGroup {
231 id: ElementId,
232 style: StyleRefinement,
233 size: Size,
234 variant: ToggleVariant,
235 disabled: bool,
236 segmented: bool,
237 items: Vec<Toggle>,
238 on_click: Option<Rc<dyn Fn(&Vec<bool>, &mut Window, &mut App) + 'static>>,
239}
240
241impl ToggleGroup {
242 pub fn new(id: impl Into<ElementId>) -> Self {
244 Self {
245 id: id.into(),
246 style: StyleRefinement::default(),
247 size: Size::default(),
248 variant: ToggleVariant::default(),
249 disabled: false,
250 segmented: false,
251 items: Vec::new(),
252 on_click: None,
253 }
254 }
255
256 pub fn child(mut self, toggle: impl Into<Toggle>) -> Self {
258 self.items.push(toggle.into());
259 self
260 }
261
262 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Toggle>>) -> Self {
264 self.items.extend(children.into_iter().map(Into::into));
265 self
266 }
267
268 pub fn on_click(
272 mut self,
273 on_click: impl Fn(&Vec<bool>, &mut Window, &mut App) + 'static,
274 ) -> Self {
275 self.on_click = Some(Rc::new(on_click));
276 self
277 }
278
279 pub fn segmented(mut self) -> Self {
284 self.segmented = true;
285 self
286 }
287}
288
289impl Sizable for ToggleGroup {
290 fn with_size(mut self, size: impl Into<Size>) -> Self {
291 self.size = size.into();
292 self
293 }
294}
295
296impl ToggleVariants for ToggleGroup {
297 fn with_variant(mut self, variant: ToggleVariant) -> Self {
298 self.variant = variant;
299 self
300 }
301}
302
303impl Disableable for ToggleGroup {
304 fn disabled(mut self, disabled: bool) -> Self {
305 self.disabled = disabled;
306 self
307 }
308}
309
310impl Styled for ToggleGroup {
311 fn style(&mut self) -> &mut StyleRefinement {
312 &mut self.style
313 }
314}
315
316impl RenderOnce for ToggleGroup {
317 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
318 let disabled = self.disabled;
319 let items_len = self.items.len();
320 let checks = self
321 .items
322 .iter()
323 .map(|item| item.checked)
324 .collect::<Vec<bool>>();
325 let clicked_index = Rc::new(Cell::new(None));
326
327 BaseToggleGroup::new(self.id)
328 .axis(Axis::Horizontal)
329 .child(
330 crate::h_flex()
331 .items_center()
332 .when(!self.segmented, |this| this.gap_2())
333 .refine_style(&self.style)
334 .children(self.items.into_iter().enumerate().map({
335 {
336 let clicked_index = clicked_index.clone();
337 move |(ix, item)| {
338 let item = if !self.segmented || items_len == 1 {
339 item
340 } else if ix == 0 {
341 item.border_corners(Corners {
342 top_left: true,
343 top_right: false,
344 bottom_left: true,
345 bottom_right: false,
346 })
347 .border_edges(Edges {
348 left: true,
349 top: true,
350 right: true,
351 bottom: true,
352 })
353 } else if ix == items_len - 1 {
354 item.border_corners(Corners {
355 top_left: false,
356 top_right: true,
357 bottom_left: false,
358 bottom_right: true,
359 })
360 .border_edges(Edges {
361 left: false,
362 top: true,
363 right: true,
364 bottom: true,
365 })
366 } else {
367 item.border_corners(Corners {
368 top_left: false,
369 top_right: false,
370 bottom_left: false,
371 bottom_right: false,
372 })
373 .border_edges(Edges {
374 left: false,
375 top: true,
376 right: true,
377 bottom: true,
378 })
379 };
380
381 let effective_disabled = disabled || item.disabled;
382 let clicked_index = clicked_index.clone();
383 item.disabled(effective_disabled)
384 .with_size(self.size)
385 .with_variant(self.variant)
386 .on_click(move |_, _, cx| {
387 clicked_index.set(Some(ix));
388 cx.propagate();
389 })
390 }
391 }
392 })),
393 )
394 .when_some(
395 (!disabled).then_some(self.on_click).flatten(),
396 |this, on_click| {
397 this.on_click(move |_, window, cx| {
398 let Some(ix) = clicked_index.take() else {
399 return;
400 };
401 let mut next = checks.clone();
402 next[ix] = !next[ix];
403 on_click(&next, window, cx);
404 })
405 },
406 )
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::{IconName, h_flex};
414 use gpui::{
415 Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
416 StatefulInteractiveElement, TestAppContext, VisualTestContext, point, px,
417 };
418 use std::cell::{Cell, RefCell};
419
420 struct ToggleHarness {
421 disabled: bool,
422 changes: Rc<RefCell<Vec<bool>>>,
423 parent_clicks: Rc<Cell<usize>>,
424 }
425
426 impl Render for ToggleHarness {
427 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
428 let changes = self.changes.clone();
429 let parent_clicks = self.parent_clicks.clone();
430 h_flex()
431 .id("toggle-parent")
432 .tab_group()
433 .size(px(100.))
434 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
435 .child(
436 Toggle::new("toggle")
437 .label("Bold")
438 .disabled(self.disabled)
439 .size_full()
440 .on_click(move |next, _, _| changes.borrow_mut().push(*next)),
441 )
442 }
443 }
444
445 fn harness(
446 cx: &mut TestAppContext,
447 disabled: bool,
448 ) -> (
449 &mut VisualTestContext,
450 Rc<RefCell<Vec<bool>>>,
451 Rc<Cell<usize>>,
452 ) {
453 cx.update(crate::init);
454 let changes = Rc::new(RefCell::new(Vec::new()));
455 let parent_clicks = Rc::new(Cell::new(0));
456 let (_, cx) = cx.add_window_view({
457 let changes = changes.clone();
458 let parent_clicks = parent_clicks.clone();
459 move |_, _| ToggleHarness {
460 disabled,
461 changes,
462 parent_clicks,
463 }
464 });
465 cx.update(|window, cx| window.draw(cx).clear(cx));
466 (cx, changes, parent_clicks)
467 }
468
469 fn activate_key(cx: &mut VisualTestContext, key: &str) {
470 let keystroke = Keystroke::parse(key).unwrap();
471 cx.simulate_event(KeyDownEvent {
472 keystroke: keystroke.clone(),
473 is_held: false,
474 prefer_character_input: false,
475 });
476 cx.simulate_event(KeyUpEvent { keystroke });
477 }
478
479 #[gpui::test]
480 fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
481 let (cx, changes, _) = harness(cx, false);
482 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
483 assert_eq!(changes.borrow().as_slice(), &[true]);
484 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
485 }
486
487 #[gpui::test]
488 fn canonical_toggle_supports_tab_enter_and_space(cx: &mut TestAppContext) {
489 let (cx, changes, _) = harness(cx, false);
490 cx.update(|window, cx| window.focus_next(cx));
491 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
492 activate_key(cx, "enter");
493 activate_key(cx, "space");
494 assert_eq!(changes.borrow().as_slice(), &[true, true]);
495 }
496
497 #[gpui::test]
498 fn canonical_disabled_toggle_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
499 let (cx, changes, parent_clicks) = harness(cx, true);
500 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
501 assert!(changes.borrow().is_empty());
502 assert_eq!(parent_clicks.get(), 0);
503 }
504
505 struct ToggleGroupHarness {
506 install_group_callback: bool,
507 child_clicks: Rc<Cell<usize>>,
508 group_changes: Rc<RefCell<Vec<Vec<bool>>>>,
509 }
510
511 impl Render for ToggleGroupHarness {
512 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
513 let child_clicks = self.child_clicks.clone();
514 let mut group = ToggleGroup::new("toggle-group")
515 .w(px(120.))
516 .child(
517 Toggle::new("one")
518 .label("One")
519 .checked(true)
520 .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
521 )
522 .child(Toggle::new("two").label("Two"));
523 if self.install_group_callback {
524 let changes = self.group_changes.clone();
525 group = group.on_click(move |next, _, _| changes.borrow_mut().push(next.clone()));
526 }
527 group
528 }
529 }
530
531 fn toggle_group_harness(
532 cx: &mut TestAppContext,
533 install_group_callback: bool,
534 ) -> (
535 &mut VisualTestContext,
536 Rc<Cell<usize>>,
537 Rc<RefCell<Vec<Vec<bool>>>>,
538 ) {
539 cx.update(crate::init);
540 let child_clicks = Rc::new(Cell::new(0));
541 let group_changes = Rc::new(RefCell::new(Vec::new()));
542 let (_, cx) = cx.add_window_view({
543 let child_clicks = child_clicks.clone();
544 let group_changes = group_changes.clone();
545 move |_, _| ToggleGroupHarness {
546 install_group_callback,
547 child_clicks,
548 group_changes,
549 }
550 });
551 cx.update(|window, cx| window.draw(cx).clear(cx));
552 (cx, child_clicks, group_changes)
553 }
554
555 #[gpui::test]
556 fn legacy_toggle_group_overrides_child_callback_even_without_group_callback(
557 cx: &mut TestAppContext,
558 ) {
559 let (cx, child_clicks, changes) = toggle_group_harness(cx, false);
560 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
561 assert_eq!(child_clicks.get(), 0);
562 assert!(changes.borrow().is_empty());
563 }
564
565 #[gpui::test]
566 fn legacy_toggle_group_pointer_flips_only_the_clicked_rendered_value(cx: &mut TestAppContext) {
567 let (cx, child_clicks, changes) = toggle_group_harness(cx, true);
568 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
569 assert_eq!(child_clicks.get(), 0);
570 assert_eq!(changes.borrow().as_slice(), &[vec![false, false]]);
571 }
572
573 #[gpui::test]
574 fn legacy_toggle_group_keyboard_click_does_not_reach_the_group_callback(
575 cx: &mut TestAppContext,
576 ) {
577 let (cx, child_clicks, changes) = toggle_group_harness(cx, true);
578 cx.update(|window, cx| window.focus_next(cx));
579 activate_key(cx, "enter");
580 assert_eq!(child_clicks.get(), 0);
581 assert!(changes.borrow().is_empty());
582 }
583
584 #[test]
585 fn instance_style_remains_the_final_visual_override() {
586 let toggle = Toggle::new("styled").checked(true).opacity(0.37);
587 assert_eq!(toggle.style.opacity, Some(0.37));
588 }
589
590 #[gpui::test]
591 fn test_toggle_builder(_cx: &mut gpui::TestAppContext) {
592 let toggle = Toggle::new("complex-toggle")
593 .label("Enable Feature")
594 .icon(IconName::Check)
595 .checked(true)
596 .outline()
597 .large()
598 .disabled(false)
599 .on_click(|_, _, _| {});
600
601 assert_eq!(toggle.children.len(), 2); assert!(toggle.checked);
603 assert_eq!(toggle.variant, ToggleVariant::Outline);
604 assert_eq!(toggle.size, Size::Large);
605 assert!(!toggle.disabled);
606 assert!(toggle.on_click.is_some());
607 }
608
609 #[gpui::test]
610 fn test_toggle_group_builder(_cx: &mut gpui::TestAppContext) {
611 let group = ToggleGroup::new("complex-group")
612 .child(Toggle::new("toggle1").label("Option 1"))
613 .child(Toggle::new("toggle2").label("Option 2").checked(true))
614 .child(Toggle::new("toggle3").label("Option 3"))
615 .outline()
616 .large()
617 .segmented()
618 .disabled(false)
619 .on_click(|_, _, _| {});
620
621 assert_eq!(group.items.len(), 3);
622 assert_eq!(group.variant, ToggleVariant::Outline);
623 assert_eq!(group.size, Size::Large);
624 assert!(group.segmented);
625 assert!(!group.disabled);
626 assert!(group.on_click.is_some());
627 }
628}