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/// Inspector-visible properties of a [`Slider`].
29///
30/// **The "companion props" pattern:** widgets that opt into the reflection-
31/// driven inspector hold a small `*Props` struct exposing only their
32/// directly-editable values.  This sidesteps two structural problems with
33/// deriving `Reflect` on the whole widget:
34///   1. `bevy_reflect::Reflect` requires `Send + Sync`, which widgets violate
35///      because they carry `Rc<Cell<…>>` and non-`Sync` callbacks.
36///   2. Sub-widgets (`Label` here) and `Arc<Font>` would force a cascading
37///      `Reflect` derive across types that don't have it.
38///
39/// The companion struct contains plain values (`f64`, `bool`, `Option<usize>`)
40/// — `Send + Sync + Reflect`-friendly — and the widget routes all reads/writes
41/// through it.  The inspector edits the companion live and the widget reacts.
42#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
43#[derive(Clone, Debug)]
44pub struct SliderProps {
45    pub value: f64,
46    pub min: f64,
47    pub max: f64,
48    pub step: f64,
49    pub show_value: bool,
50    /// Fixed decimals for the value label — overrides the step-based
51    /// auto-format when `Some`.
52    pub decimals: Option<usize>,
53    pub font_size: f64,
54}
55
56impl Default for SliderProps {
57    fn default() -> Self {
58        Self {
59            value: 0.0,
60            min: 0.0,
61            max: 1.0,
62            step: 0.01,
63            show_value: true,
64            decimals: None,
65            font_size: 12.0,
66        }
67    }
68}
69
70/// A horizontal slider for a `f64` value within `[min, max]`.
71pub struct Slider {
72    bounds: Rect,
73    children: Vec<Box<dyn Widget>>, // always empty
74    base: WidgetBase,
75    /// Reflectable, inspector-editable values — see [`SliderProps`].
76    pub props: SliderProps,
77    dragging: bool,
78    focused: bool,
79    hovered: bool,
80    on_change: Option<Box<dyn FnMut(f64)>>,
81    /// Optional external mirror of `value`.  When `Some`, `layout()` re-reads
82    /// the cell every frame so a second widget that writes the same cell
83    /// drives this slider live; `set_value` writes back.  Mirrors the
84    /// `ToggleSwitch::with_state_cell` pattern — the cell is the source-of-
85    /// truth so multiple widgets can reflect the same value bidirectionally.
86    value_cell: Option<Rc<Cell<f64>>>,
87    /// Backbuffered Label that renders the numeric value.  Updated in
88    /// `layout()` with the current formatted value so the text follows
89    /// drags live.
90    value_label: Label,
91    /// Tracks the string last pushed into `value_label` so we only
92    /// invalidate its cache when the displayed value actually changes.
93    last_value_text: String,
94}
95
96impl Slider {
97    pub fn new(value: f64, min: f64, max: f64, font: Arc<Font>) -> Self {
98        let v = value.clamp(min, max);
99        let font_size = 12.0;
100        let value_label = Label::new("", Arc::clone(&font))
101            .with_font_size(font_size)
102            .with_align(LabelAlign::Right);
103        Self {
104            bounds: Rect::default(),
105            children: Vec::new(),
106            base: WidgetBase::new(),
107            props: SliderProps {
108                value: v,
109                min,
110                max,
111                step: (max - min) / 100.0,
112                show_value: true,
113                decimals: None,
114                font_size,
115            },
116            dragging: false,
117            focused: false,
118            hovered: false,
119            on_change: None,
120            value_cell: None,
121            value_label,
122            last_value_text: String::new(),
123        }
124    }
125
126    pub fn with_step(mut self, step: f64) -> Self {
127        self.props.step = step;
128        self
129    }
130
131    /// Bind this slider's value to an external `Rc<Cell<f64>>`.
132    ///
133    /// The cell becomes the source-of-truth: `layout()` reads it every
134    /// frame so any other widget (or code path) that writes the cell
135    /// will drive this slider live; drag interactions here write back
136    /// to the cell too.  Pattern mirrors `ToggleSwitch::with_state_cell`.
137    pub fn with_value_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
138        self.props.value = cell.get().clamp(self.props.min, self.props.max);
139        self.value_cell = Some(cell);
140        self
141    }
142    pub fn with_show_value(mut self, show: bool) -> Self {
143        self.props.show_value = show;
144        self
145    }
146
147    /// Force a specific decimal count for the numeric value label.  When
148    /// unset, the format falls back to a heuristic based on `step`.
149    pub fn with_decimals(mut self, decimals: usize) -> Self {
150        self.props.decimals = Some(decimals);
151        self
152    }
153
154    pub fn with_margin(mut self, m: Insets) -> Self {
155        self.base.margin = m;
156        self
157    }
158    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
159        self.base.h_anchor = h;
160        self
161    }
162    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
163        self.base.v_anchor = v;
164        self
165    }
166    pub fn with_min_size(mut self, s: Size) -> Self {
167        self.base.min_size = s;
168        self
169    }
170    pub fn with_max_size(mut self, s: Size) -> Self {
171        self.base.max_size = s;
172        self
173    }
174
175    pub fn on_change(mut self, cb: impl FnMut(f64) + 'static) -> Self {
176        self.on_change = Some(Box::new(cb));
177        self
178    }
179
180    pub fn value(&self) -> f64 {
181        self.props.value
182    }
183
184    pub fn set_value(&mut self, v: f64) {
185        self.props.value = v.clamp(self.props.min, self.props.max);
186        if let Some(cell) = &self.value_cell {
187            cell.set(self.props.value);
188        }
189    }
190
191    fn fire(&mut self) {
192        let v = self.props.value;
193        if let Some(cell) = &self.value_cell {
194            cell.set(v);
195        }
196        if let Some(cb) = self.on_change.as_mut() {
197            cb(v);
198        }
199    }
200
201    /// Pixel X of the track's right edge.  The value label (when shown)
202    /// lives in a reserved strip to the right of this, outside the track
203    /// so a thumb at max doesn't overdraw the digits.
204    fn track_right(&self) -> f64 {
205        let reserved = if self.props.show_value {
206            VALUE_W + VALUE_GAP
207        } else {
208            0.0
209        };
210        (self.bounds.width - reserved - THUMB_R).max(THUMB_R + 1.0)
211    }
212
213    /// Pixel X of the thumb center within the track area.
214    fn thumb_x(&self) -> f64 {
215        let track_left = THUMB_R;
216        let track_right = self.track_right();
217        let t = if self.props.max > self.props.min {
218            (self.props.value - self.props.min) / (self.props.max - self.props.min)
219        } else {
220            0.0
221        };
222        track_left + t * (track_right - track_left)
223    }
224
225    fn value_from_x(&self, x: f64) -> f64 {
226        let track_left = THUMB_R;
227        let track_right = self.track_right();
228        let t = ((x - track_left) / (track_right - track_left)).clamp(0.0, 1.0);
229        let raw = self.props.min + t * (self.props.max - self.props.min);
230        // Snap to step
231        let snapped = (raw / self.props.step).round() * self.props.step;
232        snapped.clamp(self.props.min, self.props.max)
233    }
234
235    /// Format the slider's value using `decimals` if set, otherwise heuristic
236    /// based on `step`.
237    fn format_value(&self) -> String {
238        if let Some(d) = self.props.decimals {
239            return format!("{:.*}", d, self.props.value);
240        }
241        if self.props.step >= 1.0 {
242            format!("{:.0}", self.props.value)
243        } else if self.props.step >= 0.1 {
244            format!("{:.1}", self.props.value)
245        } else if self.props.step >= 0.01 {
246            format!("{:.2}", self.props.value)
247        } else {
248            format!("{:.3}", self.props.value)
249        }
250    }
251}
252
253impl Widget for Slider {
254    fn type_name(&self) -> &'static str {
255        "Slider"
256    }
257    fn bounds(&self) -> Rect {
258        self.bounds
259    }
260    fn set_bounds(&mut self, b: Rect) {
261        self.bounds = b;
262    }
263    fn children(&self) -> &[Box<dyn Widget>] {
264        &self.children
265    }
266    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
267        &mut self.children
268    }
269
270    #[cfg(feature = "reflect")]
271    fn as_reflect(&self) -> Option<&dyn bevy_reflect::Reflect> {
272        Some(&self.props)
273    }
274    #[cfg(feature = "reflect")]
275    fn as_reflect_mut(&mut self) -> Option<&mut dyn bevy_reflect::Reflect> {
276        Some(&mut self.props)
277    }
278
279    fn is_focusable(&self) -> bool {
280        true
281    }
282
283    fn margin(&self) -> Insets {
284        self.base.margin
285    }
286    fn widget_base(&self) -> Option<&WidgetBase> {
287        Some(&self.base)
288    }
289    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
290        Some(&mut self.base)
291    }
292    fn h_anchor(&self) -> HAnchor {
293        self.base.h_anchor
294    }
295    fn v_anchor(&self) -> VAnchor {
296        self.base.v_anchor
297    }
298    fn min_size(&self) -> Size {
299        self.base.min_size
300    }
301    fn max_size(&self) -> Size {
302        self.base.max_size
303    }
304
305    fn layout(&mut self, available: Size) -> Size {
306        // Re-read external cell every frame — another widget (e.g. the
307        // System window's slider) may have written a new value.  Skip
308        // while dragging so the user's in-flight drag isn't fought
309        // back by rounding inside the source cell.
310        if !self.dragging {
311            if let Some(cell) = &self.value_cell {
312                self.props.value = cell.get().clamp(self.props.min, self.props.max);
313            }
314        }
315
316        // Refresh the value-label text only when the displayed string
317        // actually changed — Label's `set_text` invalidates its cache
318        // so we want to skip this when the value is unchanged (e.g.
319        // idle frames between drags).
320        if self.props.show_value {
321            let new_text = self.format_value();
322            if new_text != self.last_value_text {
323                self.value_label.set_text(new_text.clone());
324                self.last_value_text = new_text;
325            }
326            // Size the label to exactly the reserved strip; right-align
327            // anchors the digits to the widget's right edge.
328            let lh = self.props.font_size * 1.5;
329            let _ = self.value_label.layout(Size::new(VALUE_W, lh));
330            self.value_label
331                .set_bounds(Rect::new(0.0, 0.0, VALUE_W, lh));
332        }
333
334        Size::new(available.width, WIDGET_H)
335    }
336
337    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
338        let v = ctx.visuals();
339        let h = self.bounds.height;
340        let cy = h * 0.5;
341
342        let track_right = self.track_right();
343        let track_w = (track_right - THUMB_R).max(0.0);
344
345        // Track (background)
346        ctx.set_fill_color(v.track_bg);
347        ctx.begin_path();
348        ctx.rounded_rect(THUMB_R, cy - TRACK_H * 0.5, track_w, TRACK_H, TRACK_H * 0.5);
349        ctx.fill();
350
351        // Track (filled portion up to thumb)
352        let tx = self.thumb_x();
353        if tx > THUMB_R {
354            ctx.set_fill_color(v.accent);
355            ctx.begin_path();
356            ctx.rounded_rect(
357                THUMB_R,
358                cy - TRACK_H * 0.5,
359                tx - THUMB_R,
360                TRACK_H,
361                TRACK_H * 0.5,
362            );
363            ctx.fill();
364        }
365
366        // Focus ring
367        if self.focused {
368            ctx.set_stroke_color(v.accent_focus);
369            ctx.set_line_width(2.0);
370            ctx.begin_path();
371            ctx.circle(tx, cy, THUMB_R + 3.0);
372            ctx.stroke();
373        }
374
375        // Thumb
376        let thumb_color = if self.dragging || self.focused {
377            v.accent_pressed
378        } else if self.hovered {
379            v.accent_hovered
380        } else {
381            v.accent
382        };
383        ctx.set_fill_color(thumb_color);
384        ctx.begin_path();
385        ctx.circle(tx, cy, THUMB_R);
386        ctx.fill();
387
388        ctx.set_fill_color(v.widget_bg);
389        ctx.begin_path();
390        ctx.circle(tx, cy, THUMB_R - 2.5);
391        ctx.fill();
392
393        // Value label — composed via backbuffered Label so it uses the
394        // same text-raster path as every other label in the app.  The
395        // Label is right-aligned inside its box and positioned in the
396        // reserved strip to the right of the track.
397        if self.props.show_value {
398            self.value_label.set_color(v.text_color);
399            let lb = self.value_label.bounds();
400            let strip_left = track_right + VALUE_GAP;
401            let ly = cy - lb.height * 0.5;
402            self.value_label
403                .set_bounds(Rect::new(strip_left, ly, lb.width, lb.height));
404            ctx.save();
405            ctx.translate(strip_left, ly);
406            paint_subtree(&mut self.value_label, ctx);
407            ctx.restore();
408        }
409    }
410
411    fn on_event(&mut self, event: &Event) -> EventResult {
412        match event {
413            Event::MouseMove { pos } => {
414                let was = self.hovered;
415                self.hovered = self.hit_test(*pos);
416                if self.dragging {
417                    self.props.value = self.value_from_x(pos.x);
418                    self.fire();
419                    crate::animation::request_draw();
420                    return EventResult::Consumed;
421                }
422                if was != self.hovered {
423                    crate::animation::request_draw();
424                    return EventResult::Consumed;
425                }
426                EventResult::Ignored
427            }
428            Event::MouseDown {
429                button: MouseButton::Left,
430                pos,
431                ..
432            } => {
433                self.dragging = true;
434                self.props.value = self.value_from_x(pos.x);
435                self.fire();
436                crate::animation::request_draw();
437                EventResult::Consumed
438            }
439            Event::MouseUp {
440                button: MouseButton::Left,
441                ..
442            } => {
443                let was = self.dragging;
444                self.dragging = false;
445                if was {
446                    crate::animation::request_draw();
447                }
448                EventResult::Consumed
449            }
450            Event::KeyDown { key, .. } => {
451                let changed = match key {
452                    Key::ArrowLeft => {
453                        self.props.value =
454                            (self.props.value - self.props.step).clamp(self.props.min, self.props.max);
455                        true
456                    }
457                    Key::ArrowRight => {
458                        self.props.value =
459                            (self.props.value + self.props.step).clamp(self.props.min, self.props.max);
460                        true
461                    }
462                    Key::ArrowDown => {
463                        self.props.value = (self.props.value - self.props.step * 10.0)
464                            .clamp(self.props.min, self.props.max);
465                        true
466                    }
467                    Key::ArrowUp => {
468                        self.props.value = (self.props.value + self.props.step * 10.0)
469                            .clamp(self.props.min, self.props.max);
470                        true
471                    }
472                    _ => false,
473                };
474                if changed {
475                    self.fire();
476                    crate::animation::request_draw();
477                    EventResult::Consumed
478                } else {
479                    EventResult::Ignored
480                }
481            }
482            Event::FocusGained => {
483                let was = self.focused;
484                self.focused = true;
485                if !was {
486                    crate::animation::request_draw();
487                    EventResult::Consumed
488                } else {
489                    EventResult::Ignored
490                }
491            }
492            Event::FocusLost => {
493                let was_focused = self.focused;
494                let was_dragging = self.dragging;
495                self.focused = false;
496                self.dragging = false;
497                if was_focused || was_dragging {
498                    crate::animation::request_draw();
499                    EventResult::Consumed
500                } else {
501                    EventResult::Ignored
502                }
503            }
504            _ => EventResult::Ignored,
505        }
506    }
507}