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::sync::Arc;
15
16use crate::color::Color;
17use crate::draw_ctx::DrawCtx;
18use crate::event::{Event, EventResult, Key, MouseButton};
19use crate::geometry::{Rect, Size};
20use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
21use crate::text::Font;
22use crate::widget::{paint_subtree, Widget};
23use crate::widgets::label::{Label, LabelAlign};
24
25/// Format a numeric value as a string with the given decimal places.
26/// Free function so `DragValue::new` can call it before `self` exists.
27fn format_value(value: f64, decimals: usize) -> String {
28    format!("{:.prec$}", value, prec = decimals)
29}
30
31// ── Geometry constants ─────────────────────────────────────────────────────
32
33const WIDGET_H: f64 = 24.0;
34/// Half-width of the left/right arrow indicator text.
35const ARROW_MARGIN: f64 = 8.0;
36/// Horizontal drag distance (logical px) before a press is treated as a drag.
37const DRAG_THRESHOLD: f64 = 3.0;
38
39// ── Struct ─────────────────────────────────────────────────────────────────
40
41/// A horizontal drag-to-scrub numeric value widget.
42///
43/// The user clicks and drags left or right to decrease or increase the value.
44/// A plain click (no drag) enters inline edit mode for direct keyboard entry.
45/// The current value is displayed as formatted text in the center of the widget.
46/// Left ("◀") and right ("▶") arrow triangles are drawn at the edges as an
47/// affordance for the drag direction.
48pub struct DragValue {
49    bounds: Rect,
50    children: Vec<Box<dyn Widget>>, // always empty
51    base: WidgetBase,
52
53    value: f64,
54    min: f64,
55    max: f64,
56
57    /// How many value units change per logical pixel of horizontal drag.
58    speed: f64,
59    /// Snap interval; values are rounded to the nearest multiple of `step`
60    /// after each drag update.  `0.0` means no snapping.
61    step: f64,
62    /// Number of decimal places used when formatting the displayed value.
63    decimals: usize,
64
65    font: Arc<Font>,
66    font_size: f64,
67
68    // ── Drag state ────────────────────────────────────────────────────────
69    /// True once the drag-threshold has been exceeded after a mouse-down.
70    dragging: bool,
71    /// True from mouse-down until mouse-up (covers both pre-threshold and drag phases).
72    mouse_pressed: bool,
73    /// X position where the mouse was pressed.
74    press_x: f64,
75    /// X position used as drag origin once the threshold is crossed.
76    drag_start_x: f64,
77    /// Value captured at the start of the confirmed drag.
78    drag_start_value: f64,
79
80    // ── Inline edit state ─────────────────────────────────────────────────
81    focused: bool,
82    editing: bool,
83    edit_text: String,
84    /// Cursor position as a char index into `edit_text`.
85    edit_cursor: usize,
86
87    hovered: bool,
88    on_change: Option<Box<dyn FnMut(f64)>>,
89
90    /// Formatted-value text lives in a `Label` field — DragValue draws
91    /// bg + border + arrow triangles; the label handles the value text
92    /// (including its own LCD cache).  Kept as a typed field rather
93    /// than in `children` so we can call `set_text` on value change
94    /// without downcasting.
95    value_label: Label,
96}
97
98// ── Constructors & builder methods ─────────────────────────────────────────
99
100impl DragValue {
101    /// Create a new `DragValue` with initial `value` clamped to `[min, max]`.
102    pub fn new(value: f64, min: f64, max: f64, font: Arc<Font>) -> Self {
103        let clamped = value.clamp(min, max);
104        let initial_text = format_value(clamped, 2);
105        let value_label = Label::new(initial_text, Arc::clone(&font))
106            .with_font_size(13.0)
107            .with_align(LabelAlign::Center);
108        Self {
109            bounds: Rect::default(),
110            children: Vec::new(),
111            base: WidgetBase::new(),
112            value: clamped,
113            min,
114            max,
115            speed: 1.0,
116            step: 0.0,
117            decimals: 2,
118            font,
119            font_size: 13.0,
120            dragging: false,
121            mouse_pressed: false,
122            press_x: 0.0,
123            drag_start_x: 0.0,
124            drag_start_value: 0.0,
125            focused: false,
126            editing: false,
127            edit_text: String::new(),
128            edit_cursor: 0,
129            hovered: false,
130            on_change: None,
131            value_label,
132        }
133    }
134
135    /// Set the display font size in logical pixels.
136    pub fn with_font_size(mut self, s: f64) -> Self {
137        self.font_size = s;
138        self.value_label.set_font_size(s);
139        self
140    }
141
142    /// Set a snap step.  Values are rounded to the nearest multiple of `step`
143    /// during dragging.  Pass `0.0` to disable snapping (the default).
144    pub fn with_step(mut self, step: f64) -> Self {
145        self.step = step;
146        self
147    }
148
149    /// Set the drag speed: how many value units change per logical pixel of
150    /// horizontal drag movement.  Default is `1.0`.
151    pub fn with_speed(mut self, speed: f64) -> Self {
152        self.speed = speed;
153        self
154    }
155
156    /// Set the number of decimal places shown in the formatted text.
157    pub fn with_decimals(mut self, d: usize) -> Self {
158        self.decimals = d;
159        self.sync_label();
160        self
161    }
162
163    /// Register a callback invoked with the new value on every drag update.
164    pub fn on_change(mut self, cb: impl FnMut(f64) + 'static) -> Self {
165        self.on_change = Some(Box::new(cb));
166        self
167    }
168
169    pub fn with_margin(mut self, m: Insets) -> Self {
170        self.base.margin = m;
171        self
172    }
173    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
174        self.base.h_anchor = h;
175        self
176    }
177    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
178        self.base.v_anchor = v;
179        self
180    }
181    pub fn with_min_size(mut self, s: Size) -> Self {
182        self.base.min_size = s;
183        self
184    }
185    pub fn with_max_size(mut self, s: Size) -> Self {
186        self.base.max_size = s;
187        self
188    }
189
190    // ── State accessor ─────────────────────────────────────────────────────
191
192    /// Returns the current value.
193    pub fn value(&self) -> f64 {
194        self.value
195    }
196
197    // ── Internal helpers ───────────────────────────────────────────────────
198
199    fn format_value(&self) -> String {
200        format_value(self.value, self.decimals)
201    }
202
203    fn sync_label(&mut self) {
204        self.value_label.set_text(self.format_value());
205    }
206
207    fn apply_step_and_clamp(&self, raw: f64) -> f64 {
208        let snapped = if self.step > 0.0 {
209            (raw / self.step).round() * self.step
210        } else {
211            raw
212        };
213        snapped.clamp(self.min, self.max)
214    }
215
216    fn update_from_drag(&mut self, current_x: f64) {
217        let delta = (current_x - self.drag_start_x) * self.speed;
218        let raw = self.drag_start_value + delta;
219        self.value = self.apply_step_and_clamp(raw);
220        self.sync_label();
221        let v = self.value;
222        if let Some(cb) = self.on_change.as_mut() {
223            cb(v);
224        }
225    }
226
227    fn enter_edit_mode(&mut self) {
228        self.editing = true;
229        self.edit_text = self.format_value();
230        self.edit_cursor = self.edit_text.chars().count();
231    }
232
233    fn commit_edit(&mut self) {
234        self.editing = false;
235        if let Ok(raw) = self.edit_text.trim().parse::<f64>() {
236            self.value = self.apply_step_and_clamp(raw);
237        }
238        // Always sync label back to actual value (parse success or failure).
239        self.sync_label();
240        let v = self.value;
241        if let Some(cb) = self.on_change.as_mut() {
242            cb(v);
243        }
244    }
245
246    fn cancel_edit(&mut self) {
247        self.editing = false;
248        self.sync_label();
249    }
250
251    /// Convert a char-index cursor position to a byte offset in `edit_text`.
252    fn cursor_byte_offset(&self, char_idx: usize) -> usize {
253        self.edit_text
254            .char_indices()
255            .nth(char_idx)
256            .map(|(b, _)| b)
257            .unwrap_or(self.edit_text.len())
258    }
259}
260
261// ── Widget impl ────────────────────────────────────────────────────────────
262
263impl Widget for DragValue {
264    fn type_name(&self) -> &'static str {
265        "DragValue"
266    }
267
268    fn bounds(&self) -> Rect {
269        self.bounds
270    }
271    fn set_bounds(&mut self, b: Rect) {
272        self.bounds = b;
273    }
274    fn children(&self) -> &[Box<dyn Widget>] {
275        &self.children
276    }
277    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
278        &mut self.children
279    }
280
281    fn is_focusable(&self) -> bool {
282        true
283    }
284
285    fn margin(&self) -> Insets {
286        self.base.margin
287    }
288    fn widget_base(&self) -> Option<&WidgetBase> {
289        Some(&self.base)
290    }
291    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
292        Some(&mut self.base)
293    }
294    fn h_anchor(&self) -> HAnchor {
295        self.base.h_anchor
296    }
297    fn v_anchor(&self) -> VAnchor {
298        self.base.v_anchor
299    }
300    fn min_size(&self) -> Size {
301        self.base.min_size
302    }
303    fn max_size(&self) -> Size {
304        self.base.max_size
305    }
306
307    fn layout(&mut self, available: Size) -> Size {
308        Size::new(available.width, WIDGET_H)
309    }
310
311    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
312        let v = ctx.visuals();
313        let w = self.bounds.width;
314        let h = self.bounds.height;
315        let a = v.accent;
316
317        if self.editing {
318            // ── Edit-mode appearance ──────────────────────────────────────
319            let bg = Color::rgba(a.r, a.g, a.b, 0.10);
320            ctx.set_fill_color(bg);
321            ctx.begin_path();
322            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
323            ctx.fill();
324
325            // Bright accent border signals active editing.
326            ctx.set_stroke_color(Color::rgba(a.r, a.g, a.b, 0.80));
327            ctx.set_line_width(1.5);
328            ctx.begin_path();
329            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
330            ctx.stroke();
331
332            // Render current edit_text via the value label.
333            self.value_label.set_text(self.edit_text.clone());
334            let avail_w = (w - 8.0).max(1.0);
335            let lsz = self.value_label.layout(Size::new(avail_w, h));
336            let lx = (w - lsz.width) * 0.5;
337            let ly = (h - lsz.height) * 0.5;
338            self.value_label
339                .set_bounds(Rect::new(0.0, 0.0, lsz.width, lsz.height));
340            ctx.save();
341            ctx.translate(lx, ly);
342            paint_subtree(&mut self.value_label, ctx);
343            ctx.restore();
344
345            // Draw cursor: measure text up to edit_cursor to find x position.
346            let prefix: String = self.edit_text.chars().take(self.edit_cursor).collect();
347            ctx.set_font(Arc::clone(&self.font));
348            ctx.set_font_size(self.font_size);
349            let prefix_w = ctx.measure_text(&prefix).map(|m| m.width).unwrap_or(0.0);
350            // Cursor sits at the right edge of the prefix inside the label's x offset.
351            let text_x = lx
352                + (lsz.width
353                    - ctx
354                        .measure_text(&self.edit_text)
355                        .map(|m| m.width)
356                        .unwrap_or(lsz.width))
357                    * 0.5;
358            let cursor_x = text_x + prefix_w;
359            ctx.set_fill_color(Color::rgba(
360                v.text_color.r,
361                v.text_color.g,
362                v.text_color.b,
363                0.85,
364            ));
365            ctx.begin_path();
366            ctx.rect(cursor_x, ly + 2.0, 1.5, lsz.height - 4.0);
367            ctx.fill();
368        } else {
369            // ── Normal drag-value appearance ──────────────────────────────
370            let bg = if self.dragging {
371                Color::rgba(a.r, a.g, a.b, 0.22)
372            } else if self.hovered {
373                Color::rgba(a.r, a.g, a.b, 0.14)
374            } else {
375                Color::rgba(a.r, a.g, a.b, 0.08)
376            };
377            let border = Color::rgba(a.r, a.g, a.b, 0.35);
378            let arrow = Color::rgba(a.r, a.g, a.b, 0.45);
379
380            ctx.set_fill_color(bg);
381            ctx.begin_path();
382            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
383            ctx.fill();
384
385            ctx.set_stroke_color(border);
386            ctx.set_line_width(1.0);
387            ctx.begin_path();
388            ctx.rounded_rect(0.0, 0.0, w, h, 4.0);
389            ctx.stroke();
390
391            // Arrow triangles as drag affordances.
392            let mid = h * 0.5;
393            let tri_half = 4.0;
394            let tri_w = 6.0;
395            ctx.set_fill_color(arrow);
396            ctx.begin_path();
397            ctx.move_to(ARROW_MARGIN, mid);
398            ctx.line_to(ARROW_MARGIN + tri_w, mid - tri_half);
399            ctx.line_to(ARROW_MARGIN + tri_w, mid + tri_half);
400            ctx.close_path();
401            ctx.fill();
402            ctx.begin_path();
403            ctx.move_to(w - ARROW_MARGIN, mid);
404            ctx.line_to(w - ARROW_MARGIN - tri_w, mid - tri_half);
405            ctx.line_to(w - ARROW_MARGIN - tri_w, mid + tri_half);
406            ctx.close_path();
407            ctx.fill();
408
409            let avail_w = (w - (ARROW_MARGIN + tri_w + 4.0) * 2.0).max(1.0);
410            let lsz = self.value_label.layout(Size::new(avail_w, h));
411            let lx = (w - lsz.width) * 0.5;
412            let ly = (h - lsz.height) * 0.5;
413            self.value_label
414                .set_bounds(Rect::new(0.0, 0.0, lsz.width, lsz.height));
415            ctx.save();
416            ctx.translate(lx, ly);
417            paint_subtree(&mut self.value_label, ctx);
418            ctx.restore();
419        }
420    }
421
422    fn on_event(&mut self, event: &Event) -> EventResult {
423        match event {
424            // ── Keyboard (edit mode) ──────────────────────────────────────
425            Event::KeyDown { key, .. } if self.editing => {
426                match key {
427                    Key::Char(c) => {
428                        // Only allow numeric input: digits, '.', '-'.
429                        if c.is_ascii_digit() || *c == '.' || (*c == '-' && self.edit_cursor == 0) {
430                            let byte = self.cursor_byte_offset(self.edit_cursor);
431                            self.edit_text.insert(byte, *c);
432                            self.edit_cursor += 1;
433                        }
434                    }
435                    Key::Backspace => {
436                        if self.edit_cursor > 0 {
437                            self.edit_cursor -= 1;
438                            let byte = self.cursor_byte_offset(self.edit_cursor);
439                            self.edit_text.remove(byte);
440                        }
441                    }
442                    Key::Delete => {
443                        let n = self.edit_text.chars().count();
444                        if self.edit_cursor < n {
445                            let byte = self.cursor_byte_offset(self.edit_cursor);
446                            self.edit_text.remove(byte);
447                        }
448                    }
449                    Key::ArrowLeft => {
450                        if self.edit_cursor > 0 {
451                            self.edit_cursor -= 1;
452                        }
453                    }
454                    Key::ArrowRight => {
455                        let n = self.edit_text.chars().count();
456                        if self.edit_cursor < n {
457                            self.edit_cursor += 1;
458                        }
459                    }
460                    Key::Enter => {
461                        self.commit_edit();
462                    }
463                    Key::Escape => {
464                        self.cancel_edit();
465                    }
466                    _ => {}
467                }
468                crate::animation::request_draw();
469                EventResult::Consumed
470            }
471
472            // ── Mouse events ──────────────────────────────────────────────
473            Event::MouseMove { pos } => {
474                let was = self.hovered;
475                self.hovered = self.hit_test(*pos);
476                if self.mouse_pressed && !self.editing {
477                    let dx = (pos.x - self.press_x).abs();
478                    if !self.dragging && dx >= DRAG_THRESHOLD {
479                        // Confirm drag: anchor at original press so no dead-zone jump.
480                        self.dragging = true;
481                        self.drag_start_x = self.press_x;
482                        self.drag_start_value = self.value;
483                    }
484                    if self.dragging {
485                        self.update_from_drag(pos.x);
486                        crate::animation::request_draw();
487                        return EventResult::Consumed;
488                    }
489                }
490                if was != self.hovered {
491                    crate::animation::request_draw();
492                    return EventResult::Consumed;
493                }
494                EventResult::Ignored
495            }
496            Event::MouseDown {
497                button: MouseButton::Left,
498                pos,
499                ..
500            } => {
501                if self.editing {
502                    // Already in edit mode — consume to keep focus, don't start drag.
503                    return EventResult::Consumed;
504                }
505                self.mouse_pressed = true;
506                self.dragging = false;
507                self.press_x = pos.x;
508                EventResult::Consumed
509            }
510            Event::MouseUp {
511                button: MouseButton::Left,
512                ..
513            } => {
514                let was_drag = self.dragging;
515                let was_pressed = self.mouse_pressed;
516                self.dragging = false;
517                self.mouse_pressed = false;
518                if was_pressed && !was_drag && !self.editing {
519                    self.enter_edit_mode();
520                    crate::animation::request_draw();
521                } else if was_drag {
522                    crate::animation::request_draw();
523                }
524                EventResult::Consumed
525            }
526
527            // ── Focus ─────────────────────────────────────────────────────
528            Event::FocusGained => {
529                self.focused = true;
530                crate::animation::request_draw();
531                EventResult::Ignored
532            }
533            Event::FocusLost => {
534                let was_focused = self.focused;
535                let was_editing = self.editing;
536                self.focused = false;
537                if self.editing {
538                    self.commit_edit();
539                }
540                self.dragging = false;
541                self.mouse_pressed = false;
542                if was_focused || was_editing {
543                    crate::animation::request_draw();
544                }
545                EventResult::Ignored
546            }
547
548            _ => EventResult::Ignored,
549        }
550    }
551}