Tools: Cmd held with another tool borrows the Selection tool for one gesture (#919)

In InDesign, holding Cmd (Ctrl) with the Type or a drawing tool makes a
press select, move or resize as the Selection tool would, without
switching tools; the Pen family gets Direct Selection. The engine now
borrows that tool from a Cmd-held press to the release, keeps the chosen
tool, and shows the borrowed tool's cursor while Cmd is held.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
This commit is contained in:
wstock
2026-10-15 07:18:11 +00:00
co-authored by Claude Fable 5.1
parent a930276fd7
commit c1ee3b77c5
4 changed files with 85 additions and 4 deletions
+4
View File
@@ -275,6 +275,9 @@ pub struct Session {
pub prefs: Prefs,
pub cache: Arc<Cache>,
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
/// The Selection (or Direct Selection) tool a Cmd-held press borrows for one gesture while
/// another tool stays chosen, as InDesign's Cmd/Ctrl does (#919). Dropped on release.
pub(crate) temp_tool: Option<Box<dyn designcraft_tools::Tool>>,
pub journal: Vec<(String, Value)>,
pub(crate) find_context: Option<cmd::find::FindContext>,
pub clipboard: Option<Arc<Document>>,
@@ -324,6 +327,7 @@ impl Session {
prefs: Prefs::default(),
cache: Arc::new(Cache::new()),
tool: designcraft_tools::create("selection"),
temp_tool: None,
journal: vec![],
find_context: None,
clipboard: None,
+39
View File
@@ -41,6 +41,45 @@ fn smart_quotes_and_formatting() {
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "center");
}
/// Issue #919: with Cmd held, a press with the Type (or any drawing) tool selects and drags like
/// the Selection tool, and the chosen tool stays; the Pen family borrows Direct Selection.
#[test]
fn cmd_held_press_borrows_the_selection_tool_for_one_gesture() {
use designcraft_tools::{Mods, PointerEvent, PointerKind};
let mut s = session();
let v = ViewInfo::at_zoom(1.0);
let r = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200], "content": "text", "text": "Hello"})).unwrap();
let fid = r["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": []})).unwrap();
s.set_tool("type");
let cmd = Mods { cmd: true, ..Mods::default() };
// Cmd-press on the frame selects it as an object (no caret) and a Cmd-drag moves it.
s.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(cmd), v).unwrap();
assert_eq!(s.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(fid)]);
assert!(s.doc().unwrap().selection.text.is_none(), "no caret: the Selection tool was borrowed");
s.pointer(&PointerEvent::new(PointerKind::Drag, 170.0, 150.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 150.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0).with_mods(cmd), v).unwrap();
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().bounds().x0, 150.0, "moved by the drag");
assert_eq!(s.tool_id(), "type", "the chosen tool stays");
assert!(s.temp_tool.is_none(), "the borrowed tool is gone on release");
// The next plain press is the Type tool's again: a caret in the frame.
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap();
assert!(s.doc().unwrap().selection.text.is_some(), "a plain press places the caret");
// Cmd with the Selection tool itself borrows nothing (Cmd-click selects through, as before).
s.set_tool("selection");
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0).with_mods(cmd), v).unwrap();
assert!(s.temp_tool.is_none());
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0).with_mods(cmd), v).unwrap();
// The Pen borrows the Direct Selection tool.
s.set_tool("pen");
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0).with_mods(cmd), v).unwrap();
assert_eq!(s.temp_tool.as_ref().map(|t| t.id()), Some("directSelection"));
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "pen");
}
#[test]
fn tool_gesture_creates_one_undo_step() {
let mut s = session();
+41 -3
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use designcraft_doc::{CellAddr, StoryId};
use designcraft_geom::Unit;
pub use designcraft_tools::SnapView;
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolContext, ToolKey};
use serde::Serialize;
use serde_json::Value;
@@ -63,6 +63,7 @@ impl Session {
}
self.end_composition();
self.tool = designcraft_tools::create(id);
self.temp_tool = None;
// Any other tool unloads the place cursor.
if id != "placeGun" {
self.loaded = None;
@@ -103,12 +104,40 @@ impl Session {
unit: view.unit,
stroke_in_front: view.stroke_in_front,
};
Some(f(self.tool.as_mut(), &cx))
// A gesture a Cmd-held press started runs on the borrowed selection tool.
let tool = self.temp_tool.as_deref_mut().unwrap_or(self.tool.as_mut());
Some(f(tool, &cx))
}
/// Which selection tool a Cmd-held press borrows while `tool` is chosen, as InDesign's
/// Cmd/Ctrl does: the Direct Selection tool for the Pen family and Pencil, the Selection
/// tool for the drawing, type and other tools; none for the selection tools themselves,
/// Hand, Zoom and a loaded place cursor, where Cmd means something else or nothing.
fn borrowed_selection_tool(tool: &str) -> Option<&'static str> {
match tool {
"selection" | "directSelection" | "hand" | "zoom" | "placeGun" => None,
"pen" | "addAnchor" | "deleteAnchor" | "convertDirection" | "pencil" | "smooth" | "erase" | "scissors" => Some("directSelection"),
_ => Some("selection"),
}
}
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<()> {
// Cmd held at a press: the gesture (press, drags, release) goes to the selection tool the
// chosen tool borrows, which selects, moves or resizes without a tool change (#919).
if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
&& ev.mods.cmd
&& self.temp_tool.is_none()
&& !self.tool.busy()
&& let Some(id) = Self::borrowed_selection_tool(self.tool.id())
{
self.temp_tool = Some(designcraft_tools::create(id));
}
let actions = self.with_ctx(view, |t, cx| t.pointer(cx, ev)).unwrap_or_default();
self.run_actions(actions)
let r = self.run_actions(actions);
if ev.kind == PointerKind::Up && self.temp_tool.as_ref().is_some_and(|t| !t.busy()) {
self.temp_tool = None;
}
r
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<bool> {
@@ -123,6 +152,15 @@ impl Session {
}
pub fn cursor(&mut self, p: designcraft_geom::Point, mods: Mods, view: ViewInfo) -> Cursor {
// Cmd held over the canvas shows the selection tool a press would borrow.
if mods.cmd
&& self.temp_tool.is_none()
&& !self.tool.busy()
&& let Some(id) = Self::borrowed_selection_tool(self.tool.id())
{
let borrowed = designcraft_tools::create(id);
return self.with_ctx(view, |_, cx| borrowed.cursor(cx, p, mods)).unwrap_or_default();
}
self.with_ctx(view, |t, cx| t.cursor(cx, p, mods)).unwrap_or_default()
}
+1 -1
View File
@@ -28,7 +28,7 @@ basics of editing fail for too many users on Windows and Linux.
|---|---|---|---|---|
| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md |
| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | |
| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | |
| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows it for one gesture (Direct Selection with the Pen family), #919 |
| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) |
| Live corners (yellow handle) | ✓ | ✓ | ~ | not working for some users (#186) |
| Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | |