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 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 reverse: bool,
92}
93
94impl Slider {
95 pub fn new(state: &Entity<SliderState>) -> Self {
97 Self {
98 axis: Axis::Horizontal,
99 state: state.clone(),
100 style: StyleRefinement::default(),
101 disabled: false,
102 reverse: false,
103 }
104 }
105
106 pub fn horizontal(mut self) -> Self {
108 self.axis = Axis::Horizontal;
109 self
110 }
111
112 pub fn vertical(mut self) -> Self {
114 self.axis = Axis::Vertical;
115 self
116 }
117
118 pub fn disabled(mut self, disabled: bool) -> Self {
120 self.disabled = disabled;
121 self
122 }
123
124 pub fn reverse(mut self) -> Self {
134 self.reverse = true;
135 self
136 }
137}
138
139impl Styled for Slider {
140 fn style(&mut self) -> &mut StyleRefinement {
141 &mut self.style
142 }
143}
144
145impl RenderOnce for Slider {
146 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
147 let axis = self.axis;
148 let state = self.state.read(cx);
149 let is_range = state.value().is_range();
150 let percentage = state.percentage();
151 let (bar_start, bar_end) = if self.reverse && !is_range {
152 (relative(percentage.end), relative(0.))
154 } else {
155 (relative(percentage.start), relative(1. - percentage.end))
156 };
157 let rem_size = window.rem_size();
158
159 let bar_color = self
160 .style
161 .background
162 .clone()
163 .and_then(|bg| bg.color())
164 .unwrap_or(cx.theme().tokens.slider_bar.into());
165 let thumb_bg: Background = self
166 .style
167 .text
168 .color
169 .map(Into::into)
170 .unwrap_or_else(|| cx.theme().tokens.slider_thumb.into());
171 let corner_radii = self.style.corner_radii.clone();
172 let default_radius = cx.theme().radius_full();
175 let radius = Corners {
176 top_left: corner_radii
177 .top_left
178 .map(|v| v.to_pixels(rem_size))
179 .unwrap_or(default_radius),
180 top_right: corner_radii
181 .top_right
182 .map(|v| v.to_pixels(rem_size))
183 .unwrap_or(default_radius),
184 bottom_left: corner_radii
185 .bottom_left
186 .map(|v| v.to_pixels(rem_size))
187 .unwrap_or(default_radius),
188 bottom_right: corner_radii
189 .bottom_right
190 .map(|v| v.to_pixels(rem_size))
191 .unwrap_or(default_radius),
192 };
193
194 let ring_color = cx.theme().ring;
195 let entity_id = self.state.entity_id();
196 let start_ring = is_range.then(|| ThumbRing::new(entity_id, true, ring_color, window, cx));
197 let end_ring = ThumbRing::new(entity_id, false, ring_color, window, cx);
198
199 let thumb = |position: DefiniteLength, start: bool, ring: ThumbRing| {
200 SliderThumb::new(&self.state)
201 .axis(axis)
202 .start(start)
203 .disabled(self.disabled)
204 .when(!self.disabled, |this| {
205 this.absolute()
206 .when(axis.is_horizontal(), |this| {
207 this.top(px(-5.)).left(position).ml(-px(8.))
208 })
209 .when(axis.is_vertical(), |this| {
210 this.bottom(position).left(px(-5.)).mb(-px(8.))
211 })
212 .flex()
213 .items_center()
214 .justify_center()
215 .flex_shrink_0()
216 .rounded_full_style(cx)
217 .bg(bar_color.opacity(0.5))
218 .size_4()
219 .p(px(1.))
220 .on_hover({
221 let interaction = ring.interaction.clone();
222 move |entered, _, cx| {
223 let entered = *entered;
224 interaction.update(cx, |interaction, cx| {
225 if interaction.hovered != entered {
226 interaction.hovered = entered;
227 cx.notify();
228 }
229 });
230 }
231 })
232 .capture_any_mouse_down(ring.press_listener(true))
235 .capture_any_mouse_up(ring.press_listener(false))
236 .on_mouse_up_out(MouseButton::Left, ring.press_listener(false))
237 .child(
240 div()
241 .flex_none()
242 .absolute()
243 .top(-ring.width)
244 .left(-ring.width)
245 .right(-ring.width)
246 .bottom(-ring.width)
247 .rounded_full_style(cx)
248 .border(ring.width)
249 .border_color(ring.color),
250 )
251 .child(
252 div()
253 .flex_shrink_0()
254 .size_full()
255 .rounded_full_style(cx)
256 .bg(thumb_bg),
257 )
258 })
259 };
260
261 BaseSlider::new(&self.state)
262 .axis(axis)
263 .disabled(self.disabled)
264 .flex()
265 .flex_1()
266 .items_center()
267 .justify_center()
268 .when(axis.is_vertical(), |this| this.h(px(120.)))
269 .when(axis.is_horizontal(), |this| this.w_full())
270 .refine_style(&self.style)
271 .bg(cx.theme().transparent)
272 .text_color(cx.theme().foreground)
273 .child(
274 SliderTrack::new(&self.state)
275 .axis(axis)
276 .disabled(self.disabled)
277 .flex()
278 .when(axis.is_horizontal(), |this| {
279 this.items_center().h_6().w_full()
280 })
281 .when(axis.is_vertical(), |this| {
282 this.justify_center().w_6().h_full()
283 })
284 .flex_shrink_0()
285 .child(
286 SliderIndicator::new(&self.state)
287 .relative()
288 .when(axis.is_horizontal(), |this| this.w_full().h_1p5())
289 .when(axis.is_vertical(), |this| this.h_full().w_1p5())
290 .bg(bar_color.opacity(0.2))
291 .active(|this| this.bg(bar_color.opacity(0.4)))
292 .corner_radii(radius)
293 .child(
294 div()
295 .absolute()
296 .when(axis.is_horizontal(), |this| {
297 this.h_full().left(bar_start).right(bar_end)
298 })
299 .when(axis.is_vertical(), |this| {
300 this.w_full().bottom(bar_start).top(bar_end)
301 })
302 .bg(bar_color)
303 .rounded_full_style(cx),
304 )
305 .when_some(start_ring, |this, ring| {
306 this.child(thumb(relative(percentage.start), true, ring))
307 })
308 .child(thumb(relative(percentage.end), false, end_ring)),
309 ),
310 )
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use gpui::{AppContext as _, Context, Modifiers, Render, TestAppContext, point};
317
318 use super::*;
319
320 struct Harness {
321 state: Entity<SliderState>,
322 disabled: bool,
323 }
324
325 impl Render for Harness {
326 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
327 div()
328 .w(px(100.))
329 .h(px(24.))
330 .child(Slider::new(&self.state).disabled(self.disabled))
331 }
332 }
333
334 fn harness(
335 cx: &mut TestAppContext,
336 disabled: bool,
337 ) -> (&mut gpui::VisualTestContext, Entity<SliderState>) {
338 cx.update(crate::theme::init);
339 let state = cx.new(|_| SliderState::new());
340 let result = state.clone();
341 let (_, cx) = cx.add_window_view(move |_, _| Harness { state, disabled });
342 cx.update(|window, cx| window.draw(cx).clear(cx));
343 (cx, result)
344 }
345
346 #[gpui::test]
347 fn pointer_updates_the_migrated_state(cx: &mut TestAppContext) {
348 let (cx, state) = harness(cx, false);
349 cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
350 cx.update(|_, cx| assert!((state.read(cx).value().end() - 50.).abs() < 1.));
351 }
352
353 #[gpui::test]
354 fn disabled_slider_is_inert(cx: &mut TestAppContext) {
355 let (cx, state) = harness(cx, true);
356 cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
357 cx.update(|_, cx| assert_eq!(state.read(cx).value(), SliderValue::Single(0.)));
358 }
359}