Skip to main content

sequoia_elements/
drag_value.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use sequoia_core::{Color, Edges, Length, Point, Rect, Size};
5use sequoia_input::{
6    CursorIcon, Event, EventPhase, Key, KeyEvent, Modifiers, PointerButton, PointerEvent,
7};
8use sequoia_layout::{Align, Axis, LayoutCtx, LayoutStyle};
9use sequoia_reactive::Signal;
10use sequoia_style::{DragValueStyle, Theme, VisualState};
11use sequoia_view::{AnyView, EventCtx, FontWeight, PaintCtx, Unit, View};
12
13use crate::underline;
14
15const HEIGHT: f32 = 24.0;
16
17const KEY_FRACTION: f32 = 20.0;
18
19const DRAG_PX_FULL_RANGE: f32 = 200.0;
20
21const MAX_PRECISION: usize = 9;
22
23const CLICK_SLOP: f32 = 3.0;
24
25const CARET: char = '|';
26
27#[derive(Clone, Copy)]
28struct DragState {
29    origin: Point,
30    value: f32,
31}
32
33#[derive(Clone, Default)]
34struct Gesture {
35    drag: Option<DragState>,
36    edit: Option<String>,
37
38    fresh: bool,
39
40    announced: bool,
41}
42
43pub struct DragValue {
44    value: Signal<f32>,
45    reset: Option<f32>,
46    min: f32,
47    max: f32,
48    sensitivity: Option<f32>,
49    step: Option<f32>,
50    precision: usize,
51    orientation: Axis,
52    text_color: Option<Color>,
53    line_color: Option<Color>,
54    line_width: Option<f32>,
55    line_full: bool,
56    trim_zeros: bool,
57    font_size: Option<f32>,
58    weight: Option<FontWeight>,
59    appearance: Option<DragValueStyle>,
60    style: LayoutStyle,
61    margin: Edges,
62    align: Align,
63
64    revision: Signal<u32>,
65
66    gesture: Rc<RefCell<Gesture>>,
67
68    on_change_start: Option<Rc<dyn Fn()>>,
69
70    on_commit: Option<Rc<dyn Fn(f32)>>,
71}
72
73impl DragValue {
74    pub fn new(value: Signal<f32>) -> Self {
75        Self {
76            value,
77            reset: None,
78            min: 0.0,
79            max: 1.0,
80            sensitivity: None,
81            step: None,
82            precision: 2,
83            orientation: Axis::Horizontal,
84            text_color: None,
85            line_color: None,
86            line_width: None,
87            line_full: false,
88            trim_zeros: false,
89            font_size: None,
90            weight: None,
91            appearance: None,
92            style: LayoutStyle {
93                width: Length::Fit,
94                height: Length::Px(HEIGHT),
95                ..LayoutStyle::default()
96            },
97            margin: Edges::ZERO,
98            align: Align::Start,
99            revision: Signal::new(0),
100            gesture: Rc::new(RefCell::new(Gesture::default())),
101            on_change_start: None,
102            on_commit: None,
103        }
104    }
105
106    pub fn reset_to(mut self, value: f32) -> Self {
107        self.reset = Some(value);
108        self
109    }
110
111    pub fn min(mut self, min: f32) -> Self {
112        if min.is_finite() {
113            self.min = min;
114        }
115        self
116    }
117
118    pub fn max(mut self, max: f32) -> Self {
119        if max.is_finite() {
120            self.max = max;
121        }
122        self
123    }
124
125    pub fn sensitivity(mut self, units_per_px: f32) -> Self {
126        self.sensitivity = Some(units_per_px.max(0.0));
127        self
128    }
129
130    pub fn step(mut self, step: f32) -> Self {
131        self.step = Some(step.max(0.0));
132        self
133    }
134
135    pub fn precision(mut self, precision: usize) -> Self {
136        self.precision = precision.min(MAX_PRECISION);
137        self
138    }
139
140    pub fn orientation(mut self, axis: Axis) -> Self {
141        self.orientation = axis;
142        self
143    }
144
145    pub fn appearance(mut self, style: DragValueStyle) -> Self {
146        self.appearance = Some(style);
147        self
148    }
149
150    pub fn color(mut self, color: Color) -> Self {
151        self.text_color = Some(color);
152        self
153    }
154
155    pub fn line_color(mut self, color: Color) -> Self {
156        self.line_color = Some(color);
157        self
158    }
159
160    pub fn line_width(mut self, width: f32) -> Self {
161        self.line_width = Some(width.max(0.0));
162        self
163    }
164
165    pub fn line_full(mut self, full: bool) -> Self {
166        self.line_full = full;
167        self
168    }
169
170    pub fn trim_zeros(mut self, trim: bool) -> Self {
171        self.trim_zeros = trim;
172        self
173    }
174
175    /// Horizontal alignment of the value inside the field.
176    pub fn align(mut self, align: Align) -> Self {
177        self.align = align;
178        self
179    }
180
181    pub fn font_size(mut self, size: f32) -> Self {
182        self.font_size = Some(size);
183        self
184    }
185
186    pub fn weight(mut self, weight: FontWeight) -> Self {
187        self.weight = Some(weight);
188        self
189    }
190
191    pub fn bold(mut self, bold: bool) -> Self {
192        self.weight = Some(if bold {
193            FontWeight::BOLD
194        } else {
195            FontWeight::NORMAL
196        });
197        self
198    }
199
200    pub fn width(mut self, width: impl Into<Length>) -> Self {
201        self.style = self.style.width(width.into());
202        self
203    }
204
205    pub fn height(mut self, height: impl Into<Length>) -> Self {
206        self.style = self.style.height(height.into());
207        self
208    }
209
210    pub fn min_width(mut self, v: f32) -> Self {
211        self.style = self.style.min_width(v);
212        self
213    }
214
215    pub fn max_width(mut self, v: f32) -> Self {
216        self.style = self.style.max_width(v);
217        self
218    }
219
220    pub fn min_height(mut self, v: f32) -> Self {
221        self.style = self.style.min_height(v);
222        self
223    }
224
225    pub fn max_height(mut self, v: f32) -> Self {
226        self.style = self.style.max_height(v);
227        self
228    }
229
230    pub fn flex(mut self, weight: f32) -> Self {
231        self.style = self.style.flex(weight);
232        self
233    }
234
235    pub fn expand(self) -> Self {
236        self.flex(1.0)
237    }
238
239    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
240        let m = margin.into();
241        self.margin = Edges::only(
242            sanitize_margin(m.left),
243            sanitize_margin(m.top),
244            sanitize_margin(m.right),
245            sanitize_margin(m.bottom),
246        );
247        self
248    }
249
250    pub fn margin_xy(self, x: f32, y: f32) -> Self {
251        self.margin(Edges::xy(x, y))
252    }
253
254    pub fn margin_x(mut self, x: f32) -> Self {
255        let x = sanitize_margin(x);
256        self.margin.left = x;
257        self.margin.right = x;
258        self
259    }
260
261    pub fn margin_y(mut self, y: f32) -> Self {
262        let y = sanitize_margin(y);
263        self.margin.top = y;
264        self.margin.bottom = y;
265        self
266    }
267
268    pub fn margin_left(mut self, v: f32) -> Self {
269        self.margin.left = sanitize_margin(v);
270        self
271    }
272
273    pub fn margin_right(mut self, v: f32) -> Self {
274        self.margin.right = sanitize_margin(v);
275        self
276    }
277
278    pub fn margin_top(mut self, v: f32) -> Self {
279        self.margin.top = sanitize_margin(v);
280        self
281    }
282
283    pub fn margin_bottom(mut self, v: f32) -> Self {
284        self.margin.bottom = sanitize_margin(v);
285        self
286    }
287
288    pub fn on_change_start(mut self, f: impl Fn() + 'static) -> Self {
289        self.on_change_start = Some(Rc::new(f));
290        self
291    }
292
293    pub fn on_commit(mut self, f: impl Fn(f32) + 'static) -> Self {
294        self.on_commit = Some(Rc::new(f));
295        self
296    }
297
298    fn announce(&self) {
299        let need = self.with_gesture(|g| {
300            if g.announced {
301                false
302            } else {
303                g.announced = true;
304                true
305            }
306        });
307        if need && let Some(f) = &self.on_change_start {
308            f();
309        }
310    }
311
312    fn commit_if_changed(&self, before: f32) {
313        let after = self.value.get_untracked();
314        if after != before
315            && let Some(f) = &self.on_commit
316        {
317            f(after);
318        }
319    }
320
321    fn with_gesture<R>(&self, f: impl FnOnce(&mut Gesture) -> R) -> R {
322        f(&mut self.gesture.borrow_mut())
323    }
324
325    fn anchor(&self) -> Option<DragState> {
326        self.with_gesture(|g| g.drag)
327    }
328
329    fn is_editing(&self) -> bool {
330        self.with_gesture(|g| g.edit.is_some())
331    }
332
333    fn edit_buffer(&self) -> Option<String> {
334        self.with_gesture(|g| g.edit.clone())
335    }
336
337    fn start_edit(&self, buffer: String, fresh: bool) {
338        self.with_gesture(|g| {
339            g.edit = Some(buffer);
340            g.fresh = fresh;
341        });
342        self.bump();
343    }
344
345    fn stop_edit(&self) {
346        self.with_gesture(|g| {
347            g.edit = None;
348            g.fresh = false;
349        });
350        self.bump();
351    }
352
353    fn push_char(&self, c: char) {
354        self.with_gesture(|g| {
355            if g.edit.is_none() {
356                return;
357            }
358            if g.fresh {
359                g.edit = Some(String::new());
360                g.fresh = false;
361            }
362            if let Some(buffer) = g.edit.as_mut() {
363                buffer.push(c);
364            }
365        });
366        self.bump();
367    }
368
369    fn pop_char(&self) {
370        self.with_gesture(|g| {
371            g.fresh = false;
372            if let Some(buffer) = g.edit.as_mut() {
373                buffer.pop();
374            }
375        });
376        self.bump();
377    }
378
379    fn commit_edit(&self) {
380        let Some(buffer) = self.edit_buffer() else {
381            return;
382        };
383        let before = self.value.get_untracked();
384        self.stop_edit();
385        if let Ok(parsed) = buffer.trim().replace(',', ".").parse::<f32>() {
386            self.set_value(parsed);
387        }
388        self.commit_if_changed(before);
389    }
390
391    fn bump(&self) {
392        self.revision
393            .set(self.revision.get_untracked().wrapping_add(1));
394    }
395
396    fn span(&self) -> f32 {
397        (self.max - self.min).abs().max(f32::EPSILON)
398    }
399
400    fn units_per_px(&self) -> f32 {
401        self.sensitivity.unwrap_or(self.span() / DRAG_PX_FULL_RANGE)
402    }
403
404    fn key_step(&self) -> f32 {
405        self.step.unwrap_or(self.span() / KEY_FRACTION)
406    }
407
408    fn clamped(&self, v: f32) -> f32 {
409        let lo = self.min.min(self.max);
410        let hi = self.min.max(self.max);
411        let scale = 10f32.powi(self.precision as i32);
412        let q = ((v.clamp(lo, hi) * scale).round() / scale).clamp(lo, hi);
413        if q == 0.0 { 0.0 } else { q }
414    }
415
416    fn set_value(&self, v: f32) {
417        let next = self.clamped(v);
418
419        if !next.is_finite() {
420            return;
421        }
422        if next != self.value.get_untracked() {
423            self.announce();
424            self.value.set(next);
425        }
426    }
427
428    fn text(&self, value: f32) -> String {
429        let s = format!("{value:.prec$}", prec = self.precision);
430        if self.trim_zeros && self.precision > 0 && (s.contains('.') || s.contains(',')) {
431            let t = s.trim_end_matches('0');
432            t.strip_suffix('.')
433                .or_else(|| t.strip_suffix(','))
434                .unwrap_or(t)
435                .to_owned()
436        } else {
437            s
438        }
439    }
440
441    fn display(&self, value: f32) -> String {
442        match self.edit_buffer() {
443            Some(buffer) => format!("{buffer}{CARET}"),
444            None => self.text(value),
445        }
446    }
447
448    fn font_weight(&self) -> FontWeight {
449        self.weight
450            .or_else(|| {
451                self.appearance
452                    .and_then(|s| s.font_weight)
453                    .map(FontWeight::new)
454            })
455            .unwrap_or_default()
456    }
457
458    fn resolved_font_size(&self, theme: &Theme) -> f32 {
459        self.font_size
460            .or_else(|| self.appearance.and_then(|s| s.font_size))
461            .unwrap_or(theme.font_size)
462    }
463
464    fn resolved_line_width(&self) -> f32 {
465        if let Some(w) = self
466            .line_width
467            .or_else(|| self.appearance.and_then(|s| s.line_width))
468        {
469            return w;
470        }
471        let has_line_color =
472            self.line_color.is_some() || self.appearance.and_then(|s| s.line).is_some();
473        if has_line_color {
474            return underline::LINE_W;
475        }
476        0.0
477    }
478
479    fn resolved_text_color(&self) -> Option<Color> {
480        self.text_color
481            .or_else(|| self.appearance.and_then(|s| s.text))
482    }
483
484    fn resolved_line_color(&self) -> Option<Color> {
485        self.line_color
486            .or_else(|| self.appearance.and_then(|s| s.line))
487    }
488}
489
490fn is_value_char(c: char) -> bool {
491    c.is_ascii_digit() || c == '.' || c == ',' || c == '-'
492}
493
494fn normalize_char(c: char) -> char {
495    if c == ',' { '.' } else { c }
496}
497
498impl View for DragValue {
499    fn body(&self) -> impl View + 'static {
500        Unit
501    }
502
503    fn is_leaf(&self) -> bool {
504        true
505    }
506
507    fn focusable(&self) -> bool {
508        true
509    }
510
511    fn captures_pointer(&self) -> bool {
512        true
513    }
514
515    fn has_visual_state(&self) -> bool {
516        true
517    }
518
519    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
520        let _ = self.value.get();
521        let _ = self.revision.get();
522        Some(Vec::new())
523    }
524
525    fn cursor_icon(&self, state: VisualState) -> CursorIcon {
526        if state.pressed {
527            match self.orientation {
528                Axis::Horizontal => CursorIcon::ColResize,
529                Axis::Vertical => CursorIcon::RowResize,
530            }
531        } else {
532            CursorIcon::Grab
533        }
534    }
535
536    fn layout_style(&self) -> LayoutStyle {
537        let mut s = self.style;
538        s.margin = self.margin;
539        s
540    }
541
542    fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
543        let appearance = self.appearance.unwrap_or_default();
544        let size = self.font_size.or(appearance.font_size).unwrap_or(14.0);
545        let text = self.display(self.clamped(self.value.get_untracked()));
546        let w = ctx.text_width_weighted(&text, size, self.font_weight());
547        let line_width = self.resolved_line_width();
548        let h = if line_width > 0.0 {
549            underline::content_height(size, line_width)
550        } else {
551            LayoutCtx::text_line_height(size)
552        };
553        Some(Size::new(w + underline::PAD_X * 2.0, h))
554    }
555
556    fn reuse_state(&self, old: &Self) {
557        if !Rc::ptr_eq(&self.gesture, &old.gesture) {
558            *self.gesture.borrow_mut() = old.gesture.borrow().clone();
559        }
560        self.revision.set(old.revision.get_untracked());
561    }
562
563    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
564        if ctx.phase != EventPhase::Target {
565            return;
566        }
567        match event {
568            Event::Pointer(PointerEvent::Down {
569                button: PointerButton::Left,
570                pos,
571                modifiers,
572                ..
573            }) => {
574                if modifiers.contains(Modifiers::CONTROL) {
575                    if let Some(reset) = self.reset {
576                        let before = self.value.get_untracked();
577                        self.stop_edit();
578                        self.with_gesture(|g| g.drag = None);
579                        self.set_value(reset);
580                        self.commit_if_changed(before);
581                        ctx.handled();
582                    }
583                    return;
584                }
585
586                if self.is_editing() {
587                    self.commit_edit();
588                }
589                self.with_gesture(|g| {
590                    g.drag = Some(DragState {
591                        origin: *pos,
592                        value: self.value.get_untracked(),
593                    });
594                    g.announced = false;
595                });
596                ctx.capture_pointer();
597                ctx.handled();
598            }
599            Event::Pointer(PointerEvent::Move { pos, .. }) => {
600                let Some(drag) = self.anchor() else { return };
601
602                if !ctx.state.pressed {
603                    self.with_gesture(|g| g.drag = None);
604                    self.commit_if_changed(drag.value);
605                    return;
606                }
607                let delta = match self.orientation {
608                    Axis::Horizontal => pos.x - drag.origin.x,
609
610                    Axis::Vertical => drag.origin.y - pos.y,
611                };
612                self.set_value(drag.value + delta * self.units_per_px());
613                ctx.handled();
614            }
615            Event::Pointer(PointerEvent::Up { pos, .. }) => {
616                let Some(drag) = self.anchor() else { return };
617                self.with_gesture(|g| g.drag = None);
618
619                let moved = (pos.x - drag.origin.x)
620                    .abs()
621                    .max((pos.y - drag.origin.y).abs());
622                if moved <= CLICK_SLOP {
623                    self.start_edit(self.text(self.value.get_untracked()), true);
624                } else {
625                    self.bump();
626                    self.commit_if_changed(drag.value);
627                }
628                ctx.handled();
629            }
630            Event::Key(key) => {
631                if self.is_editing() {
632                    self.on_edit_key(key);
633                    ctx.handled();
634                    return;
635                }
636
637                if let Some(c) = key.key.char().filter(|c| is_value_char(*c)) {
638                    self.start_edit(normalize_char(c).to_string(), false);
639                    ctx.request_focus();
640                    ctx.handled();
641                    return;
642                }
643                let delta = match key.key {
644                    Key::ARROW_LEFT | Key::ARROW_DOWN => -self.key_step(),
645                    Key::ARROW_RIGHT | Key::ARROW_UP => self.key_step(),
646                    _ => return,
647                };
648                let before = self.value.get_untracked();
649                self.set_value(self.value.get_untracked() + delta);
650                self.commit_if_changed(before);
651                ctx.handled();
652            }
653
654            Event::Pointer(PointerEvent::Wheel {
655                delta, modifiers, ..
656            }) => {
657                if self.is_editing() {
658                    return;
659                }
660                let mult = if modifiers.contains(Modifiers::SHIFT) {
661                    0.1
662                } else if modifiers.contains(Modifiers::CONTROL)
663                    || modifiers.contains(Modifiers::SUPER)
664                {
665                    10.0
666                } else {
667                    1.0
668                };
669                let dir = if delta.y < 0.0 { 1.0 } else { -1.0 };
670                let before = self.value.get_untracked();
671                self.set_value(before + self.span() / 100.0 * mult * dir);
672                self.commit_if_changed(before);
673                ctx.handled();
674            }
675            _ => {}
676        }
677    }
678
679    fn paint_self(&self, ctx: &mut PaintCtx) {
680        if !ctx.state.focused && self.is_editing() {
681            self.commit_edit();
682        }
683
684        let rect = ctx.rect;
685        if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
686            return;
687        }
688        let theme = ctx.theme();
689        let size = self.resolved_font_size(&theme);
690        let weight = self.font_weight();
691        let line_width = self.resolved_line_width();
692        let editing = self.is_editing();
693        let colors = underline::field_colors(
694            &theme,
695            ctx.state.hovered || ctx.state.pressed || editing,
696            ctx.state.pressed || editing,
697            self.resolved_text_color(),
698            self.resolved_line_color(),
699        );
700        let text = self.display(self.clamped(self.value.get_untracked()));
701        let text_width = ctx.text_width_weighted(&text, size, weight);
702
703        let line_h = LayoutCtx::text_line_height(size);
704        let draws_line = line_width > 0.0 && colors.line.a > 0.0;
705        // Without an underline we do not reserve space for the line, so the text
706        // is vertically centered in the whole field. With a line the layout stays as before.
707        let text_top = if draws_line {
708            rect.top()
709        } else {
710            rect.top() + ((rect.size.height - line_h) * 0.5).max(0.0)
711        };
712
713        if draws_line {
714            let w = if self.line_full {
715                (rect.size.width - underline::PAD_X * 2.0).max(0.0)
716            } else {
717                text_width.min((rect.size.width - underline::PAD_X * 2.0).max(0.0))
718            };
719            let line = Rect::from_xywh(
720                rect.left() + underline::PAD_X,
721                text_top + line_h + underline::LINE_GAP,
722                w,
723                line_width,
724            );
725            ctx.quad(line, colors.line, line_width * 0.5);
726        }
727
728        let x = match self.align {
729            Align::Center => rect.left() + (rect.size.width - text_width) * 0.5,
730            Align::End => rect.right() - underline::PAD_X - text_width,
731            _ => rect.left() + underline::PAD_X,
732        };
733        let x = x.max(rect.left());
734        let y = text_top + line_h * 0.5 + size * 0.35;
735        ctx.draw_text_weighted(&text, (x, y), size, weight, colors.text);
736    }
737}
738
739impl DragValue {
740    fn on_edit_key(&self, key: &KeyEvent) {
741        match key.key {
742            Key::ENTER => self.commit_edit(),
743            Key::ESCAPE => self.stop_edit(),
744            Key::BACKSPACE => self.pop_char(),
745            other => {
746                if let Some(c) = other.char().filter(|c| is_value_char(*c)) {
747                    self.push_char(normalize_char(c));
748                }
749            }
750        }
751    }
752}
753
754fn sanitize_margin(v: f32) -> f32 {
755    if v.is_finite() { v.max(0.0) } else { 0.0 }
756}