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.
This commit is contained in:
Taha-Mahmoodi
2026-10-22 18:56:33 +07:00
committed by bflatastic
parent 648a8f5104
commit 28ac044c74
26 changed files with 189 additions and 87 deletions
+2 -2
View File
@@ -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));
}
});
}
+2 -2
View File
@@ -73,8 +73,8 @@ fn number(ui: &mut Ui, fields: &mut serde_json::Map<String, Value>, 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;
}
});
+2 -2
View File
@@ -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));
}
});
}
+4 -4
View File
@@ -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");
});
+1 -1
View File
@@ -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));
}
}
+4 -4
View File
@@ -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) {
+2 -2
View File
@@ -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 = [
+4 -4
View File
@@ -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:"));
+4 -4
View File
@@ -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));
}
});
});
@@ -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
+2 -2
View File
@@ -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();
@@ -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));
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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<f64>, 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));
}
});
}
+3 -1
View File
@@ -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.
+4 -4
View File
@@ -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);
@@ -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));
}
});
});
+2 -2
View File
@@ -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]);
});
+2 -1
View File
@@ -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));
+2 -2
View File
@@ -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();
}
+2 -2
View File
@@ -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());
+54 -2
View File
@@ -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<Event>| {
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::<Vec<_>>()
};
// 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:?}");
}
}
}
+2 -2
View File
@@ -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:"));
+8 -6
View File
@@ -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<Rect> {
}
/// 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<f64>, decimals: i32) -> Option<f64> {
/// 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<f64>, decimals: i32, bounds: &RangeInclusive<f64>) -> Option<f64> {
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<f6
}
// On top of the label, which may sense clicks itself (selectable text).
let resp = ui.interact(label, field.with("scrub-label"), Sense::click_and_drag());
drag(ui, &resp, field, text, value, decimals)
drag(ui, &resp, field, text, value, decimals).map(|v| v.max(*bounds.start()).min(*bounds.end()))
}
/// What a drag on `resp`, the label of field `field`, does this frame.
+70 -27
View File
@@ -1,5 +1,7 @@
//! Shared widgets. Colours come from theme tokens.
use std::ops::RangeInclusive;
use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::Unit;
@@ -242,11 +244,20 @@ pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> 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<f64>, unit: Unit, width: f32) -> Option<f64> {
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> = 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>) -> 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<f64>,
) -> Option<f64> {
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<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
fn step_value(
ui: &Ui,
resp: &Response,
buf: &mut String,
bounds: &RangeInclusive<f64>,
read: impl Fn(&str) -> Option<f64>,
show: impl Fn(f64) -> String,
) -> Option<f64> {
// 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<f64>,
value: impl Into<Option<f64>>,
range: RangeInclusive<f64>,
suffix: &str,
decimals: usize,
width: f32,
) -> Option<f64> {
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<f64> {
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<f64>,
suffix: &str,
decimals: usize,
width: f32,
bounds: &RangeInclusive<f64>,
) -> Option<f64> {
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<SpinPick> {
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<f64> {
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<f64>, unit: Unit, width: f32) -> Option<Option<f64>> {
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());
+1 -1
View File
@@ -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) |