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//! 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    /// The face of a select: current value plus a chevron, shaped and toned like
204    /// [`crate::input::TextField`] so a form of fields and selects reads as one
205    /// system. One look, open or shut — the menu hanging under it is what says
206    /// which it is.
207    ///
208    /// There is no `Select` component, deliberately — a select IS this trigger
209    /// plus [`crate::popover::anchored_menu_below`] over
210    /// [`crate::popover::menu_row`]s, and the caller already owns the open
211    /// state and the selection. Wrapping that in a struct would buy an
212    /// abstraction and cost the caller its control over both.
213    fn select_trigger(&self, label: impl Into<SharedString>) -> Div {
214        let theme = self.theme();
215        stack::row()
216            .justify_between()
217            .px(px(10.0))
218            .py(px(7.0))
219            .rounded(px(Theme::button_radius()))
220            .bg(theme.input_bg)
221            .border_1()
222            .border_color(theme.border)
223            .text_style(TextStyle::Body)
224            .text_color(theme.text)
225            .cursor_pointer()
226            .child(div().min_w_0().truncate().child(label.into()))
227            .child(
228                crate::icons::icon(crate::icons::glyph::ChevronDown)
229                    .size(px(14.0))
230                    .text_color(theme.text_muted),
231            )
232    }
233
234    /// Segmented control: one pill holding mutually exclusive choices, for when
235    /// there are few enough that a [`Self::select_trigger`] would be overkill.
236    ///
237    /// `self_start` because a segmented control must hug its segments: dropped
238    /// into a `flex_col`, flexbox's default `align-items: stretch` would
239    /// otherwise blow it out to the column's full width.
240    fn toggle_group(&self) -> Div {
241        let theme = self.theme();
242        div()
243            .self_start()
244            .flex()
245            .flex_row()
246            .items_center()
247            .gap(px(TOGGLE_GROUP_PAD))
248            .p(px(TOGGLE_GROUP_PAD))
249            .rounded(px(TOGGLE_GROUP_RADIUS))
250            .bg(theme.ink(0.06))
251            .border_1()
252            .border_color(theme.border)
253    }
254
255    /// One segment. The selected segment carries the active wash over the
256    /// track's own — the two alphas stack, which is what makes it read — and
257    /// the rest are bare, so exactly one is pressed.
258    fn toggle_group_item(&self, label: impl Into<SharedString>, selected: bool) -> Div {
259        segment(self.theme(), selected)
260            .px(px(10.0))
261            .py(px(4.0))
262            .child(label.into())
263    }
264
265    /// A [`Self::toggle_group`] of labelled segments, wired: a click on one
266    /// reports its index to `on_pick`, the selected one included. Nothing is
267    /// selected when `selected` is out of range.
268    ///
269    /// ```ignore
270    /// theme.segmented("search-engine", ["Google", "DuckDuckGo"], selected,
271    ///     cx.listener(|view, ix: &usize, _, cx| view.pick_engine(*ix, cx)))
272    /// ```
273    fn segmented<Label: Into<SharedString>>(
274        &self,
275        id: impl Into<ElementId>,
276        segments: impl IntoIterator<Item = Label>,
277        selected: usize,
278        on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
279    ) -> Stateful<Div> {
280        let on_pick = Rc::new(on_pick);
281        self.toggle_group()
282            .id(id)
283            .children(segments.into_iter().enumerate().map(|(ix, label)| {
284                let on_pick = on_pick.clone();
285                self.toggle_group_item(label, ix == selected)
286                    .id(ix)
287                    .on_click(move |_, window, cx| on_pick(&ix, window, cx))
288            }))
289    }
290
291    /// A segment carrying a glyph rather than a word, for a control with no
292    /// room for one — a view switcher on a board, a density picker in a
293    /// toolbar. It comes out 24×24, the height a labelled segment takes.
294    ///
295    /// The tooltip is the caller's, and a glyph nobody recognises says nothing
296    /// without one.
297    fn toggle_group_icon(&self, icon: impl Into<Icon>, selected: bool) -> Div {
298        let theme = self.theme();
299        // Built here rather than taken, the way `control_bar::bar_button` does
300        // it: gpui reads an svg's colour off that element's own style, so a
301        // colour set on the segment would not reach the glyph.
302        segment(theme, selected)
303            .p(px(5.0))
304            .flex()
305            .items_center()
306            .justify_center()
307            .child(
308                crate::icons::icon(icon)
309                    .size(px(14.0))
310                    .text_color(match selected {
311                        true => theme.text,
312                        false => theme.text_muted,
313                    }),
314            )
315    }
316}
317
318/// What a segment looks like in each of its two states, before whatever it
319/// carries. Padding is the caller's: a word and a glyph reach the same height
320/// by different insets.
321///
322/// An unselected segment takes its own `hover`, and gpui panics on a second
323/// one — reach for a `group_hover` rather than chaining `.hover(..)` on.
324fn segment(theme: &Theme, selected: bool) -> Div {
325    let wash = theme.element_hover;
326    let item = div()
327        // Concentric with the track: 9 - 2 = 7.
328        .rounded(px(Theme::inset_radius(
329            TOGGLE_GROUP_RADIUS,
330            TOGGLE_GROUP_PAD,
331        )))
332        .border_1()
333        .border_color(crate::widgets::RING_SLOT)
334        .text_style(TextStyle::Callout)
335        .cursor_pointer();
336    if selected {
337        item.bg(theme.element_active)
338            .font_weight(gpui::FontWeight::MEDIUM)
339            .text_color(theme.text)
340    } else {
341        item.text_color(theme.text_muted)
342            .hover(move |el| el.bg(wash))
343    }
344}
345
346impl Controls for Theme {}
347
348/// The segmented track's radius, and the inset its segments come in by. Two
349/// numbers read from both [`Controls::toggle_group`] and
350/// [`Controls::toggle_group_item`], so a segment cannot stop being concentric
351/// with the track it sits in.
352const TOGGLE_GROUP_RADIUS: f32 = 9.0;
353const TOGGLE_GROUP_PAD: f32 = 2.0;