From 418f1bffc33a9eb23221bf119c6cadef24059505 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:06:54 +0430 Subject: [PATCH 1/8] 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. --- crates/ui-egui/src/dialogs/form.rs | 4 ++-- crates/ui-egui/src/dialogs/tests.rs | 27 +++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 8 +++---- crates/ui-egui/src/widgets/numeric.rs | 31 ++++++++++++++++++++++++--- crates/ui-egui/src/widgets/text.rs | 10 ++++++++- docs/effects-parity.md | 3 ++- docs/target-app-parity.md | 3 ++- 7 files changed, 74 insertions(+), 12 deletions(-) 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. | From 5da8c8e495493eaa23d31fcbc91e1bd331d34edd Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:28:16 +0430 Subject: [PATCH 2/8] Numeric: clamp committed values in point units, schedule rejection highlight repaint, and update roadmap/area evidence --- crates/ui-egui/src/dialogs/tests.rs | 29 +++++++++++++++++++++++++-- crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/src/widgets/numeric.rs | 10 +++++---- crates/ui-egui/src/widgets/text.rs | 6 +++++- docs/roadmap.md | 1 + docs/target-app-parity.md | 1 + 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 3525d4d9a..6add459f4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2534,9 +2534,34 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { 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" + // 2. Refocus Max Radius before typing invalid text + crate::widgets::arm_field_focus(&ctx, ("dlg-len", "maxRadius")); + frame(&mut app, vec![]); 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 + // The value remains the valid clamped minimum 4.0 pt, never 0.5 px, and rejection flash is set assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + assert!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"] == serde_json::json!(4.0)); +} + +#[test] +fn bounded_field_with_inches_does_not_clamp_point_bounds_to_inch_numbers() { + let mut app = app(); + let _id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + // Switch document units to Inches + app.run("document.setup", json!({"units": "Inches"})).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![]); + // Type 1 in: 72 pt is within 4..127 pt range and must not clamp to ~1.76 pt + frame(&mut app, vec![egui::Event::Text("1 in".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!(72.0)); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a57f390e4..8590a2667 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -87,7 +87,7 @@ impl std::fmt::Debug for HashOnly { } /// `parent.with(salt)` for a salt that needs only `Hash`. -fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id { +pub(crate) fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id { parent.with(HashOnly(salt)) } diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 1cae6af93..184112790 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -102,10 +102,11 @@ fn unit_field( // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. let typed = typed_unit(ui.ctx(), unit); let read = |b: &str| typed.parse(&decimal_point(b)); - let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); + let bounds_unit = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); let stops: Vec = stops.iter().map(|&s| unit.from_pt(s)).collect(); - 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 stepped = + step_value(ui, &resp, &mut buf, &bounds_unit, (|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_unit); let commit = commit && buf != shown; if commit { match read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) { @@ -117,7 +118,8 @@ fn unit_field( } None => { ui.data_mut(|d| d.remove::(id)); - ui.data_mut(|d| d.insert_temp(id.with("invalid-flash"), true)); + ui.data_mut(|d| d.insert_temp(id.with("invalid-flash"), ui.input(|i| i.time + 0.25))); + ui.ctx().request_repaint(); None } } diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 0517eba55..e688c0c2e 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -237,7 +237,11 @@ 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(); - let invalid = ui.data_mut(|d| d.remove_temp::(id.with("invalid-flash"))).unwrap_or(false); + let now = ui.input(|i| i.time); + let invalid = ui.data(|d| d.get_temp::(id.with("invalid-flash"))).is_some_and(|end| now <= end); + if invalid { + ui.ctx().request_repaint(); + } let stroke = if invalid { Stroke::new(1.5, t.accent_strong) } else if editing { diff --git a/docs/roadmap.md b/docs/roadmap.md index 6bd431b95..3febbc75c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Raster effects: bounded length fields in effect dialogs clamp on commit and reject invalid inputs visibly (#4638); estimates unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile subsequent upstream progress updates without changing the runtime or repeating completed evidence; retain every dated upstream and feature revision. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3abc4bffc..43dc70596 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1129,6 +1129,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB or PCX file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Color Halftone Max Radius 4..127 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. - **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, the substitution highlights (Highlight Substituted Fonts and Highlight Alternate Glyphs in Preferences › Type, Highlight Substituted Glyphs in Document Setup), a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). - **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness, the stored source-color model determines the adjustment before document/profile conversion and blur: subtract Darkness/100 from each RGB channel, clamped at zero, or add it to CMYK black K and Gray black, clamped at one, preserving CMYK C/M/Y. At the near-100% endpoint the shadow source becomes gray black before conversion. Shadow opacity stays separate. Original gradient stops follow the same application rule before interpolation; exact resource-level gradient parity remains open. Application SVG export keeps a live filter for simple uniform sRGB sources and uses rendered shadow images for other working RGB profiles, source models and gradients. Low-level SVG filter output warns when this preparation is needed; proportional intermediate SVG filters are not misread as additive Darkness. Exact spot/custom-color and composite-source parity remain open, and resulting CMYK ink values follow the application color pipeline. CSS export leaves the shadow out and marks the object unsupported. When a `.ai`, EPS or PDF opens from its editing data, a Drop Shadow with Darkness in an object's art style is read as one with Color is, with the shadow's Darkness percentage (100% when the style gives none). Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434). From e2828bc0649d115f7f10e5bef37073aff764fa3f Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:57:41 +0430 Subject: [PATCH 3/8] Dialogs: keep dialog open on invalid Enter commit and prevent lock nesting in numeric widget --- crates/ui-egui/src/dialogs.rs | 3 ++- crates/ui-egui/src/dialogs/tests.rs | 13 +++++++------ crates/ui-egui/src/widgets.rs | 3 ++- crates/ui-egui/src/widgets/numeric.rs | 8 ++++++-- crates/ui-egui/src/widgets/text.rs | 13 ++++++++++++- docs/roadmap.md | 2 +- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6f307d43a..eb78e4392 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -656,7 +656,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); }); - if spec.ok.is_some() && ok_enabled && !after_panel && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + let rejected = ctx.data_mut(|d| d.remove_temp::(widgets::dialog_reject_commit_flag())).unwrap_or(false); + if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { ok = true; } if discard { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 6add459f4..ccc7b3dc0 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2534,14 +2534,15 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { 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. Refocus Max Radius before typing invalid text - crate::widgets::arm_field_focus(&ctx, ("dlg-len", "maxRadius")); + // 2. Typing invalid text on Enter must NOT confirm/close the dialog; the dialog stays open + app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); frame(&mut app, vec![]); 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, and rejection flash is set - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); - assert!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"] == serde_json::json!(4.0)); + frame( + &mut app, + vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert!(app.ui.dialog.is_some(), "dialog must remain open on invalid Enter commit"); } #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8590a2667..87fa6ac84 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -57,7 +57,8 @@ pub use numeric::{ use numeric::{decimal_point, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth}; 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, + arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter, + reject_dialog_commit, set_dialog_open, take_dialog_focus, }; pub use text::{ exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 184112790..1867eb271 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -117,8 +117,12 @@ fn unit_field( Some(clamped) } None => { - ui.data_mut(|d| d.remove::(id)); - ui.data_mut(|d| d.insert_temp(id.with("invalid-flash"), ui.input(|i| i.time + 0.25))); + let end = ui.input(|i| i.time + 0.25); + ui.data_mut(|d| { + d.remove::(id); + d.insert_temp(id.with("invalid-flash"), end); + }); + crate::widgets::reject_dialog_commit(ui.ctx()); ui.ctx().request_repaint(); None } diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index e688c0c2e..9d848f39f 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -87,6 +87,16 @@ fn dialog_open_flag() -> egui::Id { egui::Id::unique("dialog-open") } +/// The flag that tells the dialog runner a field rejected an invalid commit on Enter in this frame. +pub(crate) fn dialog_reject_commit_flag() -> egui::Id { + egui::Id::unique("dialog-reject-commit") +} + +/// Suppress confirming an open dialog on Enter during this frame when an invalid value was committed. +pub(crate) fn reject_dialog_commit(ctx: &egui::Context) { + ctx.data_mut(|d| d.insert_temp(dialog_reject_commit_flag(), true)); +} + /// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field /// applies its value and confirms the dialog as Enter does ([`shift_enter_keeps_focus`]). pub(crate) fn set_dialog_open(ctx: &egui::Context, open: bool) { @@ -279,7 +289,8 @@ pub(super) fn recessed_text( d.insert_temp(id, buf.clone()); d.insert_temp(focus_id, focused); }); - let commit = resp.lost_focus() || kept; + let enter_commit = rows == 1 && editing && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let commit = resp.lost_focus() || kept || enter_commit; (buf, commit, resp, rect) } diff --git a/docs/roadmap.md b/docs/roadmap.md index 3febbc75c..e1f58f3af 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,7 @@ ## Feature submission evidence -**Feature evidence last updated:** 2026-10-22. +**Feature evidence last updated:** 2026-10-30. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. From 956412e97820f93d68555310a4a39a55b3c6e577 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:31:46 +0430 Subject: [PATCH 4/8] Tests: assert rejection highlight and dialog retention on invalid Enter commit --- crates/ui-egui/src/dialogs/tests.rs | 77 ++++++++++++++++++++--------- crates/ui-egui/src/widgets/text.rs | 7 ++- 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index ccc7b3dc0..9ee777ccf 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2518,39 +2518,65 @@ fn an_effect_dialog_keeps_a_typed_distance_within_the_effects_range() { /// 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 mut first_app = app(); + let _id = first_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(); + out }; - 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. Typing invalid text on Enter must NOT confirm/close the dialog; the dialog stays open - app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); - frame(&mut app, vec![]); - frame(&mut app, vec![egui::Event::Text("1jgghgh".into())]); + first_app.run("select.all", json!({})).unwrap(); + first_app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + frame(&mut first_app, vec![]); + // 1. Enter 0.5 px (sub-minimum): clamped immediately to 4 pt on commit + frame(&mut first_app, vec![egui::Event::Text("0.5 px".into())]); frame( - &mut app, + &mut first_app, + vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert_eq!(first_app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + + // 2. Typing invalid text on Enter must NOT confirm/close the dialog and displays rejection highlight + let mut second_app = app(); + let _id2 = second_app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let ctx2 = egui::Context::default(); + theme::install_fonts(&ctx2); + theme::apply(&ctx2, Default::default()); + let _t = crate::theme::Tokens::get(&ctx2); + let frame2 = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx2.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + out + }; + second_app.run("select.all", json!({})).unwrap(); + second_app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + frame2(&mut second_app, vec![]); + frame2(&mut second_app, vec![egui::Event::Text("1jgghgh".into())]); + let _out = frame2( + &mut second_app, vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], ); - assert!(app.ui.dialog.is_some(), "dialog must remain open on invalid Enter commit"); + // Dialog remains open + assert!(second_app.ui.dialog.is_some(), "dialog must remain open on invalid Enter commit"); + assert_eq!(second_app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(8.0)); + // The field paints with rejection highlight (width 1.5, accent_strong) + let next_out = frame2(&mut second_app, vec![]); + let has_rejection_highlight = next_out.shapes.iter().any(|s| match &s.shape { + egui::Shape::Rect(r) => (r.stroke.width - 1.5).abs() < 1e-3 && r.stroke.color.a() > 0, + _ => false, + }); + assert!(has_rejection_highlight, "Max Radius must display rejection highlight after invalid input"); } #[test] fn bounded_field_with_inches_does_not_clamp_point_bounds_to_inch_numbers() { - 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 mut test_app = app(); + let _id = test_app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); // Switch document units to Inches - app.run("document.setup", json!({"units": "Inches"})).unwrap(); + test_app.run("document.setup", json!({"units": "Inches"})).unwrap(); let ctx = egui::Context::default(); theme::install_fonts(&ctx); theme::apply(&ctx, Default::default()); @@ -2558,11 +2584,14 @@ fn bounded_field_with_inches_does_not_clamp_point_bounds_to_inch_numbers() { 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![]); + test_app.run("select.all", json!({})).unwrap(); + test_app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + frame(&mut test_app, vec![]); // Type 1 in: 72 pt is within 4..127 pt range and must not clamp to ~1.76 pt - frame(&mut app, vec![egui::Event::Text("1 in".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!(72.0)); + frame(&mut test_app, vec![egui::Event::Text("1 in".into())]); + frame( + &mut test_app, + vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert_eq!(test_app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(72.0)); } diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 9d848f39f..4b3561a89 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -248,7 +248,9 @@ pub(super) fn recessed_text( ui.set_min_size(vec2(width, height)); let rect = ui.max_rect(); let now = ui.input(|i| i.time); - let invalid = ui.data(|d| d.get_temp::(id.with("invalid-flash"))).is_some_and(|end| now <= end); + let flash_end = ui.data(|d| d.get_temp::(id.with("invalid-flash"))); + let invalid = flash_end.is_some_and(|end| now <= end); + eprintln!("RECESSED_TEXT_DRAW: now={} flash_end={:?} invalid={}", now, flash_end, invalid); if invalid { ui.ctx().request_repaint(); } @@ -291,6 +293,9 @@ pub(super) fn recessed_text( }); let enter_commit = rows == 1 && editing && ui.input(|i| i.key_pressed(egui::Key::Enter)); let commit = resp.lost_focus() || kept || enter_commit; + if editing || enter_commit || commit { + eprintln!("RECESSED_TEXT: editing={} commit={} enter_commit={} buf={:?}", editing, commit, enter_commit, buf); + } (buf, commit, resp, rect) } From 6ec5178708b99ebce6036d670168aac18b5033b1 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:54:51 +0430 Subject: [PATCH 5/8] Text: remove debug print in recessed_text and test invalid input in single dialog session --- crates/ui-egui/src/dialogs/tests.rs | 54 +++++++++++------------------ crates/ui-egui/src/widgets/text.rs | 3 -- 2 files changed, 21 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 9ee777ccf..d316b4ff5 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2518,8 +2518,8 @@ fn an_effect_dialog_keeps_a_typed_distance_within_the_effects_range() { /// 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 first_app = app(); - let _id = first_app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + 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()); @@ -2528,42 +2528,30 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { out.textures_delta.clear(); out }; - first_app.run("select.all", json!({})).unwrap(); - first_app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); - frame(&mut first_app, vec![]); + 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 on commit - frame(&mut first_app, vec![egui::Event::Text("0.5 px".into())]); - frame( - &mut first_app, - vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], - ); - assert_eq!(first_app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + 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. Typing invalid text on Enter must NOT confirm/close the dialog and displays rejection highlight - let mut second_app = app(); - let _id2 = second_app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); - let ctx2 = egui::Context::default(); - theme::install_fonts(&ctx2); - theme::apply(&ctx2, Default::default()); - let _t = crate::theme::Tokens::get(&ctx2); - let frame2 = |app: &mut VectorcraftApp, events: Vec| { - let mut out = ctx2.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); - out.textures_delta.clear(); - out - }; - second_app.run("select.all", json!({})).unwrap(); - second_app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); - frame2(&mut second_app, vec![]); - frame2(&mut second_app, vec![egui::Event::Text("1jgghgh".into())]); - let _out = frame2( - &mut second_app, + // 2. Return focus to Max Radius with Shift+Tab, type invalid text, and commit with Enter + frame( + &mut app, + vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }], + ); + frame(&mut app, vec![egui::Event::Text("1jgghgh".into())]); + let _out = frame( + &mut app, vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], ); - // Dialog remains open - assert!(second_app.ui.dialog.is_some(), "dialog must remain open on invalid Enter commit"); - assert_eq!(second_app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(8.0)); + // Dialog remains open and retains the clamped 4.0 pt rather than reverting to 0.5 px + assert!(app.ui.dialog.is_some(), "dialog must remain open on invalid Enter commit"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + // The field paints with rejection highlight (width 1.5, accent_strong) - let next_out = frame2(&mut second_app, vec![]); + let next_out = frame(&mut app, vec![]); let has_rejection_highlight = next_out.shapes.iter().any(|s| match &s.shape { egui::Shape::Rect(r) => (r.stroke.width - 1.5).abs() < 1e-3 && r.stroke.color.a() > 0, _ => false, diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 4b3561a89..9031c7411 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -293,9 +293,6 @@ pub(super) fn recessed_text( }); let enter_commit = rows == 1 && editing && ui.input(|i| i.key_pressed(egui::Key::Enter)); let commit = resp.lost_focus() || kept || enter_commit; - if editing || enter_commit || commit { - eprintln!("RECESSED_TEXT: editing={} commit={} enter_commit={} buf={:?}", editing, commit, enter_commit, buf); - } (buf, commit, resp, rect) } From 092cb2ab04de4b2a30f1f691e64393f5087b4b7d Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:23:44 +0430 Subject: [PATCH 6/8] Tests: assert rendered field text displays 4 pt after sub-minimum commit and invalid input rejection --- crates/ui-egui/src/dialogs/tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d316b4ff5..d4b6698a8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2535,6 +2535,12 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { 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)); + let s1 = frame(&mut app, vec![]); + let shows_clamped_text = s1.shapes.iter().any(|s| match &s.shape { + egui::Shape::Text(t) => t.galley.text().trim() == "4 pt", + _ => false, + }); + assert!(shows_clamped_text, "rendered field must show clamped '4 pt' text after sub-minimum commit"); // 2. Return focus to Max Radius with Shift+Tab, type invalid text, and commit with Enter frame( @@ -2557,6 +2563,11 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { _ => false, }); assert!(has_rejection_highlight, "Max Radius must display rejection highlight after invalid input"); + let shows_clamped_after_invalid = next_out.shapes.iter().any(|s| match &s.shape { + egui::Shape::Text(t) => t.galley.text().trim() == "4 pt", + _ => false, + }); + assert!(shows_clamped_after_invalid, "rendered field must retain '4 pt' text after invalid input rejection"); } #[test] From a385b5cbf5cc7f139de27e0c90bd2d8ea44ee161 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 16:55:57 +0700 Subject: [PATCH 7/8] Fields: drop a leftover debug print from the invalid-input highlight --- crates/ui-egui/src/widgets/text.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 9031c7411..2c4d25f70 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -250,7 +250,6 @@ pub(super) fn recessed_text( let now = ui.input(|i| i.time); let flash_end = ui.data(|d| d.get_temp::(id.with("invalid-flash"))); let invalid = flash_end.is_some_and(|end| now <= end); - eprintln!("RECESSED_TEXT_DRAW: now={} flash_end={:?} invalid={}", now, flash_end, invalid); if invalid { ui.ctx().request_repaint(); } From 33d15bec56247697f2b05fc9de2c4dd8d6eb9828 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 17:12:16 +0700 Subject: [PATCH 8/8] Fields: only a plain Enter commits a field early, so Ctrl/Cmd+Enter in X or Y keeps editing --- crates/ui-egui/src/widgets/text.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 2c4d25f70..a8aa9e802 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -290,7 +290,8 @@ pub(super) fn recessed_text( d.insert_temp(id, buf.clone()); d.insert_temp(focus_id, focused); }); - let enter_commit = rows == 1 && editing && ui.input(|i| i.key_pressed(egui::Key::Enter)); + // Plain Enter only: Ctrl/Cmd+Enter keeps editing a field that takes it (X and Y in Transform). + let enter_commit = rows == 1 && editing && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.is_none()); let commit = resp.lost_focus() || kept || enter_commit; (buf, commit, resp, rect) }