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..d668e504a 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,61 @@ 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:?}"); + // 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/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)) { @@ -369,9 +390,11 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> 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. - 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 +452,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 +673,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 +699,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 +728,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 +1294,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 +1565,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 +1613,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 +1634,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 +2187,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 +2225,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) |