Skip to main content

armas_basic/components/
slider.rs

1//! Slider Component
2//!
3//! Horizontal slider styled like shadcn/ui Slider.
4//! Features:
5//! - Step snapping
6//! - Double-click to reset to default
7//! - Optional velocity-based dragging (hold Ctrl/Cmd)
8//! - Labels and value display
9
10use crate::animation::{DragMode, VelocityDrag, VelocityDragConfig};
11use crate::ext::ArmasContextExt;
12use egui::{pos2, vec2, Color32, Rect, Sense, Stroke, Ui};
13
14// shadcn Slider constants
15const TRACK_HEIGHT: f32 = 6.0; // h-1.5 in tailwind (6px)
16const THUMB_RADIUS: f32 = 8.0; // size-4 thumb (16px diameter)
17
18/// Persisted drag state for slider
19#[derive(Clone)]
20struct SliderDragState {
21    drag: VelocityDrag,
22    drag_start_value: f32,
23}
24
25impl Default for SliderDragState {
26    fn default() -> Self {
27        Self {
28            drag: VelocityDrag::new(VelocityDragConfig::new().sensitivity(1.0)),
29            drag_start_value: 0.0,
30        }
31    }
32}
33
34/// Slider component
35///
36/// # Example
37///
38/// ```rust,no_run
39/// # use egui::Ui;
40/// # fn example(ui: &mut Ui) {
41/// use armas_basic::components::Slider;
42///
43/// let mut value = 50.0;
44/// let response = Slider::new(0.0, 100.0)
45///     .label("Volume")
46///     .show(ui, &mut value);
47///
48/// if response.changed {
49///     // value was modified
50/// }
51/// # }
52/// ```
53pub struct Slider {
54    id: Option<egui::Id>,
55    min: f32,
56    max: f32,
57    width: f32,
58    height: f32,
59    show_value: bool,
60    label: Option<String>,
61    suffix: Option<String>,
62    step: Option<f32>,
63    default_value: Option<f32>,
64    velocity_mode: bool,
65    sensitivity: f64,
66}
67
68impl Slider {
69    /// Create a new slider
70    #[must_use]
71    pub const fn new(min: f32, max: f32) -> Self {
72        Self {
73            id: None,
74            min,
75            max,
76            width: 200.0,
77            height: 20.0,
78            show_value: true,
79            label: None,
80            suffix: None,
81            step: None,
82            default_value: None,
83            velocity_mode: false,
84            sensitivity: 1.0,
85        }
86    }
87
88    /// Set ID for state persistence (useful for demos where slider is recreated each frame)
89    #[must_use]
90    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
91        self.id = Some(id.into());
92        self
93    }
94
95    /// Set the slider width
96    #[must_use]
97    pub const fn width(mut self, width: f32) -> Self {
98        self.width = width;
99        self
100    }
101
102    /// Set the slider height
103    #[must_use]
104    pub const fn height(mut self, height: f32) -> Self {
105        self.height = height;
106        self
107    }
108
109    /// Show or hide the value label
110    #[must_use]
111    pub const fn show_value(mut self, show: bool) -> Self {
112        self.show_value = show;
113        self
114    }
115
116    /// Set a label for the slider
117    #[must_use]
118    pub fn label(mut self, label: impl Into<String>) -> Self {
119        self.label = Some(label.into());
120        self
121    }
122
123    /// Set a suffix for the value (e.g., "%", "ms", "dB")
124    #[must_use]
125    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
126        self.suffix = Some(suffix.into());
127        self
128    }
129
130    /// Set a step value for snapping
131    #[must_use]
132    pub const fn step(mut self, step: f32) -> Self {
133        self.step = Some(step);
134        self
135    }
136
137    /// Set a default value for double-click reset
138    #[must_use]
139    pub const fn default_value(mut self, value: f32) -> Self {
140        self.default_value = Some(value);
141        self
142    }
143
144    /// Enable velocity-based dragging mode
145    ///
146    /// When enabled, holding Ctrl/Cmd while dragging uses velocity mode
147    /// where faster mouse movement = larger value changes.
148    /// This allows for fine-grained control.
149    #[must_use]
150    pub const fn velocity_mode(mut self, enabled: bool) -> Self {
151        self.velocity_mode = enabled;
152        self
153    }
154
155    /// Set the sensitivity for velocity mode (default: 1.0)
156    ///
157    /// Higher values = more responsive to mouse speed
158    #[must_use]
159    pub const fn sensitivity(mut self, sensitivity: f64) -> Self {
160        self.sensitivity = sensitivity;
161        self
162    }
163
164    /// Show the slider
165    pub fn show(self, ui: &mut Ui, value: &mut f32) -> SliderResponse {
166        let theme = ui.ctx().armas_theme();
167        let mut changed = false;
168
169        // Generate a stable ID for drag state
170        let slider_id = self.id.unwrap_or_else(|| ui.make_persistent_id("slider"));
171        let drag_state_id = slider_id.with("drag_state");
172
173        // Load state from memory if ID is set
174        if let Some(id) = self.id {
175            let state_id = id.with("slider_state");
176            let stored_value: f32 = ui
177                .ctx()
178                .data_mut(|d| d.get_temp(state_id).unwrap_or(*value));
179            *value = stored_value;
180        }
181
182        // Clamp value to range
183        *value = value.clamp(self.min, self.max);
184
185        ui.vertical(|ui| {
186            ui.spacing_mut().item_spacing.y = 4.0;
187            // Label
188            if let Some(label) = &self.label {
189                ui.horizontal(|ui| {
190                    ui.spacing_mut().item_spacing.x = 8.0;
191                    ui.label(label);
192
193                    if self.show_value {
194                        ui.allocate_space(ui.available_size());
195
196                        let value_text = self.suffix.as_ref().map_or_else(
197                            || format!("{value:.1}"),
198                            |suffix| format!("{value:.1}{suffix}"),
199                        );
200                        ui.label(value_text);
201                    }
202                });
203            }
204
205            // Slider track and handle — fill available width when bounded
206            let avail = ui.available_width();
207            let slider_width = if avail.is_finite() && avail > 0.0 {
208                avail
209            } else {
210                self.width
211            };
212            let (rect, response) =
213                ui.allocate_exact_size(vec2(slider_width, self.height), Sense::click_and_drag());
214
215            // Handle double-click to reset
216            if response.double_clicked() {
217                if let Some(default) = self.default_value {
218                    if (*value - default).abs() > 0.001 {
219                        *value = default;
220                        changed = true;
221                    }
222                }
223            }
224            // Handle drag interaction
225            else if response.drag_started() {
226                let mut drag_state = SliderDragState {
227                    drag: VelocityDrag::new(
228                        VelocityDragConfig::new().sensitivity(self.sensitivity),
229                    ),
230                    drag_start_value: *value,
231                };
232
233                if let Some(pos) = response.interact_pointer_pos() {
234                    let use_velocity =
235                        self.velocity_mode && ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
236                    drag_state
237                        .drag
238                        .begin(f64::from(*value), f64::from(pos.x), use_velocity);
239                }
240
241                ui.ctx()
242                    .data_mut(|d| d.insert_temp(drag_state_id, drag_state));
243            } else if response.dragged() {
244                if let Some(pos) = response.interact_pointer_pos() {
245                    let mut drag_state: SliderDragState = ui
246                        .ctx()
247                        .data_mut(|d| d.get_temp(drag_state_id).unwrap_or_default());
248
249                    let range = f64::from(self.max - self.min);
250
251                    if drag_state.drag.mode() == DragMode::Velocity {
252                        // Velocity mode: use drag helper
253                        let delta = drag_state.drag.update_tracked(
254                            f64::from(pos.x),
255                            range,
256                            f64::from(rect.width()),
257                        );
258                        let mut new_value = drag_state.drag_start_value + delta as f32;
259
260                        // Apply step if specified
261                        if let Some(step) = self.step {
262                            new_value = (new_value / step).round() * step;
263                        }
264
265                        if (new_value - *value).abs() > 0.001 {
266                            *value = new_value.clamp(self.min, self.max);
267                            changed = true;
268                        }
269                    } else {
270                        // Absolute mode: position maps directly to value
271                        let t = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
272                        let mut new_value = self.min + t * (self.max - self.min);
273
274                        // Apply step if specified
275                        if let Some(step) = self.step {
276                            new_value = (new_value / step).round() * step;
277                        }
278
279                        if (new_value - *value).abs() > 0.001 {
280                            *value = new_value.clamp(self.min, self.max);
281                            changed = true;
282                        }
283                    }
284
285                    ui.ctx()
286                        .data_mut(|d| d.insert_temp(drag_state_id, drag_state));
287                }
288            } else if response.drag_stopped() {
289                ui.ctx().data_mut(|d| {
290                    let mut drag_state: SliderDragState =
291                        d.get_temp(drag_state_id).unwrap_or_default();
292                    drag_state.drag.end();
293                    d.insert_temp(drag_state_id, drag_state);
294                });
295            }
296            // Handle click (not drag)
297            else if response.clicked() {
298                if let Some(pos) = response.interact_pointer_pos() {
299                    let t = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
300                    let mut new_value = self.min + t * (self.max - self.min);
301
302                    // Apply step if specified
303                    if let Some(step) = self.step {
304                        new_value = (new_value / step).round() * step;
305                    }
306
307                    if (new_value - *value).abs() > 0.001 {
308                        *value = new_value.clamp(self.min, self.max);
309                        changed = true;
310                    }
311                }
312            }
313
314            if ui.is_rect_visible(rect) {
315                let painter = ui.painter();
316
317                // Background track (using shadcn constants)
318                let track_rect =
319                    Rect::from_center_size(rect.center(), vec2(rect.width(), TRACK_HEIGHT));
320
321                painter.rect_filled(track_rect, TRACK_HEIGHT / 2.0, theme.muted());
322
323                // Filled track (progress)
324                let t = (*value - self.min) / (self.max - self.min);
325                let fill_width = track_rect.width() * t;
326                let fill_rect = Rect::from_min_size(track_rect.min, vec2(fill_width, TRACK_HEIGHT));
327
328                painter.rect_filled(fill_rect, TRACK_HEIGHT / 2.0, theme.primary());
329
330                // Handle (thumb)
331                let handle_x = track_rect.left() + fill_width;
332                let handle_center = pos2(handle_x, track_rect.center().y);
333
334                // Hover ring effect (like shadcn ring-4)
335                if response.hovered() || response.dragged() {
336                    let ring_color = theme.ring().gamma_multiply(0.5);
337                    painter.circle_filled(handle_center, THUMB_RADIUS + 4.0, ring_color);
338                }
339
340                // Handle shadow
341                painter.circle_filled(
342                    handle_center + vec2(0.0, 1.0),
343                    THUMB_RADIUS,
344                    Color32::from_black_alpha(40),
345                );
346
347                // Handle
348                let handle_color = if response.dragged() {
349                    theme.primary()
350                } else {
351                    theme.foreground()
352                };
353
354                painter.circle_filled(handle_center, THUMB_RADIUS, handle_color);
355
356                // Handle border
357                painter.circle_stroke(
358                    handle_center,
359                    THUMB_RADIUS,
360                    Stroke::new(1.0, theme.primary()),
361                );
362            }
363        });
364
365        // Save state to memory if ID is set
366        if let Some(id) = self.id {
367            let state_id = id.with("slider_state");
368            ui.ctx().data_mut(|d| {
369                d.insert_temp(state_id, *value);
370            });
371        }
372
373        let response = ui.interact(ui.min_rect(), slider_id.with("response"), Sense::hover());
374
375        SliderResponse {
376            response,
377            value: *value,
378            changed,
379        }
380    }
381}
382
383/// Response from a slider
384pub struct SliderResponse {
385    /// The UI response
386    pub response: egui::Response,
387    /// Current value
388    pub value: f32,
389    /// Whether the value changed this frame
390    pub changed: bool,
391}