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