Skip to main content

agg_gui/widgets/
slider.rs

1//! `Slider` — a horizontal range slider with a draggable thumb.
2
3use std::cell::Cell;
4use std::rc::Rc;
5use std::sync::Arc;
6
7use crate::draw_ctx::DrawCtx;
8use crate::event::{Event, EventResult, Key, MouseButton};
9use crate::geometry::{Rect, Size};
10use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
11use crate::text::Font;
12use crate::widget::{paint_subtree, Widget};
13use crate::widgets::label::{Label, LabelAlign};
14
15const TRACK_H: f64 = 4.0;
16const THUMB_R: f64 = 7.0;
17/// Total widget height.  Needs to fit the thumb (diameter `2 * THUMB_R`)
18/// plus a little breathing room for the focus ring — `22 px` keeps rows
19/// compact in settings-style panels while still being easy to grab.
20const WIDGET_H: f64 = 22.0;
21/// Default pixel budget reserved on the right for the numeric value
22/// label.  Wide enough for 4-5 glyphs at the slider's default font
23/// size.  Set to `0.0` via [`Slider::with_show_value(false)`] to hide.
24const VALUE_W: f64 = 44.0;
25/// Gap between the track's right edge and the value label's left edge.
26const VALUE_GAP: f64 = 6.0;
27
28/// A horizontal slider for a `f64` value within `[min, max]`.
29pub struct Slider {
30    bounds: Rect,
31    children: Vec<Box<dyn Widget>>, // always empty
32    base: WidgetBase,
33    value: f64,
34    min: f64,
35    max: f64,
36    step: f64,
37    show_value: bool,
38    /// Fixed decimals for the value label — overrides the step-based
39    /// auto-format when `Some`.  Matches `DragValue::with_decimals`.
40    decimals: Option<usize>,
41    font_size: f64,
42    dragging: bool,
43    focused: bool,
44    hovered: bool,
45    on_change: Option<Box<dyn FnMut(f64)>>,
46    /// Optional external mirror of `value`.  When `Some`, `layout()` re-reads
47    /// the cell every frame so a second widget that writes the same cell
48    /// drives this slider live; `set_value` writes back.  Mirrors the
49    /// `ToggleSwitch::with_state_cell` pattern — the cell is the source-of-
50    /// truth so multiple widgets can reflect the same value bidirectionally.
51    value_cell: Option<Rc<Cell<f64>>>,
52    /// Backbuffered Label that renders the numeric value.  Updated in
53    /// `layout()` with the current formatted value so the text follows
54    /// drags live.  Uses the same proven text-cache path every other
55    /// Label in the app does, which matters: previous attempts to
56    /// render the value via direct `ctx.fill_text` were unreliable on
57    /// some rendering paths.
58    value_label: Label,
59    /// Tracks the string last pushed into `value_label` so we only
60    /// invalidate its cache when the displayed value actually changes.
61    last_value_text: String,
62}
63
64impl Slider {
65    pub fn new(value: f64, min: f64, max: f64, font: Arc<Font>) -> Self {
66        let v = value.clamp(min, max);
67        let font_size = 12.0;
68        let value_label = Label::new("", Arc::clone(&font))
69            .with_font_size(font_size)
70            .with_align(LabelAlign::Right);
71        Self {
72            bounds: Rect::default(),
73            children: Vec::new(),
74            base: WidgetBase::new(),
75            value: v,
76            min,
77            max,
78            step: (max - min) / 100.0,
79            show_value: true,
80            decimals: None,
81            font_size,
82            dragging: false,
83            focused: false,
84            hovered: false,
85            on_change: None,
86            value_cell: None,
87            value_label,
88            last_value_text: String::new(),
89        }
90    }
91
92    pub fn with_step(mut self, step: f64) -> Self {
93        self.step = step;
94        self
95    }
96
97    /// Bind this slider's value to an external `Rc<Cell<f64>>`.
98    ///
99    /// The cell becomes the source-of-truth: `layout()` reads it every
100    /// frame so any other widget (or code path) that writes the cell
101    /// will drive this slider live; drag interactions here write back
102    /// to the cell too.  Pattern mirrors `ToggleSwitch::with_state_cell`.
103    pub fn with_value_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
104        self.value = cell.get().clamp(self.min, self.max);
105        self.value_cell = Some(cell);
106        self
107    }
108    pub fn with_show_value(mut self, show: bool) -> Self {
109        self.show_value = show;
110        self
111    }
112
113    /// Force a specific decimal count for the numeric value label.  When
114    /// unset, the format falls back to a heuristic based on `step`.
115    pub fn with_decimals(mut self, decimals: usize) -> Self {
116        self.decimals = Some(decimals);
117        self
118    }
119
120    pub fn with_margin(mut self, m: Insets) -> Self {
121        self.base.margin = m;
122        self
123    }
124    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
125        self.base.h_anchor = h;
126        self
127    }
128    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
129        self.base.v_anchor = v;
130        self
131    }
132    pub fn with_min_size(mut self, s: Size) -> Self {
133        self.base.min_size = s;
134        self
135    }
136    pub fn with_max_size(mut self, s: Size) -> Self {
137        self.base.max_size = s;
138        self
139    }
140
141    pub fn on_change(mut self, cb: impl FnMut(f64) + 'static) -> Self {
142        self.on_change = Some(Box::new(cb));
143        self
144    }
145
146    pub fn value(&self) -> f64 {
147        self.value
148    }
149
150    pub fn set_value(&mut self, v: f64) {
151        self.value = v.clamp(self.min, self.max);
152        if let Some(cell) = &self.value_cell {
153            cell.set(self.value);
154        }
155    }
156
157    fn fire(&mut self) {
158        let v = self.value;
159        if let Some(cell) = &self.value_cell {
160            cell.set(v);
161        }
162        if let Some(cb) = self.on_change.as_mut() {
163            cb(v);
164        }
165    }
166
167    /// Pixel X of the track's right edge.  The value label (when shown)
168    /// lives in a reserved strip to the right of this, outside the track
169    /// so a thumb at max doesn't overdraw the digits.
170    fn track_right(&self) -> f64 {
171        let reserved = if self.show_value {
172            VALUE_W + VALUE_GAP
173        } else {
174            0.0
175        };
176        (self.bounds.width - reserved - THUMB_R).max(THUMB_R + 1.0)
177    }
178
179    /// Pixel X of the thumb center within the track area.
180    fn thumb_x(&self) -> f64 {
181        let track_left = THUMB_R;
182        let track_right = self.track_right();
183        let t = if self.max > self.min {
184            (self.value - self.min) / (self.max - self.min)
185        } else {
186            0.0
187        };
188        track_left + t * (track_right - track_left)
189    }
190
191    fn value_from_x(&self, x: f64) -> f64 {
192        let track_left = THUMB_R;
193        let track_right = self.track_right();
194        let t = ((x - track_left) / (track_right - track_left)).clamp(0.0, 1.0);
195        let raw = self.min + t * (self.max - self.min);
196        // Snap to step
197        let snapped = (raw / self.step).round() * self.step;
198        snapped.clamp(self.min, self.max)
199    }
200
201    /// Format `self.value` using `decimals` if set, otherwise heuristic
202    /// based on `step`.
203    fn format_value(&self) -> String {
204        if let Some(d) = self.decimals {
205            return format!("{:.*}", d, self.value);
206        }
207        if self.step >= 1.0 {
208            format!("{:.0}", self.value)
209        } else if self.step >= 0.1 {
210            format!("{:.1}", self.value)
211        } else if self.step >= 0.01 {
212            format!("{:.2}", self.value)
213        } else {
214            format!("{:.3}", self.value)
215        }
216    }
217}
218
219impl Widget for Slider {
220    fn type_name(&self) -> &'static str {
221        "Slider"
222    }
223    fn bounds(&self) -> Rect {
224        self.bounds
225    }
226    fn set_bounds(&mut self, b: Rect) {
227        self.bounds = b;
228    }
229    fn children(&self) -> &[Box<dyn Widget>] {
230        &self.children
231    }
232    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
233        &mut self.children
234    }
235
236    fn is_focusable(&self) -> bool {
237        true
238    }
239
240    fn margin(&self) -> Insets {
241        self.base.margin
242    }
243    fn h_anchor(&self) -> HAnchor {
244        self.base.h_anchor
245    }
246    fn v_anchor(&self) -> VAnchor {
247        self.base.v_anchor
248    }
249    fn min_size(&self) -> Size {
250        self.base.min_size
251    }
252    fn max_size(&self) -> Size {
253        self.base.max_size
254    }
255
256    fn layout(&mut self, available: Size) -> Size {
257        // Re-read external cell every frame — another widget (e.g. the
258        // System window's slider) may have written a new value.  Skip
259        // while dragging so the user's in-flight drag isn't fought
260        // back by rounding inside the source cell.
261        if !self.dragging {
262            if let Some(cell) = &self.value_cell {
263                self.value = cell.get().clamp(self.min, self.max);
264            }
265        }
266
267        // Refresh the value-label text only when the displayed string
268        // actually changed — Label's `set_text` invalidates its cache
269        // so we want to skip this when the value is unchanged (e.g.
270        // idle frames between drags).
271        if self.show_value {
272            let new_text = self.format_value();
273            if new_text != self.last_value_text {
274                self.value_label.set_text(new_text.clone());
275                self.last_value_text = new_text;
276            }
277            // Size the label to exactly the reserved strip; right-align
278            // anchors the digits to the widget's right edge.
279            let lh = self.font_size * 1.5;
280            let _ = self.value_label.layout(Size::new(VALUE_W, lh));
281            self.value_label
282                .set_bounds(Rect::new(0.0, 0.0, VALUE_W, lh));
283        }
284
285        Size::new(available.width, WIDGET_H)
286    }
287
288    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
289        let v = ctx.visuals();
290        let h = self.bounds.height;
291        let cy = h * 0.5;
292
293        let track_right = self.track_right();
294        let track_w = (track_right - THUMB_R).max(0.0);
295
296        // Track (background)
297        ctx.set_fill_color(v.track_bg);
298        ctx.begin_path();
299        ctx.rounded_rect(THUMB_R, cy - TRACK_H * 0.5, track_w, TRACK_H, TRACK_H * 0.5);
300        ctx.fill();
301
302        // Track (filled portion up to thumb)
303        let tx = self.thumb_x();
304        if tx > THUMB_R {
305            ctx.set_fill_color(v.accent);
306            ctx.begin_path();
307            ctx.rounded_rect(
308                THUMB_R,
309                cy - TRACK_H * 0.5,
310                tx - THUMB_R,
311                TRACK_H,
312                TRACK_H * 0.5,
313            );
314            ctx.fill();
315        }
316
317        // Focus ring
318        if self.focused {
319            ctx.set_stroke_color(v.accent_focus);
320            ctx.set_line_width(2.0);
321            ctx.begin_path();
322            ctx.circle(tx, cy, THUMB_R + 3.0);
323            ctx.stroke();
324        }
325
326        // Thumb
327        let thumb_color = if self.dragging || self.focused {
328            v.accent_pressed
329        } else if self.hovered {
330            v.accent_hovered
331        } else {
332            v.accent
333        };
334        ctx.set_fill_color(thumb_color);
335        ctx.begin_path();
336        ctx.circle(tx, cy, THUMB_R);
337        ctx.fill();
338
339        ctx.set_fill_color(v.widget_bg);
340        ctx.begin_path();
341        ctx.circle(tx, cy, THUMB_R - 2.5);
342        ctx.fill();
343
344        // Value label — composed via backbuffered Label so it uses the
345        // same text-raster path as every other label in the app.  The
346        // Label is right-aligned inside its box and positioned in the
347        // reserved strip to the right of the track.
348        if self.show_value {
349            self.value_label.set_color(v.text_color);
350            let lb = self.value_label.bounds();
351            let strip_left = track_right + VALUE_GAP;
352            let ly = cy - lb.height * 0.5;
353            self.value_label
354                .set_bounds(Rect::new(strip_left, ly, lb.width, lb.height));
355            ctx.save();
356            ctx.translate(strip_left, ly);
357            paint_subtree(&mut self.value_label, ctx);
358            ctx.restore();
359        }
360    }
361
362    fn on_event(&mut self, event: &Event) -> EventResult {
363        match event {
364            Event::MouseMove { pos } => {
365                let was = self.hovered;
366                self.hovered = self.hit_test(*pos);
367                if self.dragging {
368                    self.value = self.value_from_x(pos.x);
369                    self.fire();
370                    crate::animation::request_draw();
371                    return EventResult::Consumed;
372                }
373                if was != self.hovered {
374                    crate::animation::request_draw();
375                    return EventResult::Consumed;
376                }
377                EventResult::Ignored
378            }
379            Event::MouseDown {
380                button: MouseButton::Left,
381                pos,
382                ..
383            } => {
384                self.dragging = true;
385                self.value = self.value_from_x(pos.x);
386                self.fire();
387                crate::animation::request_draw();
388                EventResult::Consumed
389            }
390            Event::MouseUp {
391                button: MouseButton::Left,
392                ..
393            } => {
394                let was = self.dragging;
395                self.dragging = false;
396                if was {
397                    crate::animation::request_draw();
398                }
399                EventResult::Consumed
400            }
401            Event::KeyDown { key, .. } => {
402                let changed = match key {
403                    Key::ArrowLeft => {
404                        self.value = (self.value - self.step).clamp(self.min, self.max);
405                        true
406                    }
407                    Key::ArrowRight => {
408                        self.value = (self.value + self.step).clamp(self.min, self.max);
409                        true
410                    }
411                    Key::ArrowDown => {
412                        self.value = (self.value - self.step * 10.0).clamp(self.min, self.max);
413                        true
414                    }
415                    Key::ArrowUp => {
416                        self.value = (self.value + self.step * 10.0).clamp(self.min, self.max);
417                        true
418                    }
419                    _ => false,
420                };
421                if changed {
422                    self.fire();
423                    crate::animation::request_draw();
424                    EventResult::Consumed
425                } else {
426                    EventResult::Ignored
427                }
428            }
429            Event::FocusGained => {
430                let was = self.focused;
431                self.focused = true;
432                if !was {
433                    crate::animation::request_draw();
434                    EventResult::Consumed
435                } else {
436                    EventResult::Ignored
437                }
438            }
439            Event::FocusLost => {
440                let was_focused = self.focused;
441                let was_dragging = self.dragging;
442                self.focused = false;
443                self.dragging = false;
444                if was_focused || was_dragging {
445                    crate::animation::request_draw();
446                    EventResult::Consumed
447                } else {
448                    EventResult::Ignored
449                }
450            }
451            _ => EventResult::Ignored,
452        }
453    }
454}