From b0d5ab3f68137786a0803b31f1e1024194ce2fe1 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:10:23 +0430 Subject: [PATCH] Fix #2103: arrow keys with the Artboard tool move the selected artboards and their art With the Artboard tool the arrow keys did nothing to the artboards: the tool claimed only Escape, Delete and Backspace (tools/src/xform/artboard.rs claims_key), so the arrows fell through to object.nudge in ui-egui/src/shortcuts.rs, which moved the selected art on its own (or nothing, with no art selected) and left the artboard where it was. - artboard.rs: the tool claims the arrow keys and turns each press into one artboard.move of the selected artboards that aren't locked, by the keyboard increment (Preferences > General > Keyboard Increment, read from the tool context as Touch Type does), Shift ten times as far as object.nudge does, with moveArt from the tool's Move/Copy Artwork with Artboard option, as a drag sends it. One undo step per press; nothing during a drag or with only locked artboards selected. - tests: arrow_keys_nudge_the_selected_artboards (tools: claims, step, Shift, moveArt, several boards, locked, mid-drag) and artboard_tool_arrow_keys_move_the_selected_artboards_with_their_art (engine: the artboards and the art on them move, one step per press). Verified live over --control: a 400x300 artboard with a selected 40 pt square, Artboard tool, ArrowRight: before, the artboard stayed at x 0 and the square moved to x 51 ("Move"); after, the artboard goes to x 1 and the square with it ("Move Artboard"); Shift+Down moves both 10 pt; with Move Artwork off only the artboard moves; increment 5 moves 5 pt. The drag case in the report: a drag already moves the art, but only the art that lies wholly inside the artboard and is unlocked and visible (doc/src/lib.rs art_on_artboard; locked and hidden art only with Move Locked and Hidden Artwork with Artboard). Art overlapping the artboard's edge stays behind by design, which may be what the reporter saw when dragging. --- crates/engine/src/tests_panelcmds.rs | 22 +++++++++ crates/tools/src/xform/artboard.rs | 73 +++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index e623b5f17..859fbf4e6 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -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::>(); + 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 diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index e62eb7f8d..a79fd70c9 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -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 { + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { 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 = 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.