1use std::rc::Rc;
8
9use gpui::{
10 AnyElement, App, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
11 SharedString, Styled, Window, div, point, prelude::FluentBuilder, px,
12};
13use gpui_kit_semantics::{NodeSpec, Role, Semantic};
14use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Theme, TypeScale};
15
16use crate::foundation::{
17 Disableable, FocusRing, Ident, Pressable, Selectable, Sizable, StyledExt,
18 text as foundation_text,
19};
20use crate::motion::{self, Interpolate};
21
22type ToggleHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
23type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
24
25#[derive(IntoElement)]
30pub struct Checkbox {
31 ident: Ident,
32 label: Option<SharedString>,
33 description: Option<SharedString>,
34 checked: Option<bool>,
35 disabled: bool,
36 size: ControlSize,
37 on_change: Option<ToggleHandler>,
38}
39
40impl std::fmt::Debug for Checkbox {
41 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 formatter
43 .debug_struct("Checkbox")
44 .field("ident", &self.ident)
45 .field("label", &self.label)
46 .field("checked", &self.checked)
47 .field("disabled", &self.disabled)
48 .field("has_handler", &self.on_change.is_some())
49 .finish()
50 }
51}
52
53impl Checkbox {
54 pub fn new(ident: impl Into<Ident>) -> Self {
55 Self {
56 ident: ident.into(),
57 label: None,
58 description: None,
59 checked: Some(false),
60 disabled: false,
61 size: ControlSize::Md,
62 on_change: None,
63 }
64 }
65
66 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
67 self.label = Some(label.into());
68 self
69 }
70
71 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
74 self.description = Some(description.into());
75 self
76 }
77
78 pub fn checked(mut self, checked: bool) -> Self {
79 self.checked = Some(checked);
80 self
81 }
82
83 pub fn mixed(mut self) -> Self {
85 self.checked = None;
86 self
87 }
88
89 pub fn on_change(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
90 self.on_change = Some(Rc::new(handler));
91 self
92 }
93
94 fn actionable(&self) -> bool {
95 !self.disabled && self.on_change.is_some()
96 }
97}
98
99impl Disableable for Checkbox {
100 fn disabled(mut self, disabled: bool) -> Self {
101 self.disabled = disabled;
102 self
103 }
104}
105
106impl Sizable for Checkbox {
107 fn control_size(mut self, size: ControlSize) -> Self {
108 self.size = size;
109 self
110 }
111}
112
113impl RenderOnce for Checkbox {
114 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
115 let theme = cx.theme().clone();
116 let metrics = theme.control.get(self.size);
117 let actionable = self.actionable();
118 let side = px(metrics.icon_size);
119 let on = self.checked.unwrap_or(false);
120 let mixed = self.checked.is_none();
121
122 let drawn = motion::tracked(
126 &self.ident.semantic_id(),
127 point(f32::from(u8::from(on)), f32::from(u8::from(mixed))),
128 motion::state_change(&theme),
129 window,
130 cx,
131 );
132 let filled = drawn.x.max(drawn.y);
133
134 let mark = div()
135 .size(side)
136 .flex()
137 .items_center()
138 .justify_center()
139 .flex_none()
140 .relative()
141 .radius(&theme, Radius::Small)
142 .border(px(theme.borders.hairline))
143 .border_color(
144 theme
145 .colors
146 .hairline_strong
147 .lerp(theme.colors.accent, filled),
148 )
149 .bg(theme.colors.accent.opacity(filled))
150 .when(drawn.y > 0.0, |element| {
151 element.child(
152 div()
153 .absolute()
154 .w(side * 0.5 * drawn.y)
155 .h(px(theme.borders.thick))
156 .bg(theme.colors.text_on_accent),
157 )
158 })
159 .when(drawn.x > 0.0, |element| {
160 element.child(
161 div().absolute().opacity(drawn.x).child(
162 gpui_kit_assets::icon(gpui_kit_assets::Icon::Check)
163 .size(side * (0.4 + 0.4 * drawn.x))
164 .text_color(theme.colors.text_on_accent),
165 ),
166 )
167 });
168
169 let next = !on;
170 choice_row(
171 &theme,
172 cx,
173 self.ident.clone(),
174 mark.into_any_element(),
175 self.label.clone(),
176 self.description.clone(),
177 metrics.font_size,
178 self.disabled,
179 actionable,
180 self.on_change.clone().map(move |handler| {
181 Rc::new(move |window: &mut Window, cx: &mut App| handler(next, window, cx))
182 as ActionHandler
183 }),
184 )
185 .semantic_in(
186 cx,
187 spec(
188 &self.ident,
189 Role::Checkbox,
190 self.label.clone(),
191 self.disabled,
192 )
193 .tristate(self.checked),
194 )
195 }
196}
197
198#[derive(IntoElement)]
200pub struct Radio {
201 ident: Ident,
202 label: Option<SharedString>,
203 description: Option<SharedString>,
204 selected: bool,
205 disabled: bool,
206 size: ControlSize,
207 on_select: Option<ActionHandler>,
208}
209
210impl std::fmt::Debug for Radio {
211 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 formatter
213 .debug_struct("Radio")
214 .field("ident", &self.ident)
215 .field("label", &self.label)
216 .field("selected", &self.selected)
217 .field("disabled", &self.disabled)
218 .field("has_handler", &self.on_select.is_some())
219 .finish()
220 }
221}
222
223impl Radio {
224 pub fn new(ident: impl Into<Ident>) -> Self {
225 Self {
226 ident: ident.into(),
227 label: None,
228 description: None,
229 selected: false,
230 disabled: false,
231 size: ControlSize::Md,
232 on_select: None,
233 }
234 }
235
236 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
237 self.label = Some(label.into());
238 self
239 }
240
241 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
242 self.description = Some(description.into());
243 self
244 }
245
246 pub fn on_select(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
247 self.on_select = Some(Rc::new(handler));
248 self
249 }
250}
251
252impl Disableable for Radio {
253 fn disabled(mut self, disabled: bool) -> Self {
254 self.disabled = disabled;
255 self
256 }
257}
258
259impl Selectable for Radio {
260 fn selected(mut self, selected: bool) -> Self {
261 self.selected = selected;
262 self
263 }
264}
265
266impl Sizable for Radio {
267 fn control_size(mut self, size: ControlSize) -> Self {
268 self.size = size;
269 self
270 }
271}
272
273impl RenderOnce for Radio {
274 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
275 let theme = cx.theme().clone();
276 let metrics = theme.control.get(self.size);
277 let actionable = !self.disabled && self.on_select.is_some();
278 let side = px(metrics.icon_size);
279
280 let drawn = motion::tracked(
281 &self.ident.semantic_id(),
282 f32::from(u8::from(self.selected)),
283 motion::state_change(&theme),
284 window,
285 cx,
286 );
287
288 let mark = div()
289 .size(side)
290 .flex()
291 .items_center()
292 .justify_center()
293 .flex_none()
294 .rounded_full()
295 .border(px(theme.borders.hairline))
296 .border_color(
297 theme
298 .colors
299 .hairline_strong
300 .lerp(theme.colors.accent, drawn),
301 )
302 .when(drawn > 0.0, |element| {
303 element.child(
304 div()
305 .size(side * 0.5 * drawn)
306 .rounded_full()
307 .bg(theme.colors.accent),
308 )
309 });
310
311 choice_row(
312 &theme,
313 cx,
314 self.ident.clone(),
315 mark.into_any_element(),
316 self.label.clone(),
317 self.description.clone(),
318 metrics.font_size,
319 self.disabled,
320 actionable,
321 self.on_select.clone(),
322 )
323 .semantic_in(
324 cx,
325 spec(&self.ident, Role::Radio, self.label.clone(), self.disabled)
326 .checked(self.selected),
327 )
328 }
329}
330
331#[derive(IntoElement)]
336pub struct Switch {
337 ident: Ident,
338 label: Option<SharedString>,
339 name: Option<SharedString>,
342 description: Option<SharedString>,
343 on: bool,
344 disabled: bool,
345 size: ControlSize,
346 on_change: Option<ToggleHandler>,
347}
348
349impl std::fmt::Debug for Switch {
350 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 formatter
352 .debug_struct("Switch")
353 .field("ident", &self.ident)
354 .field("label", &self.label)
355 .field("on", &self.on)
356 .field("disabled", &self.disabled)
357 .field("has_handler", &self.on_change.is_some())
358 .finish()
359 }
360}
361
362impl Switch {
363 pub fn new(ident: impl Into<Ident>) -> Self {
364 Self {
365 ident: ident.into(),
366 label: None,
367 name: None,
368 description: None,
369 on: false,
370 disabled: false,
371 size: ControlSize::Md,
372 on_change: None,
373 }
374 }
375
376 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
377 self.label = Some(label.into());
378 self
379 }
380
381 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
382 self.description = Some(description.into());
383 self
384 }
385
386 pub fn named(mut self, name: impl Into<SharedString>) -> Self {
393 self.name = Some(name.into());
394 self
395 }
396
397 pub fn on(mut self, on: bool) -> Self {
398 self.on = on;
399 self
400 }
401
402 pub fn on_change(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
403 self.on_change = Some(Rc::new(handler));
404 self
405 }
406}
407
408impl Disableable for Switch {
409 fn disabled(mut self, disabled: bool) -> Self {
410 self.disabled = disabled;
411 self
412 }
413}
414
415impl Sizable for Switch {
416 fn control_size(mut self, size: ControlSize) -> Self {
417 self.size = size;
418 self
419 }
420}
421
422impl RenderOnce for Switch {
423 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
424 let theme = cx.theme().clone();
425 let metrics = theme.control.get(self.size);
426 let actionable = !self.disabled && self.on_change.is_some();
427 let height = px(metrics.icon_size);
428 let width = height * 1.8;
429 let knob = height - px(4.0);
430
431 let drawn = motion::tracked(
434 &self.ident.semantic_id(),
435 f32::from(u8::from(self.on)),
436 motion::state_change(&theme),
437 window,
438 cx,
439 );
440
441 let track = div()
442 .w(width)
443 .h(height)
444 .flex_none()
445 .flex()
446 .items_center()
447 .rounded_full()
448 .p(px(2.0))
449 .bg(theme
450 .colors
451 .hairline_strong
452 .lerp(theme.colors.accent, drawn))
453 .child(
454 div()
455 .size(knob)
456 .rounded_full()
457 .bg(theme.colors.text_on_accent)
458 .ml((width - knob - px(4.0)) * drawn),
459 );
460
461 let next = !self.on;
462 choice_row(
463 &theme,
464 cx,
465 self.ident.clone(),
466 track.into_any_element(),
467 self.label.clone(),
468 self.description.clone(),
469 metrics.font_size,
470 self.disabled,
471 actionable,
472 self.on_change.clone().map(move |handler| {
473 Rc::new(move |window: &mut Window, cx: &mut App| handler(next, window, cx))
474 as ActionHandler
475 }),
476 )
477 .semantic_in(
478 cx,
479 spec(
480 &self.ident,
481 Role::Switch,
482 self.label.clone().or_else(|| self.name.clone()),
483 self.disabled,
484 )
485 .checked(self.on),
486 )
487 }
488}
489
490fn spec(ident: &Ident, role: Role, label: Option<SharedString>, disabled: bool) -> NodeSpec {
491 let mut spec = NodeSpec::new(ident.semantic_id(), role).disabled(disabled);
492 if let Some(label) = label {
493 spec = spec.text(label);
494 }
495 spec
496}
497
498#[allow(clippy::too_many_arguments)]
503fn choice_row(
504 theme: &Theme,
505 cx: &App,
506 ident: Ident,
507 mark: AnyElement,
508 label: Option<SharedString>,
509 description: Option<SharedString>,
510 font_size: f32,
511 disabled: bool,
512 actionable: bool,
513 handler: Option<ActionHandler>,
514) -> gpui::Stateful<gpui::Div> {
515 let text_color: Hsla = if disabled {
516 theme.colors.text_faint
517 } else {
518 theme.colors.text
519 };
520
521 let mut row = div()
522 .id(ident.element_id())
523 .flex()
524 .flex_row()
525 .items_start()
526 .gap(px(theme.space(gpui_kit_theme::Space::Sm)))
527 .when(disabled, |element| element.opacity(theme.opacity.disabled))
528 .when(actionable, |element| {
529 element
530 .cursor_pointer()
531 .tab_index(0)
532 .focus_ring(theme)
533 .pressable(cx)
534 })
535 .child(div().mt(px(1.0)).child(mark))
536 .when_some(label, |element, label| {
537 element.child(
538 div()
539 .flex()
540 .flex_col()
541 .gap(px(2.0))
542 .child(
543 foundation_text(theme, TypeScale::Label, label)
544 .text_size(px(font_size))
545 .text_color(text_color),
546 )
547 .when_some(description, |element, description| {
548 element.child(
549 foundation_text(theme, TypeScale::Caption, description)
550 .text_size(px(font_size * 0.9))
551 .text_tone(theme, gpui_kit_theme::TextTone::Muted),
552 )
553 }),
554 )
555 });
556
557 if let (true, Some(handler)) = (actionable, handler) {
558 let click = Rc::clone(&handler);
559 row.interactivity()
560 .on_click(move |_, window, cx| click(window, cx));
561 row.interactivity().on_key_down(move |event, window, cx| {
562 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
563 handler(window, cx);
564 cx.stop_propagation();
565 }
566 });
567 }
568
569 row
570}