Skip to main content

ashlar_ui/
edit.rs

1//! Edit phases: how a continuous gesture (a drag, a typed entry) maps onto one undo step.
2//!
3//! Value components ([`crate::value_field`], [`crate::slider`]) report an [`Edit`] whenever the
4//! user changes their value:
5//!
6//! - [`EditPhase::Begin`] opens a gesture (a drag started, a text entry opened). Nothing has
7//!   changed yet; open a history merge group here.
8//! - [`EditPhase::Update`] carries a live value while the gesture continues. Apply it inside the
9//!   open group (with a merge key, so the steps collapse into one).
10//! - [`EditPhase::Commit`] closes the gesture with its final value. A `Commit` without a
11//!   preceding `Begin` is a one-shot change (an arrow key step, a click on a slider track): one
12//!   undo step on its own.
13//! - [`EditPhase::Cancel`] abandons the gesture (Escape): `start` is the value to restore; roll the
14//!   group back. The component has already written `start` back into the bound value.
15
16/// Where in a gesture an edit is.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum EditPhase {
20    Begin,
21    Update,
22    Commit,
23    Cancel,
24}
25
26/// One edit event.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct Edit {
29    pub phase: EditPhase,
30    /// The value before the gesture began.
31    pub start: f64,
32    /// The value now (for `Cancel`, equal to `start`).
33    pub value: f64,
34}
35
36impl Edit {
37    pub fn begin(start: f64) -> Self {
38        Edit { phase: EditPhase::Begin, start, value: start }
39    }
40    pub fn update(start: f64, value: f64) -> Self {
41        Edit { phase: EditPhase::Update, start, value }
42    }
43    pub fn commit(start: f64, value: f64) -> Self {
44        Edit { phase: EditPhase::Commit, start, value }
45    }
46    pub fn cancel(start: f64) -> Self {
47        Edit { phase: EditPhase::Cancel, start, value: start }
48    }
49    /// Whether the bound value changed in this event.
50    pub fn changed(&self) -> bool {
51        match self.phase {
52            EditPhase::Begin => false,
53            EditPhase::Update | EditPhase::Commit => true,
54            EditPhase::Cancel => true,
55        }
56    }
57}
58
59/// A numeric range made safe: finite bounds in order (or unbounded).
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub(crate) struct Bounds {
62    pub min: f64,
63    pub max: f64,
64}
65
66impl Bounds {
67    pub fn new(range: &std::ops::RangeInclusive<f64>) -> Self {
68        let (a, b) = (*range.start(), *range.end());
69        let lo = if a.is_nan() { f64::MIN } else { a.max(f64::MIN) };
70        let hi = if b.is_nan() { f64::MAX } else { b.min(f64::MAX) };
71        if lo <= hi { Bounds { min: lo, max: hi } } else { Bounds { min: hi, max: lo } }
72    }
73
74    pub fn clamp(self, v: f64) -> f64 {
75        if v.is_nan() { self.min.max(0.0f64.min(self.max)) } else { v.clamp(self.min, self.max) }
76    }
77
78    /// Position of `v` in the range as 0..=1 (0 for an empty or unbounded range).
79    pub fn fraction(self, v: f64) -> f64 {
80        let span = self.max - self.min;
81        if span.is_finite() && span > 0.0 { ((self.clamp(v) - self.min) / span).clamp(0.0, 1.0) } else { 0.0 }
82    }
83
84    pub fn lerp(self, t: f64) -> f64 {
85        let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
86        self.clamp(self.min + (self.max - self.min) * t)
87    }
88
89    pub fn finite(self) -> bool {
90        (self.max - self.min).is_finite()
91    }
92}
93
94/// Round `v` to a multiple of `step` (when `step` is a positive finite number).
95pub(crate) fn snap(v: f64, step: f64) -> f64 {
96    if step.is_finite() && step > 0.0 && v.is_finite() {
97        let snapped = (v / step).round() * step;
98        if snapped.is_finite() { snapped } else { v }
99    } else {
100        v
101    }
102}
103
104/// Drag gesture bookkeeping kept in egui memory between frames.
105#[derive(Clone, Copy, Debug, Default)]
106pub(crate) struct Drag {
107    pub start: f64,
108    /// Pointer travel accumulated (with modifier speed applied), in value units.
109    pub travel: f64,
110    /// The value the gesture points at now.
111    pub target: f64,
112    /// Escape was pressed: ignore the rest of this drag.
113    pub cancelled: bool,
114}
115
116/// Speed multiplier from modifiers: Shift is coarse (×10), Alt (Option) or ⌘/Ctrl is fine (×0.1).
117pub(crate) fn speed_multiplier(modifiers: egui::Modifiers) -> f64 {
118    if modifiers.shift {
119        10.0
120    } else if modifiers.alt || modifiers.command {
121        0.1
122    } else {
123        1.0
124    }
125}
126
127/// How a drag maps to a value.
128pub(crate) enum DragMode<F: FnOnce(egui::Vec2, f64) -> f64> {
129    /// Pointer movement (and the modifier speed multiplier) → value change (fields, knobs).
130    Relative(F),
131    /// The value under the pointer, when known this frame (tracks).
132    Absolute(Option<f64>),
133}
134
135/// Runs the drag part of a value gesture on `response`: Begin on drag start, Update while
136/// moving, Commit on release, Cancel on Escape. Movement in the start and release frames counts,
137/// so a quick flick still changes the value.
138pub(crate) fn drive_drag<F: FnOnce(egui::Vec2, f64) -> f64>(
139    ui: &egui::Ui,
140    response: &egui::Response,
141    value: &mut f64,
142    bounds: Bounds,
143    step: f64,
144    mode: DragMode<F>,
145) -> Option<Edit> {
146    let key = response.id.with("ashlar_drag");
147    let current = bounds.clamp(*value);
148    let aim = |drag: &mut Drag| {
149        let t = match mode {
150            DragMode::Absolute(Some(abs)) => bounds.clamp(snap(abs, step)),
151            DragMode::Absolute(None) => drag.target,
152            DragMode::Relative(delta) => {
153                drag.travel += delta(response.drag_delta(), speed_multiplier(ui.input(|i| i.modifiers)));
154                bounds.clamp(snap(drag.start + drag.travel, step))
155            }
156        };
157        if t.is_finite() {
158            drag.target = t;
159        }
160    };
161    if response.drag_started() {
162        let mut drag = Drag { start: current, travel: 0.0, target: current, cancelled: false };
163        // this frame's movement is remembered and applied with the first Update
164        aim(&mut drag);
165        ui.data_mut(|d| d.insert_temp(key, drag));
166        return Some(Edit::begin(current));
167    }
168    let mut drag: Drag = ui.data(|d| d.get_temp(key))?;
169    if response.drag_stopped() || !response.dragged() {
170        // released, aborted by egui (it ends a drag on Escape), or the widget was hidden
171        ui.data_mut(|d| d.remove::<Drag>(key));
172        if drag.cancelled {
173            return None;
174        }
175        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
176            *value = drag.start;
177            return Some(Edit::cancel(drag.start));
178        }
179        aim(&mut drag);
180        *value = drag.target;
181        return Some(Edit::commit(drag.start, drag.target));
182    }
183    if drag.cancelled {
184        return None;
185    }
186    if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
187        drag.cancelled = true;
188        ui.data_mut(|d| d.insert_temp(key, drag));
189        *value = drag.start;
190        return Some(Edit::cancel(drag.start));
191    }
192    aim(&mut drag);
193    ui.data_mut(|d| d.insert_temp(key, drag));
194    if drag.target != current {
195        *value = drag.target;
196        return Some(Edit::update(drag.start, drag.target));
197    }
198    None
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn bounds_tolerate_hostile_ranges() {
207        let b = Bounds::new(&(10.0..=-10.0));
208        assert_eq!((b.min, b.max), (-10.0, 10.0));
209        let b = Bounds::new(&(f64::NAN..=f64::INFINITY));
210        assert_eq!((b.min, b.max), (f64::MIN, f64::MAX));
211        assert!(!b.finite());
212        assert_eq!(b.fraction(5.0), 0.0);
213        assert_eq!(Bounds::new(&(0.0..=1.0)).clamp(f64::NAN), 0.0);
214        assert_eq!(Bounds::new(&(2.0..=3.0)).clamp(f64::NAN), 2.0);
215        assert_eq!(Bounds::new(&(0.0..=10.0)).lerp(f64::NAN), 0.0);
216        assert_eq!(Bounds::new(&(0.0..=10.0)).fraction(2.5), 0.25);
217    }
218
219    #[test]
220    fn snapping() {
221        assert_eq!(snap(0.26, 0.25), 0.25);
222        assert_eq!(snap(7.0, 0.0), 7.0);
223        assert_eq!(snap(7.0, f64::NAN), 7.0);
224        assert_eq!(snap(f64::MAX, 1e-300), f64::MAX);
225    }
226}