Skip to main content

agg_gui/widgets/
drag_value.rs

1//! `DragValue` — a numeric scrubber that lets the user drag left/right to change a value.
2//!
3//! Displays the current value as formatted text centered inside a lightly
4//! tinted rectangle.  Clicking and dragging horizontally adjusts the value at
5//! a configurable speed; the value is clamped to `[min, max]` and optionally
6//! snapped to a step interval.
7//!
8//! A plain click (no significant drag) enters an inline edit mode: the widget
9//! shows a cursor and accepts keyboard input.  Pressing Enter or losing focus
10//! commits the edit; Escape cancels it.
11//!
12//! Typical use-case: property panels, inspector rows, compact parameter editors.
13
14use std::cell::Cell;
15use std::rc::Rc;
16use std::sync::Arc;
17
18use crate::color::Color;
19use crate::draw_ctx::DrawCtx;
20use crate::event::{Event, EventResult, Key, MouseButton};
21use crate::geometry::{Rect, Size};
22use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
23use crate::text::{measure_advance, Font};
24use crate::widget::{paint_subtree, Widget};
25use crate::widgets::label::{Label, LabelAlign};
26
27/// Format a numeric value as a string with the given decimal places.
28/// Free function so `DragValue::new` can call it before `self` exists.
29fn format_value(value: f64, decimals: usize) -> String {
30    format!("{:.prec$}", value, prec = decimals)
31}
32
33// ── Geometry constants ─────────────────────────────────────────────────────
34
35const WIDGET_H: f64 = 24.0;
36/// Half-width of the left/right arrow indicator text.
37const ARROW_MARGIN: f64 = 8.0;
38/// Horizontal half-extent of each arrow triangle (from its inner edge).
39const ARROW_TRI_W: f64 = 6.0;
40/// Extra gap between an arrow triangle and the value label, per side.
41const LABEL_SIDE_PAD: f64 = 4.0;
42/// Horizontal drag distance (logical px) before a press is treated as a drag.
43const DRAG_THRESHOLD: f64 = 3.0;
44
45/// Total horizontal space reserved on each side of the value label for the
46/// drag-arrow zone plus padding. Paint and the intrinsic-width measurement both
47/// route through this so the label never overlaps an arrow — or clips.
48const LABEL_SIDE_INSET: f64 = ARROW_MARGIN + ARROW_TRI_W + LABEL_SIDE_PAD;
49
50// ── Struct ─────────────────────────────────────────────────────────────────
51
52/// A horizontal drag-to-scrub numeric value widget.
53///
54/// The user clicks and drags left or right to decrease or increase the value.
55/// A plain click (no drag) enters inline edit mode for direct keyboard entry.
56/// The current value is displayed as formatted text in the center of the widget.
57/// Left ("◀") and right ("▶") arrow triangles are drawn at the edges as an
58/// affordance for the drag direction.
59pub struct DragValue {
60    bounds: Rect,
61    children: Vec<Box<dyn Widget>>, // always empty
62    base: WidgetBase,
63
64    value: f64,
65    min: f64,
66    max: f64,
67
68    /// How many value units change per logical pixel of horizontal drag.
69    speed: f64,
70    /// Snap interval; values are rounded to the nearest multiple of `step`
71    /// after each drag update.  `0.0` means no snapping.
72    step: f64,
73    /// Number of decimal places used when formatting the displayed value.
74    decimals: usize,
75    /// Text appended after the value in the display label (e.g. "°" or
76    /// " years"). Mirrors `Slider::with_suffix`. It is shown only in the
77    /// non-editing label; inline edit text stays numeric so parsing works.
78    suffix: String,
79
80    font: Arc<Font>,
81    font_size: f64,
82
83    // ── Drag state ────────────────────────────────────────────────────────
84    /// True once the drag-threshold has been exceeded after a mouse-down.
85    dragging: bool,
86    /// True from mouse-down until mouse-up (covers both pre-threshold and drag phases).
87    mouse_pressed: bool,
88    /// X position where the mouse was pressed.
89    press_x: f64,
90    /// X position used as drag origin once the threshold is crossed.
91    drag_start_x: f64,
92    /// Value captured at the start of the confirmed drag.
93    drag_start_value: f64,
94
95    // ── Inline edit state ─────────────────────────────────────────────────
96    focused: bool,
97    editing: bool,
98    edit_text: String,
99    /// Cursor position as a char index into `edit_text`.
100    edit_cursor: usize,
101
102    hovered: bool,
103    on_change: Option<Box<dyn FnMut(f64)>>,
104
105    /// Optional external mirror of `value`.  When `Some`, `layout()` re-reads
106    /// the cell every frame so a second widget that writes the same cell (the
107    /// gallery's Slider driving the shared scalar) updates this DragValue live;
108    /// drag/edit commits write back.  Mirrors [`Slider::with_value_cell`].
109    value_cell: Option<Rc<Cell<f64>>>,
110    /// Text last pushed into `value_label`, so the `layout()` cell re-read only
111    /// invalidates the label's cache when the displayed value actually changes.
112    last_value_text: String,
113
114    /// Formatted-value text lives in a `Label` field — DragValue draws
115    /// bg + border + arrow triangles; the label handles the value text
116    /// (including its own LCD cache).  Kept as a typed field rather
117    /// than in `children` so we can call `set_text` on value change
118    /// without downcasting.
119    value_label: Label,
120}
121
122// ── Constructors & builder methods ─────────────────────────────────────────
123
124impl DragValue {
125    /// Create a new `DragValue` with initial `value` clamped to `[min, max]`.
126    pub fn new(value: f64, min: f64, max: f64, font: Arc<Font>) -> Self {
127        let clamped = value.clamp(min, max);
128        let initial_text = format_value(clamped, 2);
129        let value_label = Label::new(initial_text.clone(), Arc::clone(&font))
130            .with_font_size(13.0)
131            .with_align(LabelAlign::Center);
132        Self {
133            bounds: Rect::default(),
134            children: Vec::new(),
135            base: WidgetBase::new(),
136            value: clamped,
137            min,
138            max,
139            speed: 1.0,
140            step: 0.0,
141            decimals: 2,
142            suffix: String::new(),
143            font,
144            font_size: 13.0,
145            dragging: false,
146            mouse_pressed: false,
147            press_x: 0.0,
148            drag_start_x: 0.0,
149            drag_start_value: 0.0,
150            focused: false,
151            editing: false,
152            edit_text: String::new(),
153            edit_cursor: 0,
154            hovered: false,
155            on_change: None,
156            value_cell: None,
157            last_value_text: initial_text,
158            value_label,
159        }
160    }
161
162    /// Set the display font size in logical pixels.
163    pub fn with_font_size(mut self, s: f64) -> Self {
164        self.font_size = s;
165        self.value_label.set_font_size(s);
166        self
167    }
168
169    /// Set a snap step.  Values are rounded to the nearest multiple of `step`
170    /// during dragging.  Pass `0.0` to disable snapping (the default).
171    pub fn with_step(mut self, step: f64) -> Self {
172        self.step = step;
173        self
174    }
175
176    /// Set the drag speed: how many value units change per logical pixel of
177    /// horizontal drag movement.  Default is `1.0`.
178    pub fn with_speed(mut self, speed: f64) -> Self {
179        self.speed = speed;
180        self
181    }
182
183    /// Set the number of decimal places shown in the formatted text.
184    pub fn with_decimals(mut self, d: usize) -> Self {
185        self.decimals = d;
186        self.sync_label();
187        self
188    }
189
190    /// Append a suffix to the displayed value (e.g. "°" or " years").
191    ///
192    /// The suffix is cosmetic: it appears in the drag label but not in the
193    /// inline edit buffer, so keyboard entry and parsing stay numeric. Mirrors
194    /// [`Slider::with_suffix`](crate::widgets::Slider::with_suffix).
195    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
196        self.suffix = suffix.into();
197        self.sync_label();
198        self
199    }
200
201    /// Register a callback invoked with the new value on every drag update.
202    pub fn on_change(mut self, cb: impl FnMut(f64) + 'static) -> Self {
203        self.on_change = Some(Box::new(cb));
204        self
205    }
206
207    /// Bind this DragValue's value to an external `Rc<Cell<f64>>`.
208    ///
209    /// The cell becomes the source-of-truth: `layout()` re-reads it every frame
210    /// so any other widget (or code path) that writes the cell drives this
211    /// DragValue live, and drag/edit commits write back to the cell.  This is
212    /// the DragValue counterpart to [`Slider::with_value_cell`](crate::widgets::Slider::with_value_cell)
213    /// and is what keeps the gallery's Slider and DragValue showing the same
214    /// number when either one is moved.
215    pub fn with_value_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
216        let v = cell.get().clamp(self.min, self.max);
217        self.value = v;
218        self.sync_label();
219        self.value_cell = Some(cell);
220        self
221    }
222
223    pub fn with_margin(mut self, m: Insets) -> Self {
224        self.base.margin = m;
225        self
226    }
227    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
228        self.base.h_anchor = h;
229        self
230    }
231    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
232        self.base.v_anchor = v;
233        self
234    }
235    pub fn with_min_size(mut self, s: Size) -> Self {
236        self.base.min_size = s;
237        self
238    }
239    pub fn with_max_size(mut self, s: Size) -> Self {
240        self.base.max_size = s;
241        self
242    }
243
244    // ── State accessor ─────────────────────────────────────────────────────
245
246    /// Returns the current value.
247    pub fn value(&self) -> f64 {
248        self.value
249    }
250
251    // ── Internal helpers ───────────────────────────────────────────────────
252
253    /// Numeric-only formatting (no suffix) — used for the inline edit buffer.
254    fn format_value(&self) -> String {
255        format_value(self.value, self.decimals)
256    }
257
258    /// Display text shown in the drag label: value plus the optional suffix.
259    fn display_text(&self) -> String {
260        format!("{}{}", self.format_value(), self.suffix)
261    }
262
263    fn sync_label(&mut self) {
264        let text = self.display_text();
265        self.last_value_text = text.clone();
266        self.value_label.set_text(text);
267    }
268
269    /// The minimum width (logical px) the widget needs so its formatted value
270    /// (plus suffix) renders without clipping between the drag arrows.
271    ///
272    /// Measures the current display text plus one extra digit of headroom — a
273    /// conservative stand-in for "the widest value this control is likely to
274    /// show" without walking the whole `[min, max]` range — then adds the arrow
275    /// zones and padding that [`paint`](Widget::paint) reserves on each side.
276    /// This is what keeps a value like `1.00` from being clipped in a narrow
277    /// host; it is reported through [`min_size`](Widget::min_size).
278    pub fn intrinsic_min_width(&self) -> f64 {
279        let text_w = measure_advance(&self.font, &self.display_text(), self.font_size);
280        let digit_w = measure_advance(&self.font, "0", self.font_size);
281        text_w + digit_w + LABEL_SIDE_INSET * 2.0
282    }
283
284    /// Push the current value into the bound cell, if any.  Keeps sibling
285    /// widgets (the gallery Slider/progress bar) in sync when this DragValue
286    /// is the one being edited or dragged.
287    fn write_back(&self) {
288        if let Some(cell) = &self.value_cell {
289            cell.set(self.value);
290        }
291    }
292
293    fn apply_step_and_clamp(&self, raw: f64) -> f64 {
294        let snapped = if self.step > 0.0 {
295            (raw / self.step).round() * self.step
296        } else {
297            raw
298        };
299        snapped.clamp(self.min, self.max)
300    }
301
302    fn update_from_drag(&mut self, current_x: f64) {
303        let delta = (current_x - self.drag_start_x) * self.speed;
304        let raw = self.drag_start_value + delta;
305        self.value = self.apply_step_and_clamp(raw);
306        self.sync_label();
307        self.write_back();
308        let v = self.value;
309        if let Some(cb) = self.on_change.as_mut() {
310            cb(v);
311        }
312    }
313
314    fn enter_edit_mode(&mut self) {
315        self.editing = true;
316        self.edit_text = self.format_value();
317        self.edit_cursor = self.edit_text.chars().count();
318    }
319
320    fn commit_edit(&mut self) {
321        self.editing = false;
322        if let Ok(raw) = self.edit_text.trim().parse::<f64>() {
323            self.value = self.apply_step_and_clamp(raw);
324        }
325        // Always sync label back to actual value (parse success or failure).
326        self.sync_label();
327        self.write_back();
328        let v = self.value;
329        if let Some(cb) = self.on_change.as_mut() {
330            cb(v);
331        }
332    }
333
334    fn cancel_edit(&mut self) {
335        self.editing = false;
336        self.sync_label();
337    }
338
339    /// Convert a char-index cursor position to a byte offset in `edit_text`.
340    fn cursor_byte_offset(&self, char_idx: usize) -> usize {
341        self.edit_text
342            .char_indices()
343            .nth(char_idx)
344            .map(|(b, _)| b)
345            .unwrap_or(self.edit_text.len())
346    }
347}
348
349// ── Widget impl ────────────────────────────────────────────────────────────
350
351impl Widget for DragValue {
352    fn type_name(&self) -> &'static str {
353        "DragValue"
354    }
355
356    fn bounds(&self) -> Rect {
357        self.bounds
358    }
359    fn set_bounds(&mut self, b: Rect) {
360        self.bounds = b;
361    }
362    fn children(&self) -> &[Box<dyn Widget>] {
363        &self.children
364    }
365    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
366        &mut self.children
367    }
368
369    fn is_focusable(&self) -> bool {
370        true
371    }
372
373    fn margin(&self) -> Insets {
374        self.base.margin
375    }
376    fn widget_base(&self) -> Option<&WidgetBase> {
377        Some(&self.base)
378    }
379    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
380        Some(&mut self.base)
381    }
382    fn h_anchor(&self) -> HAnchor {
383        self.base.h_anchor
384    }
385    fn v_anchor(&self) -> VAnchor {
386        self.base.v_anchor
387    }
388    fn min_size(&self) -> Size {
389        // Floor the width at the intrinsic content width so a narrow host can
390        // never clip the value; keep any (larger) explicit min the caller set.
391        let w = self.base.min_size.width.max(self.intrinsic_min_width());
392        Size::new(w, self.base.min_size.height)
393    }
394    fn max_size(&self) -> Size {
395        self.base.max_size
396    }
397
398    fn measure_min_height(&self, _available_w: f64) -> f64 {
399        WIDGET_H.max(self.base.min_size.height)
400    }
401
402    fn layout(&mut self, available: Size) -> Size {
403        // Re-read the external cell every frame so a sibling widget (the gallery
404        // Slider) that writes the same cell drives this DragValue live.  Skip
405        // while the user is actively dragging or editing here, so their in-flight
406        // interaction isn't fought back by the value they're producing.
407        if !self.dragging && !self.editing {
408            if let Some(cell) = &self.value_cell {
409                let raw = cell.get();
410                if !raw.is_nan() {
411                    let clamped = raw.clamp(self.min, self.max);
412                    if clamped != self.value {
413                        self.value = clamped;
414                        let text = self.display_text();
415                        // Only invalidate the Label cache when the shown value
416                        // actually changed, then request a repaint so the new
417                        // number lands on screen.
418                        if text != self.last_value_text {
419                            self.sync_label();
420                            crate::animation::request_draw();
421                        }
422                    }
423                }
424            }
425        }
426        // Never render narrower than the intrinsic content width, even if the
427        // host offers less; larger host sizing is respected as-is.
428        let w = available.width.max(self.intrinsic_min_width());
429        Size::new(w, WIDGET_H)
430    }
431
432    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
433        let v = ctx.visuals();
434        let w = self.bounds.width;
435        let h = self.bounds.height;
436        let a = v.accent;
437
438        if self.editing {
439            // ── Edit-mode appearance ──────────────────────────────────────
440            let bg = Color::rgba(a.r, a.g, a.b, 0.10);
441            ctx.set_fill_color(bg);
442            ctx.begin_path();
443            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
444            ctx.fill();
445
446            // Bright accent border signals active editing.
447            ctx.set_stroke_color(Color::rgba(a.r, a.g, a.b, 0.80));
448            ctx.set_line_width(1.5);
449            ctx.begin_path();
450            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
451            ctx.stroke();
452
453            // Render current edit_text via the value label.
454            self.value_label.set_text(self.edit_text.clone());
455            let avail_w = (w - 8.0).max(1.0);
456            let lsz = self.value_label.layout(Size::new(avail_w, h));
457            let lx = (w - lsz.width) * 0.5;
458            let ly = (h - lsz.height) * 0.5;
459            self.value_label
460                .set_bounds(Rect::new(0.0, 0.0, lsz.width, lsz.height));
461            ctx.save();
462            ctx.translate(lx, ly);
463            paint_subtree(&mut self.value_label, ctx);
464            ctx.restore();
465
466            // Draw cursor: measure text up to edit_cursor to find x position.
467            let prefix: String = self.edit_text.chars().take(self.edit_cursor).collect();
468            ctx.set_font(Arc::clone(&self.font));
469            ctx.set_font_size(self.font_size);
470            let prefix_w = ctx.measure_text(&prefix).map(|m| m.width).unwrap_or(0.0);
471            // Cursor sits at the right edge of the prefix inside the label's x offset.
472            let text_x = lx
473                + (lsz.width
474                    - ctx
475                        .measure_text(&self.edit_text)
476                        .map(|m| m.width)
477                        .unwrap_or(lsz.width))
478                    * 0.5;
479            let cursor_x = text_x + prefix_w;
480            ctx.set_fill_color(Color::rgba(
481                v.text_color.r,
482                v.text_color.g,
483                v.text_color.b,
484                0.85,
485            ));
486            ctx.begin_path();
487            ctx.rect(cursor_x, ly + 2.0, 1.5, lsz.height - 4.0);
488            ctx.fill();
489        } else {
490            // ── Normal drag-value appearance ──────────────────────────────
491            let bg = if self.dragging {
492                Color::rgba(a.r, a.g, a.b, 0.22)
493            } else if self.hovered {
494                Color::rgba(a.r, a.g, a.b, 0.14)
495            } else {
496                Color::rgba(a.r, a.g, a.b, 0.08)
497            };
498            let border = Color::rgba(a.r, a.g, a.b, 0.35);
499            let arrow = Color::rgba(a.r, a.g, a.b, 0.45);
500
501            ctx.set_fill_color(bg);
502            ctx.begin_path();
503            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
504            ctx.fill();
505
506            ctx.set_stroke_color(border);
507            ctx.set_line_width(1.0);
508            ctx.begin_path();
509            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
510            ctx.stroke();
511
512            // Arrow triangles as drag affordances.
513            let mid = h * 0.5;
514            let tri_half = 4.0;
515            let tri_w = ARROW_TRI_W;
516            ctx.set_fill_color(arrow);
517            ctx.begin_path();
518            ctx.move_to(ARROW_MARGIN, mid);
519            ctx.line_to(ARROW_MARGIN + tri_w, mid - tri_half);
520            ctx.line_to(ARROW_MARGIN + tri_w, mid + tri_half);
521            ctx.close_path();
522            ctx.fill();
523            ctx.begin_path();
524            ctx.move_to(w - ARROW_MARGIN, mid);
525            ctx.line_to(w - ARROW_MARGIN - tri_w, mid - tri_half);
526            ctx.line_to(w - ARROW_MARGIN - tri_w, mid + tri_half);
527            ctx.close_path();
528            ctx.fill();
529
530            let avail_w = (w - LABEL_SIDE_INSET * 2.0).max(1.0);
531            let lsz = self.value_label.layout(Size::new(avail_w, h));
532            let lx = (w - lsz.width) * 0.5;
533            let ly = (h - lsz.height) * 0.5;
534            self.value_label
535                .set_bounds(Rect::new(0.0, 0.0, lsz.width, lsz.height));
536            ctx.save();
537            ctx.translate(lx, ly);
538            paint_subtree(&mut self.value_label, ctx);
539            ctx.restore();
540        }
541    }
542
543    fn on_event(&mut self, event: &Event) -> EventResult {
544        match event {
545            // ── Keyboard (edit mode) ──────────────────────────────────────
546            Event::KeyDown { key, .. } if self.editing => {
547                match key {
548                    Key::Char(c) => {
549                        // Only allow numeric input: digits, '.', '-'.
550                        if c.is_ascii_digit() || *c == '.' || (*c == '-' && self.edit_cursor == 0) {
551                            let byte = self.cursor_byte_offset(self.edit_cursor);
552                            self.edit_text.insert(byte, *c);
553                            self.edit_cursor += 1;
554                        }
555                    }
556                    Key::Backspace => {
557                        if self.edit_cursor > 0 {
558                            self.edit_cursor -= 1;
559                            let byte = self.cursor_byte_offset(self.edit_cursor);
560                            self.edit_text.remove(byte);
561                        }
562                    }
563                    Key::Delete => {
564                        let n = self.edit_text.chars().count();
565                        if self.edit_cursor < n {
566                            let byte = self.cursor_byte_offset(self.edit_cursor);
567                            self.edit_text.remove(byte);
568                        }
569                    }
570                    Key::ArrowLeft => {
571                        if self.edit_cursor > 0 {
572                            self.edit_cursor -= 1;
573                        }
574                    }
575                    Key::ArrowRight => {
576                        let n = self.edit_text.chars().count();
577                        if self.edit_cursor < n {
578                            self.edit_cursor += 1;
579                        }
580                    }
581                    Key::Enter => {
582                        self.commit_edit();
583                    }
584                    Key::Escape => {
585                        self.cancel_edit();
586                    }
587                    _ => {}
588                }
589                crate::animation::request_draw();
590                EventResult::Consumed
591            }
592
593            // ── Mouse events ──────────────────────────────────────────────
594            Event::MouseMove { pos } => {
595                let was = self.hovered;
596                self.hovered = self.hit_test(*pos);
597                if self.mouse_pressed && !self.editing {
598                    let dx = (pos.x - self.press_x).abs();
599                    if !self.dragging && dx >= DRAG_THRESHOLD {
600                        // Confirm drag: anchor at original press so no dead-zone jump.
601                        self.dragging = true;
602                        self.drag_start_x = self.press_x;
603                        self.drag_start_value = self.value;
604                    }
605                    if self.dragging {
606                        self.update_from_drag(pos.x);
607                        crate::animation::request_draw();
608                        return EventResult::Consumed;
609                    }
610                }
611                if was != self.hovered {
612                    crate::animation::request_draw();
613                    return EventResult::Consumed;
614                }
615                EventResult::Ignored
616            }
617            Event::MouseDown {
618                button: MouseButton::Left,
619                pos,
620                ..
621            } => {
622                if self.editing {
623                    // Already in edit mode — consume to keep focus, don't start drag.
624                    return EventResult::Consumed;
625                }
626                self.mouse_pressed = true;
627                self.dragging = false;
628                self.press_x = pos.x;
629                EventResult::Consumed
630            }
631            Event::MouseUp {
632                button: MouseButton::Left,
633                ..
634            } => {
635                let was_drag = self.dragging;
636                let was_pressed = self.mouse_pressed;
637                self.dragging = false;
638                self.mouse_pressed = false;
639                if was_pressed && !was_drag && !self.editing {
640                    self.enter_edit_mode();
641                    crate::animation::request_draw();
642                } else if was_drag {
643                    crate::animation::request_draw();
644                }
645                EventResult::Consumed
646            }
647
648            // ── Focus ─────────────────────────────────────────────────────
649            Event::FocusGained => {
650                self.focused = true;
651                crate::animation::request_draw();
652                EventResult::Ignored
653            }
654            Event::FocusLost => {
655                let was_focused = self.focused;
656                let was_editing = self.editing;
657                self.focused = false;
658                if self.editing {
659                    self.commit_edit();
660                }
661                self.dragging = false;
662                self.mouse_pressed = false;
663                if was_focused || was_editing {
664                    crate::animation::request_draw();
665                }
666                EventResult::Ignored
667            }
668
669            _ => EventResult::Ignored,
670        }
671    }
672}
673
674#[cfg(test)]
675mod tests;