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;