From 0f5e4c261a608645423d47347b667e8e7171b1ec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 05:32:37 -0800 Subject: [PATCH] Transform previews typed values in its effect dialog --- crates/ui-egui/src/dialogs/effect.rs | 8 +- crates/ui-egui/src/dialogs/transform_each.rs | 26 +- crates/ui-egui/src/panels/tests_appearance.rs | 310 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 6 +- crates/ui-egui/src/widgets/numeric.rs | 81 ++++- docs/mcp.md | 4 + docs/target-app-parity.md | 3 +- docs/ui-parity.md | 4 +- 8 files changed, 416 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 1e1ad72fc..85282add1 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -106,10 +106,10 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(Value::Null) } -/// The effects of the first selected object that `item` (an index or null) addresses. +/// The function reads effects at `item` (an index or null) from the targeted node or first selected object. fn target_effects(app: &VectorcraftApp, item: &Value) -> Vec { let Some(st) = app.session.active() else { return vec![] }; - let node = st.selection.objects.first().and_then(|id| st.doc.node(*id)); + let node = st.selection.subjects().first().and_then(|id| st.doc.node(*id)); node.and_then(|n| n.appearance.effects_at(item.as_u64().map(|i| i as usize))).cloned().unwrap_or_default() } @@ -550,7 +550,7 @@ fn scribble_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Uni /// last previewed. fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool { ui.horizontal_top(|ui| { - ui.vertical(|ui| transform_each::sections(ui, d, unit)); + ui.vertical(|ui| transform_each::sections(ui, d, unit, true)); ui.add_space(16.0); ui.vertical(|ui| { widgets::subheader(ui, tl!("Options")); @@ -560,7 +560,7 @@ fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un form::check(ui, d, "strokes", tl!("Scale Strokes & Effects")); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Copies:")); - if let Some(n) = widgets::range_field(ui, "fx-copies", d.f64("copies", 0.0), 0.0..=1000.0, "", 0, 50.0) { + if let Some(n) = widgets::range_field_live(ui, "fx-copies", d.f64("copies", 0.0), 0.0..=1000.0, "", 0, 50.0) { d.fields.insert("copies".into(), json!(n)); } }); diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index f18cd7ece..71f5145a2 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -78,7 +78,7 @@ const MAX_SCALE: f64 = 100_000.0; /// `scaleH` and `scaleV` (rails 0–200 %) and for `moveH` and `moveV` (rails ±1000 pt, fields in /// `units`), and an angle dial for `rotate` (counter-clockwise). The fields take values past the /// rails. -pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit) { +pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit, live: bool) { let rail = Tokens::get(ui.ctx()).input_border; let track = move |_| rail; let axes = |h, v| [(h, tl!("Horizontal:")), (v, tl!("Vertical:"))]; @@ -87,7 +87,12 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc widgets::label_row(ui, label, LABEL_W, |ui| { let shown = widgets::SliderValue::field(d.f64(key, 100.0).clamp(-MAX_SCALE, MAX_SCALE), 0.0..=200.0, "%", 2); form::slider_rail_valued(ui, d, key, label, 0.0..=200.0, Some(shown), &track); - if let Some(n) = widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) { + let value = if live { + widgets::range_field_live(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) + } else { + widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) + }; + if let Some(n) = value { d.fields.insert(key.into(), json!(n)); } }); @@ -97,14 +102,25 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc widgets::label_row(ui, label, LABEL_W, |ui| { let shown = form::length_value(d, key, units, -1000.0..=1000.0); form::slider_rail_valued(ui, d, key, label, -1000.0..=1000.0, shown, &track); - form::length(ui, d, key, units, FIELD_W); + if live { + if let Some(n) = widgets::num_field_live(ui, ("dlg-len", key), Some(d.f64(key, 0.0)), units, FIELD_W) { + d.fields.insert(key.into(), json!(n)); + } + } else { + form::length(ui, d, key, units, FIELD_W); + } }); } widgets::subheader(ui, tl!("Rotate")); widgets::label_row(ui, tl!("Angle:"), LABEL_W, |ui| { let angle = d.f64("rotate", 0.0); let dialed = widgets::angle_dial(ui, "te-dial", angle, 28.0); - if let Some(a) = widgets::plain_field(ui, ("te", "rotate"), angle, "°", 2, FIELD_W).or(dialed) { + let typed = if live { + widgets::plain_field_live(ui, ("te", "rotate"), angle, "°", 2, FIELD_W) + } else { + widgets::plain_field(ui, ("te", "rotate"), angle, "°", 2, FIELD_W) + }; + if let Some(a) = typed.or(dialed) { d.fields.insert("rotate".into(), json!(a)); } }); @@ -128,7 +144,7 @@ pub(super) fn reflect_options(ui: &mut egui::Ui, d: &mut Dialog) { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let units = app.session.general_unit(); ui.horizontal_top(|ui| { - ui.vertical(|ui| sections(ui, d, units)); + ui.vertical(|ui| sections(ui, d, units, false)); ui.add_space(16.0); ui.vertical(|ui| { widgets::subheader(ui, tl!("Options")); diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 1fee26cc6..dc56d99e8 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -835,6 +835,316 @@ fn an_effects_name_edits_it_and_its_empty_space_highlights_it() { assert!(app.ui.dialog.is_some(), "a double-click on the empty space opens the dialog"); } +#[test] +fn transform_effect_name_edits_the_displayed_appearance_target() { + for target_kind in ["object", "group", "layer"] { + let mut app = app_with_rect(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + if target_kind == "group" { + let group = app.run("object.group", json!({})).unwrap()["id"].clone(); + app.run("layer.target", json!({"id": group})).unwrap(); + } else if target_kind == "layer" { + let layer = app.session.doc().unwrap().doc.layers[0].id; + app.run("layer.target", json!({"id": layer.0})).unwrap(); + } + let target = app.session.doc().unwrap().selection.subjects()[0]; + app.run("effect.apply", json!({"effect": "distort.transform", "item": null, "params": {"moveH": 12}})).unwrap(); + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click_mod(&ctx, &mut app, text_rect(&texts, "Transform").center(), egui::Modifiers::NONE); + let dialog = app.ui.dialog.as_mut().unwrap_or_else(|| panic!("Transform did not open for {target_kind}")); + assert_eq!(dialog.f64("moveH", 0.0), 12.0); + dialog.fields.insert("moveH".into(), json!(35)); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(text.lines().any(|s| s == "Transform"), "{text}"); + assert!(app.session.in_interaction()); + let rendered = vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc); + assert_eq!(rendered.over_white(30, 50), [255, 255, 255], "{target_kind}: original position"); + assert_eq!(rendered.over_white(130, 50), [255, 0, 0], "{target_kind}: transformed position"); + crate::dialogs::confirm(&mut app).unwrap(); + let node = app.session.doc().unwrap().doc.node(target).unwrap(); + assert_eq!(node.appearance.effects.len(), 1); + assert_eq!(node.appearance.effects[0].params["moveH"].as_f64(), Some(35.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.node(target).unwrap().appearance.effects[0].params["moveH"].as_f64(), Some(12.0)); + } +} + +#[test] +fn transform_effect_typed_scale_changes_the_painted_object() { + for layer_target in [false, true] { + let mut app = app_with_rect(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + if layer_target { + app.run("effect.apply", json!({"effect": "distort.transform", "item": null, "params": {"objects": false}})).unwrap(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + app.run("layer.target", json!({"id": layer.0})).unwrap(); + } + app.run("effect.apply", json!({"effect": "distort.transform", "item": null})).unwrap(); + let target = app.session.doc().unwrap().selection.subjects()[0]; + let ctx = drop_ctx(); + crate::theme::install_fonts(&ctx); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click_mod(&ctx, &mut app, text_rect(&texts, "Transform").center(), egui::Modifiers::NONE); + for _ in 0..2 { + frame_events(&ctx, &mut app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + } + frame_events(&ctx, &mut app, vec![egui::Event::Text("50".into())], |app, ui| crate::dialogs::show(app, ui.ctx())); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![enter], |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.node(target).unwrap().appearance.effects[0].params["scaleH"].as_f64(), Some(50.0)); + let rendered = vectorcraft_testkit::raster::render_artboard(doc); + assert_eq!(rendered.over_white(20, 50), [255, 255, 255], "layer target: {layer_target}"); + assert_eq!(rendered.over_white(50, 50), [255, 0, 0], "layer target: {layer_target}"); + } +} + +#[test] +fn transform_effect_preview_commit_cancel_and_undo_cover_artwork_targets() { + use vectorcraft_testkit::raster::{Image, render_artboard}; + let rightmost = + |image: &Image| (0..image.width).rev().find(|&x| (0..image.height).any(|y| image.over_white(x, y).iter().any(|&c| c < 245))).unwrap(); + for kind in ["object", "fill", "stroke", "group", "layer", "text", "image"] { + for preview in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + match kind { + "text" => { + app.run("text.create", json!({"x": 20, "y": 80, "size": 20, "text": "HI"})).unwrap(); + } + "image" => { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + app.run( + "file.place", + json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false, "at": [30, 70]}), + ) + .unwrap(); + } + _ => { + app.run("shape.rectangle", json!({"x": 20, "y": 60, "width": 20, "height": 20})).unwrap(); + } + } + if kind != "image" { + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + } + if kind == "stroke" { + app.run("paint.setFill", json!({"none": true})).unwrap(); + app.run("stroke.set", json!({"weight": 4})).unwrap(); + } + if kind == "group" { + app.run("object.group", json!({})).unwrap(); + } else if kind == "layer" { + let layer = app.session.doc().unwrap().doc.layers[0].id; + app.run("layer.target", json!({"id": layer.0})).unwrap(); + } + let item = match kind { + "fill" => Some(0), + "stroke" => Some(1), + _ => None, + }; + app.run("effect.apply", json!({"effect": "distort.transform", "item": item})).unwrap(); + let baseline = render_artboard(&app.session.doc().unwrap().doc); + let ctx = drop_ctx(); + crate::theme::install_fonts(&ctx); + let mut texts = frame_events(&ctx, &mut app, vec![], appearance::show); + if let Some(i) = item { + let label = text_rect(&texts, if i == 0 { "Fill:" } else { "Stroke:" }); + click_mod(&ctx, &mut app, egui::pos2(label.left() - 12.0, label.center().y), egui::Modifiers::NONE); + texts = frame_events(&ctx, &mut app, vec![], appearance::show); + } + click_mod(&ctx, &mut app, text_rect(&texts, "Transform").center(), egui::Modifiers::NONE); + let d = app.ui.dialog.as_mut().unwrap_or_else(|| panic!("{kind}: dialog did not open")); + d.fields.insert("moveH".into(), json!(40)); + d.fields.insert("copies".into(), json!(2)); + d.fields.insert("preview".into(), json!(preview)); + crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + let painted = render_artboard(&app.session.doc().unwrap().doc); + if preview { + assert!(rightmost(&painted) >= rightmost(&baseline) + 75, "{kind}: preview must draw both copies"); + } else { + assert_eq!(painted.rgba, baseline.rgba, "{kind}: Preview off"); + } + crate::dialogs::confirm(&mut app).unwrap(); + let committed = render_artboard(&app.session.doc().unwrap().doc); + assert!(rightmost(&committed) >= rightmost(&baseline) + 75, "{kind}: OK must draw both copies"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(render_artboard(&app.session.doc().unwrap().doc).rgba, baseline.rgba, "{kind}: undo"); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("effect.dialog", json!({"effect": "distort.transform", "item": item, "index": 0})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("moveH".into(), json!(40)); + d.fields.insert("copies".into(), json!(2)); + crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::dialogs::cancel(&mut app); + assert_eq!(render_artboard(&app.session.doc().unwrap().doc).rgba, baseline.rgba, "{kind}: Cancel"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "{kind}: Cancel must preserve undo history"); + } + } +} + +#[test] +fn transform_effect_copies_preview_while_the_field_is_still_focused() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.units_general = "cm".into(); + app.run("file.new", json!({"width": 400, "height": 1200})).unwrap(); + assert_eq!(app.session.general_unit(), Unit::named("cm").unwrap()); + app.run("shape.rectangle", json!({"x": 20, "y": 60, "width": 20, "height": 20})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("appearance.addFill", json!({})).unwrap(); + app.run("paint.setFill", json!({"item": 0, "color": "#000000"})).unwrap(); + app.run("appearance.addFill", json!({})).unwrap(); + app.run("paint.setFill", json!({"item": 1, "color": "#ffffff"})).unwrap(); + app.run("effect.apply", json!({"effect": "path.offsetPath", "item": 0, "params": {"offset": 10}})).unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": 0, "params": {"scaleH": 99.5, "moveV": 72.0 / 2.54}})).unwrap(); + let target = app.session.doc().unwrap().selection.subjects()[0]; + let ctx = drop_ctx(); + crate::theme::install_fonts(&ctx); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let fill = texts.iter().filter(|(s, _)| s == "Fill:").max_by(|(_, a), (_, b)| a.top().total_cmp(&b.top())).unwrap().1; + click_mod(&ctx, &mut app, egui::pos2(fill.left() - 12.0, fill.center().y), egui::Modifiers::NONE); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click_mod(&ctx, &mut app, text_rect(&texts, "Transform").center(), egui::Modifiers::NONE); + for _ in 0..2 { + frame_events(&ctx, &mut app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + } + let texts = frame_events(&ctx, &mut app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + let copies = text_rect(&texts, "0").center(); + click(&ctx, &mut app, copies, |app, ui| crate::dialogs::show(app, ui.ctx())); + frame_events(&ctx, &mut app, vec![egui::Event::Text("30".into())], |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(app.ui.dialog.is_some()); + assert!(ctx.memory(|m| m.focused().is_some())); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("copies", 0.0), 30.0); + let effect = &app.session.doc().unwrap().doc.node(target).unwrap().appearance.items[0].effects()[1]; + assert_eq!(effect.params["copies"].as_f64(), Some(30.0)); + let rendered = vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc); + assert!(rendered.over_white(30, 600).iter().any(|&c| c < 245), "the focused field must update the visible copy stack"); + crate::dialogs::cancel(&mut app); + let restored = vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc); + assert_eq!(restored.over_white(30, 600), [255, 255, 255]); +} + +#[test] +fn transform_effect_live_fields_keep_partial_text_and_honor_preview() { + let cm = 72.0 / 2.54; + let cases: [(&str, &str, usize, &[&str], f64); 6] = [ + ("scaleH", "100%", 0, &["5", "0", ".", "1", "2", "5", "6"], 50.13), + ("scaleV", "100%", 1, &["7", "5"], 75.0), + ("moveH", "0cm", 0, &["1", ".", "5"], 1.5 * cm), + ("moveV", "0cm", 1, &["-", "1", ",", "5"], -1.5 * cm), + ("rotate", "0°", 0, &["4", "5", ".", "1", "2", "3"], 45.123), + ("copies", "0", 0, &["2", ".", "5", "6"], 3.0), + ]; + for (key, shown, index, chunks, expected) in cases { + for preview in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.units_general = "cm".into(); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 30})).unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let params = if key == "copies" { json!({"moveH": 20}) } else { json!({}) }; + app.run("effect.apply", json!({"effect": "distort.transform", "item": null, "params": params})).unwrap(); + let target = app.session.doc().unwrap().selection.subjects()[0]; + let baseline = vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc); + app.run("effect.dialog", json!({"effect": "distort.transform", "item": null, "index": 0})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(preview)); + let ctx = drop_ctx(); + crate::theme::install_fonts(&ctx); + for _ in 0..2 { + frame_events(&ctx, &mut app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + } + let texts = frame_events(&ctx, &mut app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + let at = texts + .iter() + .filter(|(s, _)| s.replace(' ', "") == shown) + .nth(index) + .unwrap_or_else(|| panic!("{key}: missing field {shown}: {texts:?}")) + .1 + .center(); + click(&ctx, &mut app, at, |app, ui| crate::dialogs::show(app, ui.ctx())); + let select_all = + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + frame_events(&ctx, &mut app, vec![select_all], |app, ui| crate::dialogs::show(app, ui.ctx())); + for chunk in chunks { + frame_events(&ctx, &mut app, vec![egui::Event::Text((*chunk).into())], |app, ui| crate::dialogs::show(app, ui.ctx())); + } + let held = app.ui.dialog.as_ref().unwrap().f64(key, f64::NAN); + assert!((held - expected).abs() < 1e-8, "{key}, Preview {preview}: {held} != {expected}"); + let rendered = vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc); + if preview { + let applied = app.session.doc().unwrap().doc.node(target).unwrap().appearance.effects[0].params[key].as_f64().unwrap(); + assert!((applied - expected).abs() < 1e-8, "{key}: preview parameters"); + assert_ne!(rendered.rgba, baseline.rgba, "{key}: visible preview"); + } else { + assert_eq!(rendered.rgba, baseline.rgba, "{key}: Preview off"); + } + let texts = frame_events(&ctx, &mut app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + click(&ctx, &mut app, text_rect(&texts, "OK").center(), |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(app.ui.dialog.is_none()); + let applied = app.session.doc().unwrap().doc.node(target).unwrap().appearance.effects[0].params[key].as_f64().unwrap(); + assert!((applied - expected).abs() < 1e-8, "{key}: confirmed parameters"); + if preview { + assert_eq!( + vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc).rgba, + rendered.rgba, + "{key}: OK matches preview" + ); + } + assert_ne!(vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc).rgba, baseline.rgba, "{key}: OK"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc).rgba, baseline.rgba, "{key}: undo"); + } + } +} + +#[test] +fn transform_effect_sliders_update_the_painted_preview_and_cancel() { + for (key, label, index, fraction, expected, tolerance) in [ + ("scaleH", "Horizontal:", 0, 0.25, 50.0, 4.0), + ("scaleV", "Vertical:", 0, 0.25, 50.0, 4.0), + ("moveH", "Horizontal:", 1, 0.55, 100.0, 20.0), + ("moveV", "Vertical:", 1, 0.55, 100.0, 20.0), + ] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 30})).unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": null})).unwrap(); + let baseline = vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc); + app.run("effect.dialog", json!({"effect": "distort.transform", "item": null, "index": 0})).unwrap(); + let ctx = drop_ctx(); + crate::theme::install_fonts(&ctx); + for _ in 0..2 { + frame_events(&ctx, &mut app, vec![], |app, ui| crate::dialogs::show(app, ui.ctx())); + } + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + output.textures_delta.clear(); + let mut rails: Vec<_> = output + .platform_output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, n)| n.role() == egui::accesskit::Role::Slider && n.label() == Some(label)) + .filter_map(|(_, n)| n.bounds()) + .collect(); + rails.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + let bounds = rails.get(index).unwrap_or_else(|| panic!("{key}: slider not found")); + let at = egui::pos2((bounds.x0 + (bounds.x1 - bounds.x0) * fraction) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + click(&ctx, &mut app, at, |app, ui| crate::dialogs::show(app, ui.ctx())); + let value = app.ui.dialog.as_ref().unwrap().f64(key, f64::NAN); + assert!((value - expected).abs() <= tolerance, "{key}: {value}"); + assert_ne!(vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc).rgba, baseline.rgba, "{key}: slider preview"); + crate::dialogs::cancel(&mut app); + assert_eq!(vectorcraft_testkit::raster::render_artboard(&app.session.doc().unwrap().doc).rgba, baseline.rgba, "{key}: Cancel"); + } +} + /// A fill or stroke row's label ("Fill:", "Stroke:") is a link: a click opens the Color or Stroke /// panel with or without modifiers, with the tooltip "Click to edit". Its empty space only takes the /// click rules: no tooltip, and neither a Ctrl-click nor a double-click there opens a panel. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b6b4130b8..2e139d013 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -52,9 +52,9 @@ pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_labe 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, 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_kerning, spin_plain, spin_plain_named, + SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_within, opt_field, opt_field_named, + plain_field_live, range_field, range_field_live, 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_kerning, spin_plain, spin_plain_named, }; #[cfg(test)] use numeric::{decimal_point, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth}; diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 9244c32bf..91cd18eeb 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -59,14 +59,20 @@ pub fn num_field_within( bounds: RangeInclusive, ) -> Option { let label = take_previous_label(ui); - unit_field(ui, id, label, None, value, unit, width, false, bounds, &[]) + unit_field(ui, id, label, None, value, unit, width, false, bounds, &[], false) } /// [`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 { let label = take_previous_label(ui); - unit_field(ui, id, label, name, value, unit, width, false, ANY, &[]) + unit_field(ui, id, label, name, value, unit, width, false, ANY, &[], false) +} + +/// The field returns valid typed distances immediately and preserves the text being edited. +pub fn num_field_live(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { + let label = take_previous_label(ui); + unit_field(ui, id, label, None, value, unit, width, false, ANY, &[], true) } /// Any number: the bounds of a field that has none. @@ -93,6 +99,7 @@ fn unit_field( share_of_value: bool, bounds: RangeInclusive, stops: &[f64], + live: bool, ) -> Option { let id = id_with(ui.scope_id(), id); let shown = value.map(|v| unit.format(v)).unwrap_or_default(); @@ -129,7 +136,18 @@ fn unit_field( } } else { ui.data_mut(|d| d.insert_temp(id, buf.clone())); - stepped.or(scrubbed).map(|v| unit.to_pt(v)) + if let Some(v) = stepped.or(scrubbed) { + return Some(unit.to_pt(v)); + } + if live + && resp.changed() + && let Some(v) = read(&buf).filter(|v| v.is_finite()).map(|v| within(v, &bounds)).filter(|v| Some(*v) != value) + { + ui.data_mut(|d| d.insert_temp(id.with("shown"), unit.format(v))); + ui.ctx().request_repaint(); + return Some(v); + } + None } } @@ -312,7 +330,7 @@ pub fn size_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str } }); } - unit_field(ui, id, label, name, value, unit, width, true, ANY, &[]) + unit_field(ui, id, label, name, value, unit, width, true, ANY, &[], false) } /// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels @@ -346,7 +364,34 @@ pub fn range_field_named( let places = decimals.min(6) as i32; let value = value.into().map(|v| within(v, &range)); let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None, false).map(|v| within(round_to(v, places), &range)) + bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None, false, false).map(|v| within(round_to(v, places), &range)) +} + +/// The field returns valid typed values immediately within `range` and preserves incomplete input. +pub fn range_field_live( + ui: &mut Ui, + id: impl std::hash::Hash, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + let value = value.into().map(|v| within(v, &range)); + let label = take_previous_label(ui); + let field = id_with(ui.scope_id(), &id); + let result = bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, None, false, true) + .map(|v| within(round_to(v, decimals.min(6) as i32), &range)); + if let Some(value) = result { + ui.data_mut(|d| d.insert_temp(field.with("shown"), field_text(value, suffix, decimals))); + } + result +} + +/// The field returns valid typed numbers immediately and preserves the text being edited. +pub fn plain_field_live(ui: &mut Ui, id: impl std::hash::Hash, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { + let label = take_previous_label(ui); + bounded_field(ui, id, label, None, Some(value), suffix, decimals, width, &ANY, None, false, true) } /// [`range_field`] whose ↑/↓ and wheel take `step`'s steps instead of whole numbers (see @@ -366,7 +411,7 @@ pub fn range_field_stepped( let places = decimals.min(6) as i32; let value = value.into().map(|v| within(v, &range)); let label = take_previous_label(ui); - bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step), false).map(|v| within(round_to(v, places), &range)) + bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step), false, false).map(|v| within(round_to(v, places), &range)) } /// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. @@ -385,7 +430,7 @@ pub fn mixed_field_named( width: f32, ) -> Option { let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None, false) + bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None, false, false) } /// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`; ↑/↓ and the wheel take `step`'s steps @@ -404,6 +449,7 @@ fn bounded_field( bounds: &RangeInclusive, step: Option f64>, parentheses: bool, + live: bool, ) -> Option { let armed = take_armed_focus(ui, &id); let id = id_with(ui.scope_id(), id); @@ -431,7 +477,18 @@ fn bounded_field( let decimals = decimals.min(6) as i32; let stepped = step_value(ui, &resp, &mut buf, bounds, (read, show), step, &[]).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) } + if commit && buf != shown { + read(&buf) + } else if let Some(v) = stepped.or(scrubbed).filter(|&v| Some(v) != value) { + Some(v) + } else if live && resp.changed() { + read(&buf).filter(|v| v.is_finite()).map(|v| within(v, bounds)).filter(|v| Some(*v) != value).inspect(|&v| { + ui.data_mut(|d| d.insert_temp(id.with("shown"), show(v))); + ui.ctx().request_repaint(); + }) + } else { + None + } } /// The text [`plain_field`] shows for `value`: at most `decimals` places, without trailing zeros, @@ -510,7 +567,7 @@ pub fn spin_field_stops_named( ) -> Option { let label = take_previous_label(ui); spin_generic(ui, label, value, None, None, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets) + unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false) }) .and_then(SpinPick::value) } @@ -567,7 +624,7 @@ pub fn spin_field_auto_named( ) -> Option { let label = take_previous_label(ui); spin_generic(ui, label, value, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[]) + unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[], false) }) } @@ -606,7 +663,7 @@ pub fn spin_plain_named( let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); spin_generic(ui, label, value, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { - bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None, false) + bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None, false, false) }) .and_then(SpinPick::value) } @@ -638,7 +695,7 @@ pub fn spin_kerning( presets, &[], &|v| format!("{v}"), - &mut |ui, fw, label| bounded_field(ui, id, label, None, value, "", 0, fw, &(-1000.0..=f64::INFINITY), None, parentheses), + &mut |ui, fw, label| bounded_field(ui, id, label, None, value, "", 0, fw, &(-1000.0..=f64::INFINITY), None, parentheses, false), ) } diff --git a/docs/mcp.md b/docs/mcp.md index 501ce8c73..11a4a0941 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1939,6 +1939,10 @@ act on several fills/strokes at once, and `appearance.showAllHidden` makes every visible again. In remote mode, `effect.dialog {effect, index, item}` opens an applied effect's dialog prefilled (`ui.dialog.confirm` runs `effect.setParams`); see the control protocol for the "already applied" question. +Effect dialogs use the targeted object, group, or layer shown in Appearance. The Transform dialog previews valid +numbers as they are typed, including Copies, distances with units, and rotation. With Preview off, changes apply +on OK. Cancel restores the original artwork. + ## Overprint Each fill and stroke (and each character's fill and stroke) can overprint: its inks print over the inks below instead diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 646198a8f..043e46017 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -23,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard |---|---|---| | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | +| 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | | 2026-11-03 | minor | Record shipped Find Font replacement sources: Document, Recent and System (#5217); scores and estimates unchanged. | | 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | @@ -131,7 +132,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, and PDF gradients mixing a spot color with RGB or Lab stops | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | -| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | +| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 59a5cccad..7a62b78ee 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (font-family fields accept the mouse wheel, #5253; Transform previews typed values and reads the Appearance target; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -21,6 +21,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-10-26 | minor | Pencil Fidelity goes down to 0.01 pt (dialog, tool and engine; Smooth keeps 0.05) on a logarithmic slider across the Tool Options row, with hundredths in the field and key steps that fit the range (#3328); scores and estimates unchanged (estimated) | | 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. | @@ -185,6 +186,7 @@ tools, never checked side by side) isn't captured row by row. | Transform tools: click sets the reference point (snaps to anchors/centres), Alt-click opens the dialog, Alt-release copies | done | with anchors selected with Direct Selection, the tools, the dialogs and Move transform those anchors alone, about the centre of their box by default (#1408; the default point and whole-object copies are our choices) | | Move, Rotate, Scale, Reflect and Shear dialogs: live Preview; Up/Down step angles and percentages by 1, Shift by 10 | done | #1412; Preview starts off (our choice: the public documentation gives no default), Move's as it was last confirmed; OK is one undo step and Transform Again repeats it | | Transform Again, Transform Each, Scale Strokes & Effects, Scale Corners | done | | +| The Transform effect dialog previews valid typed scale, move, rotation, and Copies values while fields remain focused. Preview off defers the artwork change until OK; Cancel restores the original artwork. | done | `crates/ui-egui/src/panels/tests_appearance.rs` covers numeric input, sliders, artwork targets, preview, and undo. | | Move opens on the offset of the last move (a drag of the selection or Move) and with the Copy, Transform Objects and Preview it was last confirmed with, so Enter, Enter repeats a move or a copy | done | the public source: [an Adobe Community thread](https://community.adobe.com/questions-652/move-tool-771221) (the dialog shows the last move, drags included); that the checkboxes are kept, Preview among them, and that a nudge with the arrow keys is not a move here are our choices; Transform Patterns is General › Transform Pattern Tiles, which Move's OK sets; the offset is the session's (not kept between launches) | | Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with, and has a Copy button left of Cancel that transforms a copy of each object (no Copy checkbox) | done | #1732; the Copy button: [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html); the remembered values: a maintainer's call on [#1732](https://github.com/storytold/vectorcraft/issues/1732#issuecomment-6157505075), not the help page; Copy itself is never kept; Preview still starts off on first use (the owner's call) | | Transform Objects beside Transform Patterns in the Move, Rotate, Scale, Reflect and Shear dialogs and Transform Each; Reflect across an angle | done | #1448, #1460; off, only the pattern tiles transform; the checkboxes are always enabled (our choice); the link that keeps Transform Each's two scales equal is missing (not documented) |