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(..)`.
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.ring
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 /// Display-only color well: a 20px ring holding `color`. The caller adds
204 /// `.id(..)` and `.on_click(..)`.
205 fn color_well(&self, color: gpui::Hsla) -> Div {
206 well(self.theme().ink(0.25), color)
207 }
208
209 /// Preset colors, wired: a click on one reports its index to `on_pick`.
210 /// The ring marks `selected`; none is marked when it is out of range or
211 /// `None`. One row when `columns` is `None`; otherwise rows of `columns`.
212 ///
213 /// ```ignore
214 /// let swatches = cx.color_swatches();
215 /// theme.swatch_picker("tint", &swatches, self.tint, Some(6),
216 /// cx.listener(|view, ix: &usize, _, cx| view.pick_tint(*ix, cx)))
217 /// ```
218 fn swatch_picker(
219 &self,
220 id: impl Into<ElementId>,
221 swatches: &[crate::color::Swatch],
222 selected: Option<usize>,
223 columns: Option<usize>,
224 on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
225 ) -> Stateful<Div> {
226 let theme = self.theme();
227 let on_pick = Rc::new(on_pick);
228 let wells: Vec<_> = swatches
229 .iter()
230 .enumerate()
231 .map(|(ix, swatch)| {
232 let on_pick = on_pick.clone();
233 let ring = match selected == Some(ix) {
234 true => theme.text,
235 false => crate::widgets::RING_SLOT,
236 };
237 well(ring, swatch.resolve(theme))
238 .id(ix)
239 .on_click(move |_, window, cx| on_pick(&ix, window, cx))
240 })
241 .collect();
242 let width = columns.filter(|n| *n > 0).unwrap_or(wells.len().max(1));
243 let mut wells = wells.into_iter();
244 let rows = std::iter::from_fn(|| {
245 let row: Vec<_> = wells.by_ref().take(width).collect();
246 (!row.is_empty()).then(|| stack::row().gap(px(4.0)).children(row))
247 });
248 stack::column().id(id).gap(px(4.0)).children(rows)
249 }
250
251 /// The face of a select: current value plus a chevron, shaped and toned like
252 /// [`crate::input::TextField`] so a form of fields and selects reads as one
253 /// system. One look, open or shut — the menu hanging under it is what says
254 /// which it is.
255 ///
256 /// There is no `Select` component, deliberately — a select IS this trigger
257 /// plus [`crate::popover::anchored_menu_below`] over
258 /// [`crate::popover::menu_row`]s, and the caller already owns the open
259 /// state and the selection. Wrapping that in a struct would buy an
260 /// abstraction and cost the caller its control over both.
261 fn select_trigger(&self, label: impl Into<SharedString>) -> Div {
262 self.select_trigger_with(None::<Div>, label)
263 }
264
265 /// [`Self::select_trigger`] with `leading` drawn before the label — a
266 /// swatch or a sample of the value.
267 fn select_trigger_with(
268 &self,
269 leading: Option<impl IntoElement>,
270 label: impl Into<SharedString>,
271 ) -> Div {
272 let theme = self.theme();
273 stack::row()
274 .justify_between()
275 .px(px(10.0))
276 .py(px(7.0))
277 .rounded(px(Theme::button_radius()))
278 .bg(theme.input_bg)
279 .border_1()
280 .border_color(theme.border)
281 .text_style(TextStyle::Body)
282 .text_color(theme.text)
283 .cursor_pointer()
284 .child(
285 stack::row()
286 .min_w_0()
287 .gap(px(8.0))
288 .children(leading.map(|leading| div().flex_none().child(leading)))
289 .child(div().min_w_0().truncate().child(label.into())),
290 )
291 .child(
292 crate::icons::icon(crate::icons::glyph::ChevronDown)
293 .size(px(14.0))
294 .text_color(theme.text_muted),
295 )
296 }
297
298 /// Segmented control: one pill holding mutually exclusive choices, for when
299 /// there are few enough that a [`Self::select_trigger`] would be overkill.
300 ///
301 /// `self_start` because a segmented control must hug its segments: dropped
302 /// into a `flex_col`, flexbox's default `align-items: stretch` would
303 /// otherwise blow it out to the column's full width.
304 fn toggle_group(&self) -> Div {
305 let theme = self.theme();
306 div()
307 .self_start()
308 .flex()
309 .flex_row()
310 .items_center()
311 .gap(px(TOGGLE_GROUP_PAD))
312 .p(px(TOGGLE_GROUP_PAD))
313 .rounded(px(TOGGLE_GROUP_RADIUS))
314 .bg(theme.ink(0.06))
315 .border_1()
316 .border_color(theme.border)
317 }
318
319 /// One segment. The selected segment carries the active wash over the
320 /// track's own — the two alphas stack, which is what makes it read — and
321 /// the rest are bare, so exactly one is pressed.
322 fn toggle_group_item(&self, label: impl Into<SharedString>, selected: bool) -> Div {
323 segment(self.theme(), selected)
324 .px(px(10.0))
325 .py(px(4.0))
326 .child(label.into())
327 }
328
329 /// A [`Self::toggle_group`] of labelled segments, wired: a click on one
330 /// reports its index to `on_pick`, the selected one included. Nothing is
331 /// selected when `selected` is out of range.
332 ///
333 /// ```ignore
334 /// theme.segmented("search-engine", ["Google", "DuckDuckGo"], selected,
335 /// cx.listener(|view, ix: &usize, _, cx| view.pick_engine(*ix, cx)))
336 /// ```
337 fn segmented<Label: Into<SharedString>>(
338 &self,
339 id: impl Into<ElementId>,
340 segments: impl IntoIterator<Item = Label>,
341 selected: usize,
342 on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
343 ) -> Stateful<Div> {
344 let on_pick = Rc::new(on_pick);
345 self.toggle_group()
346 .id(id)
347 .children(segments.into_iter().enumerate().map(|(ix, label)| {
348 let on_pick = on_pick.clone();
349 self.toggle_group_item(label, ix == selected)
350 .id(ix)
351 .on_click(move |_, window, cx| on_pick(&ix, window, cx))
352 }))
353 }
354
355 /// A segment carrying a glyph rather than a word, for a control with no
356 /// room for one — a view switcher on a board, a density picker in a
357 /// toolbar. It comes out 24×24, the height a labelled segment takes.
358 ///
359 /// The tooltip is the caller's, and a glyph nobody recognises says nothing
360 /// without one.
361 fn toggle_group_icon(&self, icon: impl Into<Icon>, selected: bool) -> Div {
362 let theme = self.theme();
363 // Built here rather than taken, the way `control_bar::bar_button` does
364 // it: gpui reads an svg's colour off that element's own style, so a
365 // colour set on the segment would not reach the glyph.
366 segment(theme, selected)
367 .p(px(5.0))
368 .flex()
369 .items_center()
370 .justify_center()
371 .child(
372 crate::icons::icon(icon)
373 .size(px(14.0))
374 .text_color(match selected {
375 true => theme.text,
376 false => theme.text_muted,
377 }),
378 )
379 }
380}
381
382/// A 20px ring in `ring` around a disc of `color`.
383fn well(ring: gpui::Hsla, color: gpui::Hsla) -> Div {
384 div()
385 .flex_none()
386 .size(px(20.0))
387 .p(px(2.0))
388 .rounded_full()
389 .border_1()
390 .border_color(ring)
391 .cursor_pointer()
392 .child(div().size_full().rounded_full().bg(color))
393}
394
395/// What a segment looks like in each of its two states, before whatever it
396/// carries. Padding is the caller's: a word and a glyph reach the same height
397/// by different insets.
398///
399/// An unselected segment takes its own `hover`, and gpui panics on a second
400/// one — reach for a `group_hover` rather than chaining `.hover(..)` on.
401fn segment(theme: &Theme, selected: bool) -> Div {
402 let wash = theme.element_hover;
403 let item = div()
404 // Concentric with the track: 9 - 2 = 7.
405 .rounded(px(Theme::inset_radius(
406 TOGGLE_GROUP_RADIUS,
407 TOGGLE_GROUP_PAD,
408 )))
409 .border_1()
410 .border_color(crate::widgets::RING_SLOT)
411 .text_style(TextStyle::Callout)
412 .cursor_pointer();
413 if selected {
414 item.bg(theme.element_active)
415 .font_weight(gpui::FontWeight::MEDIUM)
416 .text_color(theme.text)
417 } else {
418 item.text_color(theme.text_muted)
419 .hover(move |el| el.bg(wash))
420 }
421}
422
423impl Controls for Theme {}
424
425/// The segmented track's radius, and the inset its segments come in by. Two
426/// numbers read from both [`Controls::toggle_group`] and
427/// [`Controls::toggle_group_item`], so a segment cannot stop being concentric
428/// with the track it sits in.
429const TOGGLE_GROUP_RADIUS: f32 = 9.0;
430const TOGGLE_GROUP_PAD: f32 = 2.0;