Skip to main content

gpui_kit/controls/
toggle_button.rs

1//! Buttons that stay in: one on its own, and a run of them.
2//!
3//! # Why this is not a [`Switch`](crate::controls::toggle::Switch)
4//!
5//! A switch is a setting. Flipping it applies something to the world — the
6//! window goes dark, the daemon starts — and it is drawn as a setting, with a
7//! label beside a track, so a reader can tell at a glance which of two states
8//! the machine is in without acting on anything.
9//!
10//! A toggle is a button. Pressing it does not change a setting; it changes
11//! what the *next* thing you do means, and it looks like a button because you
12//! act on it. Bold in a formatting bar is a toggle: it stays in, but nothing
13//! became bold when it went in. The published roles say the same thing —
14//! `Switch` publishes [`Role::Switch`], a toggle publishes [`Role::Button`]
15//! carrying a checked state — so a reader is never told a button is a setting.
16//!
17//! Both exist because a host that has only one of them has to lie with the
18//! other, and the lie is not cosmetic: a switch nobody applied is a switch
19//! reporting a state the machine is not in.
20//!
21//! # Why this is not a [`SegmentedControl`](crate::controls::segmented::SegmentedControl)
22//!
23//! A segmented control is a radio group in a strip: exactly one segment holds,
24//! always, and there is no keystroke or click that empties it. That is the
25//! right control for a required choice among a few, and [`ToggleGroup`] does
26//! not reimplement it.
27//!
28//! What a segmented control cannot say is *none* and cannot say *several*, and
29//! those are the two cases here, which is why the modes are named
30//! [`ToggleSelection::AtMostOne`] and [`ToggleSelection::Any`] rather than
31//! single and multiple. If the answer is required, reach for a segmented
32//! control.
33
34use std::rc::Rc;
35
36use gpui::{
37    App, FocusHandle, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
38    prelude::FluentBuilder,
39};
40use gpui_kit_assets::Icon;
41use gpui_kit_semantics::{NodeSpec, Role, Semantic};
42use gpui_kit_theme::{ActiveTheme, ControlSize, Space};
43
44use crate::controls::button::{Button, ButtonJoin, ButtonVariant};
45use crate::foundation::direction::{ActiveDirection, DirectionalExt};
46use crate::foundation::{Disableable, Ident, Selectable, Sizable, StyledExt};
47
48/// Reports the state the toggle should take next.
49type PressHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
50
51/// A button that stays in.
52///
53/// Whether it is in belongs to the caller: the toggle draws the state it was
54/// handed and reports the state pressing it asks for. A host that refuses the
55/// press simply does not apply it, and the button keeps showing what is true.
56#[derive(IntoElement)]
57pub struct Toggle {
58    ident: Ident,
59    label: Option<SharedString>,
60    name: Option<SharedString>,
61    glyph: Option<Icon>,
62    icon_only: bool,
63    pressed: bool,
64    disabled: bool,
65    size: ControlSize,
66    variant: ButtonVariant,
67    join: ButtonJoin,
68    semantic_parent: Option<SharedString>,
69    focus_handle: Option<FocusHandle>,
70    on_press: Option<PressHandler>,
71}
72
73impl std::fmt::Debug for Toggle {
74    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        formatter
76            .debug_struct("Toggle")
77            .field("ident", &self.ident)
78            .field("label", &self.label)
79            .field("pressed", &self.pressed)
80            .field("disabled", &self.disabled)
81            .field("has_handler", &self.on_press.is_some())
82            .finish()
83    }
84}
85
86impl Toggle {
87    pub fn new(ident: impl Into<Ident>) -> Self {
88        Self {
89            ident: ident.into(),
90            label: None,
91            name: None,
92            glyph: None,
93            icon_only: false,
94            pressed: false,
95            disabled: false,
96            size: ControlSize::Md,
97            // A toggle that is out should be quiet, because a bar of them is
98            // mostly out; going in is what the eye should catch.
99            variant: ButtonVariant::Ghost,
100            join: ButtonJoin::Alone,
101            semantic_parent: None,
102            focus_handle: None,
103            on_press: None,
104        }
105    }
106
107    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
108        self.label = Some(label.into());
109        self
110    }
111
112    /// What assistive technology and a test call this toggle, when the words
113    /// on it are not what it is called.
114    pub fn accessible_name(mut self, name: impl Into<SharedString>) -> Self {
115        self.name = Some(name.into());
116        self
117    }
118
119    pub fn icon(mut self, glyph: Icon) -> Self {
120        self.glyph = Some(glyph);
121        self
122    }
123
124    /// Draws the toggle as a square carrying only its glyph, and names it.
125    ///
126    /// The name is required for the same reason it is on
127    /// [`IconButton`](crate::controls::button::IconButton): a glyph nobody can
128    /// name is a control nobody can announce or address.
129    pub fn icon_only(mut self, glyph: Icon, name: impl Into<SharedString>) -> Self {
130        self.glyph = Some(glyph);
131        self.label = None;
132        self.icon_only = true;
133        self.name = Some(name.into());
134        self
135    }
136
137    /// Whether the toggle is in. This is the caller's answer, not the
138    /// toggle's.
139    pub fn pressed(mut self, pressed: bool) -> Self {
140        self.pressed = pressed;
141        self
142    }
143
144    pub fn variant(mut self, variant: ButtonVariant) -> Self {
145        self.variant = variant;
146        self
147    }
148
149    pub fn secondary(self) -> Self {
150        self.variant(ButtonVariant::Secondary)
151    }
152
153    pub fn ghost(self) -> Self {
154        self.variant(ButtonVariant::Ghost)
155    }
156
157    /// Where the toggle sits in a joined run of them.
158    pub fn join(mut self, join: ButtonJoin) -> Self {
159        self.join = join;
160        self
161    }
162
163    pub fn semantic_parent(mut self, parent: impl Into<SharedString>) -> Self {
164        self.semantic_parent = Some(parent.into());
165        self
166    }
167
168    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
169        self.focus_handle = Some(handle.clone());
170        self
171    }
172
173    /// Reports the state pressing asks for, which is the opposite of the one
174    /// the toggle was handed.
175    pub fn on_press(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
176        self.on_press = Some(Rc::new(handler));
177        self
178    }
179
180    fn actionable(&self) -> bool {
181        !self.disabled && self.on_press.is_some()
182    }
183}
184
185impl Disableable for Toggle {
186    fn disabled(mut self, disabled: bool) -> Self {
187        self.disabled = disabled;
188        self
189    }
190}
191
192impl Sizable for Toggle {
193    fn control_size(mut self, size: ControlSize) -> Self {
194        self.size = size;
195        self
196    }
197}
198
199impl RenderOnce for Toggle {
200    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
201        let next = !self.pressed;
202        let actionable = self.actionable();
203
204        Button::new(self.ident.clone())
205            .variant(self.variant)
206            .control_size(self.size)
207            .join(self.join)
208            .disabled(self.disabled)
209            // Selected is what paints the button in; checked is what says so
210            // out loud, including when the answer is no.
211            .selected(self.pressed)
212            .checked_state(self.pressed)
213            .when_some(self.label.clone(), |button, label| button.label(label))
214            .when_some(self.name.clone(), |button, name| {
215                button.accessible_name(name)
216            })
217            .when_some(self.glyph, |button, glyph| {
218                match (self.icon_only, self.name.clone()) {
219                    (true, Some(name)) => button.icon_only(glyph, name),
220                    _ => button.icon(glyph),
221                }
222            })
223            .when_some(self.semantic_parent.clone(), |button, parent| {
224                button.semantic_parent(parent)
225            })
226            .when_some(self.focus_handle.as_ref(), |button, handle| {
227                button.track_focus(handle)
228            })
229            .when_some(
230                actionable.then(|| self.on_press.clone()).flatten(),
231                |button, handler| button.on_click(move |window, cx| handler(next, window, cx)),
232            )
233    }
234}
235
236/// How many of a group's toggles may be in at once.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
238pub enum ToggleSelection {
239    /// One or none. Pressing the one that is in reports an empty set, which is
240    /// exactly what a [`SegmentedControl`](crate::controls::segmented::SegmentedControl)
241    /// cannot do.
242    AtMostOne,
243    /// Any number, including none and all.
244    #[default]
245    Any,
246}
247
248/// One toggle in a group, identified by business identity.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct ToggleItem {
251    id: SharedString,
252    label: SharedString,
253    name: Option<SharedString>,
254    icon: Option<Icon>,
255    icon_only: bool,
256    disabled: bool,
257}
258
259impl ToggleItem {
260    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
261        Self {
262            id: id.into(),
263            label: label.into(),
264            name: None,
265            icon: None,
266            icon_only: false,
267            disabled: false,
268        }
269    }
270
271    /// A toggle carrying only a glyph. The name is what it is called.
272    pub fn glyph(id: impl Into<SharedString>, glyph: Icon, name: impl Into<SharedString>) -> Self {
273        let name = name.into();
274        Self {
275            icon: Some(glyph),
276            icon_only: true,
277            name: Some(name.clone()),
278            ..Self::new(id, name)
279        }
280    }
281
282    pub fn icon(mut self, glyph: Icon) -> Self {
283        self.icon = Some(glyph);
284        self
285    }
286
287    /// Refuses this toggle. A refused toggle installs no handler.
288    pub fn disabled(mut self, disabled: bool) -> Self {
289        self.disabled = disabled;
290        self
291    }
292
293    pub fn id(&self) -> &SharedString {
294        &self.id
295    }
296
297    pub fn label(&self) -> &SharedString {
298        &self.label
299    }
300
301    pub fn is_disabled(&self) -> bool {
302        self.disabled
303    }
304}
305
306/// Reports the whole set the group should hold next, and which toggle was
307/// acted on to get there.
308type ChangeHandler = Rc<dyn Fn(Vec<SharedString>, SharedString, &mut Window, &mut App)>;
309
310/// A run of toggles where none, one, or several may be in.
311///
312/// The set is the caller's. The group reports the set pressing one toggle asks
313/// for, and draws whichever set the caller handed it, so a host that applies
314/// only part of the report gets exactly what it applied.
315///
316/// Every toggle is its own tab stop, because every one of them is separately
317/// actionable — unlike a segmented control, where the whole strip is one stop
318/// and the arrows move the single answer around inside it.
319#[derive(IntoElement)]
320pub struct ToggleGroup {
321    ident: Ident,
322    label: Option<SharedString>,
323    items: Vec<ToggleItem>,
324    pressed: Vec<SharedString>,
325    selection: ToggleSelection,
326    size: ControlSize,
327    variant: ButtonVariant,
328    disabled: bool,
329    on_change: Option<ChangeHandler>,
330}
331
332impl std::fmt::Debug for ToggleGroup {
333    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        formatter
335            .debug_struct("ToggleGroup")
336            .field("ident", &self.ident)
337            .field("items", &self.items.len())
338            .field("pressed", &self.pressed)
339            .field("selection", &self.selection)
340            .field("disabled", &self.disabled)
341            .field("has_handler", &self.on_change.is_some())
342            .finish()
343    }
344}
345
346impl ToggleGroup {
347    pub fn new(ident: impl Into<Ident>) -> Self {
348        Self {
349            ident: ident.into(),
350            label: None,
351            items: Vec::new(),
352            pressed: Vec::new(),
353            selection: ToggleSelection::default(),
354            size: ControlSize::Md,
355            variant: ButtonVariant::Secondary,
356            disabled: false,
357            on_change: None,
358        }
359    }
360
361    /// What the whole group is asking, for a reader who has only the tree.
362    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
363        self.label = Some(label.into());
364        self
365    }
366
367    pub fn items(mut self, items: impl IntoIterator<Item = ToggleItem>) -> Self {
368        self.items = items.into_iter().collect();
369        self
370    }
371
372    pub fn selection(mut self, selection: ToggleSelection) -> Self {
373        self.selection = selection;
374        self
375    }
376
377    /// The set that is in. Ids the group does not offer are ignored rather
378    /// than invented into toggles.
379    pub fn pressed(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
380        self.pressed = ids.into_iter().collect();
381        self
382    }
383
384    pub fn pressed_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
385        self.pressed = ids
386            .iter()
387            .map(|id| SharedString::from(id.as_ref().to_string()))
388            .collect();
389        self
390    }
391
392    pub fn variant(mut self, variant: ButtonVariant) -> Self {
393        self.variant = variant;
394        self
395    }
396
397    pub fn on_change(
398        mut self,
399        handler: impl Fn(Vec<SharedString>, SharedString, &mut Window, &mut App) + 'static,
400    ) -> Self {
401        self.on_change = Some(Rc::new(handler));
402        self
403    }
404}
405
406impl Disableable for ToggleGroup {
407    fn disabled(mut self, disabled: bool) -> Self {
408        self.disabled = disabled;
409        self
410    }
411}
412
413impl Sizable for ToggleGroup {
414    fn control_size(mut self, size: ControlSize) -> Self {
415        self.size = size;
416        self
417    }
418}
419
420/// The set pressing `id` asks for.
421///
422/// Order follows the group's own order rather than the order things were
423/// pressed in, so a host that renders the report back gets a stable answer.
424fn next_set(
425    items: &[ToggleItem],
426    pressed: &[SharedString],
427    id: &SharedString,
428    selection: ToggleSelection,
429) -> Vec<SharedString> {
430    let is_in = pressed.contains(id);
431    match (selection, is_in) {
432        (ToggleSelection::AtMostOne, true) => Vec::new(),
433        (ToggleSelection::AtMostOne, false) => vec![id.clone()],
434        (ToggleSelection::Any, _) => items
435            .iter()
436            .map(|item| &item.id)
437            .filter(|other| {
438                if *other == id {
439                    !is_in
440                } else {
441                    pressed.contains(other)
442                }
443            })
444            .cloned()
445            .collect(),
446    }
447}
448
449impl RenderOnce for ToggleGroup {
450    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
451        let theme = cx.theme().clone();
452        let parent = self.ident.semantic_id();
453        let last = self.items.len().saturating_sub(1);
454        let actionable = !self.disabled && self.on_change.is_some();
455
456        let toggles = self
457            .items
458            .iter()
459            .enumerate()
460            .map(|(index, item)| {
461                let join = match (index, last) {
462                    (_, 0) => ButtonJoin::Alone,
463                    (0, _) => ButtonJoin::Leading,
464                    (index, last) if index == last => ButtonJoin::Trailing,
465                    _ => ButtonJoin::Middle,
466                };
467                let refused = self.disabled || item.disabled;
468                let id = item.id.clone();
469                let handler = actionable
470                    .then(|| self.on_change.clone())
471                    .flatten()
472                    .filter(|_| !item.disabled);
473                let next = next_set(&self.items, &self.pressed, &item.id, self.selection);
474
475                Toggle::new(self.ident.child(item.id.as_ref()))
476                    .variant(self.variant)
477                    .control_size(self.size)
478                    .join(join)
479                    .semantic_parent(parent.clone())
480                    .pressed(self.pressed.contains(&item.id))
481                    .disabled(refused)
482                    .when(!item.icon_only, |toggle| toggle.label(item.label.clone()))
483                    .when_some(item.name.clone(), |toggle, name| {
484                        toggle.accessible_name(name)
485                    })
486                    .when_some(item.icon, |toggle, glyph| {
487                        match (item.icon_only, item.name.clone()) {
488                            (true, Some(name)) => toggle.icon_only(glyph, name),
489                            _ => toggle.icon(glyph),
490                        }
491                    })
492                    .when_some(handler, |toggle, handler| {
493                        toggle.on_press(move |_, window, cx| {
494                            handler(next.clone(), id.clone(), window, cx)
495                        })
496                    })
497            })
498            .collect::<Vec<_>>();
499
500        div()
501            .row_reading(cx.layout_direction())
502            .flex_none()
503            .gap_token(&theme, Space::Xs)
504            .children(toggles)
505            .semantic_in(cx, {
506                let mut spec = NodeSpec::new(parent, Role::Toolbar).disabled(self.disabled);
507                if let Some(label) = self.label.clone() {
508                    spec = spec.text(label);
509                }
510                spec
511            })
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn items() -> Vec<ToggleItem> {
520        vec![
521            ToggleItem::new("bold", "Bold"),
522            ToggleItem::new("italic", "Italic"),
523            ToggleItem::new("underline", "Underline").disabled(true),
524        ]
525    }
526
527    fn ids(values: &[&str]) -> Vec<SharedString> {
528        values.iter().map(|id| SharedString::from(*id)).collect()
529    }
530
531    #[test]
532    fn several_may_be_in_at_once() {
533        let items = items();
534        let pressed = ids(&["bold"]);
535        assert_eq!(
536            next_set(&items, &pressed, &"italic".into(), ToggleSelection::Any),
537            ids(&["bold", "italic"])
538        );
539        assert_eq!(
540            next_set(&items, &pressed, &"bold".into(), ToggleSelection::Any),
541            Vec::<SharedString>::new()
542        );
543    }
544
545    #[test]
546    fn the_report_follows_the_groups_own_order() {
547        let items = items();
548        let pressed = ids(&["italic"]);
549        assert_eq!(
550            next_set(&items, &pressed, &"bold".into(), ToggleSelection::Any),
551            ids(&["bold", "italic"]),
552            "the order is the group's, not the order they went in"
553        );
554    }
555
556    #[test]
557    fn at_most_one_can_be_emptied_which_is_the_whole_difference() {
558        let items = items();
559        let pressed = ids(&["bold"]);
560        assert_eq!(
561            next_set(
562                &items,
563                &pressed,
564                &"italic".into(),
565                ToggleSelection::AtMostOne
566            ),
567            ids(&["italic"])
568        );
569        assert_eq!(
570            next_set(&items, &pressed, &"bold".into(), ToggleSelection::AtMostOne),
571            Vec::<SharedString>::new(),
572            "a radio group has no move that gets here"
573        );
574    }
575}