1use gpui_kit::{
2 App, Axis, ElementId, Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
3 ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
4 Styled, Window,
5 base::{
6 self, Disableable, StyledExt as _, TestSupportExt as _,
7 slider::{SliderEvent, SliderState, SliderValue},
8 },
9 div, point,
10 prelude::FluentBuilder as _,
11 px, relative,
12};
13
14use crate::{ActiveTheme as _, Theme};
15
16#[derive(IntoElement)]
52#[non_exhaustive]
53pub struct Slider {
54 state: Entity<SliderState>,
55 id: ElementId,
56 style: StyleRefinement,
57 disabled: bool,
58 stops: Option<usize>,
59 accessibility_label: Option<SharedString>,
60 tab_index: isize,
61 tab_stop: bool,
62}
63
64impl Slider {
65 pub fn new(state: &Entity<SliderState>) -> Self {
67 Self {
68 state: state.clone(),
69 id: ("cn-slider", state.entity_id()).into(),
70 style: StyleRefinement::default(),
71 disabled: false,
72 stops: None,
73 accessibility_label: None,
74 tab_index: 0,
75 tab_stop: true,
76 }
77 }
78
79 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
81 self.id = id.into();
82 self
83 }
84
85 pub fn stops(mut self, count: usize) -> Self {
88 self.stops = Some(count);
89 self
90 }
91
92 pub fn stop_count(&self) -> Option<usize> {
94 self.stops
95 }
96
97 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
99 self.accessibility_label = Some(label.into());
100 self
101 }
102
103 pub fn tab_index(mut self, tab_index: isize) -> Self {
105 self.tab_index = tab_index;
106 self
107 }
108
109 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
111 self.tab_stop = tab_stop;
112 self
113 }
114}
115
116impl Disableable for Slider {
117 fn disabled(mut self, disabled: bool) -> Self {
118 self.disabled = disabled;
119 self
120 }
121}
122
123impl Styled for Slider {
124 fn style(&mut self) -> &mut StyleRefinement {
125 &mut self.style
126 }
127}
128
129fn nudged(key: &str, value: f32, min: f32, max: f32, step: f32) -> Option<f32> {
132 if ![value, min, max, step].iter().all(|n| n.is_finite()) || min > max {
133 return None;
134 }
135 let page = ((max - min) / 10.).max(step);
136 let next = match key {
137 "right" | "up" => value + step,
138 "left" | "down" => value - step,
139 "pageup" => value + page,
140 "pagedown" => value - page,
141 "home" => min,
142 "end" => max,
143 _ => return None,
144 }
145 .clamp(min, max);
146 ((next - value).abs() > f32::EPSILON).then_some(next)
147}
148
149impl RenderOnce for Slider {
150 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
151 let pointer_cursors = Theme::global(cx).pointer_cursors;
152 let disabled = self.disabled;
153 let percentage = self.state.read(cx).percentage().end.clamp(0., 1.);
154 let (rail, range, thumb, border, tick, tick_on, ring, lift) = {
155 let theme = cx.theme();
156 (
157 theme.progress_track,
158 theme.primary(),
159 theme.switch_thumb,
160 theme.field_border,
161 theme.muted_foreground(),
162 theme.solid_foreground,
163 theme.focus_ring(),
164 theme.base.shadow.sm.clone(),
165 )
166 };
167 let (track_height, thumb_size, tick_size, ring_spread, hit_height, radius, strength) = {
168 let theme = cx.theme();
169 let metrics = &theme.metrics;
170 (
171 metrics.slider_track,
172 metrics.slider_thumb,
173 metrics.slider_tick,
174 metrics.focus_ring,
175 if theme.touch {
176 metrics.control_md
177 } else {
178 metrics.slider_thumb
179 },
180 theme.radius_full(),
181 theme.disabled_opacity,
182 )
183 };
184 let focus_handle = window
185 .use_keyed_state(
186 ElementId::NamedChild(self.id.clone().into(), "focus".into()),
187 cx,
188 |_, cx| cx.focus_handle(),
189 )
190 .read(cx)
191 .clone();
192 let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
193 let state = self.state.clone();
194 let track_id = ElementId::NamedChild(self.id.clone().into(), "track".into());
195 let thumb_id = ElementId::NamedChild(self.id.clone().into(), "thumb".into());
196 let stops = self.stops.filter(|count| *count >= 2);
197 let tick_id = self.id.clone();
198 let ticks = stops.into_iter().flat_map(move |count| {
199 let tick_id = tick_id.clone();
200 (0..count).map(move |index| {
201 let at = index as f32 / (count - 1) as f32;
202 div()
203 .id(ElementId::NamedChild(
204 tick_id.clone().into(),
205 format!("tick-{index}").into(),
206 ))
207 .test_support()
208 .absolute()
209 .top(relative(0.5))
210 .left(relative(at))
211 .mt(-tick_size / 2.)
212 .ml(-tick_size / 2.)
213 .size(tick_size)
214 .rounded(radius)
215 .bg(if at <= percentage + f32::EPSILON {
216 tick_on
217 } else {
218 tick
219 })
220 })
221 });
222
223 div()
224 .id(self.id)
225 .test_support()
226 .flex()
227 .items_center()
228 .w_full()
229 .h(hit_height)
230 .when(disabled, |this| this.opacity(strength))
231 .map(|this| {
232 if !disabled && pointer_cursors {
233 this.cursor_pointer()
234 } else {
235 this.cursor_default()
236 }
237 })
238 .when_some(self.accessibility_label, |this, label| {
239 this.aria_label(label)
240 })
241 .refine_style(&self.style)
242 .when(!disabled, |this| {
243 let focus = focus_handle.clone();
244 let handle = focus_handle
245 .tab_index(self.tab_index)
246 .tab_stop(self.tab_stop);
247 this.track_focus(&handle)
248 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
249 window.focus(&focus, cx);
250 })
251 .on_key_down(move |event: &KeyDownEvent, window, cx| {
252 let (value, min, max, step) = {
253 let state = state.read(cx);
254 (
255 state.value().end(),
256 state.min_value(),
257 state.max_value(),
258 state.step_value(),
259 )
260 };
261 if let Some(next) =
262 nudged(event.keystroke.key.as_str(), value, min, max, step)
263 {
264 cx.stop_propagation();
265 state.update(cx, |state, cx| {
266 state.set_value(next, window, cx);
267 let value = SliderValue::Single(next);
268 cx.emit(SliderEvent::Change(value));
269 cx.emit(SliderEvent::Release(value));
270 });
271 }
272 })
273 })
274 .child(
275 base::Slider::new(&self.state)
276 .axis(Axis::Horizontal)
277 .disabled(disabled)
278 .flex()
279 .items_center()
280 .w_full()
281 .h(hit_height)
282 .child(
283 base::SliderTrack::new(&self.state)
284 .axis(Axis::Horizontal)
285 .disabled(disabled)
286 .flex()
287 .items_center()
288 .w_full()
289 .h(hit_height)
290 .child(
291 div().w_full().mx(thumb_size / 2.).child(
292 base::SliderIndicator::new(&self.state)
293 .relative()
294 .w_full()
295 .h(track_height)
296 .child(
297 div()
298 .id(track_id)
299 .test_support()
300 .absolute()
301 .size_full()
302 .rounded(radius)
303 .bg(rail)
304 .child(
305 div()
306 .absolute()
307 .top_0()
308 .bottom_0()
309 .left_0()
310 .w(relative(percentage))
311 .rounded(radius)
312 .bg(range),
313 ),
314 )
315 .children(ticks)
316 .child(
317 base::SliderThumb::new(&self.state)
318 .axis(Axis::Horizontal)
319 .disabled(disabled)
320 .absolute()
321 .top(relative(0.5))
322 .left(relative(percentage))
323 .mt(-thumb_size / 2.)
324 .ml(-thumb_size / 2.)
325 .size(thumb_size)
326 .child(
327 div()
328 .id(thumb_id)
329 .test_support()
330 .size_full()
331 .rounded(radius)
332 .bg(thumb)
333 .border_1()
334 .border_color(border)
335 .shadow(if focus_visible {
336 let mut shadows = lift.clone();
337 shadows.push(gpui_kit::BoxShadow {
338 color: ring,
339 offset: point(px(0.), px(0.)),
340 blur_radius: px(0.),
341 spread_radius: ring_spread,
342 inset: false,
343 });
344 shadows
345 } else {
346 lift
347 }),
348 ),
349 ),
350 ),
351 ),
352 ),
353 )
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn a_key_never_panics_on_a_reversed_or_broken_range() {
363 assert_eq!(nudged("right", 1., 5., 0., 1.), None, "min above max");
364 assert_eq!(nudged("right", 1., 0., f32::NAN, 1.), None);
365 assert_eq!(nudged("end", 1., 0., 5., f32::INFINITY), None);
366 assert_eq!(nudged("left", f32::NAN, 0., 5., 1.), None);
367 assert_eq!(nudged("right", 2., 0., 5., 1.), Some(3.));
368 assert_eq!(nudged("end", 2., 0., 5., 1.), Some(5.));
369 assert_eq!(nudged("right", 5., 0., 5., 1.), None);
370 }
371
372 #[test]
373 fn keys_move_the_value_one_step_and_clamp_at_the_ends() {
374 assert_eq!(nudged("right", 2., 0., 5., 1.), Some(3.));
375 assert_eq!(nudged("up", 2., 0., 5., 1.), Some(3.));
376 assert_eq!(nudged("left", 2., 0., 5., 1.), Some(1.));
377 assert_eq!(nudged("down", 2., 0., 5., 1.), Some(1.));
378 assert_eq!(nudged("home", 2., 0., 5., 1.), Some(0.));
379 assert_eq!(nudged("end", 2., 0., 5., 1.), Some(5.));
380 assert_eq!(nudged("right", 5., 0., 5., 1.), None, "already at the end");
381 assert_eq!(nudged("left", 0., 0., 5., 1.), None);
382 assert_eq!(nudged("pageup", 10., 0., 100., 1.), Some(20.));
383 assert_eq!(nudged("pagedown", 3., 0., 100., 1.), Some(0.));
384 assert_eq!(nudged("a", 2., 0., 5., 1.), None);
385 }
386
387 #[test]
388 fn a_page_is_never_smaller_than_a_step() {
389 assert_eq!(nudged("pageup", 0., 0., 5., 1.), Some(1.));
390 }
391}