Merge pull request #4384 from dotCooCoo/feat/arc-tool-drag-keys

Arc tool: C, F and Up/Down change the arc while it is dragged out
This commit is contained in:
BFlat
2026-10-29 10:54:11 +07:00
committed by GitHub
4 changed files with 106 additions and 3 deletions
+24
View File
@@ -1404,6 +1404,30 @@ fn e_during_a_marquee_gesture_selects_only_the_objects_inside() {
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool.select", "{r}");
}
/// `press_key {key: "c"}` and `{key: "f"}` while an arc is being dragged go to the Arc tool, and
/// the release draws one arc. With nothing dragged, C is a shortcut again.
#[test]
fn c_and_f_during_an_arc_gesture_go_to_the_arc_tool() {
let mut s = server();
let count = |s: &mut Server, id: u64| {
let r = call(s, id, "inspect_document", json!({}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["objects"].as_u64().unwrap()
};
let before = count(&mut s, 1);
let begin = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "drag", "x": 200, "y": 180}]);
let r = call(&mut s, 2, "pointer_gesture", json!({"tool": "arc", "events": begin}));
assert_eq!(r["isError"], false, "{r}");
for (id, key) in [(3, "c"), (4, "f")] {
let r = call(&mut s, id, "press_key", json!({"key": key}));
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool", "{key}: {r}");
}
let r = call(&mut s, 5, "pointer_gesture", json!({"tool": "arc", "events": [{"kind": "up", "x": 200, "y": 180}]}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(count(&mut s, 6), before + 1);
let r = call(&mut s, 7, "press_key", json!({"key": "c"}));
assert_ne!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool", "{r}");
}
/// A click on one of two selected objects makes it the key object: `pointer_gesture` replies
/// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go.
#[test]
+73 -2
View File
@@ -3,8 +3,9 @@
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog. The
//! start point and the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering too.
//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→
//! change grid columns / radial dividers.
//! While dragging, ↑/↓ change the arc's slope, the spiral's segments, the grid rows or the
//! concentric dividers; ←/→ change grid columns / radial dividers. C switches the arc between open
//! and closed, and F switches its base between the X and Y axes.
use serde_json::{Value, json};
use vectorcraft_geom::Point;
@@ -13,6 +14,9 @@ use crate::guides::{DrawSnap, Leave};
use crate::shape::{box_params, drag_rect, turned_box};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// How much ↑ or ↓ changes the arc's slope while it is dragged out.
const ARC_SLOPE_STEP: f64 = 10.0;
pub struct FamilyTool {
id: &'static str,
start: Option<Point>,
@@ -215,6 +219,22 @@ impl Tool for FamilyTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
let changed = match (self.id, key) {
("arc", ToolKey::Up) => {
self.slope = (self.slope + ARC_SLOPE_STEP).min(100.0);
true
}
("arc", ToolKey::Down) => {
self.slope = (self.slope - ARC_SLOPE_STEP).max(-100.0);
true
}
("arc", ToolKey::Letter('c')) => {
self.closed = !self.closed;
true
}
("arc", ToolKey::Letter('f')) => {
self.base_along = if self.base_along == "y" { "x" } else { "y" };
true
}
("spiral", ToolKey::Up) => {
self.segments = (self.segments + 1).min(1000);
true
@@ -253,6 +273,10 @@ impl Tool for FamilyTool {
vec![]
}
}
/// The Arc tool takes C and F while an arc is dragged out: C switches it between open and closed, and F switches its base axis.
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
self.id == "arc" && self.began && self.start.is_some() && matches!(key, ToolKey::Letter('c' | 'f'))
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
@@ -469,6 +493,53 @@ mod tests {
);
}
/// While an arc is dragged out, C switches it between open and closed, F switches its base
/// between the X and Y axes and ↑/↓ change its slope by 10 within −100…100; the arc drawn
/// next keeps them.
#[test]
fn arc_drag_keys_change_its_type_base_and_slope() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = FamilyTool::new("arc");
let (c, f) = (ToolKey::Letter('c'), ToolKey::Letter('f'));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert!(!t.claims_key(&cx, c), "no drag yet");
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
assert!(t.claims_key(&cx, c) && t.claims_key(&cx, f) && !t.claims_key(&cx, ToolKey::Letter('e')));
let preview = |a: Vec<Action>| match &a[..] {
[Action::Preview(c, v)] if c == "shape.arc" => v.clone(),
_ => panic!("{a:?}"),
};
let v = preview(t.key(&cx, c, Mods::default()));
assert_eq!((v["closed"].clone(), v["baseAlong"].clone(), v["slope"].clone()), (json!(true), json!("x"), json!(50.0)));
let v = preview(t.key(&cx, f, Mods::default()));
assert_eq!((v["closed"].clone(), v["baseAlong"].clone()), (json!(true), json!("y")));
assert_eq!((v["x"].clone(), v["y"].clone(), v["reference"].clone()), (json!(10.0), json!(10.0), json!(0)), "the reference point stays");
assert_eq!(preview(t.key(&cx, ToolKey::Up, Mods::default()))["slope"], json!(60.0));
for _ in 0..30 {
t.key(&cx, ToolKey::Down, Mods::default());
}
assert_eq!(preview(t.key(&cx, ToolKey::Down, Mods::default()))["slope"], json!(-100.0));
for _ in 0..30 {
t.key(&cx, ToolKey::Up, Mods::default());
}
assert_eq!(preview(t.key(&cx, ToolKey::Up, Mods::default()))["slope"], json!(100.0));
let v = preview(t.key(&cx, c, Mods::default()));
assert_eq!(v["closed"], json!(false), "C again opens it");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]);
assert!(!t.claims_key(&cx, c), "the drag is over");
let o = t.options();
assert_eq!((o["closed"].clone(), o["baseAlong"].clone(), o["slope"].clone()), (json!(false), json!("y"), json!(100.0)));
// The other tools of the family leave C and F to their shortcuts.
let mut g = FamilyTool::new("rectangularGrid");
g.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
g.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
assert!(!g.claims_key(&cx, c) && !g.claims_key(&cx, f));
}
/// General › Constrain Angle: grids are drawn along the turned axes, Shift's square too; at 0°
/// and whole turns as before.
#[test]
+8 -1
View File
@@ -416,7 +416,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. |
| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. |
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |
| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee). |
| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc). |
| `type_text` | `{text}` | Remote only. |
| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields}` (a missing `value` is `null`). The app stores all of them or none. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and `action` is not sent. Then `action` `confirm` (OK, `ui.dialog.confirm`) or `cancel` (`ui.dialog.cancel`). Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. |
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
@@ -472,6 +472,13 @@ mode. To press the key during a drag, end one
With nothing being dragged, `e` chooses the tool bound to E.
While an arc is being dragged out with the Arc tool, `press_key {key: "c"}` switches it between an open and a closed
path, `{key: "f"}` switches its base between the X and Y axes in the same box, and `{key: "up"}` and `{key: "down"}`
raise and lower its slope by 10, within −100…100. Each press switches once. The arc drawn on release uses these
values, and they stay as the Arc tool's `closed`, `baseAlong` and `slope` options for the next arc. With nothing being
dragged, `c` chooses the tool bound to C, and `f` runs the shortcut bound to F in remote mode (headless has no
binding for it).
Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed
in paint order (0 is painted first, the bottom row of the Appearance panel). Cmd+/ runs `appearance.addFill` and
Cmd+Alt+/ runs `appearance.addStroke` (`press_key {key: "/", mods: {cmd: true, alt: true}}`). `paint.setFill`, `paint.setStroke`,
+1
View File
@@ -647,6 +647,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
While Alt is held, the Paintbrush (before a press and while drawing) shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules.
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).