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, ink};
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 { 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 { 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().border_color(ink(0.25)).bg(ink(0.03))
80 };
81 if checked {
82 box_.child(
83 crate::icons::icon(crate::icons::glyph::Check)
84 .size(px(11.0))
85 .text_color(theme.on_solid),
86 )
87 } else {
88 box_
89 }
90 }
91
92 /// Display-only radio button: a 16px ring with an inner dot when selected.
93 /// Radios are a *set* — the caller owns which index is on.
94 fn radio_button(&self, selected: bool) -> Div {
95 let theme = self.theme();
96 div()
97 .flex_none()
98 .size(px(16.0))
99 .rounded_full()
100 .border_1()
101 .border_color(if selected { theme.text } else { ink(0.25) })
102 .bg(ink(0.03))
103 .flex()
104 .items_center()
105 .justify_center()
106 .when(selected, |ring| {
107 ring.child(div().size(px(8.0)).rounded_full().bg(theme.text))
108 })
109 }
110
111 /// Determinate progress bar. `fraction` is clamped to `0..=1`; the track
112 /// keeps its full width so the row never reflows as the value moves.
113 fn progress_bar(&self, fraction: f32) -> Div {
114 let theme = self.theme();
115 let fraction = fraction.clamp(0.0, 1.0);
116 div()
117 .w_full()
118 .h(px(4.0))
119 .rounded_full()
120 .bg(ink(0.12))
121 .child(
122 div()
123 .h_full()
124 .w(gpui::relative(fraction))
125 .rounded_full()
126 .bg(theme.text),
127 )
128 }
129
130 /// Display-only slider: filled track behind a knob at `fraction` (clamped
131 /// to `0..=1`). Dragging is the caller's — it owns the value and the
132 /// mouse handlers; this is the paint.
133 ///
134 /// The element *is* the drag source, so the gesture is
135 /// grab-anywhere-and-slide, and [`slider_fraction`] turns the pointer into
136 /// the value — passing the element's own id, because every slider hears
137 /// every slider's drag:
138 ///
139 /// ```ignore
140 /// focus::focusable(&theme, &self.slider, theme.slider(self.level))
141 /// .id("slider")
142 /// .on_drag(SliderDrag("slider".into()), |_, _, _, cx| cx.new(|_| gpui::Empty))
143 /// .on_drag_move(cx.listener(|view, event: &DragMoveEvent<SliderDrag>, _, cx| {
144 /// let Some(fraction) = widgets::slider_fraction(event, "slider", cx) else {
145 /// return;
146 /// };
147 /// view.level = fraction;
148 /// cx.notify();
149 /// }))
150 /// ```
151 fn slider(&self, fraction: f32) -> Div {
152 let theme = self.theme();
153 let fraction = fraction.clamp(0.0, 1.0);
154 div()
155 .w_full()
156 .h(px(16.0))
157 .border_1()
158 .border_color(crate::widgets::RING_SLOT)
159 .rounded(px(4.0))
160 .flex()
161 .items_center()
162 .relative()
163 .cursor_pointer()
164 .child(
165 div()
166 .w_full()
167 .h(px(4.0))
168 .rounded_full()
169 .bg(ink(0.12))
170 .child(
171 div()
172 .h_full()
173 .w(gpui::relative(fraction))
174 .rounded_full()
175 .bg(theme.text),
176 ),
177 )
178 .child(
179 // Inset by the knob's own width so it never overhangs the track.
180 div().absolute().left(gpui::relative(fraction)).child(
181 div()
182 .size(px(14.0))
183 .ml(px(-7.0))
184 .rounded_full()
185 .bg(theme.text),
186 ),
187 )
188 }
189
190 /// The face of a select: current value plus a chevron, shaped and toned like
191 /// [`crate::input::TextField`] so a form of fields and selects reads as one
192 /// system. One look, open or shut — the menu hanging under it is what says
193 /// which it is.
194 ///
195 /// There is no `Select` component, deliberately — a select IS this trigger
196 /// plus [`crate::popover::anchored_menu_below`] over
197 /// [`crate::popover::menu_row`]s, and the caller already owns the open
198 /// state and the selection. Wrapping that in a struct would buy an
199 /// abstraction and cost the caller its control over both.
200 fn select_trigger(&self, label: impl Into<SharedString>) -> Div {
201 let theme = self.theme();
202 stack::row()
203 .justify_between()
204 .px(px(10.0))
205 .py(px(7.0))
206 .rounded(px(Theme::button_radius()))
207 .bg(theme.input_bg)
208 .border_1()
209 .border_color(theme.border)
210 .text_style(TextStyle::Body)
211 .text_color(theme.text)
212 .cursor_pointer()
213 .child(div().min_w_0().truncate().child(label.into()))
214 .child(
215 crate::icons::icon(crate::icons::glyph::ChevronDown)
216 .size(px(14.0))
217 .text_color(theme.text_muted),
218 )
219 }
220
221 /// Segmented control: one pill holding mutually exclusive choices, for when
222 /// there are few enough that a [`Self::select_trigger`] would be overkill.
223 ///
224 /// `self_start` because a segmented control must hug its segments: dropped
225 /// into a `flex_col`, flexbox's default `align-items: stretch` would
226 /// otherwise blow it out to the column's full width.
227 fn toggle_group(&self) -> Div {
228 let theme = self.theme();
229 div()
230 .self_start()
231 .flex()
232 .flex_row()
233 .items_center()
234 .gap(px(TOGGLE_GROUP_PAD))
235 .p(px(TOGGLE_GROUP_PAD))
236 .rounded(px(TOGGLE_GROUP_RADIUS))
237 .bg(ink(0.06))
238 .border_1()
239 .border_color(theme.border)
240 }
241
242 /// One segment. The selected segment carries the active wash over the
243 /// track's own — the two alphas stack, which is what makes it read — and
244 /// the rest are bare, so exactly one is pressed.
245 fn toggle_group_item(&self, label: impl Into<SharedString>, selected: bool) -> Div {
246 let theme = self.theme();
247 let mut item = div()
248 .px(px(10.0))
249 .py(px(4.0))
250 // Concentric with the track: 9 - 2 = 7.
251 .rounded(px(Theme::inset_radius(
252 TOGGLE_GROUP_RADIUS,
253 TOGGLE_GROUP_PAD,
254 )))
255 .border_1()
256 .border_color(crate::widgets::RING_SLOT)
257 .text_style(TextStyle::Callout)
258 .cursor_pointer()
259 .child(label.into());
260 item = if selected {
261 item.bg(theme.element_active)
262 .font_weight(gpui::FontWeight::MEDIUM)
263 .text_color(theme.text)
264 } else {
265 item.text_color(theme.text_muted)
266 };
267 item
268 }
269}
270
271impl Controls for Theme {}
272
273/// The segmented track's radius, and the inset its segments come in by. Two
274/// numbers read from both [`Controls::toggle_group`] and
275/// [`Controls::toggle_group_item`], so a segment cannot stop being concentric
276/// with the track it sits in.
277const TOGGLE_GROUP_RADIUS: f32 = 9.0;
278const TOGGLE_GROUP_PAD: f32 = 2.0;