Skip to main content

ui/widgets/
controls.rs

1//! Display-only controls — toggle, checkbox, radio, progress, slider, color
2//! well, select face, segmented control. State is always the caller's; each
3//! control is the paint plus its gesture contract, and the caller adds
4//! `.id(..)` / handlers — but for [`Controls::segmented`], which takes them.
5//!
6//! A catalog trait, like every widget group: import it to unlock
7//! `theme.toggle(..)`, `theme.slider(..)`, `theme.toggle_group()`,
8//! `theme.segmented(..)`, `theme.picture_picker(..)`.
9
10use crate::stack;
11use std::rc::Rc;
12
13use gpui::{
14    AnyElement, App, Axis, BoxShadow, Div, DragMoveEvent, ElementId, SharedString, Stateful,
15    Window, div, point, prelude::*, px,
16};
17use icons::Icon;
18use theme::{TextStyle, Theme, ThemeExt, Typeset};
19
20/// The drag payload of a [`Controls::slider`], carrying the id of the slider
21/// the gesture started on.
22///
23/// The id is what keeps sliders apart: gpui delivers a drag move to *every*
24/// listener of the payload's type, not just the element under the pointer, so
25/// a page of five sliders would move all five at once.
26pub struct SliderDrag(pub ElementId);
27
28/// Where a slider drag lands on the track it is asked about, or `None` when the
29/// gesture belongs to another slider. `id` is the one that element carries.
30pub fn slider_fraction(
31    event: &DragMoveEvent<SliderDrag>,
32    id: impl Into<ElementId>,
33    cx: &App,
34) -> Option<f32> {
35    (event.drag(cx).0 == id.into()).then(|| {
36        crate::widgets::axis_fraction(event.event.position, event.bounds, Axis::Horizontal, 0.0)
37    })
38}
39
40pub trait Controls: ThemeExt {
41    /// Display-only toggle switch (the reference branch-picker.tsx `Toggle`):
42    /// an 18×32 pill whose knob slides right and track flips white when on.
43    /// State is owned by the parent row — the caller adds `.id(..)` and
44    /// `.on_click(..)`.
45    fn toggle(&self, on: bool) -> Div {
46        let theme = self.theme();
47        div()
48            .flex_none()
49            .w(px(32.0))
50            .h(px(18.0))
51            .rounded_full()
52            .bg(if on { theme.text } else { theme.ink(0.15) })
53            .border_1()
54            .border_color(crate::widgets::RING_SLOT)
55            .relative()
56            .child(
57                // One less than the 2px inset it looks like: absolute insets
58                // resolve against the padding box, which the ring slot has
59                // already moved in by a pixel.
60                div()
61                    .absolute()
62                    .top(px(1.0))
63                    .left(px(if on { 15.0 } else { 1.0 }))
64                    .size(px(14.0))
65                    .rounded_full()
66                    .bg(if on { theme.on_solid } else { theme.ink(0.7) }),
67            )
68    }
69
70    /// Display-only checkbox: a 16px rounded square that fills with the text
71    /// tone and shows a check when on. State is the caller's; add
72    /// `.id(..)`/`.on_click(..)`.
73    fn checkbox(&self, checked: bool) -> Div {
74        let theme = self.theme();
75        let mut box_ = div()
76            .flex_none()
77            .size(px(16.0))
78            .rounded(px(4.0))
79            .flex()
80            .items_center()
81            .justify_center();
82        box_ = if checked {
83            box_.border_1()
84                .border_color(crate::widgets::RING_SLOT)
85                .bg(theme.solid)
86        } else {
87            box_.border_1()
88                .border_color(theme.ink(0.25))
89                .bg(theme.ink(0.03))
90        };
91        if checked {
92            box_.child(
93                crate::icons::icon(crate::icons::glyph::Check)
94                    .size(px(11.0))
95                    .text_color(theme.on_solid),
96            )
97        } else {
98            box_
99        }
100    }
101
102    /// Display-only radio button: a 16px ring with an inner dot when selected.
103    /// Radios are a *set* — the caller owns which index is on.
104    fn radio_button(&self, selected: bool) -> Div {
105        let theme = self.theme();
106        div()
107            .flex_none()
108            .size(px(16.0))
109            .rounded_full()
110            .border_1()
111            .border_color(if selected {
112                theme.ring
113            } else {
114                theme.ink(0.25)
115            })
116            .bg(theme.ink(0.03))
117            .flex()
118            .items_center()
119            .justify_center()
120            .when(selected, |ring| {
121                ring.child(div().size(px(8.0)).rounded_full().bg(theme.solid))
122            })
123    }
124
125    /// Determinate progress bar. `fraction` is clamped to `0..=1`; the track
126    /// keeps its full width so the row never reflows as the value moves.
127    fn progress_bar(&self, fraction: f32) -> Div {
128        let theme = self.theme();
129        let fraction = fraction.clamp(0.0, 1.0);
130        div()
131            .w_full()
132            .h(px(4.0))
133            .rounded_full()
134            .bg(theme.ink(0.12))
135            .child(
136                div()
137                    .h_full()
138                    .w(gpui::relative(fraction))
139                    .rounded_full()
140                    .bg(theme.solid),
141            )
142    }
143
144    /// Display-only slider: filled track behind a knob at `fraction` (clamped
145    /// to `0..=1`). Dragging is the caller's — it owns the value and the
146    /// mouse handlers; this is the paint.
147    ///
148    /// The element *is* the drag source, so the gesture is
149    /// grab-anywhere-and-slide, and [`slider_fraction`] turns the pointer into
150    /// the value — passing the element's own id, because every slider hears
151    /// every slider's drag:
152    ///
153    /// ```ignore
154    /// focus::focusable(&theme, &self.slider, theme.slider(self.level))
155    ///     .id("slider")
156    ///     .on_drag(SliderDrag("slider".into()), |_, _, _, cx| cx.new(|_| gpui::Empty))
157    ///     .on_drag_move(cx.listener(|view, event: &DragMoveEvent<SliderDrag>, _, cx| {
158    ///         let Some(fraction) = widgets::slider_fraction(event, "slider", cx) else {
159    ///             return;
160    ///         };
161    ///         view.level = fraction;
162    ///         cx.notify();
163    ///     }))
164    /// ```
165    fn slider(&self, fraction: f32) -> Div {
166        let theme = self.theme();
167        let fraction = fraction.clamp(0.0, 1.0);
168        div()
169            .w_full()
170            .h(px(16.0))
171            .border_1()
172            .border_color(crate::widgets::RING_SLOT)
173            .rounded(px(4.0))
174            .flex()
175            .items_center()
176            .relative()
177            .cursor_pointer()
178            .child(
179                div()
180                    .w_full()
181                    .h(px(4.0))
182                    .rounded_full()
183                    .bg(theme.ink(0.12))
184                    .child(
185                        div()
186                            .h_full()
187                            .w(gpui::relative(fraction))
188                            .rounded_full()
189                            .bg(theme.solid),
190                    ),
191            )
192            .child(
193                // Inset by the knob's own width so it never overhangs the track.
194                div().absolute().left(gpui::relative(fraction)).child(
195                    div()
196                        .size(px(14.0))
197                        .ml(px(-7.0))
198                        .rounded_full()
199                        .bg(theme.solid),
200                ),
201            )
202    }
203
204    /// Display-only color well: a 20px ring holding `color`. The caller adds
205    /// `.id(..)` and `.on_click(..)`.
206    fn color_well(&self, color: gpui::Hsla) -> Div {
207        well(self.theme().ink(0.25), color)
208    }
209
210    /// Preset colors, wired: a click on one reports its index to `on_pick`.
211    /// The ring marks `selected`; none is marked when it is out of range or
212    /// `None`. One row when `columns` is `None`; otherwise rows of `columns`.
213    ///
214    /// ```ignore
215    /// let swatches = cx.color_swatches();
216    /// theme.swatch_picker("tint", &swatches, self.tint, Some(6),
217    ///     cx.listener(|view, ix: &usize, _, cx| view.pick_tint(*ix, cx)))
218    /// ```
219    fn swatch_picker(
220        &self,
221        id: impl Into<ElementId>,
222        swatches: &[crate::color::Swatch],
223        selected: Option<usize>,
224        columns: Option<usize>,
225        on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
226    ) -> Stateful<Div> {
227        let theme = self.theme();
228        let on_pick = Rc::new(on_pick);
229        let wells: Vec<_> = swatches
230            .iter()
231            .enumerate()
232            .map(|(ix, swatch)| {
233                let on_pick = on_pick.clone();
234                let ring = match selected == Some(ix) {
235                    true => theme.text,
236                    false => crate::widgets::RING_SLOT,
237                };
238                well(ring, swatch.resolve(theme))
239                    .id(ix)
240                    .on_click(move |_, window, cx| on_pick(&ix, window, cx))
241            })
242            .collect();
243        let width = columns.filter(|n| *n > 0).unwrap_or(wells.len().max(1));
244        let mut wells = wells.into_iter();
245        let rows = std::iter::from_fn(|| {
246            let row: Vec<_> = wells.by_ref().take(width).collect();
247            (!row.is_empty()).then(|| stack::row().gap(px(4.0)).children(row))
248        });
249        stack::column().id(id).gap(px(4.0)).children(rows)
250    }
251
252    /// The face of a select: current value plus a chevron, shaped and toned like
253    /// [`crate::input::TextField`] so a form of fields and selects reads as one
254    /// system. One look, open or shut — the menu hanging under it is what says
255    /// which it is.
256    ///
257    /// There is no `Select` component, deliberately — a select IS this trigger
258    /// plus [`crate::popover::anchored_menu_below`] over
259    /// [`crate::popover::menu_row`]s, and the caller already owns the open
260    /// state and the selection. Wrapping that in a struct would buy an
261    /// abstraction and cost the caller its control over both.
262    fn select_trigger(&self, label: impl Into<SharedString>) -> Div {
263        self.select_trigger_with(None::<Div>, label)
264    }
265
266    /// [`Self::select_trigger`] with `leading` drawn before the label — a
267    /// swatch or a sample of the value.
268    fn select_trigger_with(
269        &self,
270        leading: Option<impl IntoElement>,
271        label: impl Into<SharedString>,
272    ) -> Div {
273        let theme = self.theme();
274        stack::row()
275            .justify_between()
276            .px(px(10.0))
277            .py(px(7.0))
278            .rounded(px(Theme::button_radius()))
279            .bg(theme.input_bg)
280            .border_1()
281            .border_color(theme.border)
282            .text_style(TextStyle::Body)
283            .text_color(theme.text)
284            .cursor_pointer()
285            .child(
286                stack::row()
287                    .min_w_0()
288                    .gap(px(8.0))
289                    .children(leading.map(|leading| div().flex_none().child(leading)))
290                    .child(div().min_w_0().truncate().child(label.into())),
291            )
292            .child(
293                crate::icons::icon(crate::icons::glyph::ChevronDown)
294                    .size(px(14.0))
295                    .text_color(theme.text_muted),
296            )
297    }
298
299    /// Segmented control: one pill holding mutually exclusive choices, for when
300    /// there are few enough that a [`Self::select_trigger`] would be overkill.
301    ///
302    /// `self_start` because a segmented control must hug its segments: dropped
303    /// into a `flex_col`, flexbox's default `align-items: stretch` would
304    /// otherwise blow it out to the column's full width.
305    fn toggle_group(&self) -> Div {
306        let theme = self.theme();
307        div()
308            .self_start()
309            .flex()
310            .flex_row()
311            .items_center()
312            .gap(px(TOGGLE_GROUP_PAD))
313            .p(px(TOGGLE_GROUP_PAD))
314            .rounded(px(TOGGLE_GROUP_RADIUS))
315            .bg(theme.ink(0.06))
316            .border_1()
317            .border_color(theme.border)
318    }
319
320    /// One segment. The selected segment carries the active wash over the
321    /// track's own — the two alphas stack, which is what makes it read — and
322    /// the rest are bare, so exactly one is pressed.
323    fn toggle_group_item(&self, label: impl Into<SharedString>, selected: bool) -> Div {
324        segment(self.theme(), selected)
325            .px(px(10.0))
326            .py(px(4.0))
327            .child(label.into())
328    }
329
330    /// A [`Self::toggle_group`] of labelled segments, wired: a click on one
331    /// reports its index to `on_pick`, the selected one included. Nothing is
332    /// selected when `selected` is out of range.
333    ///
334    /// ```ignore
335    /// theme.segmented("search-engine", ["Google", "DuckDuckGo"], selected,
336    ///     cx.listener(|view, ix: &usize, _, cx| view.pick_engine(*ix, cx)))
337    /// ```
338    fn segmented<Label: Into<SharedString>>(
339        &self,
340        id: impl Into<ElementId>,
341        segments: impl IntoIterator<Item = Label>,
342        selected: usize,
343        on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
344    ) -> Stateful<Div> {
345        let on_pick = Rc::new(on_pick);
346        self.toggle_group()
347            .id(id)
348            .children(segments.into_iter().enumerate().map(|(ix, label)| {
349                let on_pick = on_pick.clone();
350                self.toggle_group_item(label, ix == selected)
351                    .id(ix)
352                    .on_click(move |_, window, cx| on_pick(&ix, window, cx))
353            }))
354    }
355
356    /// A segment carrying a glyph rather than a word, for a control with no
357    /// room for one — a view switcher on a board, a density picker in a
358    /// toolbar. It comes out 24×24, the height a labelled segment takes.
359    ///
360    /// The tooltip is the caller's, and a glyph nobody recognises says nothing
361    /// without one.
362    fn toggle_group_icon(&self, icon: impl Into<Icon>, selected: bool) -> Div {
363        let theme = self.theme();
364        // Built here rather than taken, the way `control_bar::bar_button` does
365        // it: gpui reads an svg's colour off that element's own style, so a
366        // colour set on the segment would not reach the glyph.
367        segment(theme, selected)
368            .p(px(5.0))
369            .flex()
370            .items_center()
371            .justify_center()
372            .child(
373                crate::icons::icon(icon)
374                    .size(px(14.0))
375                    .text_color(match selected {
376                        true => theme.text,
377                        false => theme.text_muted,
378                    }),
379            )
380    }
381
382    /// A row of tiles, each a picture over its label — macOS's appearance
383    /// picker. The `selected` tile is ringed in [`Theme::accent`]; a click on
384    /// any reports its index to `on_pick`. Nothing is selected when `selected`
385    /// is out of range.
386    ///
387    /// Each picture is the caller's, at the size it draws itself; the tile
388    /// clips it to its rounded frame.
389    fn picture_picker(
390        &self,
391        id: impl Into<ElementId>,
392        tiles: impl IntoIterator<Item = (AnyElement, SharedString)>,
393        selected: usize,
394        on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
395    ) -> Stateful<Div> {
396        let theme = self.theme();
397        let on_pick = Rc::new(on_pick);
398        let radius = px(Theme::panel_radius());
399        div()
400            .id(id)
401            .flex()
402            .flex_row()
403            .gap(px(12.0))
404            .children(tiles.into_iter().enumerate().map(|(ix, (picture, label))| {
405                let on = ix == selected;
406                let on_pick = on_pick.clone();
407                let hover = theme.border_strong;
408                // The ring is a shadow, so choosing a tile moves nothing.
409                let ring = BoxShadow {
410                    color: theme.accent,
411                    offset: point(px(0.0), px(0.0)),
412                    blur_radius: px(0.0),
413                    spread_radius: px(PICTURE_RING),
414                    inset: false,
415                };
416                div()
417                    .id(ix)
418                    .group("picture-tile")
419                    .flex()
420                    .flex_col()
421                    .items_center()
422                    .gap(px(6.0))
423                    .cursor_pointer()
424                    .on_click(move |_, window, cx| on_pick(&ix, window, cx))
425                    .child(
426                        div()
427                            .rounded(radius)
428                            .border_1()
429                            .border_color(theme.border)
430                            .overflow_hidden()
431                            .when(on, |frame| frame.shadow(vec![ring]))
432                            .when(!on, |frame| {
433                                frame.group_hover("picture-tile", move |frame| {
434                                    frame.border_color(hover)
435                                })
436                            })
437                            .child(picture),
438                    )
439                    .child(
440                        div()
441                            .text_style(TextStyle::Callout)
442                            .text_color(match on {
443                                true => theme.text,
444                                false => theme.text_muted,
445                            })
446                            .when(on, |label| label.font_weight(gpui::FontWeight::MEDIUM))
447                            .child(label),
448                    )
449            }))
450    }
451}
452
453/// How far a [`Controls::picture_picker`] tile's selection ring stands
454/// outside its frame.
455const PICTURE_RING: f32 = 2.0;
456
457/// A 20px ring in `ring` around a disc of `color`.
458fn well(ring: gpui::Hsla, color: gpui::Hsla) -> Div {
459    div()
460        .flex_none()
461        .size(px(20.0))
462        .p(px(2.0))
463        .rounded_full()
464        .border_1()
465        .border_color(ring)
466        .cursor_pointer()
467        .child(div().size_full().rounded_full().bg(color))
468}
469
470/// What a segment looks like in each of its two states, before whatever it
471/// carries. Padding is the caller's: a word and a glyph reach the same height
472/// by different insets.
473///
474/// An unselected segment takes its own `hover`, and gpui panics on a second
475/// one — reach for a `group_hover` rather than chaining `.hover(..)` on.
476fn segment(theme: &Theme, selected: bool) -> Div {
477    let wash = theme.element_hover;
478    let item = div()
479        // Concentric with the track: 9 - 2 = 7.
480        .rounded(px(Theme::inset_radius(
481            TOGGLE_GROUP_RADIUS,
482            TOGGLE_GROUP_PAD,
483        )))
484        .border_1()
485        .border_color(crate::widgets::RING_SLOT)
486        .text_style(TextStyle::Callout)
487        .cursor_pointer();
488    if selected {
489        item.bg(theme.element_active)
490            .font_weight(gpui::FontWeight::MEDIUM)
491            .text_color(theme.text)
492    } else {
493        item.text_color(theme.text_muted)
494            .hover(move |el| el.bg(wash))
495    }
496}
497
498impl Controls for Theme {}
499
500/// The segmented track's radius, and the inset its segments come in by. Two
501/// numbers read from both [`Controls::toggle_group`] and
502/// [`Controls::toggle_group_item`], so a segment cannot stop being concentric
503/// with the track it sits in.
504const TOGGLE_GROUP_RADIUS: f32 = 9.0;
505const TOGGLE_GROUP_PAD: f32 = 2.0;