Merge pull request #1154 from storytold/fix/1139-multi-artboard-select

#1139: Shift-click selects several artboards, which move and delete together
This commit is contained in:
BFlat 2026-10-11 16:04:49 +07:00 committed by GitHub
commit f5fab833d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 310 additions and 49 deletions

File diff suppressed because one or more lines are too long

View File

@ -129,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
artboard_new
),
cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete),
cmd!(
"artboard.delete",
"Delete Artboard",
["Window", "Artboards"],
None,
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
has_doc,
artboard_delete
),
cmd!(
"artboard.setProps",
"Artboard Options…",
@ -749,17 +757,16 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
s.edit("Delete Artboard", |d, sel| {
if d.artboards.len() <= 1 {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let id = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
// Its guides go with it.
let id = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(id));
Ok(())
})?;
ok()

View File

@ -78,6 +78,21 @@ fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
/// in order and once each.
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let listed = match (p.get("indices"), p.get("index")) {
(Some(v), _) => point_indices(v),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(cmd, "missing index")),
};
let mut indices =
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
indices.sort_unstable();
indices.dedup();
Ok(indices)
}
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;

View File

@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Artboard",
[],
None,
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the art fully inside it; locked and hidden art only with prefs moveLockedWithArtboard); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
has_doc,
artboard_move
),
@ -366,26 +366,41 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- artboards ----------
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let art = super::panelcmds::artboard_art(s, i, bool_or(p, "moveArt", false))?;
let move_art = bool_or(p, "moveArt", false);
// Each artboard's art, an object counted once (artboards can overlap).
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
for &i in &indices {
let art = super::panelcmds::artboard_art(s, i, move_art)?;
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
arts.push((i, art));
}
let scale_strokes = s.prefs.scale_strokes;
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if copy {
return super::panelcmds::copy_artboard(d, i, dv, &art, scale_strokes);
}
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
let id = a.id;
d.move_artboard_guides(id, dv);
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
let (mut index, mut moved) = (vec![], vec![]);
for (i, art) in &arts {
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if copy {
let (at, copies) = super::panelcmds::copy_artboard(d, *i, dv, art, scale_strokes)?;
index.push(at);
moved.extend(copies);
continue;
}
let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
let id = a.id;
d.move_artboard_guides(id, dv);
for id in art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
index.push(*i);
moved.extend(art.iter().copied());
}
Ok((i, art.clone()))
Ok((index, moved))
})?;
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(json!({ "index": index.first(), "indices": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
}

View File

@ -627,3 +627,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap();
assert_eq!(undo_len(&s), history + 1);
}
/// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step
/// each, the art on each moving with it, and one artboard always left.
#[test]
fn several_artboards_move_and_delete_together() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 1400, "y": 0, "width": 200, "height": 200})).unwrap();
let r = rect(&mut s, 1050.0, 50.0, 50.0, 50.0);
let before: Vec<Rect> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let out = s.execute("artboard.move", &json!({"indices": [2, 1], "dx": 10, "dy": 20, "moveArt": true})).unwrap();
assert_eq!(out["indices"], json!([1, 2]));
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect, before[0]);
assert_eq!(
(d.artboards[1].rect, d.artboards[2].rect),
(before[1] + vectorcraft_geom::Vec2::new(10.0, 20.0), before[2] + vectorcraft_geom::Vec2::new(10.0, 20.0))
);
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 1060.0, "the art on artboard 2 moved with it");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[2].rect, before[2], "one undo step");
for bad in [json!({"indices": [1, 9]}), json!({"indices": []}), json!({"indices": [-1]}), json!({"indices": "1"})] {
assert!(s.execute("artboard.move", &json!({"indices": bad["indices"], "dx": 1})).is_err(), "{bad}");
}
assert!(s.execute("artboard.delete", &json!({"indices": [0, 1, 2]})).is_err(), "one is always left");
s.execute("artboard.delete", &json!({"indices": [2, 0]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [before[1]]);
}

View File

@ -1,6 +1,8 @@
//! Artboard tool (Shift+O).
//!
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
//! Click an artboard to make it active (dashed bounds, 8 handles and its name); Shift-click adds
//! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on
//! all of them (#1139). Drag inside moves
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
//! leaves the artboard where it was), drag a handle resizes it
//! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard,
@ -26,6 +28,8 @@ enum Drag {
/// last drag event (the step under way is a duplicate).
Move {
index: usize,
/// Several artboards move together (the selected ones, `index` among them).
many: bool,
start: Point,
rect: Rect,
began: bool,
@ -48,6 +52,8 @@ enum Drag {
pub struct ArtboardTool {
/// Index of the active artboard.
pub active: usize,
/// The other selected artboards (Shift-click), in order.
pub others: Vec<usize>,
/// Move artwork with the artboard.
pub move_art: bool,
/// Scale artwork with the artboard: a resize takes the art fully inside it along, and keeps the
@ -69,6 +75,7 @@ impl Default for ArtboardTool {
fn default() -> Self {
Self {
active: 0,
others: vec![],
move_art: true,
scale_art: false,
drag: None,
@ -90,6 +97,31 @@ impl ArtboardTool {
cx.doc.artboards.get(self.active).map(|a| a.rect)
}
/// The selected artboards that are still in the document: the active one and the others,
/// in order.
fn selected(&self, cx: &ToolContext) -> Vec<usize> {
let mut all: Vec<usize> = std::iter::once(self.active).chain(self.others.iter().copied()).filter(|i| *i < cx.doc.artboards.len()).collect();
all.sort_unstable();
all.dedup();
all
}
/// Shift-click on artboard `i`: into the selection, active, or out of it (another becomes
/// active).
fn toggle(&mut self, cx: &ToolContext, i: usize) {
let mut sel = self.selected(cx);
if let Some(at) = sel.iter().position(|j| *j == i) {
if sel.len() > 1 {
sel.remove(at);
self.active = if i == self.active { sel.first().copied().unwrap_or(0) } else { self.active };
}
} else {
sel.push(i);
self.active = i;
}
self.others = sel.into_iter().filter(|j| *j != self.active).collect();
}
/// Gather the Smart Guide targets for dragging artboard `index` (call before the first preview,
/// while the document still shows where everything started).
fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) {
@ -199,15 +231,24 @@ impl Tool for ArtboardTool {
if let Some(i) = cx.doc.artboard_at(p)
&& let Some(a) = cx.doc.artboards.get(i)
{
self.active = i;
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
if m.shift {
self.toggle(cx, i);
} else if !self.selected(cx).contains(&i) {
// A plain click on an artboard that isn't selected selects just it; on a
// selected one, the selection stays, so the drag moves them all.
self.others.clear();
self.active = i;
}
let many = self.selected(cx).len() > 1 && self.selected(cx).contains(&i);
self.drag = Some(Drag::Move { index: i, many, start: p, rect: a.rect, began: false, copy: false });
} else {
self.others.clear();
let p = self.draw.press(cx, p, &[], None);
self.drag = Some(Drag::Create { start: p, cur: p });
}
vec![]
}
(PointerKind::Drag, Some(Drag::Move { index, start, rect, began, copy })) => {
(PointerKind::Drag, Some(Drag::Move { index, many, start, rect, began, copy })) => {
let mut out = vec![];
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
if !began {
@ -221,13 +262,14 @@ impl Tool for ArtboardTool {
out.push(Action::Cancel);
out.push(Action::Begin(label(m.alt).into()));
}
self.drag = Some(Drag::Move { index, start, rect, began: true, copy: m.alt });
self.drag = Some(Drag::Move { index, many, start, rect, began: true, copy: m.alt });
let d = self.snap_move(cx, rect, move_delta(start, p, m.shift), m.alt);
self.preview = Some(rect + d);
out.push(Action::Preview(
"artboard.move".into(),
json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }),
));
let mut v = json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt });
if many {
v["indices"] = json!(self.selected(cx));
}
out.push(Action::Preview("artboard.move".into(), v));
out
}
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, grab, began })) => {
@ -259,10 +301,14 @@ impl Tool for ArtboardTool {
(PointerKind::Up, Some(d)) => {
self.end_drag();
match d {
Drag::Move { began: true, copy, .. } => {
// The copy, last in the previewed document, becomes the active artboard.
Drag::Move { began: true, copy, many, .. } => {
// The copies, last in the previewed document, become the selection (the
// last one active).
if copy {
self.active = cx.doc.artboards.len().saturating_sub(1);
let n = cx.doc.artboards.len();
let k = if many { self.selected(cx).len().min(n) } else { 1 };
self.active = n.saturating_sub(1);
self.others = (n.saturating_sub(k)..n.saturating_sub(1)).collect();
}
vec![Action::Commit]
}
@ -300,7 +346,7 @@ impl Tool for ArtboardTool {
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& self.drag.is_none()
&& cx.doc.artboards.len() > 1
&& cx.doc.artboards.len() > self.selected(cx).len()
&& self.active < cx.doc.artboards.len()
}
@ -310,9 +356,14 @@ impl Tool for ArtboardTool {
if !self.claims_key(cx, key) {
return vec![];
}
let i = self.active;
self.active = i.saturating_sub(1);
vec![Action::Exec("artboard.delete".into(), json!({ "index": i }))]
let sel = self.selected(cx);
self.active = sel.first().copied().unwrap_or(0).saturating_sub(1);
self.others.clear();
let params = match sel.as_slice() {
[i] => json!({ "index": i }),
_ => json!({ "indices": sel }),
};
vec![Action::Exec("artboard.delete".into(), params)]
}
ToolKey::Escape => {
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
@ -326,6 +377,7 @@ impl Tool for ArtboardTool {
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == "created" {
self.active = cx.doc.artboards.len().saturating_sub(1);
self.others.clear();
}
}
@ -340,6 +392,10 @@ impl Tool for ArtboardTool {
}
return o;
}
// The other selected artboards: dashed bounds (in a drag the document shows them moved).
for r in self.others.iter().filter_map(|i| cx.doc.artboards.get(*i)).map(|a| a.rect) {
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
}
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
let r = self.preview.unwrap_or(ab.rect);
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
@ -377,12 +433,24 @@ impl Tool for ArtboardTool {
}
fn options(&self) -> Value {
json!({ "active": self.active, "moveArt": self.move_art, "scaleArt": self.scale_art })
json!({ "active": self.active, "selected": std::iter::once(self.active).chain(self.others.iter().copied()).collect::<Vec<_>>(), "moveArt": self.move_art, "scaleArt": self.scale_art })
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"active" => self.active = value.as_u64().unwrap_or(0) as usize,
"active" => {
self.active = value.as_u64().unwrap_or(0) as usize;
self.others.clear();
}
// The selected artboards, the first active.
"selected" => {
let all: Vec<usize> =
value.as_array().into_iter().flatten().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).collect();
if let Some((first, rest)) = all.split_first() {
self.active = *first;
self.others = rest.iter().copied().filter(|i| i != first).collect();
}
}
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
"scaleArt" => self.scale_art = value.as_bool().unwrap_or(false),
_ => {}
@ -523,6 +591,42 @@ mod tests {
assert_eq!(v["width"].as_f64(), Some(210.0));
}
/// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one
/// out again, a plain click on an unselected one selects just it, and Delete removes all the
/// selected ones (one artboard is always left).
#[test]
fn shift_click_selects_several_artboards_that_move_and_delete_together() {
let mut d = two_boards();
let mut third = d.artboards[0].clone();
third.rect = Rect::new(0.0, 600.0, 200.0, 800.0);
d.artboards.push(third);
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Default::default() };
let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| {
t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m));
t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m));
};
let mut t = ArtboardTool::default();
click(&mut t, 450.0, 450.0, Mods::default());
click(&mut t, 700.0, 100.0, shift);
assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active");
// A drag from either moves both.
t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)));
assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0)));
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]);
// Delete removes both; with all three selected it can't, so the key isn't taken.
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]);
t.set_option("selected", &json!([0, 1, 2]));
assert!(!t.claims_key(&c, ToolKey::Delete));
// Shift-click takes one out; a plain click on an unselected one selects just it.
click(&mut t, 100.0, 700.0, shift);
assert_eq!(t.options()["selected"], json!([0, 1]));
click(&mut t, 100.0, 700.0, Mods::default());
assert_eq!(t.options()["selected"], json!([2]));
}
/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's
/// proportions, and never snaps to the art scaling with it.
#[test]

View File

@ -5,7 +5,7 @@
//! fits it in the window.
use egui::{Sense, Ui, pos2, vec2};
use serde_json::json;
use serde_json::{Value, json};
use super::{pstate, set_pstate};
use crate::theme::Tokens;
@ -40,6 +40,46 @@ pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
}
/// The selected artboards (of `n`): while the Artboard tool is in use, those it has selected
/// (Shift-click, #1139), else the active one.
pub(crate) fn picked(app: &VectorcraftApp, n: usize) -> Vec<usize> {
let tool = (app.session.tool_id() == "artboard").then(|| app.session.tool_options()["selected"].clone());
let mut all: Vec<usize> = tool
.iter()
.filter_map(Value::as_array)
.flatten()
.filter_map(Value::as_u64)
.filter_map(|i| usize::try_from(i).ok())
.filter(|i| *i < n)
.collect();
if all.is_empty() {
all.push(selected(app, n));
}
all.sort_unstable();
all.dedup();
all
}
/// Shift- or Ctrl/Cmd-click on artboard `i`'s row while the Artboard tool is in use: into its
/// selection or out of it (the active one stays the navigator's).
fn toggle(app: &mut VectorcraftApp, i: usize, n: usize) {
let mut all = picked(app, n);
match all.iter().position(|j| *j == i) {
Some(at) if all.len() > 1 => {
all.remove(at);
}
Some(_) => return,
None => all.insert(0, i),
}
let active = if all.contains(&i) { i } else { all.first().copied().unwrap_or(0) };
all.retain(|j| *j != active);
all.insert(0, active);
if let Some(v) = app.view_mut() {
v.artboard = active;
}
app.session.set_tool_option("selected", &json!(all));
}
/// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it
/// is the tool, the Artboard tool's.
pub(crate) fn select(app: &mut VectorcraftApp, i: usize) {
@ -72,6 +112,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
}
let sel = selected(app, abs.len());
let all = picked(app, abs.len());
let editing: Option<usize> = pstate(ui.ctx(), "ab-edit");
widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
@ -82,7 +123,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), RowDrag(i));
}
if i == sel {
if all.contains(&i) {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
@ -120,7 +161,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.select_tool("artboard");
select(app, i);
}
if resp.clicked() {
let several = ui.input(|inp| inp.modifiers.shift || inp.modifiers.command);
if resp.clicked() && several && app.session.tool_id() == "artboard" {
toggle(app, i, abs.len());
} else if resp.clicked() {
select(app, i);
}
// Double-clicking the number goes to the artboard (fits it in the window); the name
@ -159,8 +203,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else if new.clicked() && app.run("artboard.new", json!({})).is_ok() {
select(app, n);
}
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() {
app.run("artboard.delete", json!({"index": sel})).ok();
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > all.len(), 24.0).clicked()
&& app.run("artboard.delete", json!({ "indices": all })).is_ok()
{
select(app, all.first().copied().unwrap_or(0).saturating_sub(1));
}
});
}
@ -255,6 +301,49 @@ mod tests {
assert_eq!(picked(&mut app, json!({})), json!([a]));
assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b]));
}
/// With the Artboard tool, Shift-click on a row adds its artboard to the tool's selection or
/// takes it out (#1139), and every selected row is highlighted.
#[test]
fn shift_clicking_rows_selects_several_artboards() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
for x in [400, 800] {
app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap();
}
app.select_tool("artboard");
select(&mut app, 0);
let ctx = egui::Context::default();
let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0)));
let frame = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
};
let shapes = frame(&mut app, 0.0, vec![]);
let third = shapes
.iter()
.find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text() == "Artboard 3" => Some(t.pos + vec2(4.0, 4.0)),
_ => None,
})
.unwrap();
let click = |at: egui::Pos2| {
let b =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT };
vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::PointerMoved(at), b(true), b(false)]
};
frame(&mut app, 1.0, click(third));
assert_eq!(picked(&app, 3), [0, 2]);
let rows = frame(&mut app, 2.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)])
.iter()
.filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected))
.count();
assert_eq!(rows, 2, "both rows highlighted");
frame(&mut app, 3.0, click(third));
assert_eq!(picked(&app, 3), [0]);
}
/// Double-clicking a row's number goes to its artboard and its name renames it; a row's
/// Options button edits that artboard with the Artboard tool, and the tool's artboard is the
/// active one.

View File

@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Edit Toolbar: the All Tools drawer beside the toolbar, tools dragged in and out, #1018; earlier: Direct Selection whole-path drags and Alt-drag copies, #1104) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (several artboards selected at once, #1139; earlier: the Edit Toolbar drawer, #1018) · **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,
@ -126,6 +126,7 @@ tools, never checked side by side) isn't captured row by row.
| Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | |
| Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | |
| Alt-drag duplicates (objects, artboards with their art) | done | |
| Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139) |
| Shape tools: Shift constrains, Alt from centre, Space moves while dragging, arrows change sides/points/rows | done | |
| Tool double-click opens its options | done | |
| Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | |
@ -168,6 +169,7 @@ re-measure of the panels added since.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) |
| 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) |
| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) |
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |