Skip to main content

gpui_kit/controls/
toggle.rs

1//! Controls that report a choice: checkbox, radio, and switch.
2//!
3//! All three read their state from the caller and report an intent. They never
4//! hold the answer themselves, so a host that refuses a change simply does not
5//! apply it, and the control keeps showing what is actually true.
6
7use 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/// A box that reports one of on, off, or partly on.
26///
27/// The mixed state is for a parent whose children disagree; it is reported as
28/// mixed rather than guessed into on or off.
29#[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    /// Secondary text under the label, for a consequence the typist should
72    /// know before choosing.
73    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    /// Neither on nor off, because the things this box stands for disagree.
84    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        // The two marks are tracked separately so mixed and checked can cross
123        // over each other: the bar shrinks while the check draws, and the box
124        // is never momentarily empty between the two states.
125        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/// One option in a set where exactly one can hold.
199#[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/// A control that takes effect the moment it is flipped.
332///
333/// Use a switch for something that applies immediately, and a checkbox for
334/// something that applies when a form is submitted.
335#[derive(IntoElement)]
336pub struct Switch {
337    ident: Ident,
338    label: Option<SharedString>,
339    /// A name for a reader who has only the tree, when the words on screen
340    /// belong to something else.
341    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    /// Names the switch without drawing the name.
387    ///
388    /// A switch at the right edge of a settings row is named by the row, and
389    /// repeating those words beside the track would say everything twice. The
390    /// name still has to reach the tree, because a control nobody can name is
391    /// a control nobody can operate without looking at it.
392    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        // The knob is placed by margin rather than by a relative offset, so
432        // the switch is the same size at every point of the slide.
433        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/// The shared frame: a mark, a label, and the whole row as the target.
499///
500/// The row is the hit area rather than the mark alone, because a small square
501/// is a hard thing to hit and the label means the same thing.
502#[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}