Merge pull request #2053 from sanyamsingh001/codex/r76-warp-options

G1: Add Warp Options controls for live effects
This commit is contained in:
BFlat
2026-10-16 05:12:08 +07:00
committed by GitHub
10 changed files with 789 additions and 16 deletions
+66 -2
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
has_doc,
set_params
),
@@ -301,12 +301,23 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
let warp_id = p
.get("warpStyle")
.map(|v| -> Result<String> {
let style = v.as_str().filter(|style| effects::WarpStyle::from_id(style).is_some()).ok_or_else(|| bad(C, "unknown Warp style"))?;
Ok(format!("warp.{style}"))
})
.transpose()?;
let visible = p.get("visible").and_then(Value::as_bool);
let item = item_target(s, p, C)?;
for id in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
let target = item.effects_item(&n.appearance, C)?;
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
}
if let Some(e) = effect
&& effects::is_three_d(&e.id)
&& visible != Some(false)
{
@@ -323,6 +334,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
}
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
let Some(e) = fx.get_mut(index) else { return false };
if let Some(id) = &warp_id {
e.id.clone_from(id);
}
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, &params), &mut e.params) {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
@@ -783,6 +797,56 @@ mod tests {
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
}
#[test]
fn warp_options_style_change_preserves_slot_and_visibility() {
let (mut s, id) = session_with_rect();
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap();
}
s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}}));
assert!(result.is_ok(), "an applied warp can change style in place: {result:?}");
let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap();
let n = node(&s, id);
let new = n.appearance.effects_at(Some(1)).unwrap();
assert_eq!(new.len(), 3);
assert_eq!(new[0], old[0]);
assert_eq!(new[2], old[2]);
assert_eq!(new[1].id, "warp.flag");
assert_eq!(new[1].params["bend"], 25.0);
assert!(!new[1].visible);
assert!(n.appearance.effects.is_empty());
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
assert!(!s.doc().unwrap().is_dirty());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
}
#[test]
fn warp_options_non_warp_target_rejection_is_atomic() {
let (mut s, first) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap();
s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo = s.doc().unwrap().history.undo.len();
assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err());
let state = s.doc().unwrap();
assert_eq!(state.doc, before);
assert_eq!(state.selection, selection);
assert_eq!(state.history.undo.len(), undo);
assert!(!state.is_dirty());
}
#[test]
fn list_remove_and_set_params() {
let (mut s, id) = session_with_rect();
+28 -2
View File
@@ -17,7 +17,13 @@ use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| heading_label(d), body, confirm, preview: true, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| if super::warp_effect::is_warp(&d.str("__effect")) { tl!("Warp Options").to_string() } else { heading_label(d) },
body,
confirm,
preview: true,
..DialogSpec::FORM
};
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
@@ -127,6 +133,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let (id, relative) = (d.str("__effect"), d.bool("relative"));
if super::warp_effect::is_warp(&id) {
super::warp_effect::body(ui, d);
let (cmd, params) = command(d);
form::preview(app, ui, d, "Warp Options", cmd, params);
return false;
}
if vectorcraft_effects::is_three_d(&id) {
super::revolve::body(app, ui, d);
let (cmd, params) = command(d);
@@ -522,7 +534,14 @@ fn command(d: &Dialog) -> (&'static str, Value) {
let params = form::params(d);
let item = d.fields.get("__item").cloned();
if let Some(index) = d.fields.get("__index") {
return ("effect.setParams", json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params}));
let mut p = json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params});
let id = d.str("__effect");
// The displayed style comes from the first target. Preserve other targets' Warp IDs
// unless the user deliberately chooses Style, including after a preview rollback.
if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") {
p["warpStyle"] = json!(id.strip_prefix("warp."));
}
return ("effect.setParams", p);
}
let mut p = json!({"effect": d.str("__effect"), "params": params});
if let Some(item) = item {
@@ -533,6 +552,13 @@ fn command(d: &Dialog) -> (&'static str, Value) {
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (cmd, p) = command(d);
if super::warp_effect::is_warp(&d.str("__effect")) {
let r = form::commit_preview(app, cmd, p.clone())?;
if cmd == "effect.apply" {
app.last_effect = Some((d.str("__effect"), p["params"].clone()));
}
return Ok(r);
}
let r = if app.session.in_interaction() {
// Live preview already applied: keep it (the interaction commits as one undo step).
let _ = app.session.preview(cmd, &p);
+4
View File
@@ -98,6 +98,7 @@ mod tools;
mod transform;
pub mod transform_each;
pub mod variables;
mod warp_effect;
pub mod width_point;
use serde_json::{Value, json};
@@ -576,3 +577,6 @@ mod tests_rectangular_grid;
#[cfg(test)]
mod tests_shortcut_import;
#[cfg(test)]
mod tests_warp;
+577
View File
@@ -0,0 +1,577 @@
//! Effect > Warp uses the existing modal and live-effect commands, without changing envelope art.
use egui::{Event, Pos2, Rect, Shape, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_engine::Session;
use super::*;
fn app() -> (VectorcraftApp, NodeId) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 50, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap();
(app, NodeId(id))
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
ctx
}
fn frame_output(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> egui::FullOutput {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0))), events, ..Default::default() };
let mut output = ctx.run_ui(input, |ui| show(app, ui.ctx()));
output.textures_delta.clear();
output
}
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let output = frame_output(app, ctx, events);
fn collect(shape: &Shape, clip: Rect, texts: &mut Vec<(String, Rect)>) {
match shape {
Shape::Text(text) => {
let rect = text.visual_bounding_rect();
if clip.contains(rect.center()) {
texts.push((text.galley.text().to_string(), rect));
}
}
Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, clip, texts)),
_ => {}
}
}
let mut texts = vec![];
for shape in &output.shapes {
collect(&shape.shape, shape.clip_rect, &mut texts);
}
texts
}
fn settle(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, Rect)> {
for _ in 0..3 {
frame(app, ctx, vec![]);
}
frame(app, ctx, vec![])
}
fn click_text(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
let texts = frame(app, ctx, vec![]);
let at = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("missing {label:?}: {texts:?}")).1.center();
click_at(app, ctx, at);
}
fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
for event in [
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() },
] {
frame(app, ctx, vec![event]);
}
}
fn set(app: &mut VectorcraftApp, values: Value) {
// Supply dialog inputs as the control channel does; the production frame previews them.
app.ui.dialog.as_mut().unwrap().fields.extend(values.as_object().unwrap().clone());
}
#[test]
fn warp_options_controls_cover_every_existing_style() {
for (style, label) in vectorcraft_effects::WARP_STYLES {
let (mut app, _) = app();
app.run("effect.dialog", json!({"effect": format!("warp.{style}"), "item": null})).unwrap();
let ctx = context();
let texts = settle(&mut app, &ctx);
for want in [
"Warp Options",
"Style:",
label.trim_end_matches('…'),
"Horizontal",
"Vertical",
"Bend:",
"Distortion",
"Horizontal:",
"Vertical:",
"Preview",
"OK",
"Cancel",
] {
assert!(texts.iter().any(|(text, _)| text == want), "{style}: missing {want:?}: {texts:?}");
}
assert!(!texts.iter().any(|(text, _)| text == "Orientation:"));
cancel(&mut app);
}
}
#[test]
fn warp_options_style_orientation_clicks_preview_and_cancel_preserve_redo() {
let (mut app, id) = app();
app.run("object.move", json!({"dx": 10, "dy": 0})).unwrap();
app.run("edit.undo", json!({})).unwrap();
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let selection = app.session.doc().unwrap().selection.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let redo = app.session.doc().unwrap().history.redo.len();
assert_eq!(redo, 1);
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
let ctx = context();
settle(&mut app, &ctx);
click_text(&mut app, &ctx, "Arc");
click_text(&mut app, &ctx, "Arc Lower");
click_text(&mut app, &ctx, "Vertical");
set(&mut app, json!({"bend": -30.0, "horizontal": 20.0, "vertical": -15.0}));
frame(&mut app, &ctx, vec![Event::PointerMoved(pos2(10.0, 10.0))]);
let state = app.session.doc().unwrap();
let effects = &state.doc.node(id).unwrap().appearance.effects;
assert_eq!(effects.len(), 1);
assert_eq!(effects[0].id, "warp.arcLower");
assert_eq!(effects[0].params["orientation"], "vertical");
assert_eq!(effects[0].params["bend"], -30.0);
assert_eq!(effects[0].params["horizontal"], 20.0);
assert_eq!(effects[0].params["vertical"], -15.0);
assert_eq!(state.history.undo.len(), undo);
click_text(&mut app, &ctx, "Cancel");
let state = app.session.doc().unwrap();
assert_eq!(state.doc, before);
assert_eq!(state.selection, selection);
assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo));
assert!(!state.is_dirty());
assert!(!app.session.in_interaction() && app.ui.dialog.is_none());
app.run("edit.redo", json!({})).unwrap();
assert_ne!(app.session.doc().unwrap().doc, before, "the original Redo remains usable");
}
#[test]
fn warp_options_existing_style_edit_keeps_stack_target_visibility_and_history() {
for item in [Value::Null, json!(1)] {
let (mut app, id) = app();
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
app.run("effect.apply", json!({"effect": effect, "item": item})).unwrap();
}
app.run("effect.setParams", json!({"index": 1, "item": item, "visible": false, "params": {"bend": -25.0}})).unwrap();
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let selection = app.session.doc().unwrap().selection.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let last_effect = app.last_effect.clone();
app.run("effect.dialog", json!({"effect": "warp.arc", "index": 1, "item": item})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["bend"], -25.0);
let ctx = context();
settle(&mut app, &ctx);
click_text(&mut app, &ctx, "Arc");
click_text(&mut app, &ctx, "Arc Lower");
set(&mut app, json!({"bend": 35.0}));
frame(&mut app, &ctx, vec![]);
click_text(&mut app, &ctx, "OK");
let state = app.session.doc().unwrap();
let target = item.as_u64().map(|n| n as usize);
let old = before.node(id).unwrap().appearance.effects_at(target).unwrap();
let new = state.doc.node(id).unwrap().appearance.effects_at(target).unwrap();
assert_eq!(new.len(), old.len());
assert_eq!(new[0], old[0]);
assert_eq!(new[2], old[2]);
assert_eq!(new[1].id, "warp.arcLower");
assert_eq!(new[1].params["bend"], 35.0);
assert!(!new[1].visible);
assert_eq!(state.selection, selection);
assert_eq!(state.history.undo.len(), undo + 1);
assert_eq!(app.last_effect, last_effect, "editing does not replace Last Effect");
let after = state.doc.clone();
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
assert!(!app.session.doc().unwrap().is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
}
#[test]
fn warp_options_preview_toggle_and_ok_use_latest_values() {
let (mut app, id) = app();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
let ctx = context();
settle(&mut app, &ctx);
// Exercise the checkbox transition; assigning its stored boolean bypasses the click.
click_text(&mut app, &ctx, "Preview");
assert!(!app.ui.dialog.as_ref().unwrap().bool("preview"));
assert_eq!(app.session.doc().unwrap().doc, before, "clicking Preview off rolls back");
set(&mut app, json!({"bend": 75.0}));
frame(&mut app, &ctx, vec![]);
assert_eq!(app.session.doc().unwrap().doc, before, "turning Preview off rolls back");
click_text(&mut app, &ctx, "Preview");
assert!(app.ui.dialog.as_ref().unwrap().bool("preview"));
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["bend"], 75.0);
set(&mut app, json!({"bend": -40.0}));
confirm(&mut app).unwrap();
let state = app.session.doc().unwrap();
assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].params["bend"], -40.0);
assert_eq!(state.history.undo.len(), undo + 1);
assert_eq!(app.last_effect.as_ref().unwrap().1["bend"], -40.0);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
/// Locate the actual painted number beside its row label, without requesting widget focus.
fn click_number(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
let texts = frame(app, ctx, vec![]);
let row = texts.iter().find(|(text, _)| text == label).unwrap().1;
let number = texts
.iter()
.find(|(text, rect)| {
rect.left() > row.right() && (rect.center().y - row.center().y).abs() < 6.0 && text.trim_end_matches('%').parse::<f64>().is_ok()
})
.unwrap_or_else(|| panic!("no numeric field beside {label}: {texts:?}"));
click_at(app, ctx, number.1.center());
}
fn replace_number(text: &str) -> Vec<Event> {
let modifiers = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
vec![
Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers },
Event::Key { key: egui::Key::A, physical_key: None, pressed: false, repeat: false, modifiers },
Event::Text(text.into()),
]
}
#[test]
fn warp_options_numeric_entry_immediate_accept_uses_latest_values() {
for preview in [true, false] {
for enter in [true, false] {
let (mut app, id) = app();
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let selection = app.session.doc().unwrap().selection.clone();
let undo = app.session.doc().unwrap().history.undo.len();
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
let ctx = context();
settle(&mut app, &ctx);
if !preview {
click_text(&mut app, &ctx, "Preview");
assert_eq!(app.session.doc().unwrap().doc, before);
}
// Actual keyboard input: moving to the next field commits the previous one.
click_number(&mut app, &ctx, "Horizontal:");
frame(&mut app, &ctx, replace_number("23%"));
click_number(&mut app, &ctx, "Vertical:");
frame(&mut app, &ctx, replace_number("-19%"));
click_number(&mut app, &ctx, "Bend:");
let texts = frame(&mut app, &ctx, vec![]);
let ok = texts.iter().find(|(text, _)| text == "OK").unwrap().1.center();
if preview {
let effect = &app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0];
assert_eq!(effect.params["horizontal"], 23.0);
assert_eq!(effect.params["vertical"], -19.0);
assert_eq!(effect.params["bend"], 50.0);
} else {
assert_eq!(app.session.doc().unwrap().doc, before, "typing with Preview off must not edit the document");
}
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
let mut events = replace_number("-37%");
if enter {
// The final Text and Enter arrive in the same frame, with no idle preview first.
events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
}
frame(&mut app, &ctx, events);
if !enter {
// Click immediately after typing; don't blur the field through another control.
click_at(&mut app, &ctx, ok);
}
assert!(app.ui.dialog.is_none() && !app.session.in_interaction(), "preview={preview}, enter={enter}");
let state = app.session.doc().unwrap();
let effects = &state.doc.node(id).unwrap().appearance.effects;
assert_eq!(effects.len(), 1);
assert_eq!(effects[0].id, "warp.arc");
assert_eq!(effects[0].params["bend"], -37.0);
assert_eq!(effects[0].params["horizontal"], 23.0);
assert_eq!(effects[0].params["vertical"], -19.0);
assert_eq!(effects[0].params["orientation"], "horizontal");
assert_eq!(state.selection, selection);
assert_eq!(state.history.undo.len(), undo + 1);
assert_eq!(app.last_effect.as_ref().unwrap().1["bend"], -37.0);
let after = state.doc.clone();
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
assert!(!app.session.doc().unwrap().is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
}
}
#[test]
fn warp_options_slider_drag_previews_exact_parameter_and_cancel_restores() {
let (mut app, id) = app();
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let selection = app.session.doc().unwrap().selection.clone();
let undo = app.session.doc().unwrap().history.undo.len();
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
let ctx = context();
let texts = settle(&mut app, &ctx);
let row = texts.iter().find(|(text, _)| text == "Bend:").unwrap().1;
// The real color_slider paints one outlined rail in this row. Locate that painted rail,
// not a hardcoded screen position or an injected dialog value.
fn rail(shape: &Shape, clip: Rect, row: Rect) -> Option<Rect> {
match shape {
Shape::Rect(shape)
if shape.rect.width() > 80.0
&& (shape.rect.height() - 7.0).abs() < 0.1
&& shape.rect.left() > row.right()
&& (shape.rect.center().y - row.center().y).abs() < 6.0
&& clip.contains(shape.rect.center()) =>
{
Some(shape.rect)
}
Shape::Vec(shapes) => shapes.iter().find_map(|shape| rail(shape, clip, row)),
_ => None,
}
}
let output = frame_output(&mut app, &ctx, vec![]);
let bar = output.shapes.iter().find_map(|shape| rail(&shape.shape, shape.clip_rect, row)).expect("Bend must have a visible slider rail");
let from = pos2(bar.left() + 0.75 * bar.width(), bar.center().y);
let to = pos2(bar.left() + 0.25 * bar.width(), bar.center().y);
frame(&mut app, &ctx, vec![Event::PointerMoved(from)]);
frame(
&mut app,
&ctx,
vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }],
);
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
let effect = &app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0];
assert_eq!(effect.params["bend"], -50.0, "the held slider must update the actual live effect");
assert_eq!(effect.params["horizontal"], 0.0);
assert_eq!(effect.params["vertical"], 0.0);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
frame(
&mut app,
&ctx,
vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }],
);
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["bend"], -50.0);
click_text(&mut app, &ctx, "Cancel");
let state = app.session.doc().unwrap();
assert_eq!(state.doc, before);
assert_eq!(state.selection, selection);
assert_eq!(state.history.undo.len(), undo);
assert!(!state.is_dirty());
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
}
#[test]
fn warp_options_mixed_styles_change_only_after_explicit_style_choice() {
for item in [Value::Null, json!(1)] {
for edit in ["none", "bend", "style"] {
for accept in [false, true] {
let (mut app, first) = app();
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
app.run("effect.apply", json!({"effect": effect, "item": item})).unwrap();
}
let second =
NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
for effect in ["path.offsetPath", "warp.flag", "stylize.roundCorners"] {
app.run("effect.apply", json!({"effect": effect, "item": item})).unwrap();
}
app.run("effect.setParams", json!({"index": 1, "item": item, "visible": false})).unwrap();
app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap();
app.run("object.move", json!({"dx": 5, "dy": 0})).unwrap();
app.run("edit.undo", json!({})).unwrap();
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let selection = app.session.doc().unwrap().selection.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let redo = app.session.doc().unwrap().history.redo.len();
assert_eq!(redo, 1);
let last_effect = app.last_effect.clone();
let mut expected = before.clone();
for id in [first, second] {
let node = std::sync::Arc::make_mut(&mut expected).node_mut(id).unwrap();
let effects = node.appearance.effects_mut(item.as_u64().map(|i| i as usize)).unwrap();
match edit {
"bend" => effects[1].params["bend"] = json!(25.0),
"style" => effects[1].id = "warp.arcLower".into(),
_ => {}
}
}
// Ordinary Effect menu route: first object's Arc is already present, then Edit.
app.run("effect.dialog", json!({"effect": "warp.arc", "item": item})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS);
let ctx = context();
settle(&mut app, &ctx);
click_text(&mut app, &ctx, "Edit");
settle(&mut app, &ctx);
assert_eq!(app.session.doc().unwrap().doc, before, "opening must not broadcast the first target's style");
match edit {
"bend" => {
click_number(&mut app, &ctx, "Bend:");
frame(&mut app, &ctx, replace_number("25%"));
// Blur via the existing orientation choice; it stays Horizontal.
click_text(&mut app, &ctx, "Horizontal");
}
"style" => {
click_text(&mut app, &ctx, "Arc");
click_text(&mut app, &ctx, "Arc Lower");
}
_ => {}
}
assert_eq!(app.session.doc().unwrap().doc, expected, "edit={edit}, item={item}");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
click_text(&mut app, &ctx, "Preview");
assert!(!app.ui.dialog.as_ref().unwrap().bool("preview"));
assert_eq!(app.session.doc().unwrap().doc, before);
click_text(&mut app, &ctx, "Preview");
assert!(app.ui.dialog.as_ref().unwrap().bool("preview"));
assert_eq!(app.session.doc().unwrap().doc, expected, "Style intent survives Preview off/on");
click_text(&mut app, &ctx, if accept { "OK" } else { "Cancel" });
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
let state = app.session.doc().unwrap();
assert_eq!(state.selection, selection);
assert_eq!(app.last_effect, last_effect);
if accept {
// Whole-document equality also checks both targets, sibling effects and
// the first visible / second hidden Warp. Existing OK history stays one step.
assert_eq!(state.doc, expected);
assert_eq!(state.history.undo.len(), undo + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
assert!(!app.session.doc().unwrap().is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, expected);
} else {
assert_eq!(state.doc, before);
assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo));
assert!(!state.is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_ne!(app.session.doc().unwrap().doc, before, "Cancel keeps the original Redo usable");
}
}
}
}
}
fn warp_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) {
frame(app, ctx, vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]);
frame(
app,
ctx,
vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, Event::ModifiersChanged(egui::Modifiers::NONE)],
);
}
fn tab_to_warp_control(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str, modifiers: egui::Modifiers) -> egui::Id {
// The popup container is also a Tab stop: its rectangle contains every option.
// Require the real widget's focus announcement as well as its visible label.
for _ in 0..32 {
let mut events = frame_output(
app,
ctx,
vec![Event::ModifiersChanged(modifiers), Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }],
)
.platform_output
.events;
events.extend(
frame_output(
app,
ctx,
vec![
Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers },
Event::ModifiersChanged(egui::Modifiers::NONE),
],
)
.platform_output
.events,
);
if !events.iter().any(|event| {
let egui::output::OutputEvent::FocusGained(info) = event else { return false };
info.enabled
&& match info.typ {
egui::WidgetType::ComboBox => info.current_text_value.as_deref() == Some(label),
egui::WidgetType::Button => info.label.as_deref() == Some(label),
_ => false,
}
}) {
continue;
}
let texts = frame(app, ctx, vec![]);
let at = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("missing keyboard target {label}")).1.center();
if let Some(id) = ctx.memory(|m| m.focused())
&& ctx.read_response(id).is_some_and(|response| response.rect.contains(at) && ctx.layer_id_at(at) == Some(response.layer_id))
{
return id;
}
}
panic!("Tab traversal did not focus {label}");
}
#[test]
fn warp_options_style_keyboard_enter_does_not_accept_dialog() {
for preview in [true, false] {
let (mut app, id) = app();
app.run("object.move", json!({"dx": 10, "dy": 0})).unwrap();
let moved = app.session.doc().unwrap().doc.clone();
app.run("edit.undo", json!({})).unwrap();
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let selection = app.session.doc().unwrap().selection.clone();
let entries = |items: &[vectorcraft_engine::HistoryEntry]| {
items.iter().map(|entry| (entry.label.clone(), entry.doc.clone(), entry.selection.clone())).collect::<Vec<_>>()
};
let undo = entries(&app.session.doc().unwrap().history.undo);
let redo = entries(&app.session.doc().unwrap().history.redo);
assert_eq!(redo.len(), 1);
let last_effect = app.last_effect.clone();
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
let ctx = context();
settle(&mut app, &ctx);
if !preview {
click_text(&mut app, &ctx, "Preview");
}
assert_eq!(app.ui.dialog.as_ref().unwrap().bool("preview"), preview);
let style_id = tab_to_warp_control(&mut app, &ctx, "Arc", egui::Modifiers::SHIFT);
// Both directions are actual key events, including returning to the Style combo.
warp_key(&mut app, &ctx, egui::Key::Tab, egui::Modifiers::NONE);
assert_ne!(ctx.memory(|m| m.focused()), Some(style_id));
warp_key(&mut app, &ctx, egui::Key::Tab, egui::Modifiers::SHIFT);
assert_eq!(ctx.memory(|m| m.focused()), Some(style_id));
assert!(!egui::ComboBox::is_open(&ctx, style_id));
warp_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE);
assert!(app.ui.dialog.is_some(), "WARP_STYLE_ENTER_OPEN: opening Style must not accept Warp Options");
assert!(egui::ComboBox::is_open(&ctx, style_id));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("__effect"), "warp.arc");
assert_eq!(entries(&app.session.doc().unwrap().history.undo), undo);
assert_eq!(entries(&app.session.doc().unwrap().history.redo), redo);
assert_eq!(app.last_effect, last_effect);
tab_to_warp_control(&mut app, &ctx, "Arc Lower", egui::Modifiers::NONE);
warp_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE);
assert!(app.ui.dialog.is_some(), "WARP_STYLE_ENTER_CHOICE: choosing Style must not accept Warp Options");
assert!(!egui::ComboBox::is_open(&ctx, style_id));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("__effect"), "warp.arcLower");
assert!(app.ui.dialog.as_ref().unwrap().bool("__warp_style_changed"));
let state = app.session.doc().unwrap();
assert_eq!(state.selection, selection);
assert_eq!(entries(&state.history.undo), undo);
assert_eq!(entries(&state.history.redo), redo);
assert_eq!(app.last_effect, last_effect);
if preview {
assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].id, "warp.arcLower");
assert!(app.session.in_interaction());
} else {
assert_eq!(state.doc, before);
assert!(!state.is_dirty() && !app.session.in_interaction());
}
click_text(&mut app, &ctx, "Cancel");
let state = app.session.doc().unwrap();
assert_eq!(state.doc, before);
assert_eq!(state.selection, selection);
assert_eq!(entries(&state.history.undo), undo);
assert_eq!(entries(&state.history.redo), redo);
assert!(!state.is_dirty());
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, moved);
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema.
use egui::{Pos2, Rect, Stroke, vec2};
use serde_json::json;
use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point};
use super::form;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
pub(super) fn is_warp(id: &str) -> bool {
id.strip_prefix("warp.").and_then(WarpStyle::from_id).is_some()
}
fn shown(label: &str) -> String {
tl!(label).trim_end_matches('…').to_string()
}
/// An original three-by-three grid, bent by VectorCraft's own warp map. No reference icon artwork.
fn style_preview(ui: &mut egui::Ui, suffix: &str) {
let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover());
let Some(style) = WarpStyle::from_id(suffix) else { return };
let mut lines = vec![];
let mut bounds = Rect::NOTHING;
for vertical in [false, true] {
for fixed in [-1.0, 0.0, 1.0] {
let line: Vec<Pos2> = (0..=16)
.map(|i| {
let along = -1.0 + f64::from(i) / 8.0;
let (x, y) = if vertical { (fixed, along) } else { (along, fixed) };
let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y);
let p = Pos2::new(x as f32, y as f32);
bounds.extend_with(p);
p
})
.collect();
lines.push(line);
}
}
let room = rect.shrink(2.0);
let scale = (room.width() / bounds.width().max(0.001)).min(room.height() / bounds.height().max(0.001));
let stroke = Stroke::new(1.0, Tokens::get(ui.ctx()).text);
for line in lines {
let points = line.into_iter().map(|p| room.center() + (p - bounds.center()) * scale).collect();
ui.painter().add(egui::Shape::line(points, stroke));
}
}
pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) {
const LABEL_W: f32 = 84.0;
let id = d.str("__effect");
let suffix = id.strip_prefix("warp.").unwrap_or_default();
let current = WARP_STYLES.iter().find(|(style, _)| *style == suffix).map_or_else(|| suffix.to_string(), |(_, label)| shown(label));
widgets::label_row(ui, tl!("Style:"), LABEL_W, |ui| {
style_preview(ui, suffix);
if let Some((style, label)) = widgets::combo(ui, "warp-style", &current, 170.0, false, |ui| {
let mut chosen = None;
for (style, label) in WARP_STYLES {
ui.horizontal(|ui| {
style_preview(ui, style);
if ui.add(egui::Button::selectable(style == suffix, shown(label))).clicked() {
chosen = Some((style, label));
ui.close();
}
});
}
chosen
}) {
d.fields.insert("__effect".into(), json!(format!("warp.{style}")));
d.fields.insert("__warp_style_changed".into(), json!(true));
d.fields.insert("__label".into(), json!(label.trim_end_matches('…')));
}
});
let orientation = d.str("orientation");
widgets::label_row(ui, "", LABEL_W, |ui| {
for (value, label) in [("horizontal", tl!("Horizontal")), ("vertical", tl!("Vertical"))] {
if widgets::radio(ui, label, orientation == value, true) {
d.fields.insert("orientation".into(), json!(value));
}
}
});
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, ("bend", tl!("Bend:"), LABEL_W), -100.0..=100.0, "%", &move |_| rail);
ui.add_space(6.0);
widgets::subheader(ui, tl!("Distortion"));
for (key, label) in [("horizontal", tl!("Horizontal:")), ("vertical", tl!("Vertical:"))] {
form::slider_w(ui, d, (key, label, LABEL_W), -100.0..=100.0, "%", &move |_| rail);
}
}
+10 -4
View File
@@ -755,13 +755,19 @@ pub(crate) fn combo<R>(
if searchable {
return searchable_combo(ui, ui.id().with(id), current, width - 4.0, list);
}
egui::ComboBox::from_id_salt(ui.id().with(id))
let id = ui.id().with(id);
let button_id = ui.make_persistent_id(egui::IdSalt::new(id));
let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || egui::ComboBox::is_open(ui.ctx(), button_id);
let response = egui::ComboBox::from_id_salt(id)
.selected_text(egui::RichText::new(current).size(12.0))
.width(width - 4.0)
.height(ui.spacing().combo_height)
.show_ui(ui, list)
.inner
.flatten()
.show_ui(ui, list);
// The button or open list handles Enter before the modal can accept it.
if enter_for_combo {
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
response.inner.flatten()
})
.inner
}
+4 -3
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
@@ -182,6 +182,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut
| Behaviour | Status | Notes |
|---|---|---|
| Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. |
| Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) |
| Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) |
| Object › Expand on radial, grid and mirror repeats makes groups of their instances | done | #1765 (#1756) |
@@ -225,6 +226,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged |
| 2026-10-14 | minor | G11: open-path anchor Delete leaves separate surviving runs; dedicated Remove Anchors remains connected. Keyboard and history regression coverage added; scores and hours unchanged. |
| 2026-10-14 | minor | Curvature: the Curvature tool's curve: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; the rubber band, dragged, inserted and converted points use the same rule; the Pencil's fitting unchanged (#1236, in part); scores unchanged (estimated) |
| 2026-10-14 | minor | Quick Image Trace presets reveal the adjustment panel with Advanced open; Properties reopens it. Sliders keep their preset row until release so the re-trace is committed. UI regressions added; scores and remaining estimates unchanged. |