G1: Add Warp Options controls for live effects

Expose existing Warp styles, orientation and percentage controls through the shared live effect dialog while preserving preview history and effect targets. Keep ordinary Enter owned by focused non-searchable combo controls.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
sanyamsingh001
2026-10-16 04:45:03 +07:00
committed by bflatastic
co-authored by Codex
parent ed6d474042
commit 848d902354
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. |