Part of #1429: Alt-drags with the selection and transform tools copy the objects once per drag

On each pointer move of an Alt-drag with the Selection, Direct
Selection, Group Selection, Rotate, Scale, Reflect or Shear tool,
Session::preview put the drag's snapshot back and ran object.transform
with copy again, and duplicate_in copied every selected object again.
The Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview
on copied every selected object again on each change of a value.

The interaction now keeps the untransformed copies that its first
preview with copy makes of the snapshot. Each later preview that
copies the same objects of the same snapshot transforms only those
copies. The copies keep their ids, and the document, the selection,
the undo step and the journal are the same as before. With 25,000
rectangles selected, a release build of vectorcraft-cli took a median
of 138.5 ms for each Alt-drag move after the first preview and takes
105.7 ms with this change.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-13 02:21:35 +07:00
1 parent 08c8c11798
commit 571289ddb6
4 files changed
+239 -2

No files matched your search

+42 -2
View File
@@ -269,7 +269,9 @@ pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) ->
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
/// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Outside an
/// interaction, records `xf` and the options it used for Transform Again.
/// interaction, records `xf` and the options it used for Transform Again. In an interaction whose
/// document is still its snapshot, a transform with `copy` keeps its copies ([`preview_copies`]),
/// and the next one that copies the same objects of the same snapshot transforms those copies.
///
/// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no
/// copy, only those anchors transform, their handles with them, and the objects selected whole.
@@ -291,8 +293,19 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
} else if none || (!copy && xf == Affine::IDENTITY) {
if copy { vec![] } else { ids }
} else {
let kept = if copy { preview_copies(s, &ids)? } else { None };
s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
let targets = match &kept {
// The document becomes the snapshot with the kept copies, untransformed, and the
// loop below transforms only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None if copy => duplicate_in(d, sel, &ids, Affine::IDENTITY)?,
None => ids.clone(),
};
// Transforms don't move nodes in the tree, so the paths found up front stay right.
let paths = d.index_paths(targets.iter().copied());
for id in &targets {
@@ -318,6 +331,33 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Returns the copies of `roots` for a transform with `copy` in an interaction whose document is
/// still its snapshot, as in a preview ([`Session::preview`] puts the snapshot back first). These
/// are the copies the interaction keeps when they were made of that snapshot for the same `roots`;
/// otherwise this copies `roots` with [`duplicate_in`] in a clone of the snapshot, untransformed,
/// and the interaction keeps that clone. It returns `None` outside an interaction, and when the
/// document is no longer the snapshot, as for a command run in a typing session or a script batch
/// after an earlier edit there: the transform then copies the current document.
fn preview_copies(s: &mut Session, roots: &[NodeId]) -> Result<Option<Arc<crate::PreviewCopies>>> {
let st = s.doc_mut()?;
let Some(it) = &mut st.interaction else { return Ok(None) };
if !Arc::ptr_eq(&st.doc, &it.doc) {
return Ok(None);
}
if let Some(k) = &it.copies
&& Arc::ptr_eq(&k.from, &it.doc)
&& k.roots == roots
{
return Ok(Some(k.clone()));
}
let mut doc = it.doc.clone();
let mut selection = st.selection.clone();
let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?;
let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids });
it.copies = Some(k.clone());
Ok(Some(k))
}
/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text
/// reflows at its size. False for anything else (and type in perspective), which transforms as
/// usual.
+20
View File
@@ -217,6 +217,25 @@ pub struct Interaction {
/// The perspective transform the previews make (`perspective.transform` params): Transform
/// Again repeats it once the drag is committed.
pub perspective_again: Option<Value>,
/// A transform with `copy` keeps the copies it made of the snapshot here ([`PreviewCopies`]).
pub(crate) copies: Option<Arc<PreviewCopies>>,
}
/// This holds the copies that a transform with `copy` made of an interaction's snapshot
/// ([`cmd::object::apply_transform`]). A later transform with `copy` of the same objects, while
/// the document is still that snapshot, starts from `doc` and transforms only the copies.
#[derive(Debug)]
pub(crate) struct PreviewCopies {
/// The copies were made from this snapshot.
pub(crate) from: Arc<Document>,
/// The transform copied these objects.
pub(crate) roots: Vec<NodeId>,
/// This is `from` with the copies added, untransformed.
pub(crate) doc: Arc<Document>,
/// This is the selection after copying, with the copies selected.
pub(crate) selection: Selection,
/// These are the copies' ids, in the order of `roots`.
pub(crate) ids: Vec<NodeId>,
}
/// An open undo group ([`Session::begin_undo_group`]): the edits made in it are one undo step.
@@ -1496,6 +1515,7 @@ impl Session {
variables_highlight: st.variables_highlight.clone(),
isolation: st.isolation,
perspective_again: None,
copies: None,
});
Ok(())
}
+176
View File
@@ -73,6 +73,182 @@ fn rotate_tool_alt_release_copies() {
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
}
/// Returns a session with a group of two rectangles and a rectangle to its right, both selected.
fn group_and_rect() -> Session {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
let b = rect(&mut s, 160.0, 100.0, 40.0, 40.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
rect(&mut s, 220.0, 100.0, 40.0, 40.0);
s.execute("select.all", &json!({})).unwrap();
s
}
/// Sends `kind` at `(x, y)` to the active tool, with Alt held when `alt` is set.
fn drag_at(s: &mut Session, kind: PointerKind, (x, y): (f64, f64), alt: bool) {
let mods = Mods { alt, ..Mods::default() };
s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), ViewInfo::default()).unwrap();
}
/// Returns the command and params of the interaction's last preview.
fn previewed(s: &Session) -> (String, Value) {
s.doc().unwrap().interaction.as_ref().and_then(|it| it.preview.clone()).unwrap()
}
/// Returns the document and the selected objects that `cmd` with `params` gives on a new
/// [`group_and_rect`] session, after `first` when it is given.
fn on_a_new_session(first: Option<(&str, &Value)>, cmd: &str, params: &Value) -> (Document, Vec<NodeId>) {
let mut t = group_and_rect();
if let Some((c, p)) = first {
t.execute(c, p).unwrap();
}
t.execute(cmd, params).unwrap();
let st = t.doc().unwrap();
(Document::clone(&st.doc), st.selection.objects.clone())
}
/// Returns true when the active document and its selected objects are `want`.
fn shows(s: &Session, want: &(Document, Vec<NodeId>)) -> bool {
let st = s.doc().unwrap();
*st.doc == want.0 && st.selection.objects == want.1
}
/// An Alt-drag with the Selection tool or the Rotate tool copies the selection on its first move,
/// and the moves after it transform those copies: the interaction keeps them, made once, until
/// the drag ends. The Move dialog's Preview with Copy previews `object.move` with `copy` in the
/// same way.
#[test]
fn an_alt_drag_copies_the_selection_once() {
let kept = |s: &Session| s.doc().unwrap().interaction.as_ref().and_then(|it| it.copies.clone());
for (tool, [press, moves @ ..]) in [
("selection", [(120.0, 120.0), (140.0, 120.0), (160.0, 120.0), (180.0, 120.0)]),
("rotate", [(300.0, 120.0), (300.0, 140.0), (300.0, 160.0), (300.0, 180.0)]),
] {
let mut s = group_and_rect();
s.select_tool(tool, ViewInfo::default()).unwrap();
drag_at(&mut s, PointerKind::Down, press, true);
let mut first = None;
for at in moves {
drag_at(&mut s, PointerKind::Drag, at, true);
let k = kept(&s).unwrap_or_else(|| panic!("{tool}: the move to {at:?} keeps its copies"));
assert!(Arc::ptr_eq(first.get_or_insert_with(|| k.clone()), &k), "{tool}: the move to {at:?} transforms the first move's copies");
}
drag_at(&mut s, PointerKind::Up, moves[2], true);
assert!(s.doc().unwrap().interaction.is_none());
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 4, "{tool}: the objects and their copies");
}
let mut s = group_and_rect();
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 10, "dy": 0, "copy": true})).unwrap();
let first = kept(&s).expect("the first preview keeps its copies");
s.preview("object.move", &json!({"dx": 20, "dy": 0, "copy": true})).unwrap();
assert!(kept(&s).is_some_and(|k| Arc::ptr_eq(&k, &first)), "the second preview transforms the first one's copies");
}
/// Each move of an Alt-drag with the Selection tool leaves the document and the selected copies
/// that one `object.transform` with `copy` and that move's matrix gives, with the same ids on every
/// move; the release keeps the last move as one undo step named Copy.
#[test]
fn each_move_of_an_alt_drag_gives_what_one_transform_with_copy_gives() {
let mut s = group_and_rect();
let (before, undo) = (s.doc().unwrap().doc.clone(), undo_len(&s));
s.select_tool("selection", ViewInfo::default()).unwrap();
drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true);
let mut copies = None;
for x in [140.0, 160.0, 125.0, 190.0] {
drag_at(&mut s, PointerKind::Drag, (x, 120.0), true);
let (cmd, params) = previewed(&s);
assert!(params["copy"] == json!(true), "{params}");
assert!(shows(&s, &on_a_new_session(None, &cmd, &params)), "the move to {x}");
let ids = s.doc().unwrap().selection.objects.clone();
let first = copies.get_or_insert_with(|| ids.clone());
assert_eq!(*first, ids, "the copies keep their ids");
}
drag_at(&mut s, PointerKind::Up, (190.0, 120.0), true);
let (cmd, params) = s.journal.last().cloned().unwrap();
assert!(shows(&s, &on_a_new_session(None, &cmd, &params)));
assert_eq!(undo_len(&s), undo + 1);
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy"));
s.execute("edit.undo", &json!({})).unwrap();
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
}
/// While Alt is released during a drag, the previews move the selected objects themselves; once Alt
/// is pressed again, they copy the objects again, with the ids of the first copies. The release
/// names the undo step for the last move.
#[test]
fn releasing_alt_during_a_drag_moves_the_objects_themselves() {
let mut s = group_and_rect();
let originals = s.doc().unwrap().selection.objects.clone();
s.select_tool("selection", ViewInfo::default()).unwrap();
drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true);
drag_at(&mut s, PointerKind::Drag, (140.0, 120.0), true);
let copies = s.doc().unwrap().selection.objects.clone();
for (x, alt) in [(160.0, false), (180.0, true), (200.0, false)] {
drag_at(&mut s, PointerKind::Drag, (x, 120.0), alt);
let (cmd, params) = previewed(&s);
assert!(params["copy"] == json!(alt), "{params}");
assert!(shows(&s, &on_a_new_session(None, &cmd, &params)), "the move to {x}, Alt: {alt}");
let selected = if alt { &copies } else { &originals };
assert_eq!(&s.doc().unwrap().selection.objects, selected);
}
drag_at(&mut s, PointerKind::Up, (200.0, 120.0), false);
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2, "no copies");
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Move"));
}
/// A transform with `copy` of the objects selected when an Alt-drag started, run while the drag is
/// open after its first move, copies the document as that move left it: the move's copies stay,
/// and new copies go directly above the objects.
#[test]
fn a_copy_run_during_an_alt_drag_copies_the_document_as_the_move_left_it() {
let mut s = group_and_rect();
let ids: Vec<u64> = s.doc().unwrap().selection.objects.iter().map(|i| i.0).collect();
s.select_tool("selection", ViewInfo::default()).unwrap();
drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true);
drag_at(&mut s, PointerKind::Drag, (140.0, 120.0), true);
let (cmd, params) = previewed(&s);
let again = json!({"matrix": [1, 0, 0, 1, 0, 60], "copy": true, "ids": ids});
s.execute("object.transform", &again).unwrap();
assert!(shows(&s, &on_a_new_session(Some((&cmd, &params)), "object.transform", &again)));
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 6, "the objects and two copies of each");
}
/// A command that changes the drag's snapshot during an Alt-drag (showing the perspective grid)
/// keeps its change in the moves after it, which copy the changed snapshot.
#[test]
fn a_grid_shown_during_an_alt_drag_stays_shown_in_the_moves_after_it() {
let mut s = group_and_rect();
s.select_tool("selection", ViewInfo::default()).unwrap();
drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true);
drag_at(&mut s, PointerKind::Drag, (140.0, 120.0), true);
let show = json!({"visible": true});
s.execute("perspective.grid.show", &show).unwrap();
drag_at(&mut s, PointerKind::Drag, (160.0, 120.0), true);
let (cmd, params) = previewed(&s);
assert!(shows(&s, &on_a_new_session(Some(("perspective.grid.show", &show)), &cmd, &params)));
}
/// Canceling an Alt-drag after a few moves puts back the document and the selection it started
/// from, with no undo step, and it ends the interaction, which held the copies.
#[test]
fn canceling_an_alt_drag_leaves_the_document_as_it_was() {
let mut s = group_and_rect();
let (before, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), undo_len(&s));
s.select_tool("selection", ViewInfo::default()).unwrap();
drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true);
for x in [140.0, 160.0, 180.0] {
drag_at(&mut s, PointerKind::Drag, (x, 120.0), true);
}
s.cancel_interaction().unwrap();
let st = s.doc().unwrap();
assert!(Arc::ptr_eq(&st.doc, &before), "the document it started from");
assert_eq!(st.selection, selection);
assert_eq!(undo_len(&s), undo);
assert!(s.doc().unwrap().interaction.is_none());
}
#[test]
fn rotate_tool_alt_click_requests_dialog() {
let mut s = session();
+1
View File
@@ -509,6 +509,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429).
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`).