1use crate::control::{PressReleaseExt as _, QuietSurfaceExt as _, composed_button};
18use crate::handlers::SharedHandler;
19use crate::motion::disclosure_progress;
20use crate::surface::{hint, selection_surface};
21use crate::theme::SemanticStyledExt as _;
22use gpui::{
23 AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
24 ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
25 StyleRefinement, Styled, Window, accesskit, div, prelude::FluentBuilder as _,
26};
27use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, v_flex};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ChoiceOption {
32 id: SharedString,
33 label: SharedString,
34 description: Option<SharedString>,
35 disabled: bool,
36}
37
38impl ChoiceOption {
39 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
41 Self {
42 id: id.into(),
43 label: label.into(),
44 description: None,
45 disabled: false,
46 }
47 }
48
49 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
51 self.description = Some(description.into());
52 self
53 }
54
55 pub fn disabled(mut self, disabled: bool) -> Self {
57 self.disabled = disabled;
58 self
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ChoiceEvent {
65 Chosen {
67 group: SharedString,
69 option: SharedString,
71 },
72}
73
74#[derive(IntoElement)]
89pub struct ChoiceGroup {
90 id: SharedString,
91 label: Option<SharedString>,
92 options: Vec<ChoiceOption>,
93 selected: Option<SharedString>,
94 on_event: Option<SharedHandler<ChoiceEvent>>,
95 style: StyleRefinement,
96}
97
98impl ChoiceGroup {
99 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
101 Self {
102 id: id.into(),
103 label: Some(label.into()),
104 options: Vec::new(),
105 selected: None,
106 on_event: None,
107 style: StyleRefinement::default(),
108 }
109 }
110
111 pub fn unlabelled(id: impl Into<SharedString>) -> Self {
114 Self {
115 id: id.into(),
116 label: None,
117 options: Vec::new(),
118 selected: None,
119 on_event: None,
120 style: StyleRefinement::default(),
121 }
122 }
123
124 pub fn options(mut self, options: impl IntoIterator<Item = ChoiceOption>) -> Self {
126 self.options = options.into_iter().collect();
127 self
128 }
129
130 pub fn selected(mut self, option: impl Into<SharedString>) -> Self {
132 self.selected = Some(option.into());
133 self
134 }
135
136 pub fn selection(mut self, option: Option<SharedString>) -> Self {
138 self.selected = option;
139 self
140 }
141
142 pub fn on_event(
144 mut self,
145 handler: impl Fn(&ChoiceEvent, &mut Window, &mut App) + 'static,
146 ) -> Self {
147 self.on_event = Some(std::rc::Rc::new(handler));
148 self
149 }
150}
151
152impl Styled for ChoiceGroup {
153 fn style(&mut self) -> &mut StyleRefinement {
154 &mut self.style
155 }
156}
157
158impl RenderOnce for ChoiceGroup {
159 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
160 let tokens = cx.theme().semantic_tokens();
161 let root_id = ElementId::from(self.id.clone());
162 let group = self.id.clone();
163 let handler = self.on_event;
164 let selected = self.selected;
165 let label = self.label.clone();
166 let rows: Vec<AnyElement> = self
167 .options
168 .into_iter()
169 .enumerate()
170 .map(|(position, option)| {
171 let chosen = selected.as_ref() == Some(&option.id);
172 choice_row(
173 &root_id,
174 &group,
175 option,
176 chosen,
177 position,
178 handler.clone(),
179 window,
180 cx,
181 )
182 })
183 .collect();
184 let set_size = rows.len();
185
186 v_flex()
187 .id(root_id)
188 .gap(tokens.spacing.xs)
189 .role(Role::RadioGroup)
190 .when_some(label.clone(), |group, label| group.aria_label(label))
191 .aria_size_of_set(set_size)
192 .refine_style(&self.style)
193 .when_some(label, |group, label| {
194 group.child(
195 div()
196 .text_token(tokens.typography.sm)
197 .text_color(cx.theme().foreground)
198 .child(label),
199 )
200 })
201 .children(rows)
202 }
203}
204
205fn indicator_size(cx: &App) -> Pixels {
212 crate::sizing::slot_sm(cx)
213}
214
215fn control_seat(control: impl IntoElement, cx: &App) -> gpui::Div {
223 let tokens = cx.theme().semantic_tokens();
224 crate::surface::leading_control_slot(
225 tokens.typography.sm.line_height,
226 switch_track_width(indicator_size(cx)),
227 control,
228 )
229}
230
231fn switch_track_width(slot: Pixels) -> Pixels {
233 slot * 1.6
234}
235
236#[allow(clippy::too_many_arguments)]
238fn choice_row(
239 root_id: &ElementId,
240 group: &SharedString,
241 option: ChoiceOption,
242 chosen: bool,
243 position: usize,
244 handler: Option<SharedHandler<ChoiceEvent>>,
245 window: &mut Window,
246 cx: &mut App,
247) -> AnyElement {
248 let tokens = cx.theme().semantic_tokens();
249 let row_id = ElementId::from((root_id.clone(), option.id.clone()));
250 let press_id = ElementId::from((row_id.clone(), "press"));
251 let debug_group = group.clone();
254 let debug_option = option.id.clone();
255 let event = ChoiceEvent::Chosen {
256 group: group.clone(),
257 option: option.id.clone(),
258 };
259 let fill = disclosure_progress(
262 gpui_base::motion::TransitionId::from(ElementId::from((row_id.clone(), "chosen"))),
263 chosen,
264 window,
265 cx,
266 );
267 let ring = indicator_size(cx);
268 let accessibility_label = option.label.clone();
269
270 let row = composed_button(row_id.clone(), accessibility_label)
271 .w_full()
272 .justify_start()
273 .items_start()
274 .gap(tokens.spacing.sm)
275 .px(tokens.spacing.sm)
276 .py(tokens.spacing.xs)
277 .role(Role::RadioButton)
278 .aria_toggled(if chosen {
279 accesskit::Toggled::True
280 } else {
281 accesskit::Toggled::False
282 })
283 .aria_position_in_set(position + 1)
284 .debug_selector(move || format!("choice-{debug_group}-{debug_option}"))
285 .child(
286 control_seat(
287 div()
288 .flex()
289 .items_center()
290 .justify_center()
291 .size(ring)
292 .rounded_full()
293 .border_1()
294 .border_color(if chosen {
295 cx.theme().primary
296 } else {
297 cx.theme().border
298 })
299 .child(
300 div()
301 .size(ring * 0.5 * fill)
302 .rounded_full()
303 .bg(cx.theme().primary),
304 ),
305 cx,
306 )
307 .debug_selector({
308 let (group, option) = (group.clone(), option.id.clone());
309 move || format!("choice-seat-{group}-{option}")
310 }),
311 )
312 .child(
313 v_flex()
314 .min_w_0()
315 .gap(tokens.spacing.xxs)
316 .child(
317 div()
318 .debug_selector({
319 let (group, option) = (group.clone(), option.id.clone());
320 move || format!("choice-label-{group}-{option}")
321 })
322 .text_token(tokens.typography.sm)
323 .text_color(cx.theme().foreground)
324 .child(option.label.clone()),
325 )
326 .when_some(option.description.clone(), |body, description| {
327 body.child(hint(description, cx))
328 }),
329 );
330
331 let row = selection_surface(row, chosen, tokens.radius.lg, tokens.spacing.xs, None, cx)
334 .border_1()
335 .border_color(cx.theme().transparent)
336 .focus_visible(|style| style.border_color(cx.theme().ring))
337 .press_release(press_id, tokens.radius.md, window, cx);
338
339 if option.disabled {
340 return row.disabled(true).into_any_element();
341 }
342
343 match handler {
344 Some(handler) => row
345 .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
346 .into_any_element(),
347 None => row.into_any_element(),
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
353pub enum ToggleShape {
354 #[default]
356 Check,
357 Switch,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum ToggleEvent {
364 Toggled {
366 id: SharedString,
368 on: bool,
370 },
371}
372
373#[derive(IntoElement)]
385pub struct Toggle {
386 id: SharedString,
387 label: SharedString,
388 description: Option<SharedString>,
389 shape: ToggleShape,
390 on: bool,
391 disabled: bool,
392 on_event: Option<SharedHandler<ToggleEvent>>,
393 style: StyleRefinement,
394}
395
396impl Toggle {
397 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
399 Self {
400 id: id.into(),
401 label: label.into(),
402 description: None,
403 shape: ToggleShape::default(),
404 on: false,
405 disabled: false,
406 on_event: None,
407 style: StyleRefinement::default(),
408 }
409 }
410
411 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
413 self.description = Some(description.into());
414 self
415 }
416
417 pub fn shape(mut self, shape: ToggleShape) -> Self {
419 self.shape = shape;
420 self
421 }
422
423 pub fn on(mut self, on: bool) -> Self {
425 self.on = on;
426 self
427 }
428
429 pub fn disabled(mut self, disabled: bool) -> Self {
431 self.disabled = disabled;
432 self
433 }
434
435 pub fn on_event(
437 mut self,
438 handler: impl Fn(&ToggleEvent, &mut Window, &mut App) + 'static,
439 ) -> Self {
440 self.on_event = Some(std::rc::Rc::new(handler));
441 self
442 }
443}
444
445impl Styled for Toggle {
446 fn style(&mut self) -> &mut StyleRefinement {
447 &mut self.style
448 }
449}
450
451impl RenderOnce for Toggle {
452 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
453 toggle_button(self, window, cx)
454 }
455}
456
457fn toggle_button(toggle: Toggle, window: &mut Window, cx: &mut App) -> gpui_base::Button {
463 let tokens = cx.theme().semantic_tokens();
464 let row_id = ElementId::from(toggle.id.clone());
465 let press_id = ElementId::from((row_id.clone(), "press"));
466 let debug_id = toggle.id.clone();
467 let event = ToggleEvent::Toggled {
468 id: toggle.id.clone(),
469 on: !toggle.on,
470 };
471 let travel = disclosure_progress(
472 gpui_base::motion::TransitionId::from(ElementId::from((row_id.clone(), "on"))),
473 toggle.on,
474 window,
475 cx,
476 );
477 let slot = indicator_size(cx);
478 let indicator = match toggle.shape {
479 ToggleShape::Check => check_indicator(slot, travel, cx),
480 ToggleShape::Switch => switch_indicator(slot, travel, cx),
481 };
482 let role = match toggle.shape {
483 ToggleShape::Check => Role::CheckBox,
484 ToggleShape::Switch => Role::Switch,
485 };
486
487 let row = composed_button(row_id.clone(), toggle.label.clone())
488 .w_full()
489 .justify_start()
490 .items_start()
491 .gap(tokens.spacing.sm)
492 .px(tokens.spacing.sm)
493 .py(tokens.spacing.xs)
494 .role(role)
495 .aria_toggled(if toggle.on {
496 accesskit::Toggled::True
497 } else {
498 accesskit::Toggled::False
499 })
500 .debug_selector(move || format!("toggle-{debug_id}"))
501 .refine_style(&toggle.style)
502 .child(control_seat(indicator, cx))
503 .child(
504 v_flex()
505 .min_w_0()
506 .gap(tokens.spacing.xxs)
507 .child(
508 div()
509 .text_token(tokens.typography.sm)
510 .text_color(cx.theme().foreground)
511 .child(toggle.label.clone()),
512 )
513 .when_some(toggle.description.clone(), |body, description| {
514 body.child(hint(description, cx))
515 }),
516 )
517 .quiet_press_surface(press_id, tokens.radius.md, window, cx);
518
519 let handler = toggle.on_event.filter(|_| !toggle.disabled);
520 row.when(toggle.disabled, |row| row.disabled(true))
521 .when_some(handler, |row, handler| {
522 row.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
523 })
524}
525
526fn check_indicator(slot: Pixels, travel: f32, cx: &App) -> AnyElement {
528 let tokens = cx.theme().semantic_tokens();
529 div()
530 .flex()
531 .items_center()
532 .justify_center()
533 .size(slot)
534 .rounded(tokens.radius.sm)
535 .border_1()
536 .border_color(if travel > 0.5 {
537 cx.theme().primary
538 } else {
539 cx.theme().border
540 })
541 .bg(cx.theme().primary.opacity(travel))
542 .child(
543 Icon::new(IconName::Check)
544 .xsmall()
545 .text_color(cx.theme().primary_foreground.opacity(travel)),
546 )
547 .into_any_element()
548}
549
550fn switch_indicator(slot: Pixels, travel: f32, cx: &App) -> AnyElement {
553 let tokens = cx.theme().semantic_tokens();
554 let track_width = switch_track_width(slot);
557 let track_height = slot * 0.75;
558 let inset = tokens.spacing.xxs;
559 let thumb = track_height - inset * 2.0;
560 div()
561 .relative()
562 .flex()
563 .items_center()
564 .w(track_width)
565 .h(track_height)
566 .rounded_full()
567 .bg(cx.theme().border.opacity(1.0 - travel))
570 .child(
571 div()
572 .absolute()
573 .inset_0()
574 .rounded_full()
575 .bg(cx.theme().primary.opacity(travel)),
576 )
577 .child(
578 div()
579 .absolute()
580 .ml(inset + (track_width - thumb - inset * 2.0) * travel)
581 .size(thumb)
582 .rounded_full()
583 .bg(cx.theme().background),
584 )
585 .into_any_element()
586}