ccf_gpui_widgets/widgets/
slider.rs1use std::cell::Cell;
39use std::rc::Rc;
40
41use gpui::prelude::*;
42use gpui::*;
43
44use crate::theme::{get_theme_or, Theme};
45use crate::utils::format_display_value;
46use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
47
48#[derive(Clone, Debug)]
50pub enum SliderEvent {
51 Change(f64),
53 ChangeComplete,
55}
56
57#[doc(hidden)]
59#[derive(Clone)]
60struct SliderDragState;
61
62#[doc(hidden)]
64struct EmptyDragView;
65
66impl Render for EmptyDragView {
67 fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
68 div().size_0()
69 }
70}
71
72pub struct Slider {
74 value: f64,
75 min: f64,
76 max: f64,
77 step: Option<f64>,
78 focus_handle: FocusHandle,
79 custom_theme: Option<Theme>,
80 show_value: bool,
81 display_precision: Option<usize>,
83 enabled: bool,
84
85 track_origin: Rc<Cell<f32>>,
87 track_width: Rc<Cell<f32>>,
88
89 dragging: bool,
91}
92
93impl EventEmitter<SliderEvent> for Slider {}
94
95impl Focusable for Slider {
96 fn focus_handle(&self, _cx: &App) -> FocusHandle {
97 self.focus_handle.clone()
98 }
99}
100
101impl Slider {
102 pub fn new(cx: &mut Context<Self>) -> Self {
104 Self {
105 value: 0.0,
106 min: 0.0,
107 max: 100.0,
108 step: None,
109 focus_handle: cx.focus_handle().tab_stop(true),
110 custom_theme: None,
111 show_value: false,
112 display_precision: None,
113 enabled: true,
114 track_origin: Rc::new(Cell::new(0.0)),
115 track_width: Rc::new(Cell::new(0.0)),
116 dragging: false,
117 }
118 }
119
120 #[must_use]
122 pub fn with_value(mut self, value: f64) -> Self {
123 self.value = value.clamp(self.min, self.max);
124 self
125 }
126
127 #[must_use]
129 pub fn min(mut self, min: f64) -> Self {
130 self.min = min;
131 self.value = self.value.clamp(self.min, self.max);
132 self
133 }
134
135 #[must_use]
137 pub fn max(mut self, max: f64) -> Self {
138 self.max = max;
139 self.value = self.value.clamp(self.min, self.max);
140 self
141 }
142
143 #[must_use]
145 pub fn step(mut self, step: f64) -> Self {
146 self.step = Some(step);
147 self
148 }
149
150 #[must_use]
152 pub fn show_value(mut self, show: bool) -> Self {
153 self.show_value = show;
154 self
155 }
156
157 #[must_use]
159 pub fn display_precision(mut self, precision: usize) -> Self {
160 self.display_precision = Some(precision);
161 self
162 }
163
164 #[must_use]
166 pub fn theme(mut self, theme: Theme) -> Self {
167 self.custom_theme = Some(theme);
168 self
169 }
170
171 #[must_use]
173 pub fn with_enabled(mut self, enabled: bool) -> Self {
174 self.enabled = enabled;
175 self
176 }
177
178 pub fn value(&self) -> f64 {
180 self.value
181 }
182
183 pub fn get_min(&self) -> f64 {
185 self.min
186 }
187
188 pub fn get_max(&self) -> f64 {
190 self.max
191 }
192
193 pub fn get_step(&self) -> Option<f64> {
195 self.step
196 }
197
198 pub fn get_display_precision(&self) -> Option<usize> {
200 self.display_precision
201 }
202
203 pub fn is_enabled(&self) -> bool {
205 self.enabled
206 }
207
208 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
210 if self.enabled != enabled {
211 self.enabled = enabled;
212 cx.notify();
213 }
214 }
215
216 pub fn set_value(&mut self, value: f64, cx: &mut Context<Self>) {
218 let normalized = self.normalize_value(value);
219 if (self.value - normalized).abs() > f64::EPSILON {
220 self.value = normalized;
221 cx.emit(SliderEvent::Change(self.value));
222 cx.notify();
223 }
224 }
225
226 pub fn focus_handle(&self) -> &FocusHandle {
228 &self.focus_handle
229 }
230
231 fn percentage(&self) -> f64 {
233 if (self.max - self.min).abs() < f64::EPSILON {
234 0.0
235 } else {
236 (self.value - self.min) / (self.max - self.min)
237 }
238 }
239
240 fn normalize_value(&self, value: f64) -> f64 {
242 let snapped = if let Some(step) = self.step {
243 if step > 0.0 {
244 let offset = value - self.min;
245 let n = (offset / step).round();
246 self.min + n * step
247 } else {
248 value
249 }
250 } else {
251 value
252 };
253
254 snapped.clamp(self.min, self.max)
255 }
256
257 fn format_value(&self) -> String {
259 format_display_value(self.value, self.display_precision)
260 }
261
262 fn set_value_from_position(&mut self, x: f32, cx: &mut Context<Self>) {
264 let track_origin = self.track_origin.get();
265 let track_width = self.track_width.get();
266
267 if track_width > 0.0 {
268 let relative_x = (x - track_origin).clamp(0.0, track_width);
269 let percentage = (relative_x / track_width) as f64;
270 let raw_value = self.min + percentage * (self.max - self.min);
271 let normalized = self.normalize_value(raw_value);
272
273 if (self.value - normalized).abs() > f64::EPSILON {
274 self.value = normalized;
275 cx.emit(SliderEvent::Change(self.value));
276 cx.notify();
277 }
278 }
279 }
280
281 fn adjust_value(&mut self, direction: f64, multiplier: f64, cx: &mut Context<Self>) {
282 let step = self.step.unwrap_or(1.0) * multiplier * direction;
283 let new_value = self.normalize_value(self.value + step);
284 if (self.value - new_value).abs() > f64::EPSILON {
285 self.value = new_value;
286 cx.emit(SliderEvent::Change(self.value));
287 cx.notify();
288 }
289 }
290
291 fn increment(&mut self, multiplier: f64, cx: &mut Context<Self>) {
292 self.adjust_value(1.0, multiplier, cx);
293 }
294
295 fn decrement(&mut self, multiplier: f64, cx: &mut Context<Self>) {
296 self.adjust_value(-1.0, multiplier, cx);
297 }
298
299 fn go_to_min(&mut self, cx: &mut Context<Self>) {
300 self.set_value(self.min, cx);
301 }
302
303 fn go_to_max(&mut self, cx: &mut Context<Self>) {
304 self.set_value(self.max, cx);
305 }
306
307 fn start_drag(&mut self) {
308 self.dragging = true;
309 }
310
311 fn end_drag(&mut self, cx: &mut Context<Self>) {
312 if self.dragging {
313 self.dragging = false;
314 cx.emit(SliderEvent::ChangeComplete);
315 }
316 }
317}
318
319impl Render for Slider {
320 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
321 let theme = get_theme_or(cx, self.custom_theme.as_ref());
322 let focus_handle = self.focus_handle.clone();
323 let is_focused = self.focus_handle.is_focused(window);
324 let percentage = self.percentage();
325 let show_value = self.show_value;
326 let display_value = self.format_value();
327 let enabled = self.enabled;
328
329 let track_height = 6.0;
331 let thumb_size = 16.0;
332
333 let track_origin = self.track_origin.clone();
335 let track_width = self.track_width.clone();
336
337 let track_bg = if enabled { theme.bg_input } else { theme.disabled_bg };
339 let filled_bg = if enabled { theme.primary } else { theme.disabled_text };
340 let thumb_border = if enabled { theme.primary } else { theme.disabled_text };
341 let value_color = if enabled { theme.text_value } else { theme.disabled_text };
342
343 let mut track_element = div()
345 .id("ccf_slider_track")
346 .relative()
347 .flex_1()
348 .h(px(thumb_size)) .cursor_for_enabled(enabled)
350 .child(
352 canvas(
353 {
354 let origin = track_origin.clone();
355 let width = track_width.clone();
356 move |bounds, _window, _cx| {
357 origin.set(bounds.origin.x.into());
358 width.set(bounds.size.width.into());
359 bounds
360 }
361 },
362 |_, _, _, _| {},
363 )
364 .size_full()
365 .absolute()
366 )
367 .child(
369 div()
370 .absolute()
371 .top(px((thumb_size - track_height) / 2.0))
372 .left_0()
373 .right_0()
374 .h(px(track_height))
375 .rounded_full()
376 .bg(rgb(track_bg))
377 )
378 .child(
380 div()
381 .absolute()
382 .top(px((thumb_size - track_height) / 2.0))
383 .left_0()
384 .w(relative(percentage as f32))
385 .h(px(track_height))
386 .rounded_full()
387 .bg(rgb(filled_bg))
388 )
389 .child(
391 div()
392 .absolute()
393 .top_0()
394 .left(relative(percentage as f32))
396 .ml(px(-(thumb_size / 2.0)))
397 .w(px(thumb_size))
398 .h(px(thumb_size))
399 .rounded_full()
400 .bg(rgb(theme.bg_white))
401 .border_2()
402 .border_color(rgb(thumb_border))
403 .when(enabled, |d| d.shadow_sm())
404 );
405
406 if enabled {
408 track_element = track_element
409 .on_mouse_down(MouseButton::Left, cx.listener(|slider, event: &MouseDownEvent, window, cx| {
411 if !slider.enabled {
412 return;
413 }
414 slider.focus_handle.focus(window);
415 slider.start_drag();
416 let x: f32 = event.position.x.into();
417 slider.set_value_from_position(x, cx);
418 }))
419 .on_drag(SliderDragState, |_state, _position, _window, cx| {
421 cx.new(|_| EmptyDragView)
422 })
423 .on_drag_move(cx.listener(|slider, event: &DragMoveEvent<SliderDragState>, _window, cx| {
425 if !slider.enabled {
426 return;
427 }
428 if slider.dragging {
429 let x: f32 = event.event.position.x.into();
430 slider.set_value_from_position(x, cx);
431 }
432 }))
433 .on_mouse_up(MouseButton::Left, cx.listener(|slider, _event: &MouseUpEvent, _window, cx| {
435 slider.end_drag(cx);
436 }))
437 .on_mouse_up_out(MouseButton::Left, cx.listener(|slider, _event: &MouseUpEvent, _window, cx| {
438 slider.end_drag(cx);
439 }));
440 }
441
442 with_focus_actions(
443 div()
444 .id("ccf_slider")
445 .track_focus(&focus_handle)
446 .tab_stop(enabled),
447 cx,
448 )
449 .on_key_down(cx.listener(|slider, event: &KeyDownEvent, window, cx| {
450 if !slider.enabled {
451 return;
452 }
453 if handle_tab_navigation(event, window) {
454 return;
455 }
456 let multiplier = if event.keystroke.modifiers.shift { 10.0 } else { 1.0 };
457 match event.keystroke.key.as_str() {
458 "left" => slider.decrement(multiplier, cx),
459 "right" => slider.increment(multiplier, cx),
460 "home" => slider.go_to_min(cx),
461 "end" => slider.go_to_max(cx),
462 _ => {}
463 }
464 }))
465 .flex()
466 .flex_row()
467 .gap_3()
468 .items_center()
469 .w_full()
470 .py_1()
471 .px_1()
472 .rounded_sm()
473 .border_2()
474 .border_color(if is_focused && enabled { rgb(theme.border_focus) } else { rgba(0x00000000) })
475 .child(track_element)
476 .when(show_value, |d| {
477 d.child(
478 div()
479 .min_w(px(40.0))
480 .text_sm()
481 .text_color(rgb(value_color))
482 .text_right()
483 .child(display_value)
484 )
485 })
486 }
487}