Merge pull request #3549 from Taha-Mahmoodi/fix/2103-artboard-nudge

Fix #2103: arrow keys with the Artboard tool move the selected artboards and their art
This commit is contained in:
BFlat
2026-10-22 16:16:48 +07:00
committed by GitHub
2 changed files with 89 additions and 6 deletions
+22
View File
@@ -352,6 +352,28 @@ fn duplicate_artboards_copies_every_selected_artboard_in_one_step() {
}
}
/// #2103: with the Artboard tool, an arrow key moves the selected artboards and the art on them by
/// the keyboard increment (Shift: ten times as far) in one undo step each; the selected art
/// doesn't move on its own.
#[test]
fn artboard_tool_arrow_keys_move_the_selected_artboards_with_their_art() {
use vectorcraft_tools::{Mods, ToolKey};
let mut s = session();
let [a, b, c] = two_selected_artboards(&mut s);
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 4})).unwrap();
let v = ViewInfo::default();
assert!(s.tool_claims_key(ToolKey::Right, v));
let undo = s.doc().unwrap().history.undo.len();
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Right, Mods { shift: true, ..Mods::default() }, v).unwrap();
let xs = |s: &Session| boards(s).into_iter().map(|(x, _)| x).collect::<Vec<_>>();
assert_eq!(xs(&s), [44.0, 1044.0, 0.0]);
assert_eq!([left(&s, a), left(&s, b), left(&s, c)], [54.0, 1054.0, 10.0], "the art moves with its artboard");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 2, "one step per press");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((xs(&s), left(&s, a)), (vec![4.0, 1004.0, 0.0], 14.0));
}
/// With the Artboard tool, Copy takes every selected artboard and the art on each (the public Cut,
/// copy, and paste artboards page). Paste adds a copy of each with its art in one undo step,
/// together right of the last artboard; Paste in Place puts them where they were. The tool then
+67 -6
View File
@@ -12,13 +12,15 @@
//! 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,
//! always proportional, and the art fully inside scales with it), drag on the pasteboard draws a new artboard, Delete removes
//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`) and Escape returns to
//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`), the arrow keys move the
//! selected artboards by the keyboard increment (Shift ten times as far; with their artwork when
//! `moveArt` is on) and Escape returns to
//! the Selection tool (during a drag it only cancels the drag). Moving and resizing snap like drawing
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
//! objects (never to the dragged artboard or the art moving or scaling with it); the artboard's own bleed edges
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]). A locked artboard (the
//! Artboards panel's lock column) is selected by a click, but a drag doesn't move it or copy it,
//! it shows no handles, and Delete leaves it.
//! Artboards panel's lock column) is selected by a click, but a drag or an arrow key doesn't move it
//! or copy it, it shows no handles, and Delete leaves it.
use serde_json::{Value, json};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@@ -469,12 +471,13 @@ impl Tool for ArtboardTool {
/// Escape owns the request to return to Selection (or cancel an active drag), so the UI
/// applies the tool's request instead of falling through to its general Escape behaviour.
/// Delete/Backspace belong to the artboards even when deletion is protected or a drag is
/// active: falling through to Clear would delete selected artwork instead.
/// active: falling through to Clear would delete selected artwork instead. So do the arrows:
/// they nudge the artboards, never the selected art on its own (#2103).
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Escape | ToolKey::Delete | ToolKey::Backspace)
matches!(key, ToolKey::Escape | ToolKey::Delete | ToolKey::Backspace | ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down)
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
match key {
ToolKey::Delete | ToolKey::Backspace => {
// A locked artboard isn't deleted.
@@ -508,6 +511,28 @@ impl Tool for ArtboardTool {
vec![Action::SwitchTool("selection".into())]
}
}
ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down => {
// The selected artboards that aren't locked move by the keyboard increment (Shift:
// ten times as far, as object.nudge moves art), with their art when Move Artwork
// with Artboard is on, as a drag moves them: one step per press.
let moving: Vec<usize> = self.selected(cx).into_iter().filter(|i| cx.doc.artboards.get(*i).is_some_and(|a| !a.locked)).collect();
if self.drag.is_some() || moving.is_empty() {
return vec![];
}
let step = cx.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
let (dx, dy) = match key {
ToolKey::Left => (-step, 0.0),
ToolKey::Right => (step, 0.0),
ToolKey::Up => (0.0, -step),
_ => (0.0, step),
};
let mut v = json!({ "dx": dx, "dy": dy, "moveArt": self.move_art });
match moving.as_slice() {
[i] => v["index"] = json!(i),
_ => v["indices"] = json!(moving),
}
vec![Action::Exec("artboard.move".into(), v)]
}
_ => vec![],
}
}
@@ -823,6 +848,42 @@ mod tests {
assert_eq!(t.options()["selected"], json!([2]));
}
/// The arrow keys move the selected artboards by the keyboard increment, Shift ten times as far,
/// with their art when Move Artwork with Artboard is on (#2103): the tool claims them, so they
/// no longer fall through to object.nudge and move only the selected art. A locked artboard
/// stays, and during a drag the arrows do nothing.
#[test]
fn arrow_keys_nudge_the_selected_artboards() {
let mut d = two_boards();
let mut third = d.artboards[0].clone();
third.rect = Rect::new(0.0, 600.0, 200.0, 800.0);
third.locked = true;
d.artboards.push(third);
let (s, p) = (Selection::default(), paint());
let mut c = cx(&d, &s, &p);
c.keyboard_increment = 2.0;
let shift = Mods { shift: true, ..Default::default() };
let mut t = ArtboardTool { active: 1, ..Default::default() };
let moved = |p: Value| vec![Action::Exec("artboard.move".into(), p)];
for key in [ToolKey::Left, ToolKey::Right, ToolKey::Up, ToolKey::Down] {
assert!(t.claims_key(&c, key), "{key:?}");
}
assert_eq!(t.key(&c, ToolKey::Right, Mods::default()), moved(json!({"index": 1, "dx": 2.0, "dy": 0.0, "moveArt": true})));
assert_eq!(t.key(&c, ToolKey::Up, shift), moved(json!({"index": 1, "dx": 0.0, "dy": -20.0, "moveArt": true})));
t.move_art = false;
t.set_option("selected", &json!([0, 1, 2]));
assert_eq!(
t.key(&c, ToolKey::Left, Mods::default()),
moved(json!({"indices": [0, 1], "dx": -2.0, "dy": 0.0, "moveArt": false})),
"the locked artboard stays"
);
t.set_option("selected", &json!([2]));
assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "only a locked artboard selected");
t.set_option("selected", &json!([1]));
t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0));
assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "not during a drag");
}
/// A click selects a locked artboard, but a drag neither moves it nor copies it (Alt), a press
/// on its edge doesn't resize it, it shows no handles, the pointer over it is the arrow, and
/// Delete leaves it. With an unlocked artboard selected too, a drag and Delete act on that one.