Skip to main content

ccf_gpui_widgets/widgets/
slider.rs

1//! Slider widget
2//!
3//! A horizontal slider for selecting numeric values within a range.
4//! Supports mouse dragging, keyboard navigation, and optional value display.
5//!
6//! # Features
7//!
8//! - **Click** anywhere on the track to set the value
9//! - **Click and drag** the thumb or track to adjust the value smoothly
10//!   - Hold **Shift** for fast adjustment (10x step)
11//!   - Hold **Alt/Option** for slow/fine adjustment (0.1x step)
12//! - **Arrow keys** to increment/decrement when focused
13//! - **Home/End** keys to jump to min/max values
14//!
15//! # Example
16//!
17//! ```ignore
18//! use ccf_gpui_widgets::widgets::Slider;
19//!
20//! let slider = cx.new(|cx| {
21//!     Slider::new(cx)
22//!         .with_value(50.0)
23//!         .min(0.0)
24//!         .max(100.0)
25//!         .step(1.0)
26//!         .show_value(true)
27//! });
28//!
29//! // Subscribe to changes
30//! cx.subscribe(&slider, |this, _slider, event: &SliderEvent, cx| {
31//!     match event {
32//!         SliderEvent::Change(value) => println!("Value: {}", value),
33//!         SliderEvent::ChangeComplete => println!("Drag ended"),
34//!     }
35//! }).detach();
36//! ```
37
38use 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/// Events emitted by Slider
49#[derive(Clone, Debug)]
50pub enum SliderEvent {
51    /// Value changed during interaction
52    Change(f64),
53    /// Interaction (drag) completed
54    ChangeComplete,
55}
56
57/// Marker type for slider drag operations
58#[doc(hidden)]
59#[derive(Clone)]
60struct SliderDragState;
61
62/// Empty view used as drag visual (we don't want a visible drag indicator)
63#[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
72/// Slider widget for selecting numeric values
73pub 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 (decimal places)
82    display_precision: Option<usize>,
83    enabled: bool,
84
85    // Measured track dimensions
86    track_origin: Rc<Cell<f32>>,
87    track_width: Rc<Cell<f32>>,
88
89    // Drag state
90    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    /// Create a new slider
103    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    /// Set initial value (builder pattern)
121    #[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    /// Set minimum value (builder pattern)
128    #[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    /// Set maximum value (builder pattern)
136    #[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    /// Set step value (builder pattern)
144    #[must_use]
145    pub fn step(mut self, step: f64) -> Self {
146        self.step = Some(step);
147        self
148    }
149
150    /// Show value display (builder pattern)
151    #[must_use]
152    pub fn show_value(mut self, show: bool) -> Self {
153        self.show_value = show;
154        self
155    }
156
157    /// Set display precision (builder pattern)
158    #[must_use]
159    pub fn display_precision(mut self, precision: usize) -> Self {
160        self.display_precision = Some(precision);
161        self
162    }
163
164    /// Set custom theme (builder pattern)
165    #[must_use]
166    pub fn theme(mut self, theme: Theme) -> Self {
167        self.custom_theme = Some(theme);
168        self
169    }
170
171    /// Set enabled state (builder pattern)
172    #[must_use]
173    pub fn with_enabled(mut self, enabled: bool) -> Self {
174        self.enabled = enabled;
175        self
176    }
177
178    /// Get the current value
179    pub fn value(&self) -> f64 {
180        self.value
181    }
182
183    /// Get the minimum value
184    pub fn get_min(&self) -> f64 {
185        self.min
186    }
187
188    /// Get the maximum value
189    pub fn get_max(&self) -> f64 {
190        self.max
191    }
192
193    /// Get the step value
194    pub fn get_step(&self) -> Option<f64> {
195        self.step
196    }
197
198    /// Get the display precision (decimal places)
199    pub fn get_display_precision(&self) -> Option<usize> {
200        self.display_precision
201    }
202
203    /// Check if the slider is enabled
204    pub fn is_enabled(&self) -> bool {
205        self.enabled
206    }
207
208    /// Set enabled state programmatically
209    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    /// Set value programmatically
217    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    /// Get the focus handle
227    pub fn focus_handle(&self) -> &FocusHandle {
228        &self.focus_handle
229    }
230
231    /// Calculate the percentage (0.0-1.0) of the current value within the range
232    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    /// Snap value to step and clamp to range
241    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    /// Format value for display
258    fn format_value(&self) -> String {
259        format_display_value(self.value, self.display_precision)
260    }
261
262    /// Set value from pixel position on track
263    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        // Dimensions
330        let track_height = 6.0;
331        let thumb_size = 16.0;
332
333        // Clone for closures
334        let track_origin = self.track_origin.clone();
335        let track_width = self.track_width.clone();
336
337        // Colors based on enabled state
338        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        // Build track element with filled portion and thumb
344        let mut track_element = div()
345            .id("ccf_slider_track")
346            .relative()
347            .flex_1()
348            .h(px(thumb_size)) // Height includes thumb space
349            .cursor_for_enabled(enabled)
350            // Canvas to measure track position and dimensions
351            .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            // Track background (centered vertically)
368            .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            // Filled portion
379            .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            // Thumb
390            .child(
391                div()
392                    .absolute()
393                    .top_0()
394                    // Position thumb so its center is at the value position
395                    .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        // Only register interaction handlers when enabled
407        if enabled {
408            track_element = track_element
409                // Mouse down starts drag
410                .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                // Initiate drag
420                .on_drag(SliderDragState, |_state, _position, _window, cx| {
421                    cx.new(|_| EmptyDragView)
422                })
423                // Track drag movement
424                .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                // End drag on mouse up
434                .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}