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