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