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