Fix #4638: clamp bounded effect length inputs on commit and reject invalid input visibly

In effect dialogs (like Color Halftone), length fields with defined bounds
(e.g. Max Radius 4..127 pt) were previously checked only after num_field
returned, leaving the field's temporary buffer holding the un-clamped string
(e.g. '0.5 px'). Subsequently entering invalid text caused the field to
fall back to that sub-minimum buffer.

- Added num_field_within to pass effect parameter bounds directly to unit_field.
- When committed, clamp the parsed value within bounds and update the field
  buffer to the formatted clamped value so the UI reflects what is applied.
- When invalid text is committed, clear the invalid buffer and trigger a
  one-frame border highlight so invalid input is rejected visibly.
This commit is contained in:
Taha-Mahmoodi
2026-10-30 16:55:55 +07:00
committed by bflatastic
parent c9504597ea
commit 418f1bffc3
7 changed files with 74 additions and 12 deletions
+2 -2
View File
@@ -58,8 +58,8 @@ pub(super) fn length_within(
range: Option<std::ops::RangeInclusive<f64>>,
) -> bool {
let value = d.fields.contains_key(key).then(|| d.f64(key, 0.0));
let Some(v) = crate::widgets::num_field(ui, ("dlg-len", key), value, unit, width) else { return false };
let v = range.map_or(v, |r| v.max(*r.start()).min(*r.end()));
let bounds = range.unwrap_or(crate::widgets::ANY);
let Some(v) = crate::widgets::num_field_within(ui, ("dlg-len", key), value, unit, width, bounds) else { return false };
d.fields.insert(key.into(), json!(v));
true
}
+27
View File
@@ -2513,3 +2513,30 @@ fn an_effect_dialog_keeps_a_typed_distance_within_the_effects_range() {
assert_eq!(node.appearance.effects.last().map(|e| e.params["maxRadius"].clone()), Some(json!(applied)), "{typed}");
}
}
/// #4638: typing invalid input in an effect length field does not silently revert to a sub-minimum
/// or out-of-range value; sub-minimum input is clamped immediately and invalid input clears cleanly.
#[test]
fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() {
let mut app = app();
let _id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap();
frame(&mut app, vec![]);
// 1. Enter 0.5 px (sub-minimum): clamped immediately to 4 pt
frame(&mut app, vec![egui::Event::Text("0.5 px".into())]);
frame(&mut app, vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0));
// 2. Type invalid input "1jgghgh"
frame(&mut app, vec![egui::Event::Text("1jgghgh".into())]);
frame(&mut app, vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
// The value remains the valid clamped minimum 4.0 pt, never 0.5 px
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0));
}
+4 -4
View File
@@ -47,14 +47,14 @@ pub use dropdowns::{
};
pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader};
pub(crate) use labels::{label_field, take_previous_label};
pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit};
pub use numeric::{
SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, opt_field, opt_field_named, range_field, range_field_named,
range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named,
spin_field_stops_named, spin_plain, spin_plain_named,
SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, num_field_within, opt_field, opt_field_named, range_field,
range_field_named, range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named,
spin_field_named, spin_field_stops_named, spin_plain, spin_plain_named,
};
#[cfg(test)]
use numeric::{decimal_point, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth};
pub(crate) use numeric::{round_to, set_bare_numbers_are_points, typed_unit};
pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar};
pub(crate) use text::{
arm_field_focus, dialog_focus_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter, set_dialog_open, take_dialog_focus,
+28 -3
View File
@@ -49,6 +49,19 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option<f64>, unit
num_field_named(ui, id, None, value, unit, width)
}
/// [`num_field`] whose value is clamped to `bounds` (points).
pub fn num_field_within(
ui: &mut Ui,
id: impl std::hash::Hash,
value: Option<f64>,
unit: Unit,
width: f32,
bounds: RangeInclusive<f64>,
) -> Option<f64> {
let label = take_previous_label(ui);
unit_field(ui, id, label, None, value, unit, width, false, bounds, &[])
}
/// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with
/// its label painted separately): screen readers name the field `name` (already translated).
pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option<f64>, unit: Unit, width: f32) -> Option<f64> {
@@ -57,7 +70,7 @@ pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>
}
/// Any number: the bounds of a field that has none.
const ANY: RangeInclusive<f64> = f64::NEG_INFINITY..=f64::INFINITY;
pub(crate) 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 {
@@ -94,10 +107,22 @@ fn unit_field(
let stepped = step_value(ui, &resp, &mut buf, &bounds, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None, &stops);
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 {
read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None })
match read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) {
Some(v) => {
let clamped = within(v, &bounds);
let formatted = unit.format(clamped);
ui.data_mut(|d| d.insert_temp(id, formatted));
Some(clamped)
}
None => {
ui.data_mut(|d| d.remove::<String>(id));
ui.data_mut(|d| d.insert_temp(id.with("invalid-flash"), true));
None
}
}
} else {
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
stepped.or(scrubbed).map(|v| unit.to_pt(v))
}
}
+9 -1
View File
@@ -237,7 +237,15 @@ pub(super) fn recessed_text(
.allocate_ui_with_layout(vec2(width, height), layout, |ui| {
ui.set_min_size(vec2(width, height));
let rect = ui.max_rect();
ui.painter().rect(rect, 5.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside);
let invalid = ui.data_mut(|d| d.remove_temp::<bool>(id.with("invalid-flash"))).unwrap_or(false);
let stroke = if invalid {
Stroke::new(1.5, t.accent_strong)
} else if editing {
Stroke::new(1.0, t.accent)
} else {
Stroke::new(1.0, t.input_border)
};
ui.painter().rect(rect, 5.0, t.input, stroke, StrokeKind::Inside);
let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 };
let resp = egui::Frame::NONE
.inner_margin(margin)
+2 -1
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Effect dialogs: bounded length fields (Color Halftone Max Radius) clamp on commit and visibly reject invalid input (#4638); scores unchanged. |
| 2026-10-23 | minor | Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)): the Pathfinder panel's Trap… (`object.pathfinder.trap`, one undo step, the traps grouped above the topmost selected object and selected) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add an overprinting strip of the lighter colour inside the darker one where two solid colours meet (Reverse Traps: the darker into the lighter, but not a rich black), with Thickness, Height/Width, Tint Reduction and Traps with Process Color; the effect also has Precision and Remove Redundant Points. Recounted: Hard Mix (#3061) and Soft Mix had shipped, so Pathfinder 11/13 → 13/13 and Illustrator effects 46/50 → 49/50 (SVG Filters remains); remaining 6–10 → 4–8 h. Tests: `vectorcraft_pathops` `trap::tests`, `vectorcraft_effects` `trap::tests` and `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `vectorcraft_engine` `tests_trap`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. |
| 2026-10-22 | minor | The Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, with visible checkboxes (#885); on a fill's or stroke's own Transform effect the last two are still missing. Counts unchanged. |
| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. |
+2 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Smart Pen, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Effects: bounded effect length inputs clamp on commit and reject invalid text visibly (#4638)) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Effects: bounded length inputs clamp on commit and reject invalid text visibly (#4638); scores unchanged. |
| 2026-10-29 | minor | File formats: attached Affinity Recolor Adjustment (RcRA) layers import as color overlays (#4420); scores unchanged. |
| 2026-10-29 | minor | Type core: copied text runs retain character attributes across document switches and canvas pastes (#4459); scores unchanged. |
| 2026-10-29 | minor | Type core: soft return (Shift+Enter forced line break, U+2028) breaks lines without starting new paragraphs (#2754); scores unchanged. |