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
206            let slider_width = self.width;
207            let (rect, response) =
208                ui.allocate_exact_size(vec2(slider_width, self.height), Sense::click_and_drag());
209
210            // Handle double-click to reset
211            if response.double_clicked() {
212                if let Some(default) = self.default_value {
213                    if (*value - default).abs() > 0.001 {
214                        *value = default;
215                        changed = true;
216                    }
217                }
218            }
219            // Handle drag interaction
220            else if response.drag_started() {
221                let mut drag_state = SliderDragState {
222                    drag: VelocityDrag::new(
223                        VelocityDragConfig::new().sensitivity(self.sensitivity),
224                    ),
225                    drag_start_value: *value,
226                };
227
228                if let Some(pos) = response.interact_pointer_pos() {
229                    let use_velocity =
230                        self.velocity_mode && ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
231                    drag_state
232                        .drag
233                        .begin(f64::from(*value), f64::from(pos.x), use_velocity);
234                }
235
236                ui.ctx()
237                    .data_mut(|d| d.insert_temp(drag_state_id, drag_state));
238            } else if response.dragged() {
239                if let Some(pos) = response.interact_pointer_pos() {
240                    let mut drag_state: SliderDragState = ui
241                        .ctx()
242                        .data_mut(|d| d.get_temp(drag_state_id).unwrap_or_default());
243
244                    let range = f64::from(self.max - self.min);
245
246                    if drag_state.drag.mode() == DragMode::Velocity {
247                        // Velocity mode: use drag helper
248                        let delta = drag_state.drag.update_tracked(
249                            f64::from(pos.x),
250                            range,
251                            f64::from(rect.width()),
252                        );
253                        let mut new_value = drag_state.drag_start_value + delta as f32;
254
255                        // Apply step if specified
256                        if let Some(step) = self.step {
257                            new_value = (new_value / step).round() * step;
258                        }
259
260                        if (new_value - *value).abs() > 0.001 {
261                            *value = new_value.clamp(self.min, self.max);
262                            changed = true;
263                        }
264                    } else {
265                        // Absolute mode: position maps directly to value
266                        let t = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
267                        let mut new_value = self.min + t * (self.max - self.min);
268
269                        // Apply step if specified
270                        if let Some(step) = self.step {
271                            new_value = (new_value / step).round() * step;
272                        }
273
274                        if (new_value - *value).abs() > 0.001 {
275                            *value = new_value.clamp(self.min, self.max);
276                            changed = true;
277                        }
278                    }
279
280                    ui.ctx()
281                        .data_mut(|d| d.insert_temp(drag_state_id, drag_state));
282                }
283            } else if response.drag_stopped() {
284                ui.ctx().data_mut(|d| {
285                    let mut drag_state: SliderDragState =
286                        d.get_temp(drag_state_id).unwrap_or_default();
287                    drag_state.drag.end();
288                    d.insert_temp(drag_state_id, drag_state);
289                });
290            }
291            // Handle click (not drag)
292            else if response.clicked() {
293                if let Some(pos) = response.interact_pointer_pos() {
294                    let t = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
295                    let mut new_value = self.min + t * (self.max - self.min);
296
297                    // Apply step if specified
298                    if let Some(step) = self.step {
299                        new_value = (new_value / step).round() * step;
300                    }
301
302                    if (new_value - *value).abs() > 0.001 {
303                        *value = new_value.clamp(self.min, self.max);
304                        changed = true;
305                    }
306                }
307            }
308
309            if ui.is_rect_visible(rect) {
310                let painter = ui.painter();
311
312                // Background track (using shadcn constants)
313                let track_rect =
314                    Rect::from_center_size(rect.center(), vec2(rect.width(), TRACK_HEIGHT));
315
316                painter.rect_filled(track_rect, TRACK_HEIGHT / 2.0, theme.muted());
317
318                // Filled track (progress)
319                let t = (*value - self.min) / (self.max - self.min);
320                let fill_width = track_rect.width() * t;
321                let fill_rect = Rect::from_min_size(track_rect.min, vec2(fill_width, TRACK_HEIGHT));
322
323                painter.rect_filled(fill_rect, TRACK_HEIGHT / 2.0, theme.primary());
324
325                // Handle (thumb)
326                let handle_x = track_rect.left() + fill_width;
327                let handle_center = pos2(handle_x, track_rect.center().y);
328
329                // Hover ring effect (like shadcn ring-4)
330                if response.hovered() || response.dragged() {
331                    let ring_color = theme.ring().gamma_multiply(0.5);
332                    painter.circle_filled(handle_center, THUMB_RADIUS + 4.0, ring_color);
333                }
334
335                // Handle shadow
336                painter.circle_filled(
337                    handle_center + vec2(0.0, 1.0),
338                    THUMB_RADIUS,
339                    Color32::from_black_alpha(40),
340                );
341
342                // Handle
343                let handle_color = if response.dragged() {
344                    theme.primary()
345                } else {
346                    theme.foreground()
347                };
348
349                painter.circle_filled(handle_center, THUMB_RADIUS, handle_color);
350
351                // Handle border
352                painter.circle_stroke(
353                    handle_center,
354                    THUMB_RADIUS,
355                    Stroke::new(1.0, theme.primary()),
356                );
357            }
358        });
359
360        // Save state to memory if ID is set
361        if let Some(id) = self.id {
362            let state_id = id.with("slider_state");
363            ui.ctx().data_mut(|d| {
364                d.insert_temp(state_id, *value);
365            });
366        }
367
368        let response = ui.interact(ui.min_rect(), slider_id.with("response"), Sense::hover());
369
370        SliderResponse {
371            response,
372            value: *value,
373            changed,
374        }
375    }
376}
377
378/// Response from a slider
379pub struct SliderResponse {
380    /// The UI response
381    pub response: egui::Response,
382    /// Current value
383    pub value: f32,
384    /// Whether the value changed this frame
385    pub changed: bool,
386}