1use std::sync::Arc;
2
3use crate::{ActiveTheme, AxisExt, StyledExt, ThemeStyled as _};
4pub use gpui_base::slider::{SliderEvent, SliderScale, SliderState, SliderValue};
5use gpui_base::{Slider as BaseSlider, SliderIndicator, SliderThumb, SliderTrack, spring};
6
7use gpui::{
8 App, Axis, Background, Corners, DefiniteLength, ElementId, Entity, EntityId, Hsla,
9 InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, RenderOnce,
10 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
11 prelude::FluentBuilder as _, px, relative,
12};
13
14const THUMB_RING_WIDTH: Pixels = px(3.);
16const THUMB_RING_OPACITY: f32 = 0.5;
18struct ThumbRing {
20 interaction: Entity<ThumbInteraction>,
21 width: Pixels,
22 color: Hsla,
23}
24
25#[derive(Default)]
28struct ThumbInteraction {
29 hovered: bool,
30 pressed: bool,
31}
32
33impl ThumbInteraction {
34 fn is_active(&self) -> bool {
38 self.hovered || self.pressed
39 }
40}
41
42impl ThumbRing {
43 fn new(id: EntityId, start: bool, color: Hsla, window: &mut Window, cx: &mut App) -> Self {
45 let channel = if start { "start" } else { "end" };
46 let interaction = window.use_keyed_state(
47 ElementId::NamedChild(Arc::new(("slider-thumb-ring", id).into()), channel.into()),
48 cx,
49 |_, _| ThumbInteraction::default(),
50 );
51 let progress = spring(
52 (("slider-thumb-ring", id), channel),
53 if interaction.read(cx).is_active() {
54 1.
55 } else {
56 0.
57 },
58 cx.theme().motion_tokens().spring_control,
59 window,
60 cx,
61 );
62
63 Self {
64 interaction,
65 width: THUMB_RING_WIDTH * progress,
66 color: color.alpha(THUMB_RING_OPACITY * progress),
67 }
68 }
69
70 fn press_listener<E>(&self, pressed: bool) -> impl Fn(&E, &mut Window, &mut App) + use<E> {
72 let interaction = self.interaction.clone();
73 move |_, _, cx| {
74 interaction.update(cx, |interaction, cx| {
75 if interaction.pressed != pressed {
76 interaction.pressed = pressed;
77 cx.notify();
78 }
79 });
80 }
81 }
82}
83
84#[derive(IntoElement)]
86pub struct Slider {
87 state: Entity<SliderState>,
88 axis: Axis,
89 style: StyleRefinement,
90 disabled: bool,
91 accessibility_label: Option<SharedString>,
92 reverse: bool,
93}
94
95impl Slider {
96 pub fn new(state: &Entity<SliderState>) -> Self {
98 Self {
99 axis: Axis::Horizontal,
100 state: state.clone(),
101 style: StyleRefinement::default(),
102 disabled: false,
103 accessibility_label: None,
104 reverse: false,
105 }
106 }
107
108 pub fn horizontal(mut self) -> Self {
110 self.axis = Axis::Horizontal;
111 self
112 }
113
114 pub fn vertical(mut self) -> Self {
116 self.axis = Axis::Vertical;
117 self
118 }
119
120 pub fn disabled(mut self, disabled: bool) -> Self {
122 self.disabled = disabled;
123 self
124 }
125
126 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
128 self.accessibility_label = Some(label.into());
129 self
130 }
131
132 pub fn reverse(mut self) -> Self {
142 self.reverse = true;
143 self
144 }
145}
146
147impl Styled for Slider {
148 fn style(&mut self) -> &mut StyleRefinement {
149 &mut self.style
150 }
151}
152
153impl RenderOnce for Slider {
154 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
155 let axis = self.axis;
156 let state = self.state.read(cx);
157 let is_range = state.value().is_range();
158 let percentage = state.percentage();
159 let (bar_start, bar_end) = if self.reverse && !is_range {
160 (relative(percentage.end), relative(0.))
162 } else {
163 (relative(percentage.start), relative(1. - percentage.end))
164 };
165 let rem_size = window.rem_size();
166
167 let bar_color = self
168 .style
169 .background
170 .clone()
171 .and_then(|bg| bg.color())
172 .unwrap_or(cx.theme().tokens.slider_bar.into());
173 let thumb_bg: Background = self
174 .style
175 .text
176 .color
177 .map(Into::into)
178 .unwrap_or_else(|| cx.theme().tokens.slider_thumb.into());
179 let corner_radii = self.style.corner_radii.clone();
180 let default_radius = cx.theme().radius_full();
183 let radius = Corners {
184 top_left: corner_radii
185 .top_left
186 .map(|v| v.to_pixels(rem_size))
187 .unwrap_or(default_radius),
188 top_right: corner_radii
189 .top_right
190 .map(|v| v.to_pixels(rem_size))
191 .unwrap_or(default_radius),
192 bottom_left: corner_radii
193 .bottom_left
194 .map(|v| v.to_pixels(rem_size))
195 .unwrap_or(default_radius),
196 bottom_right: corner_radii
197 .bottom_right
198 .map(|v| v.to_pixels(rem_size))
199 .unwrap_or(default_radius),
200 };
201
202 let ring_color = cx.theme().ring;
203 let entity_id = self.state.entity_id();
204 let start_ring = is_range.then(|| ThumbRing::new(entity_id, true, ring_color, window, cx));
205 let end_ring = ThumbRing::new(entity_id, false, ring_color, window, cx);
206
207 let thumb = |position: DefiniteLength, start: bool, ring: ThumbRing| {
208 SliderThumb::new(&self.state)
209 .axis(axis)
210 .start(start)
211 .disabled(self.disabled)
212 .when(!self.disabled, |this| {
213 this.absolute()
214 .when(axis.is_horizontal(), |this| {
215 this.top(px(-5.)).left(position).ml(-px(8.))
216 })
217 .when(axis.is_vertical(), |this| {
218 this.bottom(position).left(px(-5.)).mb(-px(8.))
219 })
220 .flex()
221 .items_center()
222 .justify_center()
223 .flex_shrink_0()
224 .rounded_full_style(cx)
225 .bg(bar_color.opacity(0.5))
226 .size_4()
227 .p(px(1.))
228 .on_hover({
229 let interaction = ring.interaction.clone();
230 move |entered, _, cx| {
231 let entered = *entered;
232 interaction.update(cx, |interaction, cx| {
233 if interaction.hovered != entered {
234 interaction.hovered = entered;
235 cx.notify();
236 }
237 });
238 }
239 })
240 .capture_any_mouse_down(ring.press_listener(true))
243 .capture_any_mouse_up(ring.press_listener(false))
244 .on_mouse_up_out(MouseButton::Left, ring.press_listener(false))
245 .child(
248 div()
249 .flex_none()
250 .absolute()
251 .top(-ring.width)
252 .left(-ring.width)
253 .right(-ring.width)
254 .bottom(-ring.width)
255 .rounded_full_style(cx)
256 .border(ring.width)
257 .border_color(ring.color),
258 )
259 .child(
260 div()
261 .flex_shrink_0()
262 .size_full()
263 .rounded_full_style(cx)
264 .bg(thumb_bg),
265 )
266 })
267 };
268
269 BaseSlider::new(&self.state)
270 .axis(axis)
271 .disabled(self.disabled)
272 .when_some(self.accessibility_label, |this, label| {
273 this.accessibility_label(label)
274 })
275 .flex()
276 .flex_1()
277 .items_center()
278 .justify_center()
279 .when(axis.is_vertical(), |this| this.h(px(120.)))
280 .when(axis.is_horizontal(), |this| this.w_full())
281 .refine_style(&self.style)
282 .bg(cx.theme().transparent)
283 .text_color(cx.theme().foreground)
284 .child(
285 SliderTrack::new(&self.state)
286 .axis(axis)
287 .disabled(self.disabled)
288 .flex()
289 .when(axis.is_horizontal(), |this| {
290 this.items_center().h_6().w_full()
291 })
292 .when(axis.is_vertical(), |this| {
293 this.justify_center().w_6().h_full()
294 })
295 .flex_shrink_0()
296 .child(
297 SliderIndicator::new(&self.state)
298 .relative()
299 .when(axis.is_horizontal(), |this| this.w_full().h_1p5())
300 .when(axis.is_vertical(), |this| this.h_full().w_1p5())
301 .bg(bar_color.opacity(0.2))
302 .active(|this| this.bg(bar_color.opacity(0.4)))
303 .corner_radii(radius)
304 .child(
305 div()
306 .absolute()
307 .when(axis.is_horizontal(), |this| {
308 this.h_full().left(bar_start).right(bar_end)
309 })
310 .when(axis.is_vertical(), |this| {
311 this.w_full().bottom(bar_start).top(bar_end)
312 })
313 .bg(bar_color)
314 .rounded_full_style(cx),
315 )
316 .when_some(start_ring, |this, ring| {
317 this.child(thumb(relative(percentage.start), true, ring))
318 })
319 .child(thumb(relative(percentage.end), false, end_ring)),
320 ),
321 )
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use gpui::{AppContext as _, Context, Modifiers, Render, TestAppContext, point};
328
329 use super::*;
330
331 struct Harness {
332 state: Entity<SliderState>,
333 disabled: bool,
334 }
335
336 impl Render for Harness {
337 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
338 div()
339 .w(px(100.))
340 .h(px(24.))
341 .child(Slider::new(&self.state).disabled(self.disabled))
342 }
343 }
344
345 fn harness(
346 cx: &mut TestAppContext,
347 disabled: bool,
348 ) -> (&mut gpui::VisualTestContext, Entity<SliderState>) {
349 cx.update(crate::theme::init);
350 let state = cx.new(|_| SliderState::new());
351 let result = state.clone();
352 let (_, cx) = cx.add_window_view(move |_, _| Harness { state, disabled });
353 cx.update(|window, cx| window.draw(cx).clear(cx));
354 (cx, result)
355 }
356
357 #[gpui::test]
358 fn pointer_updates_the_migrated_state(cx: &mut TestAppContext) {
359 let (cx, state) = harness(cx, false);
360 cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
361 cx.update(|_, cx| assert!((state.read(cx).value().end() - 50.).abs() < 1.));
362 }
363
364 #[gpui::test]
365 fn disabled_slider_is_inert(cx: &mut TestAppContext) {
366 let (cx, state) = harness(cx, true);
367 cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
368 cx.update(|_, cx| assert_eq!(state.read(cx).value(), SliderValue::Single(0.)));
369 }
370}