From 28ac044c74c821269356329254e00ca68d00d477 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:25:18 +0430 Subject: [PATCH 1/2] Fix #3370: stepping a number field stops at its bounds: a stroke weight at 0, a miter limit at 1 Wheel notches and the arrow keys over a focused number field step the value its text shows (widgets::step_value), and nothing there knew the field's bounds: the Stroke panel's Weight went on to -3 pt and its Limit to 0 x in the field, while the panel only clamped what it applied (the spinner's min, Limit's clamp to 1..500), so the next step went on from the out-of-range text. A label scrub (scrub::field) had the same gap. step_value and scrub::field now take the field's bounds and stop at them, so the field shows where they stopped and the value applied is the same. The fields that have bounds pass them: - spin_field / spin_field_auto / spin_plain: their `min` (the spinner and a typed value already stopped there); - range_field: its range; it now also takes a value that may be blank (None), as mixed_field does; - opt_field (dash and gap lengths): 0; - the Revolve dialog's values: their range. Fields that clamped only what they applied now say their range with range_field instead: the Stroke panel's Limit (1..500), the Transparency and Properties opacity (0..100), the Transform panel's polygon sides, and the dialogs' bounded counts, percentages and lengths (Blend Options, Envelope, Flattener, Liquify, Freehand, Live Paint, Path ops, Perspective Grid, PNG ppi, Print, Save for Web, Shape Builder, Slices, Swatch Options, Symbol options, Justification, Dimension, PDF page, the effect dialogs' number rows). docs/ui-parity.md's number-field row says so. Test: panels::stroke::tests::the_wheel_stops_weight_at_zero_and_limit_at_one (the Stroke panel in a headless frame; focus Weight at 1 pt and turn the wheel down four notches: the field shows 0 pt and the stroke is 0 pt; the same with Limit at 2 x: 1 x and a limit of 1). It fails without the change (the field showed -3 pt) and passes with it; the other numeric field, scrub and dialog tests still pass. --- crates/ui-egui/src/dialogs/blend_options.rs | 4 +- crates/ui-egui/src/dialogs/dimension.rs | 4 +- crates/ui-egui/src/dialogs/envelope.rs | 4 +- crates/ui-egui/src/dialogs/flatten.rs | 8 +- crates/ui-egui/src/dialogs/form.rs | 2 +- crates/ui-egui/src/dialogs/freehand.rs | 8 +- crates/ui-egui/src/dialogs/import_pdf.rs | 4 +- crates/ui-egui/src/dialogs/justification.rs | 8 +- crates/ui-egui/src/dialogs/liquify.rs | 8 +- .../src/dialogs/live_paint_selection.rs | 4 +- crates/ui-egui/src/dialogs/path_ops.rs | 4 +- .../ui-egui/src/dialogs/perspective_grid.rs | 4 +- crates/ui-egui/src/dialogs/png_options.rs | 4 +- crates/ui-egui/src/dialogs/print.rs | 4 +- crates/ui-egui/src/dialogs/revolve.rs | 4 +- crates/ui-egui/src/dialogs/save_for_web.rs | 8 +- .../src/dialogs/shape_builder_options.rs | 6 +- crates/ui-egui/src/dialogs/slices.rs | 4 +- crates/ui-egui/src/dialogs/swatch_options.rs | 3 +- crates/ui-egui/src/dialogs/symbol_options.rs | 4 +- crates/ui-egui/src/panels/properties.rs | 4 +- crates/ui-egui/src/panels/stroke.rs | 56 ++++++++++- crates/ui-egui/src/panels/transform.rs | 4 +- crates/ui-egui/src/scrub.rs | 14 +-- crates/ui-egui/src/widgets.rs | 97 +++++++++++++------ docs/ui-parity.md | 2 +- 26 files changed, 189 insertions(+), 87 deletions(-) diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs index 4e139ff53..9ab6e3249 100644 --- a/crates/ui-egui/src/dialogs/blend_options.rs +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -76,8 +76,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { m => { let steps = d.f64("steps", 5.0); ui.add_enabled_ui(m == "steps", |ui| { - if let Some(n) = widgets::plain_field(ui, "blend-steps", steps, "", 0, 72.0) { - d.fields.insert("steps".into(), json!(n.round().clamp(1.0, 1000.0))); + if let Some(n) = widgets::range_field(ui, "blend-steps", steps, 1.0..=1000.0, "", 0, 72.0) { + d.fields.insert("steps".into(), json!(n)); } }); } diff --git a/crates/ui-egui/src/dialogs/dimension.rs b/crates/ui-egui/src/dialogs/dimension.rs index e494928bb..30afcbf8f 100644 --- a/crates/ui-egui/src/dialogs/dimension.rs +++ b/crates/ui-egui/src/dialogs/dimension.rs @@ -73,8 +73,8 @@ fn number(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, l let mut changed = false; widgets::label_row(ui, tl!(label), 96.0, |ui| { let value = fields.get(key).and_then(Value::as_f64).unwrap_or_default(); - if let Some(v) = widgets::plain_field(ui, ("dimension", key), value, suffix, 2, 90.0) { - fields.insert(key.into(), json!(v.clamp(range.0, range.1))); + if let Some(v) = widgets::range_field(ui, ("dimension", key), value, range.0..=range.1, suffix, 2, 90.0) { + fields.insert(key.into(), json!(v)); changed = true; } }); diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 0cdb2b241..f537595eb 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -135,8 +135,8 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { /// A whole number field row clamped to 1..50. fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { widgets::label_row(ui, label, LABEL_W, |ui| { - if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) { - set(d, key, json!(n.round().clamp(1.0, 50.0))); + if let Some(n) = widgets::range_field(ui, ("env-count", key), d.f64(key, 4.0), 1.0..=50.0, "", 0, 60.0) { + set(d, key, json!(n)); } }); } diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index 26c7fb4fb..fbef5dbee 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -205,8 +205,8 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions o.balance = b; } dim(ui, tl!("Vectors")); - if let Some(v) = widgets::plain_field(ui, (id, "balance"), o.balance, "", 0, FIELD - 12.0) { - o.balance = v.round().clamp(0.0, 100.0); + if let Some(v) = widgets::range_field(ui, (id, "balance"), o.balance, 0.0..=100.0, "", 0, FIELD - 12.0) { + o.balance = v; } }); ui.add_space(4.0); @@ -215,8 +215,8 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions widgets::dim_label(ui, label); ui.horizontal(|ui| { let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi }; - if let Some(n) = widgets::plain_field(ui, (id, key), *v, "", 0, FIELD) { - *v = n.round().clamp(1.0, 2400.0); + if let Some(n) = widgets::range_field(ui, (id, key), *v, 1.0..=2400.0, "", 0, FIELD) { + *v = n; } dim(ui, "ppi"); }); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index ffa232135..430216cc8 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -651,7 +651,7 @@ pub(super) fn slider_field_named( let v = d.f64(key, 0.0).clamp(min, max); let shown = crate::widgets::SliderValue::field(v, range.clone(), suffix, 0); slider_rail_valued(ui, d, key, name, range, Some(shown), track); - if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) { + if let Some(n) = crate::widgets::range_field(ui, ("dlg-field", key), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) { d.fields.insert(key.into(), json!(n)); } } diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index f61f0d403..50e4d2c4c 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -56,8 +56,8 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: let label_w = label_w(d); let v = d.f64(key, 0.0); widgets::label_row(ui, label, label_w, |ui| { - if let Some(x) = widgets::plain_field(ui, ("freehand-field", key), v, suffix, decimals, 72.0) { - d.fields.insert(key.into(), json!(x.clamp(range.0, range.1))); + if let Some(x) = widgets::range_field(ui, ("freehand-field", key), v, range.0..=range.1, suffix, decimals, 72.0) { + d.fields.insert(key.into(), json!(x)); } }); } @@ -75,8 +75,8 @@ fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) { let (x, _, keyed) = widgets::color_slider_stepped(ui, "freehand-fidelity", tl!("Fidelity:"), Some(shown), at, form::SLIDER_WIDTH, &|_| t.input_border); new = x.map(|x| ((min + x as f64 * (max - min)) * 10.0).round() / 10.0).or(keyed); - if let Some(x) = widgets::plain_field(ui, "freehand-fidelity-field", v, "pt", 1, 64.0) { - new = Some(x.clamp(0.05, 100.0)); + if let Some(x) = widgets::range_field(ui, "freehand-fidelity-field", v, 0.05..=100.0, "pt", 1, 64.0) { + new = Some(x); } }); if let Some(n) = new.filter(|n| *n != v) { diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index c485a3d89..74d27aa6a 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -249,8 +249,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { nav(ui, "chevrons-left", tl!("First Page"), 1, current > 1), nav(ui, "chevron-left", tl!("Previous Page"), current.saturating_sub(1).max(1), current > 1), ]; - if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) { - current = (v.max(1.0) as usize).min(count.max(1)); + if let Some(v) = widgets::range_field(ui, "pdf-page", current as f64, 1.0..=count.max(1) as f64, "", 0, 40.0) { + current = v as usize; } ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {count}"), &[("count", &count.to_string())])).color(t.text_dim)); let go2 = [ diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index d00d12277..054c08159 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -126,8 +126,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { for (suffix, _) in COLUMNS { let key = format!("{prefix}{suffix}"); let value = d.fields.get(&key).and_then(|v| v.as_f64()); - if let Some(v) = widgets::mixed_field(ui, ("justification", key.as_str()), value, "%", 0, 70.0) { - d.fields.insert(key, json!(v.clamp(*lo, *hi))); + if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), value, *lo..=*hi, "%", 0, 70.0) { + d.fields.insert(key, json!(v)); } } ui.end_row(); @@ -138,8 +138,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { label(ui, tl!("Auto Leading:")); let (lo, hi) = JustificationOptions::AUTO_LEADING; let leading = d.fields.get("autoLeading").and_then(|v| v.as_f64()); - if let Some(v) = widgets::mixed_field(ui, ("justification", "autoLeading"), leading, "%", 0, 64.0) { - d.fields.insert("autoLeading".into(), json!(v.clamp(lo, hi))); + if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), leading, lo..=hi, "%", 0, 64.0) { + d.fields.insert("autoLeading".into(), json!(v)); } ui.end_row(); label(ui, tl!("Single Word Justification:")); diff --git a/crates/ui-egui/src/dialogs/liquify.rs b/crates/ui-egui/src/dialogs/liquify.rs index cc2f841bb..40aa677f1 100644 --- a/crates/ui-egui/src/dialogs/liquify.rs +++ b/crates/ui-egui/src/dialogs/liquify.rs @@ -87,15 +87,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); } widgets::label_row(ui, tl!("Angle:"), LABEL_W, |ui| { - if let Some(v) = widgets::plain_field(ui, "lq-angle", d.f64("angle", 0.0), "°", 1, 110.0) { - d.fields.insert("angle".into(), json!(v.clamp(-360.0, 360.0))); + if let Some(v) = widgets::range_field(ui, "lq-angle", d.f64("angle", 0.0), -360.0..=360.0, "°", 1, 110.0) { + d.fields.insert("angle".into(), json!(v)); } }); let pressure = d.bool("usePressure"); widgets::label_row(ui, tl!("Intensity:"), LABEL_W, |ui| { ui.add_enabled_ui(!pressure, |ui| { - if let Some(v) = widgets::plain_field(ui, "lq-intensity", d.f64("intensity", 50.0), "%", 0, 110.0) { - d.fields.insert("intensity".into(), json!(v.clamp(1.0, 100.0))); + if let Some(v) = widgets::range_field(ui, "lq-intensity", d.f64("intensity", 50.0), 1.0..=100.0, "%", 0, 110.0) { + d.fields.insert("intensity".into(), json!(v)); } }); }); diff --git a/crates/ui-egui/src/dialogs/live_paint_selection.rs b/crates/ui-egui/src/dialogs/live_paint_selection.rs index a7e4c4149..8ba8cf49b 100644 --- a/crates/ui-egui/src/dialogs/live_paint_selection.rs +++ b/crates/ui-egui/src/dialogs/live_paint_selection.rs @@ -67,8 +67,8 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (min, max) = LP_SELECT_WIDTH_RANGE; let v = d.f64("width", min); widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| { - if let Some(x) = widgets::plain_field(ui, "lp-select-width", v, "px", 0, 72.0) { - d.fields.insert("width".into(), json!(x.round().clamp(min, max))); + if let Some(x) = widgets::range_field(ui, "lp-select-width", v, min..=max, "px", 0, 72.0) { + d.fields.insert("width".into(), json!(x)); } }); false diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs index b3197b5cb..d627405f1 100644 --- a/crates/ui-egui/src/dialogs/path_ops.rs +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -72,8 +72,8 @@ fn split_grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { egui::Grid::new("dlg").num_columns(4).spacing([10.0, 8.0]).show(ui, |ui| { for (number, label, ..) in axes { form::row_label(ui, label); - if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", number), d.f64(number, 2.0), "", 0, WIDTH) { - d.fields.insert(number.into(), json!(v.round().clamp(1.0, 500.0))); + if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", number), d.f64(number, 2.0), 1.0..=500.0, "", 0, WIDTH) { + d.fields.insert(number.into(), json!(v)); } } ui.end_row(); diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index 81c815ccc..6ca855f76 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -336,8 +336,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (x, _, keyed) = widgets::color_slider_stepped(ui, "persp-opacity", tl!("Opacity:"), Some(shown), (v / 100.0) as f32, form::SLIDER_WIDTH, &track); let mut new = x.map(|x| (f64::from(x) * 100.0).round()).or(keyed); - if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) { - new = Some(x.round().clamp(0.0, 100.0)); + if let Some(x) = widgets::range_field(ui, "persp-opacity-field", v, 0.0..=100.0, "%", 0, 52.0) { + new = Some(x); } if let Some(n) = new.filter(|n| *n != v) { d.fields.insert("opacity".into(), json!(n)); diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 93d3284e6..d21692ac3 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -143,9 +143,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("__otherPpi".into(), json!(i == 3)); } if preset.is_none() - && let Some(v) = widgets::plain_field(ui, "ro-ppi-other", ppi, " ppi", 0, 80.0) + && let Some(v) = widgets::range_field(ui, "ro-ppi-other", ppi, 1.0..=4608.0, " ppi", 0, 80.0) { - d.fields.insert("ppi".into(), json!(v.round().clamp(1.0, 4608.0))); + d.fields.insert("ppi".into(), json!(v)); } }); ui.end_row(); diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index ea4f58d39..eb163d8c1 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -558,8 +558,8 @@ fn art_texture(app: &mut VectorcraftApp, ctx: &egui::Context, layers: PrintLayer fn whole(ui: &mut egui::Ui, d: &mut Dialog, path: &str, range: std::ops::RangeInclusive, enabled: bool) { let v = get(d, path).as_f64().unwrap_or(*range.start()); ui.add_enabled_ui(enabled, |ui| { - if let Some(x) = widgets::plain_field(ui, path, v, "", 0, 56.0) { - set(d, path, json!(x.round().clamp(*range.start(), *range.end()) as u64)); + if let Some(x) = widgets::range_field(ui, path, v, range, "", 0, 56.0) { + set(d, path, json!(x as u64)); } }); } diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 3a53ce59d..ca281d95d 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -520,7 +520,9 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, if response.changed() { next = Some(value); } - if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) { + if let Some(n) = + scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32, &(from(range.0)..=from(range.1))) + { next = Some(n); } // Lengths' rails cover a useful working range; larger values can still be typed. diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 6ad18da4a..73f50fef7 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -967,12 +967,12 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option d.fields.insert(key.into(), v); }; widgets::label_row(ui, tl!("W:"), LABEL_W, |ui| { - if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) { - set(d, "width", json!(v.round().max(1.0))); + if let Some(v) = widgets::range_field(ui, "sfw-w", f64::from(w), 1.0..=f64::INFINITY, " px", 0, 80.0) { + set(d, "width", json!(v)); } widgets::field_label(ui, tl!("H:")); - if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) { - set(d, "height", json!(v.round().max(1.0))); + if let Some(v) = widgets::range_field(ui, "sfw-h", f64::from(h), 1.0..=f64::INFINITY, " px", 0, 80.0) { + set(d, "height", json!(v)); } }); let percent = region.map_or(100.0, |(_, scale)| (scale * 100.0 * 100.0).round() / 100.0); diff --git a/crates/ui-egui/src/dialogs/shape_builder_options.rs b/crates/ui-egui/src/dialogs/shape_builder_options.rs index 84d4cfbce..7fe4427a9 100644 --- a/crates/ui-egui/src/dialogs/shape_builder_options.rs +++ b/crates/ui-egui/src/dialogs/shape_builder_options.rs @@ -48,8 +48,10 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("gapLength".into(), json!(lengths.get(i).copied().unwrap_or(9.0))); } ui.add_enabled_ui(current == 3, |ui| { - if let Some(n) = widgets::plain_field(ui, "shape-builder-gap-length", len, "pt", 1, 64.0) { - d.fields.insert("gapLength".into(), json!(n.clamp(0.0, vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP))); + if let Some(n) = + widgets::range_field(ui, "shape-builder-gap-length", len, 0.0..=vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP, "pt", 1, 64.0) + { + d.fields.insert("gapLength".into(), json!(n)); } }); }); diff --git a/crates/ui-egui/src/dialogs/slices.rs b/crates/ui-egui/src/dialogs/slices.rs index 723f5be62..7efcd2905 100644 --- a/crates/ui-egui/src/dialogs/slices.rs +++ b/crates/ui-egui/src/dialogs/slices.rs @@ -144,8 +144,8 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: if widgets::radio_button(ui, labels[1], !by_size, enabled) { d.fields.insert(mode.into(), json!("count")); } - if let Some(n) = widgets::plain_field(ui, ("divide", count), d.f64(count, 2.0), "", 0, 50.0) { - d.fields.insert(count.into(), json!(n.round().max(1.0) as u64)); + if let Some(n) = widgets::range_field(ui, ("divide", count), d.f64(count, 2.0), 1.0..=f64::INFINITY, "", 0, 50.0) { + d.fields.insert(count.into(), json!(n as u64)); } widgets::dim_label(ui, labels[1]); }); diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 4b74171be..fdb2843fb 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -220,7 +220,8 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { c2[i] = mode.value_at(i, nv); new = Some((from_components(mode, &c2), c2)); } - if let Some(fv) = widgets::plain_field(ui, ("swatch-field", i), v as f64, mode.suffix(i), 0, 52.0) { + let bounds = f64::from(mode.min(i))..=f64::from(mode.max(i)); + if let Some(fv) = widgets::range_field(ui, ("swatch-field", i), v as f64, bounds, mode.suffix(i), 0, 52.0) { let mut c2 = comps.clone(); c2[i] = (fv as f32).clamp(mode.min(i), mode.max(i)); new = Some((from_components(mode, &c2), c2)); diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index b4167c129..f6b09c311 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -116,8 +116,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { for (key, text) in SLICE_KEYS.into_iter().zip([tl!("Left:"), tl!("Top:"), tl!("Right:"), tl!("Bottom:")]) { label(ui, text); let v = d.f64(key, 100.0 / 3.0); - if let Some(n) = widgets::plain_field(ui, ("dlg-field", key), v, "%", 0, 64.0) { - d.fields.insert(key.into(), json!(n.clamp(0.0, 100.0))); + if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, 0.0..=100.0, "%", 0, 64.0) { + d.fields.insert(key.into(), json!(n)); } ui.end_row(); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 32848575a..42403f222 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -643,8 +643,8 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0)); ui.add_space(8.0); - if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, fw) { - app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); + if let Some(o) = widgets::range_field(ui, "ap-op", n.opacity as f64 * 100.0, 0.0..=100.0, "%", 0, fw) { + app.run("object.setProps", json!({"opacity": o})).ok(); } if widgets::icon_button(ui, "ellipsis", tl!("Transparency"), false, 22.0).clicked() { app.ui.open_panel = Some("transparency".into()); diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index c1255e2dd..86de5f204 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -179,8 +179,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::dim_label(ui, tl!("Limit:")); let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0); if !matches!(join, Some(LineJoin::Round | LineJoin::Bevel)) { - if let Some(v) = widgets::mixed_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), " x", 0, 50.0) { - set(app, json!({"miterLimit": v.clamp(1.0, 500.0)})); + if let Some(v) = widgets::range_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), 1.0..=500.0, " x", 0, 50.0) { + set(app, json!({"miterLimit": v})); } } else { ui.label(egui::RichText::new(format!("{lim:.0} x")).color(t.text_disabled)); @@ -592,4 +592,56 @@ mod tests { assert_eq!(current_stroke(&app).map(|s| (s.join, s.width)), Some((LineJoin::Round, 4.0))); frame(&mut app); } + + /// #3370: the wheel over the focused Weight field stops at 0 and over the focused Limit field + /// at 1, in the field and in the document; it went on to -3 pt and 0 x. + #[test] + fn the_wheel_stops_weight_at_zero_and_limit_at_one() { + use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, Pos2}; + use vectorcraft_engine::Session; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 80})).unwrap(); + app.run("stroke.set", json!({"weight": 1, "join": "miter", "miterLimit": 2})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + // One frame of the panel → the texts painted, with their rects. + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 600.0))); + let raw = egui::RawInput { events, time: Some(time), screen_rect, focused: true, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .into_iter() + .filter_map(|s| match s.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + _ => None, + }) + .collect::>() + }; + // The weight and the limit. + let stroke = |app: &VectorcraftApp| current_stroke(app).map(|s| [s.width, s.miter_limit]).unwrap(); + for (shown, after, which, low) in [("1 pt", "0 pt", 0, 0.0), ("2 x", "1 x", 1, 1.0)] { + let texts = frame(&mut app, vec![]); + let at = texts.iter().find(|(t, _)| t == shown).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {shown:?} in {texts:?}")); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(&mut app, vec![Event::PointerMoved(at), button(true)]); + frame(&mut app, vec![button(false)]); + frame(&mut app, vec![]); + for _ in 0..4 { + let notch = Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + frame(&mut app, vec![Event::PointerMoved(at), notch]); + } + let texts = frame(&mut app, vec![]); + assert_eq!(stroke(&app)[which], low, "{shown}: {:?}", stroke(&app)); + assert!(texts.iter().any(|(t, _)| t == after), "{shown} → {after}: {texts:?}"); + } + } } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 92cedee07..ac4598aca 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -308,8 +308,8 @@ pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live let mut set = None; egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { widgets::dim_label(ui, tl!("Sides:")); - if let Some(s) = widgets::plain_field(ui, (id, "sides"), f64::from(*sides), "", 0, fw) { - set = Some(("sides", json!(s.round().max(3.0) as u64))); + if let Some(s) = widgets::range_field(ui, (id, "sides"), f64::from(*sides), 3.0..=f64::INFINITY, "", 0, fw) { + set = Some(("sides", json!(s as u64))); } ui.end_row(); widgets::dim_label(ui, tl!("Angle:")); diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs index 91b7f10c1..77df5782e 100644 --- a/crates/ui-egui/src/scrub.rs +++ b/crates/ui-egui/src/scrub.rs @@ -9,6 +9,8 @@ //! drag is one undo step and Escape takes it back. Preferences › General › Scrub Numeric Fields by //! Dragging turns it off. +use std::ops::RangeInclusive; + use egui::{Context, CursorIcon, Id, Key, Rect, Response, Sense, Ui}; use crate::VectorcraftApp; @@ -131,11 +133,11 @@ fn take_label(ui: &Ui, field: Rect) -> Option { } /// Scrub numeric field `field`, its box at `rect`, showing `text`: `value` in its units, rounded -/// to `decimals` places. → The value a drag on the field's label steps it to, or the value it -/// started at when Escape cancels the drag. A click on the label (or a press held without -/// stepping) focuses the field with all of `text` selected. Nothing for a field without a label, -/// disabled or showing no value (objects that differ). -pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option, decimals: i32) -> Option { +/// to `decimals` places and kept within `bounds` (#3370). → The value a drag on the field's label +/// steps it to, or the value it started at when Escape cancels the drag. A click on the label (or a +/// press held without stepping) focuses the field with all of `text` selected. Nothing for a field +/// without a label, disabled or showing no value (objects that differ). +pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option, decimals: i32, bounds: &RangeInclusive) -> Option { let label = take_label(ui, rect)?; let value = value.filter(|v| v.is_finite())?; if !enabled(ui.ctx()) || !ui.is_enabled() { @@ -143,7 +145,7 @@ pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option Unit { /// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in /// points in a picas field with Numbers Without Units Are Points on). pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option { - unit_field(ui, id, value, unit, width, false) + unit_field(ui, id, value, unit, width, false, ANY) +} + +/// Any number: the bounds of a field that has none. +const ANY: RangeInclusive = f64::NEG_INFINITY..=f64::INFINITY; + +/// `v` within `bounds` (bounds the wrong way round or not numbers don't panic, as `f64::clamp` would). +fn within(v: f64, bounds: &RangeInclusive) -> f64 { + v.max(*bounds.start()).min(*bounds.end()) } /// [`num_field`]; with `share_of_value` a percentage typed alone is that share of `value` -/// ([`share_of`]). +/// ([`share_of`]). ↑/↓, the wheel and a scrub stop at `bounds` (points), and the field shows where +/// they stopped. fn unit_field( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, @@ -254,6 +265,7 @@ fn unit_field( unit: Unit, width: f32, share_of_value: bool, + bounds: RangeInclusive, ) -> Option { let id = ui.id().with(id); let shown = value.map(|v| unit.format(v)).unwrap_or_default(); @@ -262,8 +274,9 @@ fn unit_field( select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. let read = typed_unit(ui.ctx(), unit); - let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(read.parse(b)?)), |v| unit.format(unit.to_pt(v))); - let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS); + let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); + let stepped = step_value(ui, &resp, &mut buf, &bounds, |b| Some(unit.from_pt(read.parse(b)?)), |v| unit.format(unit.to_pt(v))); + let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds); let commit = commit && buf != shown; ui.data_mut(|d| d.insert_temp(id, buf.clone())); if commit { @@ -310,12 +323,20 @@ fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 { /// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` /// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step /// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to -/// the next tenth, and a Shift step adds or takes ten. The new value is applied at once, as in +/// the next tenth, and a Shift step adds or takes ten. Steps stop at the field's `bounds` (#3370: +/// a weight stops at 0, a miter limit at 1). The new value is applied at once, as in /// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing /// replaces it. Returns the new number when a step was taken. While the pointer is over the focused /// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the /// wheel to the panel and the canvas. -fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option, show: impl Fn(f64) -> String) -> Option { +fn step_value( + ui: &Ui, + resp: &Response, + buf: &mut String, + bounds: &RangeInclusive, + read: impl Fn(&str) -> Option, + show: impl Fn(f64) -> String, +) -> Option { // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the // window to report keyboard focus. if !ui.memory(|m| m.has_focus(resp.id)) { @@ -370,8 +391,8 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> for (mods, size, n) in steps { v = if mods == egui::Modifiers::SHIFT { v + size * n } else { step_to_multiple(v, size, n) }; } - // Rounded as fields show it. - let v = round_to(v, STEP_DECIMALS); + // Rounded as fields show it, within the field's bounds. + let v = within(round_to(v, STEP_DECIMALS), bounds); *buf = show(v); ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); select_all(ui, resp.id, buf); @@ -429,7 +450,7 @@ pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value } }); } - unit_field(ui, id, value, unit, width, true) + unit_field(ui, id, value, unit, width, true, ANY) } /// Select all of `text` in text field `id`. @@ -650,20 +671,22 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu mixed_field(ui, id, Some(value), suffix, decimals, width) } -/// [`plain_field`] for a number kept within `range` (the values of dialogs and preferences): what is -/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count). +/// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels +/// such as the Stroke panel's Limit); `None` shows a blank field, as [`mixed_field`] does. What is +/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count), and +/// ↑/↓, the wheel and a scrub stop at its ends in the field too (#3370). pub fn range_field( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, - value: f64, - range: std::ops::RangeInclusive, + value: impl Into>, + range: RangeInclusive, suffix: &str, decimals: usize, width: f32, ) -> Option { - let (lo, hi) = (*range.start(), *range.end()); let places = decimals.min(6) as i32; - plain_field(ui, id, value.clamp(lo, hi), suffix, decimals, width).map(|v| round_to(v, places).clamp(lo, hi)) + let value = value.into().map(|v| within(v, &range)); + bounded_field(ui, id, value, suffix, decimals, width, &range).map(|v| within(round_to(v, places), &range)) } /// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. @@ -674,6 +697,19 @@ pub fn mixed_field( suffix: &str, decimals: usize, width: f32, +) -> Option { + bounded_field(ui, id, value, suffix, decimals, width, &ANY) +} + +/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`. +fn bounded_field( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + value: Option, + suffix: &str, + decimals: usize, + width: f32, + bounds: &RangeInclusive, ) -> Option { let id = ui.id().with(id); let show = |value: f64| field_text(value, suffix, decimals); @@ -690,8 +726,8 @@ pub fn mixed_field( // ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on // its label scrubs it. let decimals = decimals.min(6) as i32; - let stepped = step_value(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals)); - let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals); + let stepped = step_value(ui, &resp, &mut buf, bounds, read, show).map(|v| round_to(v, decimals)); + let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals, bounds); if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } } @@ -1256,8 +1292,8 @@ pub fn opacity_blend( dim_label(ui, "Opacity:"); ui.spacing_mut().item_spacing.x = 0.0; ui.add_enabled_ui(enabled, |ui| { - if let Some(o) = mixed_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), "%", 0, 50.0) { - edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released)); + if let Some(o) = range_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), 0.0..=100.0, "%", 0, 50.0) { + edit = Some(TransparencyEdit::Opacity(o, Live::Released)); } }); let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); @@ -1527,8 +1563,9 @@ fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) /// Numeric field with an up/down spinner on the left and a preset dropdown on the right /// (Stroke weight, font size…): an editable combo box whose field keeps typing, ↑/↓ stepping and /// label scrubbing. `value` (points) shows in `unit`, blank when `None` (the selection's values -/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. Returns the -/// committed, stepped or picked value. +/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. The spinner, ↑/↓, +/// the wheel and a scrub stop at `min` (#3370), and a typed value below it applies `min`. Returns +/// the committed, stepped or picked value. #[allow(clippy::too_many_arguments)] pub fn spin_field( ui: &mut Ui, @@ -1574,7 +1611,9 @@ pub fn spin_field_auto( min: f64, presets: &[f64], ) -> Option { - spin_generic(ui, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| num_field(ui, id, value, unit, fw)) + spin_generic(ui, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| { + unit_field(ui, id, value, unit, fw, false, min..=f64::INFINITY) + }) } /// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix; a @@ -1593,8 +1632,10 @@ pub fn spin_plain( ) -> Option { let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, value, None, width, step, min, presets, &fmt, &mut |ui, fw| mixed_field(ui, id, value, suffix, decimals, fw)) - .and_then(SpinPick::value) + spin_generic(ui, value, None, width, step, min, presets, &fmt, &mut |ui, fw| { + bounded_field(ui, id, value, suffix, decimals, fw, &(min..=f64::INFINITY)) + }) + .and_then(SpinPick::value) } #[allow(clippy::too_many_arguments)] @@ -2144,7 +2185,8 @@ pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> /// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without /// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit (Enter, Shift+Enter or /// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units -/// and arithmetic work as in [`num_field`]. +/// and arithmetic work as in [`num_field`]. The value is a length: ↑/↓, the wheel and a scrub stop +/// at 0. pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option> { let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); @@ -2181,8 +2223,9 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: let resp = framed.inner; select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it. - let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v))); - let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS); + let lengths = 0.0..=f64::INFINITY; + let stepped = step_value(ui, &resp, &mut buf, &lengths, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v))); + let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &lengths); let focused = ui.memory(|m| m.has_focus(id)); ui.data_mut(|d| { d.insert_temp(id, buf.clone()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8b31bddbe..b24ea5586 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -172,7 +172,7 @@ tools, never checked side by side) isn't captured row by row. |---|---|---| | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | -| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991) | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | From cc499d7e8ebe11d0738a159f4478968d38e6711d Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:24:03 +0430 Subject: [PATCH 2/2] Fix #3370: each step stops at the bounds, so a step back in the same frame starts from there Review follow-up (CodeRabbit): step_value clamped once after all of a frame's steps, so at the lower bound a step down then a step up in the same frame left the field at the bound. Each step is now clamped as it is applied. The Stroke panel test steps down and up in one frame at each bound and expects one step above it; it fails without the change. --- crates/ui-egui/src/panels/stroke.rs | 5 +++++ crates/ui-egui/src/widgets.rs | 2 ++ 2 files changed, 7 insertions(+) diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 86de5f204..d668e504a 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -642,6 +642,11 @@ mod tests { let texts = frame(&mut app, vec![]); assert_eq!(stroke(&app)[which], low, "{shown}: {:?}", stroke(&app)); assert!(texts.iter().any(|(t, _)| t == after), "{shown} → {after}: {texts:?}"); + // At the bound, a step down and a step up in one frame end one step above it. + let key = |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + frame(&mut app, vec![key(egui::Key::ArrowDown), key(egui::Key::ArrowUp)]); + let texts = frame(&mut app, vec![]); + assert!(texts.iter().any(|(t, _)| t == shown), "{after}, down and up → {shown}: {texts:?}"); } } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a89a84586..00452d98d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -390,6 +390,8 @@ fn step_value( let mut v = read(buf)?; for (mods, size, n) in steps { v = if mods == egui::Modifiers::SHIFT { v + size * n } else { step_to_multiple(v, size, n) }; + // Each step stops at the bounds, so a step back in the same frame starts from there. + v = within(v, bounds); } // Rounded as fields show it, within the field's bounds. let v = within(round_to(v, STEP_DECIMALS), bounds);