diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 941687ae4..ce456fb26 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -58,8 +58,8 @@ pub(super) fn length_within( range: Option>, ) -> 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 } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index ae9fb664f..3525d4d9a 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -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| { + 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)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e4032b53d..a57f390e4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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, diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index e815d75fe..1cae6af93 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -49,6 +49,19 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, 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, + unit: Unit, + width: f32, + bounds: RangeInclusive, +) -> Option { + 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, unit: Unit, width: f32) -> Option { @@ -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::NEG_INFINITY..=f64::INFINITY; +pub(crate) 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 { @@ -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::(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)) } } diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index ccde3ba38..0517eba55 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -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::(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) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 312096587..0c0bc052d 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2ee6feda9..3abc4bffc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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. |