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(..)`, `theme.picture_picker(..)`.
9
10use crate::stack;
11use std::rc::Rc;
12
13use gpui::{
14 AnyElement, App, Axis, BoxShadow, Div, DragMoveEvent, ElementId, SharedString, Stateful,
15 Window, div, point, prelude::*, px,
16};
17use icons::Icon;
18use theme::{TextStyle, Theme, ThemeExt, Typeset};
19
20/// The drag payload of a [`Controls::slider`], carrying the id of the slider
21/// the gesture started on.
22///
23/// The id is what keeps sliders apart: gpui delivers a drag move to *every*
24/// listener of the payload's type, not just the element under the pointer, so
25/// a page of five sliders would move all five at once.
26pub struct SliderDrag(pub ElementId);
27
28/// Where a slider drag lands on the track it is asked about, or `None` when the
29/// gesture belongs to another slider. `id` is the one that element carries.
30pub fn slider_fraction(
31 event: &DragMoveEvent<SliderDrag>,
32 id: impl Into<ElementId>,
33 cx: &App,
34) -> Option<f32> {
35 (event.drag(cx).0 == id.into()).then(|| {
36 crate::widgets::axis_fraction(event.event.position, event.bounds, Axis::Horizontal, 0.0)
37 })
38}
39
40pub trait Controls: ThemeExt {
41 /// Display-only toggle switch (the reference branch-picker.tsx `Toggle`):
42 /// an 18×32 pill whose knob slides right and track fills with the text
43 /// tone when on.
44 /// State is owned by the parent row — the caller adds `.id(..)` and
45 /// `.on_click(..)`.
46 fn toggle(&self, on: bool) -> Div {
47 let theme = self.theme();
48 div()
49 .flex_none()
50 .w(px(32.0))
51 .h(px(18.0))
52 .rounded_full()
53 .bg(if on { theme.text } else { theme.ink(0.15) })
54 .border_1()
55 .border_color(crate::widgets::RING_SLOT)
56 .relative()
57 .child(
58 // One less than the 2px inset it looks like: absolute insets
59 // resolve against the padding box, which the ring slot has
60 // already moved in by a pixel.
61 div()
62 .absolute()
63 .top(px(1.0))
64 .left(px(if on { 15.0 } else { 1.0 }))
65 .size(px(14.0))
66 .rounded_full()
67 .bg(if on { theme.on_solid } else { theme.ink(0.7) }),
68 )
69 }
70
71 /// Display-only checkbox: a 16px rounded square that fills with the text
72 /// tone and shows a check when on. State is the caller's; add
73 /// `.id(..)`/`.on_click(..)`.
74 fn checkbox(&self, checked: bool) -> Div {
75 let theme = self.theme();
76 let mut box_ = div()
77 .flex_none()
78 .size(px(16.0))
79 .rounded(px(4.0))
80 .flex()
81 .items_center()
82 .justify_center();
83 box_ = if checked {
84 box_.border_1()
85 .border_color(crate::widgets::RING_SLOT)
86 .bg(theme.solid)
87 } else {
88 box_.border_1()
89 .border_color(theme.ink(0.25))
90 .bg(theme.ink(0.03))
91 };
92 if checked {
93 box_.child(
94 crate::icons::icon(crate::icons::glyph::Check)
95 .size(px(11.0))
96 .text_color(theme.on_solid),
97 )
98 } else {
99 box_
100 }
101 }
102
103 /// Display-only radio button: a 16px ring with an inner dot when selected.
104 /// Radios are a *set* — the caller owns which index is on.
105 fn radio_button(&self, selected: bool) -> Div {
106 let theme = self.theme();
107 div()
108 .flex_none()
109 .size(px(16.0))
110 .rounded_full()
111 .border_1()
112 .border_color(if selected {
113 theme.ring
114 } else {
115 theme.ink(0.25)
116 })
117 .bg(theme.ink(0.03))
118 .flex()
119 .items_center()
120 .justify_center()
121 .when(selected, |ring| {
122 ring.child(div().size(px(8.0)).rounded_full().bg(theme.solid))
123 })
124 }
125
126 /// Determinate progress bar. `fraction` is clamped to `0..=1`; the track
127 /// keeps its full width so the row never reflows as the value moves.
128 fn progress_bar(&self, fraction: f32) -> Div {
129 let theme = self.theme();
130 let fraction = fraction.clamp(0.0, 1.0);
131 div()
132 .w_full()
133 .h(px(4.0))
134 .rounded_full()
135 .bg(theme.ink(0.12))
136 .child(
137 div()
138 .h_full()
139 .w(gpui::relative(fraction))
140 .rounded_full()
141 .bg(theme.solid),
142 )
143 }
144
145 /// Display-only slider: filled track behind a knob at `fraction` (clamped
146 /// to `0..=1`). Dragging is the caller's — it owns the value and the
147 /// mouse handlers; this is the paint.
148 ///
149 /// The element *is* the drag source, so the gesture is
150 /// grab-anywhere-and-slide, and [`slider_fraction`] turns the pointer into
151 /// the value — passing the element's own id, because every slider hears
152 /// every slider's drag:
153 ///
154 /// ```ignore
155 /// focus::focusable(&theme, &self.slider, theme.slider(self.level))
156 /// .id("slider")
157 /// .on_drag(SliderDrag("slider".into()), |_, _, _, cx| cx.new(|_| gpui::Empty))
158 /// .on_drag_move(cx.listener(|view, event: &DragMoveEvent<SliderDrag>, _, cx| {
159 /// let Some(fraction) = widgets::slider_fraction(event, "slider", cx) else {
160 /// return;
161 /// };
162 /// view.level = fraction;
163 /// cx.notify();
164 /// }))
165 /// ```
166 fn slider(&self, fraction: f32) -> Div {
167 let theme = self.theme();
168 let fraction = fraction.clamp(0.0, 1.0);
169 div()
170 .w_full()
171 .h(px(16.0))
172 .border_1()
173 .border_color(crate::widgets::RING_SLOT)
174 .rounded(px(4.0))
175 .flex()
176 .items_center()
177 .relative()
178 .cursor_pointer()
179 .child(
180 div()
181 .w_full()
182 .h(px(4.0))
183 .rounded_full()
184 .bg(theme.ink(0.12))
185 .child(
186 div()
187 .h_full()
188 .w(gpui::relative(fraction))
189 .rounded_full()
190 .bg(theme.solid),
191 ),
192 )
193 .child(
194 // Inset by the knob's own width so it never overhangs the track.
195 div().absolute().left(gpui::relative(fraction)).child(
196 div()
197 .size(px(14.0))
198 .ml(px(-7.0))
199 .rounded_full()
200 .bg(theme.solid),
201 ),
202 )
203 }
204
205 /// Display-only color well: a 20px ring holding `color`. The caller adds
206 /// `.id(..)` and `.on_click(..)`.
207 fn color_well(&self, color: gpui::Hsla) -> Div {
208 well(self.theme().ink(0.25), color)
209 }
210
211 /// Preset colors, wired: a click on one reports its index to `on_pick`.
212 /// The ring marks `selected`; none is marked when it is out of range or
213 /// `None`. One row when `columns` is `None`; otherwise rows of `columns`.
214 ///
215 /// ```ignore
216 /// let swatches = cx.color_swatches();
217 /// theme.swatch_picker("tint", &swatches, self.tint, Some(6),
218 /// cx.listener(|view, ix: &usize, _, cx| view.pick_tint(*ix, cx)))
219 /// ```
220 fn swatch_picker(
221 &self,
222 id: impl Into<ElementId>,
223 swatches: &[crate::color::Swatch],
224 selected: Option<usize>,
225 columns: Option<usize>,
226 on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
227 ) -> Stateful<Div> {
228 let theme = self.theme();
229 let on_pick = Rc::new(on_pick);
230 let wells: Vec<_> = swatches
231 .iter()
232 .enumerate()
233 .map(|(ix, swatch)| {
234 let on_pick = on_pick.clone();
235 let ring = match selected == Some(ix) {
236 true => theme.text,
237 false => crate::widgets::RING_SLOT,
238 };
239 well(ring, swatch.resolve(theme))
240 .id(ix)
241 .on_click(move |_, window, cx| on_pick(&ix, window, cx))
242 })
243 .collect();
244 let width = columns.filter(|n| *n > 0).unwrap_or(wells.len().max(1));
245 let mut wells = wells.into_iter();
246 let rows = std::iter::from_fn(|| {
247 let row: Vec<_> = wells.by_ref().take(width).collect();
248 (!row.is_empty()).then(|| stack::row().gap(px(4.0)).children(row))
249 });
250 stack::column().id(id).gap(px(4.0)).children(rows)
251 }
252
253 /// The face of a select: current value plus a chevron, shaped and toned like
254 /// [`crate::input::TextField`] so a form of fields and selects reads as one
255 /// system. One look, open or shut — the menu hanging under it is what says
256 /// which it is.
257 ///
258 /// There is no `Select` component, deliberately — a select IS this trigger
259 /// plus [`crate::popover::anchored_menu_below`] over
260 /// [`crate::popover::menu_row`]s, and the caller already owns the open
261 /// state and the selection. Wrapping that in a struct would buy an
262 /// abstraction and cost the caller its control over both.
263 fn select_trigger(&self, label: impl Into<SharedString>) -> Div {
264 self.select_trigger_with(None::<Div>, label)
265 }
266
267 /// [`Self::select_trigger`] with `leading` drawn before the label — a
268 /// swatch or a sample of the value.
269 fn select_trigger_with(
270 &self,
271 leading: Option<impl IntoElement>,
272 label: impl Into<SharedString>,
273 ) -> Div {
274 let theme = self.theme();
275 stack::row()
276 .justify_between()
277 .px(px(10.0))
278 .py(px(7.0))
279 .rounded(px(Theme::button_radius()))
280 .bg(theme.input_bg)
281 .border_1()
282 .border_color(theme.border)
283 .text_style(TextStyle::Body)
284 .text_color(theme.text)
285 .cursor_pointer()
286 .child(
287 stack::row()
288 .min_w_0()
289 .gap(px(8.0))
290 .children(leading.map(|leading| div().flex_none().child(leading)))
291 .child(div().min_w_0().truncate().child(label.into())),
292 )
293 .child(
294 crate::icons::icon(crate::icons::glyph::ChevronDown)
295 .size(px(14.0))
296 .text_color(theme.text_muted),
297 )
298 }
299
300 /// Segmented control: one pill holding mutually exclusive choices, for when
301 /// there are few enough that a [`Self::select_trigger`] would be overkill.
302 ///
303 /// `self_start` because a segmented control must hug its segments: dropped
304 /// into a `flex_col`, flexbox's default `align-items: stretch` would
305 /// otherwise blow it out to the column's full width.
306 fn toggle_group(&self) -> Div {
307 let theme = self.theme();
308 div()
309 .self_start()
310 .flex()
311 .flex_row()
312 .items_center()
313 .gap(px(TOGGLE_GROUP_PAD))
314 .p(px(TOGGLE_GROUP_PAD))
315 .rounded(px(TOGGLE_GROUP_RADIUS))
316 .bg(theme.ink(0.06))
317 .border_1()
318 .border_color(theme.border)
319 }
320
321 /// One segment. The selected segment carries the active wash over the
322 /// track's own — the two alphas stack, which is what makes it read — and
323 /// the rest are bare, so exactly one is pressed.
324 ///
325 /// An unselected segment carries its own `hover`, and gpui panics on a
326 /// second: chain a `group_hover`, never `.hover(..)`. The same holds for
327 /// [`Self::toggle_group_icon`].
328 fn toggle_group_item(&self, label: impl Into<SharedString>, selected: bool) -> Div {
329 segment(self.theme(), selected)
330 .px(px(10.0))
331 .py(px(4.0))
332 .child(label.into())
333 }
334
335 /// A [`Self::toggle_group`] of labelled segments, wired: a click on one
336 /// reports its index to `on_pick`, the selected one included. Nothing is
337 /// selected when `selected` is out of range.
338 ///
339 /// ```ignore
340 /// theme.segmented("search-engine", ["Google", "DuckDuckGo"], selected,
341 /// cx.listener(|view, ix: &usize, _, cx| view.pick_engine(*ix, cx)))
342 /// ```
343 fn segmented<Label: Into<SharedString>>(
344 &self,
345 id: impl Into<ElementId>,
346 segments: impl IntoIterator<Item = Label>,
347 selected: usize,
348 on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
349 ) -> Stateful<Div> {
350 let on_pick = Rc::new(on_pick);
351 self.toggle_group()
352 .id(id)
353 .children(segments.into_iter().enumerate().map(|(ix, label)| {
354 let on_pick = on_pick.clone();
355 self.toggle_group_item(label, ix == selected)
356 .id(ix)
357 .on_click(move |_, window, cx| on_pick(&ix, window, cx))
358 }))
359 }
360
361 /// A segment carrying a glyph rather than a word, for a control with no
362 /// room for one — a view switcher on a board, a density picker in a
363 /// toolbar. It comes out 24×24, the height a labelled segment takes.
364 ///
365 /// The tooltip is the caller's, and a glyph nobody recognises says nothing
366 /// without one.
367 fn toggle_group_icon(&self, icon: impl Into<Icon>, selected: bool) -> Div {
368 let theme = self.theme();
369 // Built here rather than taken, the way `control_bar::bar_button` does
370 // it: gpui reads an svg's colour off that element's own style, so a
371 // colour set on the segment would not reach the glyph.
372 segment(theme, selected)
373 .p(px(5.0))
374 .flex()
375 .items_center()
376 .justify_center()
377 .child(
378 crate::icons::icon(icon)
379 .size(px(14.0))
380 .text_color(match selected {
381 true => theme.text,
382 false => theme.text_muted,
383 }),
384 )
385 }
386
387 /// A row of tiles, each a picture over its label — macOS's appearance
388 /// picker. The `selected` tile is ringed in [`Theme::accent`]; a click on
389 /// any reports its index to `on_pick`. Nothing is selected when `selected`
390 /// is out of range.
391 ///
392 /// Each picture is the caller's, at the size it draws itself; the tile
393 /// clips it to its rounded frame.
394 fn picture_picker(
395 &self,
396 id: impl Into<ElementId>,
397 tiles: impl IntoIterator<Item = (AnyElement, SharedString)>,
398 selected: usize,
399 on_pick: impl Fn(&usize, &mut Window, &mut App) + 'static,
400 ) -> Stateful<Div> {
401 let theme = self.theme();
402 let on_pick = Rc::new(on_pick);
403 let radius = px(Theme::panel_radius());
404 div()
405 .id(id)
406 .flex()
407 .flex_row()
408 .gap(px(12.0))
409 .children(tiles.into_iter().enumerate().map(|(ix, (picture, label))| {
410 let on = ix == selected;
411 let on_pick = on_pick.clone();
412 let hover = theme.border_strong;
413 // The ring is a shadow, so choosing a tile moves nothing.
414 let ring = BoxShadow {
415 color: theme.accent,
416 offset: point(px(0.0), px(0.0)),
417 blur_radius: px(0.0),
418 spread_radius: px(PICTURE_RING),
419 inset: false,
420 };
421 div()
422 .id(ix)
423 .group("picture-tile")
424 .flex()
425 .flex_col()
426 .items_center()
427 .gap(px(6.0))
428 .cursor_pointer()
429 .on_click(move |_, window, cx| on_pick(&ix, window, cx))
430 .child(
431 div()
432 .rounded(radius)
433 .border_1()
434 .border_color(theme.border)
435 .overflow_hidden()
436 .when(on, |frame| frame.shadow(vec![ring]))
437 .when(!on, |frame| {
438 frame.group_hover("picture-tile", move |frame| {
439 frame.border_color(hover)
440 })
441 })
442 .child(picture),
443 )
444 .child(
445 div()
446 .text_style(TextStyle::Callout)
447 .text_color(match on {
448 true => theme.text,
449 false => theme.text_muted,
450 })
451 .when(on, |label| label.font_weight(gpui::FontWeight::MEDIUM))
452 .child(label),
453 )
454 }))
455 }
456}
457
458/// How far a [`Controls::picture_picker`] tile's selection ring stands
459/// outside its frame.
460const PICTURE_RING: f32 = 2.0;
461
462/// A 20px ring in `ring` around a disc of `color`.
463fn well(ring: gpui::Hsla, color: gpui::Hsla) -> Div {
464 div()
465 .flex_none()
466 .size(px(20.0))
467 .p(px(2.0))
468 .rounded_full()
469 .border_1()
470 .border_color(ring)
471 .cursor_pointer()
472 .child(div().size_full().rounded_full().bg(color))
473}
474
475/// What a segment looks like in each of its two states, before whatever it
476/// carries. Padding is the caller's: a word and a glyph reach the same height
477/// by different insets.
478///
479/// An unselected segment takes its own `hover`, and gpui panics on a second
480/// one — reach for a `group_hover` rather than chaining `.hover(..)` on.
481fn segment(theme: &Theme, selected: bool) -> Div {
482 let wash = theme.element_hover;
483 let item = div()
484 // Concentric with the track: 9 - 2 = 7.
485 .rounded(px(Theme::inset_radius(
486 TOGGLE_GROUP_RADIUS,
487 TOGGLE_GROUP_PAD,
488 )))
489 .border_1()
490 .border_color(crate::widgets::RING_SLOT)
491 .text_style(TextStyle::Callout)
492 .cursor_pointer();
493 if selected {
494 item.bg(theme.element_active)
495 .font_weight(gpui::FontWeight::MEDIUM)
496 .text_color(theme.text)
497 } else {
498 item.text_color(theme.text_muted)
499 .hover(move |el| el.bg(wash))
500 }
501}
502
503impl Controls for Theme {}
504
505/// The segmented track's radius, and the inset its segments come in by. Two
506/// numbers read from both [`Controls::toggle_group`] and
507/// [`Controls::toggle_group_item`], so a segment cannot stop being concentric
508/// with the track it sits in.
509const TOGGLE_GROUP_RADIUS: f32 = 9.0;
510const TOGGLE_GROUP_PAD: f32 = 2.0;