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