Skip to main content

armas_basic/components/
range_slider.rs

1//! Range Slider Component
2//!
3//! Horizontal slider with two thumbs for selecting a range (min/max).
4
5use crate::ext::ArmasContextExt;
6use egui::{pos2, vec2, Color32, Rect, Response, Sense, Stroke, Ui};
7
8/// Which thumb is being dragged
9#[derive(Clone, Copy, Debug, PartialEq, Default)]
10enum DragTarget {
11    #[default]
12    None,
13    Min,
14    Max,
15    Both,
16}
17
18/// Persisted drag state for range slider
19#[derive(Clone, Default)]
20struct RangeSliderDragState {
21    target: DragTarget,
22    drag_start_min: f32,
23    drag_start_max: f32,
24    drag_start_x: f32,
25}
26
27/// Geometry parameters for slider layout
28struct SliderGeometry<'a> {
29    track_rect: &'a Rect,
30    thumb_radius: f32,
31    min_x: f32,
32    max_x: f32,
33}
34
35/// Range slider with two thumbs for min/max selection
36///
37/// # Example
38///
39/// ```rust,no_run
40/// # use egui::Ui;
41/// # fn example(ui: &mut Ui) {
42/// use armas_basic::components::RangeSlider;
43///
44/// let mut min = 20.0;
45/// let mut max = 80.0;
46/// RangeSlider::new(0.0, 100.0)
47///     .label("Price range")
48///     .show(ui, &mut min, &mut max);
49/// # }
50/// ```
51pub struct RangeSlider {
52    id: Option<egui::Id>,
53    range_min: f32,
54    range_max: f32,
55    width: f32,
56    height: f32,
57    show_value: bool,
58    label: Option<String>,
59    suffix: Option<String>,
60    step: Option<f32>,
61    min_gap: f32,
62    allow_range_drag: bool,
63}
64
65impl RangeSlider {
66    /// Create a new range slider
67    #[must_use]
68    pub const fn new(range_min: f32, range_max: f32) -> Self {
69        Self {
70            id: None,
71            range_min,
72            range_max,
73            width: 200.0,
74            height: 20.0,
75            show_value: true,
76            label: None,
77            suffix: None,
78            step: None,
79            min_gap: 0.0,
80            allow_range_drag: true,
81        }
82    }
83
84    /// Set ID for state persistence
85    #[must_use]
86    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
87        self.id = Some(id.into());
88        self
89    }
90
91    /// Set the slider width
92    #[must_use]
93    pub const fn width(mut self, width: f32) -> Self {
94        self.width = width;
95        self
96    }
97
98    /// Set the slider height
99    #[must_use]
100    pub const fn height(mut self, height: f32) -> Self {
101        self.height = height;
102        self
103    }
104
105    /// Show or hide the value label
106    #[must_use]
107    pub const fn show_value(mut self, show: bool) -> Self {
108        self.show_value = show;
109        self
110    }
111
112    /// Set a label for the slider
113    #[must_use]
114    pub fn label(mut self, label: impl Into<String>) -> Self {
115        self.label = Some(label.into());
116        self
117    }
118
119    /// Set a suffix for the values (e.g., "%", "ms", "Hz")
120    #[must_use]
121    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
122        self.suffix = Some(suffix.into());
123        self
124    }
125
126    /// Set a step value for snapping
127    #[must_use]
128    pub const fn step(mut self, step: f32) -> Self {
129        self.step = Some(step);
130        self
131    }
132
133    /// Set minimum gap between min and max thumbs
134    #[must_use]
135    pub const fn min_gap(mut self, gap: f32) -> Self {
136        self.min_gap = gap;
137        self
138    }
139
140    /// Allow dragging the filled region to move both thumbs together
141    #[must_use]
142    pub const fn allow_range_drag(mut self, allow: bool) -> Self {
143        self.allow_range_drag = allow;
144        self
145    }
146
147    /// Show the range slider
148    pub fn show(
149        self,
150        ui: &mut Ui,
151        min_value: &mut f32,
152        max_value: &mut f32,
153    ) -> RangeSliderResponse {
154        let theme = ui.ctx().armas_theme();
155        let mut changed = false;
156
157        let slider_id = self
158            .id
159            .unwrap_or_else(|| ui.make_persistent_id("range_slider"));
160        let drag_state_id = slider_id.with("drag_state");
161
162        // Load state from memory if ID is set (for demos where values reset each frame)
163        if let Some(id) = self.id {
164            let min_state_id = id.with("min_value");
165            let max_state_id = id.with("max_value");
166            *min_value = ui
167                .ctx()
168                .data_mut(|d| d.get_temp(min_state_id).unwrap_or(*min_value));
169            *max_value = ui
170                .ctx()
171                .data_mut(|d| d.get_temp(max_state_id).unwrap_or(*max_value));
172        }
173
174        // Clamp and ensure min <= max
175        self.clamp_values(min_value, max_value);
176
177        ui.vertical(|ui| {
178            ui.spacing_mut().item_spacing.y = 4.0;
179
180            // Label and values
181            self.draw_label(ui, *min_value, *max_value);
182
183            // Allocate slider area
184            let (rect, response) =
185                ui.allocate_exact_size(vec2(self.width, self.height), Sense::click_and_drag());
186
187            // Track and thumb sizes (matching shadcn: h-1.5 track, size-4 thumb)
188            let track_height = 6.0;
189            let thumb_radius = 8.0;
190            let track_rect =
191                Rect::from_center_size(rect.center(), vec2(rect.width(), track_height));
192
193            // Calculate thumb positions
194            let min_x = self.value_to_x(*min_value, &track_rect);
195            let max_x = self.value_to_x(*max_value, &track_rect);
196
197            let geometry = SliderGeometry {
198                track_rect: &track_rect,
199                thumb_radius,
200                min_x,
201                max_x,
202            };
203
204            // Handle interactions
205            let drag_state = self.handle_interaction(
206                ui,
207                &response,
208                drag_state_id,
209                &geometry,
210                min_value,
211                max_value,
212                &mut changed,
213            );
214
215            // Determine which thumb is hovered (for per-thumb hover effect)
216            let hovered_thumb = if response.hovered() {
217                response.hover_pos().and_then(|pos| {
218                    let dist_to_min = (pos.x - geometry.min_x).abs();
219                    let dist_to_max = (pos.x - geometry.max_x).abs();
220                    if dist_to_min <= geometry.thumb_radius {
221                        Some(DragTarget::Min)
222                    } else if dist_to_max <= geometry.thumb_radius {
223                        Some(DragTarget::Max)
224                    } else {
225                        None
226                    }
227                })
228            } else {
229                None
230            };
231
232            // Draw the slider
233            self.draw(
234                ui,
235                &response,
236                &geometry,
237                track_height,
238                &drag_state,
239                hovered_thumb,
240                &theme,
241            );
242        });
243
244        // Save state to memory if ID is set
245        if let Some(id) = self.id {
246            let min_state_id = id.with("min_value");
247            let max_state_id = id.with("max_value");
248            ui.ctx().data_mut(|d| {
249                d.insert_temp(min_state_id, *min_value);
250                d.insert_temp(max_state_id, *max_value);
251            });
252        }
253
254        let response = ui.interact(ui.min_rect(), slider_id.with("response"), Sense::hover());
255
256        RangeSliderResponse {
257            response,
258            min_value: *min_value,
259            max_value: *max_value,
260            changed,
261        }
262    }
263
264    fn clamp_values(&self, min_value: &mut f32, max_value: &mut f32) {
265        *min_value = min_value.clamp(self.range_min, self.range_max);
266        *max_value = max_value.clamp(self.range_min, self.range_max);
267        if *min_value > *max_value {
268            std::mem::swap(min_value, max_value);
269        }
270    }
271
272    fn draw_label(&self, ui: &mut Ui, min_value: f32, max_value: f32) {
273        if self.label.is_none() && !self.show_value {
274            return;
275        }
276
277        ui.horizontal(|ui| {
278            ui.spacing_mut().item_spacing.x = 8.0;
279
280            if let Some(label) = &self.label {
281                ui.label(label);
282            }
283
284            if self.show_value {
285                ui.allocate_space(ui.available_size());
286                ui.label(format!(
287                    "{} - {}",
288                    self.format_value(min_value),
289                    self.format_value(max_value)
290                ));
291            }
292        });
293    }
294
295    fn format_value(&self, value: f32) -> String {
296        self.suffix.as_ref().map_or_else(
297            || format!("{value:.1}"),
298            |suffix| format!("{value:.1}{suffix}"),
299        )
300    }
301
302    fn apply_step(&self, value: f32) -> f32 {
303        self.step
304            .map_or(value, |step| (value / step).round() * step)
305    }
306
307    fn value_to_x(&self, value: f32, track_rect: &Rect) -> f32 {
308        let t = (value - self.range_min) / (self.range_max - self.range_min);
309        track_rect.left() + t * track_rect.width()
310    }
311
312    fn x_to_value(&self, x: f32, track_rect: &Rect) -> f32 {
313        let t = ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0);
314        self.range_min + t * (self.range_max - self.range_min)
315    }
316
317    fn determine_target(
318        &self,
319        pos_x: f32,
320        min_x: f32,
321        max_x: f32,
322        handle_radius: f32,
323    ) -> DragTarget {
324        let dist_to_min = (pos_x - min_x).abs();
325        let dist_to_max = (pos_x - max_x).abs();
326        let in_range = pos_x > min_x + handle_radius && pos_x < max_x - handle_radius;
327
328        if self.allow_range_drag
329            && in_range
330            && dist_to_min > handle_radius
331            && dist_to_max > handle_radius
332        {
333            DragTarget::Both
334        } else if dist_to_min <= dist_to_max {
335            DragTarget::Min
336        } else {
337            DragTarget::Max
338        }
339    }
340
341    fn handle_interaction(
342        &self,
343        ui: &mut Ui,
344        response: &Response,
345        drag_state_id: egui::Id,
346        geometry: &SliderGeometry,
347        min_value: &mut f32,
348        max_value: &mut f32,
349        changed: &mut bool,
350    ) -> RangeSliderDragState {
351        let mut drag_state: RangeSliderDragState = ui
352            .ctx()
353            .data_mut(|d| d.get_temp(drag_state_id).unwrap_or_default());
354
355        // Handle drag start
356        if response.drag_started() {
357            if let Some(pos) = response.interact_pointer_pos() {
358                drag_state.target = self.determine_target(
359                    pos.x,
360                    geometry.min_x,
361                    geometry.max_x,
362                    geometry.thumb_radius,
363                );
364                drag_state.drag_start_min = *min_value;
365                drag_state.drag_start_max = *max_value;
366                drag_state.drag_start_x = pos.x;
367            }
368        }
369
370        // Handle dragging
371        if response.dragged() {
372            if let Some(pos) = response.interact_pointer_pos() {
373                // Fallback if target wasn't set
374                if drag_state.target == DragTarget::None {
375                    drag_state.target = self.determine_target(
376                        pos.x,
377                        geometry.min_x,
378                        geometry.max_x,
379                        geometry.thumb_radius,
380                    );
381                    drag_state.drag_start_min = *min_value;
382                    drag_state.drag_start_max = *max_value;
383                    drag_state.drag_start_x = pos.x;
384                }
385
386                self.update_values_from_drag(
387                    pos.x,
388                    geometry.track_rect,
389                    &drag_state,
390                    min_value,
391                    max_value,
392                    changed,
393                );
394            }
395        }
396
397        // Handle drag end
398        if response.drag_stopped() {
399            drag_state.target = DragTarget::None;
400        }
401
402        // Save state
403        ui.ctx()
404            .data_mut(|d| d.insert_temp(drag_state_id, drag_state.clone()));
405
406        drag_state
407    }
408
409    fn update_values_from_drag(
410        &self,
411        pos_x: f32,
412        track_rect: &Rect,
413        drag_state: &RangeSliderDragState,
414        min_value: &mut f32,
415        max_value: &mut f32,
416        changed: &mut bool,
417    ) {
418        let raw_value = self.x_to_value(pos_x, track_rect);
419
420        match drag_state.target {
421            DragTarget::Min => {
422                let new_value = self
423                    .apply_step(raw_value)
424                    .clamp(self.range_min, *max_value - self.min_gap);
425
426                if (new_value - *min_value).abs() > 0.001 {
427                    *min_value = new_value;
428                    *changed = true;
429                }
430            }
431            DragTarget::Max => {
432                let new_value = self
433                    .apply_step(raw_value)
434                    .clamp(*min_value + self.min_gap, self.range_max);
435
436                if (new_value - *max_value).abs() > 0.001 {
437                    *max_value = new_value;
438                    *changed = true;
439                }
440            }
441            DragTarget::Both => {
442                let delta_x = pos_x - drag_state.drag_start_x;
443                let delta_value = delta_x / track_rect.width() * (self.range_max - self.range_min);
444
445                let range_size = drag_state.drag_start_max - drag_state.drag_start_min;
446                let mut new_min = drag_state.drag_start_min + delta_value;
447                #[allow(clippy::useless_let_if_seq)]
448                let mut new_max = drag_state.drag_start_max + delta_value;
449
450                // Clamp to bounds while preserving range size
451                if new_min < self.range_min {
452                    new_min = self.range_min;
453                    new_max = self.range_min + range_size;
454                }
455                if new_max > self.range_max {
456                    new_max = self.range_max;
457                    new_min = self.range_max - range_size;
458                }
459
460                new_min = self.apply_step(new_min);
461                new_max = self.apply_step(new_max);
462
463                if (new_min - *min_value).abs() > 0.001 || (new_max - *max_value).abs() > 0.001 {
464                    *min_value = new_min;
465                    *max_value = new_max;
466                    *changed = true;
467                }
468            }
469            DragTarget::None => {}
470        }
471    }
472
473    fn draw(
474        &self,
475        ui: &Ui,
476        response: &Response,
477        geometry: &SliderGeometry,
478        track_height: f32,
479        drag_state: &RangeSliderDragState,
480        hovered_thumb: Option<DragTarget>,
481        theme: &crate::Theme,
482    ) {
483        let painter = ui.painter();
484
485        // Background track
486        painter.rect_filled(*geometry.track_rect, track_height / 2.0, theme.muted());
487
488        // Filled region between thumbs
489        let fill_rect = Rect::from_min_max(
490            pos2(geometry.min_x, geometry.track_rect.top()),
491            pos2(geometry.max_x, geometry.track_rect.bottom()),
492        );
493        painter.rect_filled(fill_rect, track_height / 2.0, theme.primary());
494
495        // Draw thumbs
496        for (x, is_min) in [(geometry.min_x, true), (geometry.max_x, false)] {
497            let center = pos2(x, geometry.track_rect.center().y);
498            let this_target = if is_min {
499                DragTarget::Min
500            } else {
501                DragTarget::Max
502            };
503
504            // Determine if this thumb is active (being dragged)
505            let is_active = response.dragged()
506                && (drag_state.target == this_target || drag_state.target == DragTarget::Both);
507
508            // Determine if this specific thumb is hovered
509            let is_hovered = hovered_thumb == Some(this_target);
510
511            // Hover ring effect (like shadcn ring-4 with ring-ring/50)
512            if is_active || is_hovered {
513                let ring_color = theme.ring().gamma_multiply(0.5);
514                painter.circle_filled(center, geometry.thumb_radius + 4.0, ring_color);
515            }
516
517            // Shadow
518            painter.circle_filled(
519                center + vec2(0.0, 1.0),
520                geometry.thumb_radius,
521                Color32::from_black_alpha(40),
522            );
523
524            // Handle fill
525            let handle_color = if is_active {
526                theme.primary()
527            } else {
528                theme.foreground()
529            };
530
531            painter.circle_filled(center, geometry.thumb_radius, handle_color);
532            painter.circle_stroke(
533                center,
534                geometry.thumb_radius,
535                Stroke::new(1.0, theme.primary()),
536            );
537        }
538    }
539}
540
541/// Response from a range slider
542pub struct RangeSliderResponse {
543    /// The UI response
544    pub response: Response,
545    /// Current minimum value
546    pub min_value: f32,
547    /// Current maximum value
548    pub max_value: f32,
549    /// Whether either value changed this frame
550    pub changed: bool,
551}