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