Skip to main content

ui/widgets/
controls.rs

1//! Display-only controls — toggle, checkbox, radio, progress, slider, select
2//! face, segmented control. State is always the caller's; each control is the
3//! paint plus its gesture contract, and the caller adds `.id(..)` / handlers.
4//!
5//! A catalog trait, like every widget group: import it to unlock
6//! `theme.toggle(..)`, `theme.slider(..)`, `theme.toggle_group()`.
7
8use crate::stack;
9use gpui::{App, Axis, Div, DragMoveEvent, ElementId, SharedString, div, prelude::*, px};
10use icons::Icon;
11use theme::{TextStyle, Theme, ThemeExt, Typeset};
12
13/// The drag payload of a [`Controls::slider`], carrying the id of the slider
14/// the gesture started on.
15///
16/// The id is what keeps sliders apart: gpui delivers a drag move to *every*
17/// listener of the payload's type, not just the element under the pointer, so
18/// a page of five sliders would move all five at once.
19pub struct SliderDrag(pub ElementId);
20
21/// Where a slider drag lands on the track it is asked about, or `None` when the
22/// gesture belongs to another slider. `id` is the one that element carries.
23pub fn slider_fraction(
24    event: &DragMoveEvent<SliderDrag>,
25    id: impl Into<ElementId>,
26    cx: &App,
27) -> Option<f32> {
28    (event.drag(cx).0 == id.into()).then(|| {
29        crate::widgets::axis_fraction(event.event.position, event.bounds, Axis::Horizontal, 0.0)
30    })
31}
32
33pub trait Controls: ThemeExt {
34    /// Display-only toggle switch (the reference branch-picker.tsx `Toggle`):
35    /// an 18×32 pill whose knob slides right and track flips white when on.
36    /// State is owned by the parent row — the caller adds `.id(..)` and
37    /// `.on_click(..)`.
38    fn toggle(&self, on: bool) -> Div {
39        let theme = self.theme();
40        div()
41            .flex_none()
42            .w(px(32.0))
43            .h(px(18.0))
44            .rounded_full()
45            .bg(if on { theme.text } else { theme.ink(0.15) })
46            .border_1()
47            .border_color(crate::widgets::RING_SLOT)
48            .relative()
49            .child(
50                // One less than the 2px inset it looks like: absolute insets
51                // resolve against the padding box, which the ring slot has
52                // already moved in by a pixel.
53                div()
54                    .absolute()
55                    .top(px(1.0))
56                    .left(px(if on { 15.0 } else { 1.0 }))
57                    .size(px(14.0))
58                    .rounded_full()
59                    .bg(if on { theme.on_solid } else { theme.ink(0.7) }),
60            )
61    }
62
63    /// Display-only checkbox: a 16px rounded square that fills with the text
64    /// tone and shows a check when on. State is the caller's; add
65    /// `.id(..)`/`.on_click(..)`.
66    fn checkbox(&self, checked: bool) -> Div {
67        let theme = self.theme();
68        let mut box_ = div()
69            .flex_none()
70            .size(px(16.0))
71            .rounded(px(4.0))
72            .flex()
73            .items_center()
74            .justify_center();
75        box_ = if checked {
76            box_.border_1()
77                .border_color(crate::widgets::RING_SLOT)
78                .bg(theme.text)
79        } else {
80            box_.border_1()
81                .border_color(theme.ink(0.25))
82                .bg(theme.ink(0.03))
83        };
84        if checked {
85            box_.child(
86                crate::icons::icon(crate::icons::glyph::Check)
87                    .size(px(11.0))
88                    .text_color(theme.on_solid),
89            )
90        } else {
91            box_
92        }
93    }
94
95    /// Display-only radio button: a 16px ring with an inner dot when selected.
96    /// Radios are a *set* — the caller owns which index is on.
97    fn radio_button(&self, selected: bool) -> Div {
98        let theme = self.theme();
99        div()
100            .flex_none()
101            .size(px(16.0))
102            .rounded_full()
103            .border_1()
104            .border_color(if selected {
105                theme.text
106            } else {
107                theme.ink(0.25)
108            })
109            .bg(theme.ink(0.03))
110            .flex()
111            .items_center()
112            .justify_center()
113            .when(selected, |ring| {
114                ring.child(div().size(px(8.0)).rounded_full().bg(theme.text))
115            })
116    }
117
118    /// Determinate progress bar. `fraction` is clamped to `0..=1`; the track
119    /// keeps its full width so the row never reflows as the value moves.
120    fn progress_bar(&self, fraction: f32) -> Div {
121        let theme = self.theme();
122        let fraction = fraction.clamp(0.0, 1.0);
123        div()
124            .w_full()
125            .h(px(4.0))
126            .rounded_full()
127            .bg(theme.ink(0.12))
128            .child(
129                div()
130                    .h_full()
131                    .w(gpui::relative(fraction))
132                    .rounded_full()
133                    .bg(theme.text),
134            )
135    }
136
137    /// Display-only slider: filled track behind a knob at `fraction` (clamped
138    /// to `0..=1`). Dragging is the caller's — it owns the value and the
139    /// mouse handlers; this is the paint.
140    ///
141    /// The element *is* the drag source, so the gesture is
142    /// grab-anywhere-and-slide, and [`slider_fraction`] turns the pointer into
143    /// the value — passing the element's own id, because every slider hears
144    /// every slider's drag:
145    ///
146    /// ```ignore
147    /// focus::focusable(&theme, &self.slider, theme.slider(self.level))
148    ///     .id("slider")
149    ///     .on_drag(SliderDrag("slider".into()), |_, _, _, cx| cx.new(|_| gpui::Empty))
150    ///     .on_drag_move(cx.listener(|view, event: &DragMoveEvent<SliderDrag>, _, cx| {
151    ///         let Some(fraction) = widgets::slider_fraction(event, "slider", cx) else {
152    ///             return;
153    ///         };
154    ///         view.level = fraction;
155    ///         cx.notify();
156    ///     }))
157    /// ```
158    fn slider(&self, fraction: f32) -> Div {
159        let theme = self.theme();
160        let fraction = fraction.clamp(0.0, 1.0);
161        div()
162            .w_full()
163            .h(px(16.0))
164            .border_1()
165            .border_color(crate::widgets::RING_SLOT)
166            .rounded(px(4.0))
167            .flex()
168            .items_center()
169            .relative()
170            .cursor_pointer()
171            .child(
172                div()
173                    .w_full()
174                    .h(px(4.0))
175                    .rounded_full()
176                    .bg(theme.ink(0.12))
177                    .child(
178                        div()
179                            .h_full()
180                            .w(gpui::relative(fraction))
181                            .rounded_full()
182                            .bg(theme.text),
183                    ),
184            )
185            .child(
186                // Inset by the knob's own width so it never overhangs the track.
187                div().absolute().left(gpui::relative(fraction)).child(
188                    div()
189                        .size(px(14.0))
190                        .ml(px(-7.0))
191                        .rounded_full()
192                        .bg(theme.text),
193                ),
194            )
195    }
196
197    /// The face of a select: current value plus a chevron, shaped and toned like
198    /// [`crate::input::TextField`] so a form of fields and selects reads as one
199    /// system. One look, open or shut — the menu hanging under it is what says
200    /// which it is.
201    ///
202    /// There is no `Select` component, deliberately — a select IS this trigger
203    /// plus [`crate::popover::anchored_menu_below`] over
204    /// [`crate::popover::menu_row`]s, and the caller already owns the open
205    /// state and the selection. Wrapping that in a struct would buy an
206    /// abstraction and cost the caller its control over both.
207    fn select_trigger(&self, label: impl Into<SharedString>) -> Div {
208        let theme = self.theme();
209        stack::row()
210            .justify_between()
211            .px(px(10.0))
212            .py(px(7.0))
213            .rounded(px(Theme::button_radius()))
214            .bg(theme.input_bg)
215            .border_1()
216            .border_color(theme.border)
217            .text_style(TextStyle::Body)
218            .text_color(theme.text)
219            .cursor_pointer()
220            .child(div().min_w_0().truncate().child(label.into()))
221            .child(
222                crate::icons::icon(crate::icons::glyph::ChevronDown)
223                    .size(px(14.0))
224                    .text_color(theme.text_muted),
225            )
226    }
227
228    /// Segmented control: one pill holding mutually exclusive choices, for when
229    /// there are few enough that a [`Self::select_trigger`] would be overkill.
230    ///
231    /// `self_start` because a segmented control must hug its segments: dropped
232    /// into a `flex_col`, flexbox's default `align-items: stretch` would
233    /// otherwise blow it out to the column's full width.
234    fn toggle_group(&self) -> Div {
235        let theme = self.theme();
236        div()
237            .self_start()
238            .flex()
239            .flex_row()
240            .items_center()
241            .gap(px(TOGGLE_GROUP_PAD))
242            .p(px(TOGGLE_GROUP_PAD))
243            .rounded(px(TOGGLE_GROUP_RADIUS))
244            .bg(theme.ink(0.06))
245            .border_1()
246            .border_color(theme.border)
247    }
248
249    /// One segment. The selected segment carries the active wash over the
250    /// track's own — the two alphas stack, which is what makes it read — and
251    /// the rest are bare, so exactly one is pressed.
252    fn toggle_group_item(&self, label: impl Into<SharedString>, selected: bool) -> Div {
253        segment(self.theme(), selected)
254            .px(px(10.0))
255            .py(px(4.0))
256            .child(label.into())
257    }
258
259    /// A segment carrying a glyph rather than a word, for a control with no
260    /// room for one — a view switcher on a board, a density picker in a
261    /// toolbar. It comes out 24×24, the height a labelled segment takes.
262    ///
263    /// The tooltip is the caller's, and a glyph nobody recognises says nothing
264    /// without one.
265    fn toggle_group_icon(&self, icon: impl Into<Icon>, selected: bool) -> Div {
266        let theme = self.theme();
267        // Built here rather than taken, the way `control_bar::bar_button` does
268        // it: gpui reads an svg's colour off that element's own style, so a
269        // colour set on the segment would not reach the glyph.
270        segment(theme, selected)
271            .p(px(5.0))
272            .flex()
273            .items_center()
274            .justify_center()
275            .child(
276                crate::icons::icon(icon)
277                    .size(px(14.0))
278                    .text_color(match selected {
279                        true => theme.text,
280                        false => theme.text_muted,
281                    }),
282            )
283    }
284}
285
286/// What a segment looks like in each of its two states, before whatever it
287/// carries. Padding is the caller's: a word and a glyph reach the same height
288/// by different insets.
289///
290/// An unselected segment takes its own `hover`, and gpui panics on a second
291/// one — reach for a `group_hover` rather than chaining `.hover(..)` on.
292fn segment(theme: &Theme, selected: bool) -> Div {
293    let wash = theme.element_hover;
294    let item = div()
295        // Concentric with the track: 9 - 2 = 7.
296        .rounded(px(Theme::inset_radius(
297            TOGGLE_GROUP_RADIUS,
298            TOGGLE_GROUP_PAD,
299        )))
300        .border_1()
301        .border_color(crate::widgets::RING_SLOT)
302        .text_style(TextStyle::Callout)
303        .cursor_pointer();
304    if selected {
305        item.bg(theme.element_active)
306            .font_weight(gpui::FontWeight::MEDIUM)
307            .text_color(theme.text)
308    } else {
309        item.text_color(theme.text_muted)
310            .hover(move |el| el.bg(wash))
311    }
312}
313
314impl Controls for Theme {}
315
316/// The segmented track's radius, and the inset its segments come in by. Two
317/// numbers read from both [`Controls::toggle_group`] and
318/// [`Controls::toggle_group_item`], so a segment cannot stop being concentric
319/// with the track it sits in.
320const TOGGLE_GROUP_RADIUS: f32 = 9.0;
321const TOGGLE_GROUP_PAD: f32 = 2.0;