mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
Merge pull request #4754 from dotCooCoo/feat/transform-pattern-backquote-drag
Transform tools: backquote drag transforms only the pattern
This commit is contained in:
@@ -116,7 +116,7 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
|
||||
let err = |e: vectorcraft_engine::EngineError| e.to_string();
|
||||
let mut drag = |down: Point| -> Result<f64, String> {
|
||||
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };
|
||||
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0 };
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
|
||||
s.select_tool("directSelection", view).map_err(err)?;
|
||||
let undo = s.doc().map_err(err)?.history.undo.len();
|
||||
@@ -215,7 +215,7 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
let first = s.doc().map_err(err)?.selection.objects.first().copied();
|
||||
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
|
||||
if let Some(down) = at {
|
||||
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };
|
||||
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), backquote: false, pressure: 1.0 };
|
||||
s.select_tool("selection", view).map_err(err)?;
|
||||
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
|
||||
let mut dx = 0.0;
|
||||
|
||||
@@ -368,6 +368,57 @@ fn transforms_without_transform_objects_move_only_the_pattern() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let patterned = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
let solid = rect(&mut s, 220.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "transformPatternTiles", "value": false})).unwrap();
|
||||
sel(&mut s, &[patterned, solid]);
|
||||
let before_patterned = node(&s, patterned);
|
||||
let before_solid = node(&s, solid);
|
||||
let history = crate::tests::history(&s).0;
|
||||
let v = ViewInfo { show_bbox: false, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Drag, 145.0, 125.0) }, v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 145.0, 125.0) }, v).unwrap();
|
||||
let after = node(&s, patterned);
|
||||
assert_eq!(after.geometric_bounds(), before_patterned.geometric_bounds());
|
||||
assert_eq!(node(&s, solid), before_solid);
|
||||
for paint in [after.appearance.fill_paint(), after.appearance.stroke_paint()] {
|
||||
let Paint::Pattern { xf, .. } = paint else { panic!("pattern paint expected") };
|
||||
assert_eq!(xf.as_coeffs()[4], 20.0);
|
||||
}
|
||||
assert_eq!(crate::tests::history(&s).0, history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, patterned), before_patterned);
|
||||
assert_eq!(node(&s, solid), before_solid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_backquote_drag_changes_stroke_only_pattern() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let id = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [id.0], "none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [id.0], "swatch": "Dots"})).unwrap();
|
||||
sel(&mut s, &[id]);
|
||||
let before = node(&s, id).geometric_bounds();
|
||||
let v = ViewInfo { show_bbox: false, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Drag, 120.0, 125.0) }, v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 120.0, 125.0) }, v).unwrap();
|
||||
let after = node(&s, id);
|
||||
assert_eq!(after.geometric_bounds(), before);
|
||||
let Paint::Pattern { xf, .. } = after.appearance.stroke_paint() else { panic!("pattern stroke expected") };
|
||||
assert_eq!(xf.as_coeffs()[4], 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_reports_patterns_and_edit_state() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -192,7 +192,9 @@ impl Headless {
|
||||
let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?;
|
||||
let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?;
|
||||
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base);
|
||||
let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) };
|
||||
let backquote =
|
||||
e.get("backquote").and_then(Value::as_bool).unwrap_or_else(|| p.get("backquote").and_then(Value::as_bool).unwrap_or(false));
|
||||
let ev = PointerEvent { kind, pos: Point::new(x, y), mods, backquote, pressure: PointerEvent::json_pressure(e) };
|
||||
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
|
||||
self.apply_ui_requests(reqs, &mut requests)?;
|
||||
// With no frames to show the highlight in later, a hover waits for the work the tool
|
||||
|
||||
@@ -1398,6 +1398,30 @@ fn selection_preferences_apply_to_pointer_gestures() {
|
||||
assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_gesture_forwards_backquote_on_each_event() {
|
||||
let mut s = server();
|
||||
let made = call(&mut s, 1, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 50, "height": 50, "fill": "#ff0000"}));
|
||||
let id = serde_json::from_str::<Value>(&text_of(&made)).unwrap()["id"].as_u64().unwrap();
|
||||
let selected = call(&mut s, 2, "run_command", json!({"command": "select.set", "params": {"ids": [id]}}));
|
||||
assert_eq!(selected["isError"], false, "{selected}");
|
||||
let drag = call(
|
||||
&mut s,
|
||||
3,
|
||||
"pointer_gesture",
|
||||
json!({"tool": "selection", "backquote": true, "events": [
|
||||
{"kind": "down", "x": 125, "y": 125},
|
||||
{"kind": "drag", "x": 145, "y": 125},
|
||||
{"kind": "up", "x": 145, "y": 125}
|
||||
]}),
|
||||
);
|
||||
assert_eq!(drag["isError"], false, "{drag}");
|
||||
let inspected = call(&mut s, 4, "inspect_document", json!({}));
|
||||
let doc = serde_json::from_str::<Value>(&text_of(&inspected)).unwrap();
|
||||
let rect = doc["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).unwrap();
|
||||
assert_eq!(rect["bounds"]["x"], 100.0, "{doc}");
|
||||
}
|
||||
|
||||
/// A Shift-drag marquee reaches agents (#483): `pointer_gesture` with `mods.shift` toggles the
|
||||
/// objects it reaches, so a selected one leaves the selection and an unselected one joins it.
|
||||
#[test]
|
||||
|
||||
@@ -209,11 +209,13 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"x": num("Document x (pt)"),
|
||||
"y": num("Document y (pt)"),
|
||||
"mods": mods_schema(),
|
||||
"backquote": {"type": "boolean", "description": "Backquote held for this event: Selection and transform tool drags transform only pattern paint"},
|
||||
"pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"),
|
||||
"holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"),
|
||||
}), &["kind", "x", "y"]),
|
||||
},
|
||||
"mods": mods_schema(),
|
||||
"backquote": {"type": "boolean", "description": "Default backquote held state for events: Selection and transform tool drags transform only pattern paint"},
|
||||
}),
|
||||
&["events"],
|
||||
),
|
||||
|
||||
@@ -141,6 +141,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
if let Some(m) = a.get("mods") {
|
||||
p["mods"] = m.clone();
|
||||
}
|
||||
if let Some(backquote) = a.get("backquote") {
|
||||
p["backquote"] = backquote.clone();
|
||||
}
|
||||
j(b.call("ui.pointer", p)?)
|
||||
}
|
||||
"draw_path" => j(draw_path(b, a)?),
|
||||
|
||||
@@ -261,7 +261,7 @@ fn anchor_point_shift_constrains_a_blend_spine_handle() {
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let mut tool = AnchorTool::new("anchorPoint");
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 190.0, 330.0));
|
||||
let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, pressure: 1.0 };
|
||||
let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, backquote: false, pressure: 1.0 };
|
||||
let actions = tool.pointer(&c, &drag);
|
||||
// The pointer (40, 30) keeps its length of 50; Shift snaps it to 45 degrees.
|
||||
assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args))
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct PointerEvent {
|
||||
pub pos: Point,
|
||||
#[serde(default)]
|
||||
pub mods: Mods,
|
||||
/// Whether backquote is held for this event.
|
||||
#[serde(default)]
|
||||
pub backquote: bool,
|
||||
#[serde(default = "one")]
|
||||
pub pressure: f32,
|
||||
}
|
||||
@@ -48,7 +51,7 @@ fn one() -> f32 {
|
||||
|
||||
impl PointerEvent {
|
||||
pub fn new(kind: PointerKind, x: f64, y: f64) -> Self {
|
||||
Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 }
|
||||
Self { kind, pos: Point::new(x, y), mods: Mods::default(), backquote: false, pressure: 1.0 }
|
||||
}
|
||||
pub fn with_mods(mut self, m: Mods) -> Self {
|
||||
self.mods = m;
|
||||
@@ -65,6 +68,21 @@ impl PointerEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backquote_json_defaults_false_and_decodes_true() {
|
||||
let event = PointerEvent::new(PointerKind::Down, 10.0, 20.0);
|
||||
let mut value = serde_json::to_value(event).unwrap();
|
||||
value.as_object_mut().unwrap().remove("backquote");
|
||||
assert!(!serde_json::from_value::<PointerEvent>(value.clone()).unwrap().backquote);
|
||||
value["backquote"] = serde_json::json!(true);
|
||||
assert!(serde_json::from_value::<PointerEvent>(value).unwrap().backquote);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys tools care about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToolKey {
|
||||
|
||||
+86
-29
@@ -123,6 +123,10 @@ pub struct SelectionTool {
|
||||
/// The params of the move's last preview: in Shaper's Construction mode the move ends with
|
||||
/// them and `releaseOutside` ([`construction`]).
|
||||
last_move: Option<Value>,
|
||||
/// Held state used for the most recent bounding-box preview.
|
||||
last_backquote: bool,
|
||||
/// Pointer position used for the most recent bounding-box preview.
|
||||
last_box_pos: Option<Point>,
|
||||
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
|
||||
snapped: bool,
|
||||
guide: GuideEdit,
|
||||
@@ -745,8 +749,12 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
(PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => self.drag_moving(cx, p, m, (start, press, hold, began)),
|
||||
(PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => {
|
||||
self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, began))
|
||||
}
|
||||
(PointerKind::Drag, State::Scaling { handle, bx, areas }) => {
|
||||
self.last_backquote = ev.backquote;
|
||||
self.last_box_pos = Some(p);
|
||||
// Scale in the box's own frame: along the objects' axes when it is rotated.
|
||||
let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||
self.guides.clear();
|
||||
@@ -760,7 +768,7 @@ impl Tool for SelectionTool {
|
||||
// visual box the dragged size and keeps its opposite handle (with Alt, its center)
|
||||
// where it is. A drag that flips the box scales by the matrix.
|
||||
let [sx, _, _, sy, _, _] = a.as_coeffs();
|
||||
let (cmd, mut params) = if cx.preview_bounds && cx.selection.anchors.is_empty() && sx > 0.0 && sy > 0.0 {
|
||||
let (cmd, mut params) = if !ev.backquote && cx.preview_bounds && cx.selection.anchors.is_empty() && sx > 0.0 && sy > 0.0 {
|
||||
let reference = if m.alt { 4 } else { handle.opposite().reference() };
|
||||
("object.setBounds", json!({ "width": nr.width(), "height": nr.height(), "reference": reference, "copy": false }))
|
||||
} else {
|
||||
@@ -769,12 +777,23 @@ impl Tool for SelectionTool {
|
||||
if areas {
|
||||
params["typeAreas"] = json!(true);
|
||||
}
|
||||
if ev.backquote {
|
||||
params["objects"] = json!(false);
|
||||
params["patterns"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview(cmd.into(), params)]
|
||||
}
|
||||
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
||||
self.last_backquote = ev.backquote;
|
||||
self.last_box_pos = Some(p);
|
||||
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
|
||||
let mut params = json!({ "matrix": matrix_json(a), "copy": false });
|
||||
if ev.backquote {
|
||||
params["objects"] = json!(false);
|
||||
params["patterns"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview("object.transform".into(), params)]
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle, enclosed };
|
||||
@@ -807,35 +826,21 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
b.finish()
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, deselect, key, .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.moving = None;
|
||||
let last = self.last_move.take();
|
||||
match (began, deselect, key) {
|
||||
// In Construction mode, an original shape dropped wholly outside the group's
|
||||
// bounds leaves the group, in the move's undo step (`releaseOutside`).
|
||||
(true, ..) => match last.filter(|p| construction(cx) && p["copy"] != json!(true)) {
|
||||
Some(mut params) => {
|
||||
params["releaseOutside"] = json!(true);
|
||||
vec![Action::Preview("object.transform".into(), params), Action::Commit]
|
||||
}
|
||||
None => vec![Action::Commit],
|
||||
},
|
||||
(false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
// A click on the key object again: no key.
|
||||
(false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))],
|
||||
(false, None, Some(id)) if can_be_key(cx, id) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))],
|
||||
(false, None, _) => vec![],
|
||||
}
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, deselect, key, .. }) => self.release_move(cx, ev.backquote, began, deselect, key),
|
||||
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
||||
let mut out = if ev.backquote || self.last_backquote {
|
||||
self.last_box_pos.map_or_else(Vec::new, |pos| self.pointer(cx, &PointerEvent { kind: PointerKind::Drag, pos, ..*ev }))
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
self.last_backquote = false;
|
||||
self.last_box_pos = None;
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
vec![Action::Commit]
|
||||
out.push(Action::Commit);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Idle;
|
||||
@@ -1042,6 +1047,43 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
mod tests;
|
||||
|
||||
impl SelectionTool {
|
||||
fn release_move(&mut self, cx: &ToolContext, backquote: bool, began: bool, deselect: Option<NodeId>, key: Option<NodeId>) -> Vec<Action> {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.moving = None;
|
||||
let changed_mode = self.last_move.as_ref().is_some_and(|params| params.get("objects").is_some()) != backquote;
|
||||
let last = self.last_move.take().map(|mut params| {
|
||||
if backquote {
|
||||
params["objects"] = json!(false);
|
||||
params["patterns"] = json!(true);
|
||||
} else if let Some(map) = params.as_object_mut() {
|
||||
map.remove("objects");
|
||||
map.remove("patterns");
|
||||
}
|
||||
params
|
||||
});
|
||||
match (began, deselect, key) {
|
||||
// In Construction mode, an original shape dropped wholly outside the group's
|
||||
// bounds leaves the group, in the move's undo step (`releaseOutside`).
|
||||
(true, ..) if backquote || changed_mode || (construction(cx) && last.as_ref().is_some_and(|p| p["copy"] != json!(true))) => match last {
|
||||
Some(mut params) => {
|
||||
if construction(cx) && params["copy"] != json!(true) && !backquote {
|
||||
params["releaseOutside"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview("object.transform".into(), params), Action::Commit]
|
||||
}
|
||||
None => vec![Action::Commit],
|
||||
},
|
||||
(true, ..) => vec![Action::Commit],
|
||||
(false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
// A click on the key object again: no key.
|
||||
(false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))],
|
||||
(false, None, Some(id)) if can_be_key(cx, id) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))],
|
||||
(false, None, _) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn repeat_controls(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
if let State::Mirror(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
@@ -1176,10 +1218,14 @@ impl SelectionTool {
|
||||
// grid, else with Smart Guides to the other objects, else with Snap to
|
||||
// Point to their anchors; the ruler guides with Snap to Point.
|
||||
self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx)));
|
||||
self.last_backquote = false;
|
||||
self.last_box_pos = None;
|
||||
self.state = State::Scaling { handle, bx, areas };
|
||||
return Some(vec![Action::Begin(label.into())]);
|
||||
}
|
||||
Some(BoxHit::Rotate) => {
|
||||
self.last_backquote = false;
|
||||
self.last_box_pos = None;
|
||||
self.state = State::Rotating { center: bx.center(), start: p };
|
||||
return Some(vec![Action::Begin("Rotate".into())]);
|
||||
}
|
||||
@@ -1202,7 +1248,14 @@ impl SelectionTool {
|
||||
};
|
||||
}
|
||||
|
||||
fn drag_moving(&mut self, cx: &ToolContext, p: Point, m: Mods, (start, press, hold, began): (Point, Point, Point, bool)) -> Vec<Action> {
|
||||
fn drag_moving(
|
||||
&mut self,
|
||||
cx: &ToolContext,
|
||||
p: Point,
|
||||
m: Mods,
|
||||
backquote: bool,
|
||||
(start, press, hold, began): (Point, Point, Point, bool),
|
||||
) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(press) < Self::drag_threshold(cx) {
|
||||
@@ -1220,7 +1273,11 @@ impl SelectionTool {
|
||||
self.snapped = (d - unsnapped).hypot() > 1e-9;
|
||||
self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None };
|
||||
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
|
||||
let params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt });
|
||||
let mut params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt });
|
||||
if backquote {
|
||||
params["objects"] = json!(false);
|
||||
params["patterns"] = json!(true);
|
||||
}
|
||||
self.last_move = Some(params.clone());
|
||||
out.push(Action::Preview("object.transform".into(), params));
|
||||
out
|
||||
|
||||
@@ -1301,6 +1301,57 @@ fn handle_drag_scales() {
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backquote_move_uses_release_state_and_one_commit() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 170.0, 150.0) });
|
||||
assert!(matches!(drag.as_slice(), [Action::Begin(_), Action::Preview(_, v)] if v["objects"] == false && v["patterns"] == true));
|
||||
let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, 170.0, 150.0) });
|
||||
assert!(matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v["objects"] == false && v["patterns"] == true));
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 170.0, 150.0) });
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 170.0, 150.0));
|
||||
assert!(matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("objects").is_none() && v.get("patterns").is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backquote_box_scale_and_rotate_use_pattern_matrix_at_release() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = ToolContext { preview_bounds: true, ..cx(&d, &s, &p) };
|
||||
for (from, to) in [((200.0, 200.0), (300.0, 300.0)), ((210.0, 210.0), (150.0, 230.0))] {
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1));
|
||||
let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.0, to.1) });
|
||||
assert!(
|
||||
matches!(drag.as_slice(), [Action::Preview(c, v)] if c == "object.transform" && v["objects"] == false && v["patterns"] == true),
|
||||
"{drag:?}"
|
||||
);
|
||||
let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, to.0, to.1) });
|
||||
assert!(
|
||||
matches!(up.as_slice(), [Action::Preview(c, v), Action::Commit] if c == "object.transform" && v["objects"] == false && v["patterns"] == true),
|
||||
"{up:?}"
|
||||
);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1));
|
||||
t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.0, to.1) });
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, to.0, to.1));
|
||||
assert!(
|
||||
matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("objects").is_none() && v.get("patterns").is_none()),
|
||||
"{up:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape during a bounding-box scale or rotation cancels it, and the release after it commits
|
||||
/// nothing.
|
||||
#[test]
|
||||
|
||||
@@ -89,8 +89,13 @@ fn about(o: Point, a: Affine) -> Affine {
|
||||
|
||||
/// The transform `m` of the selection (of a copy with `copy`): of the anchors selected with Direct
|
||||
/// Selection alone, when there are some.
|
||||
fn preview(m: Affine, copy: bool) -> Action {
|
||||
Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": copy, "anchors": true }))
|
||||
fn preview(m: Affine, copy: bool, backquote: bool) -> Action {
|
||||
let mut params = json!({ "matrix": matrix_json(m), "copy": copy, "anchors": true });
|
||||
if backquote {
|
||||
params["objects"] = json!(false);
|
||||
params["patterns"] = json!(true);
|
||||
}
|
||||
Action::Preview("object.transform".into(), params)
|
||||
}
|
||||
|
||||
/// Reflection across the line through the origin at angle `theta` (radians, screen space).
|
||||
@@ -243,14 +248,14 @@ impl Tool for TransformTool {
|
||||
d.last = m;
|
||||
self.drag = Some(d);
|
||||
self.measure = Some((p, text));
|
||||
out.push(preview(m, ev.mods.alt));
|
||||
out.push(preview(m, ev.mods.alt, ev.backquote));
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
let Some(d) = self.end_drag() else { return vec![] };
|
||||
if d.began {
|
||||
// The Alt state at release decides whether the result is a copy.
|
||||
vec![preview(d.last, ev.mods.alt), Action::Commit]
|
||||
vec![preview(d.last, ev.mods.alt, ev.backquote), Action::Commit]
|
||||
} else {
|
||||
// A click sets the reference point; with Alt, it opens the dialog too.
|
||||
self.origin = Some((d.pick, cx.selection.objects.clone()));
|
||||
@@ -343,6 +348,51 @@ mod tests {
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
#[test]
|
||||
fn backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
for kind in [TransformKind::Rotate, TransformKind::Reflect, TransformKind::Scale, TransformKind::Shear] {
|
||||
let mut t = TransformTool::new(kind);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0));
|
||||
let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0) });
|
||||
assert!(
|
||||
matches!(drag.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["objects"] == false && v["patterns"] == true),
|
||||
"{kind:?}: {drag:?}"
|
||||
);
|
||||
assert_ne!(matrix(&drag[1]), Affine::IDENTITY, "{kind:?}");
|
||||
let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, 290.0, 210.0) });
|
||||
assert!(
|
||||
matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v["objects"] == false && v["patterns"] == true),
|
||||
"{kind:?}: {up:?}"
|
||||
);
|
||||
let mut t = TransformTool::new(kind);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0));
|
||||
t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0) });
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 290.0, 210.0));
|
||||
assert!(
|
||||
matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("objects").is_none() && v.get("patterns").is_none()),
|
||||
"{kind:?}: {up:?}"
|
||||
);
|
||||
let mut t = TransformTool::new(kind);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0));
|
||||
t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0) });
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 290.0, 210.0)).is_empty());
|
||||
}
|
||||
let mut t = TransformTool::new(TransformKind::Scale);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0));
|
||||
let mods = Mods { alt: true, shift: true, ..Default::default() };
|
||||
let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0).with_mods(mods) });
|
||||
assert!(matches!(&drag[1], Action::Preview(_, v) if v["copy"] == true && v["objects"] == false && v["patterns"] == true));
|
||||
let c = matrix(&drag[1]).as_coeffs();
|
||||
assert!((c[0] - c[3]).abs() < 1e-9);
|
||||
let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, 290.0, 210.0).with_mods(mods) });
|
||||
assert!(matches!(&up[0], Action::Preview(_, v) if v["copy"] == true && v["objects"] == false));
|
||||
}
|
||||
fn alt() -> Mods {
|
||||
Mods { alt: true, ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(super) fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_en
|
||||
// and would otherwise outlive the drag that owns it (#2826).
|
||||
// Nothing to roll back without a document; the tool still gets its mouse-up.
|
||||
let _ = app.session.cancel_interaction();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0 }, view);
|
||||
egui::DragAndDrop::clear_payload(ui.ctx());
|
||||
}
|
||||
Some(_) => {}
|
||||
@@ -249,7 +249,7 @@ fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// With no document open there is nothing to roll back; the tool still gets its mouse-up.
|
||||
let _ = app.session.cancel_interaction();
|
||||
let view = app.view_info();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0 }, view);
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Drag>(drag_id());
|
||||
d.remove::<bool>(drag_id().with("art-gone"));
|
||||
|
||||
@@ -201,7 +201,13 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
}
|
||||
let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 };
|
||||
let ev = PointerEvent {
|
||||
kind,
|
||||
pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)),
|
||||
mods: mods(ui.input(|i| i.modifiers), false),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: 1.0,
|
||||
};
|
||||
if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
|
||||
@@ -126,7 +126,13 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
} else if let Some(p) = hover_tool
|
||||
&& (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover()))
|
||||
{
|
||||
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||||
let ev = PointerEvent {
|
||||
kind: PointerKind::Move,
|
||||
pos: xf.to_doc(p),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: 1.0,
|
||||
};
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
// The pointer left the canvas (for a panel, or out of the window) or went under a floating
|
||||
@@ -265,7 +271,13 @@ fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) {
|
||||
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space }
|
||||
} else {
|
||||
// Cmd with another tool drags with the selection tool used last (the session lends it).
|
||||
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) };
|
||||
let ev = PointerEvent {
|
||||
kind: PointerKind::Down,
|
||||
pos: xf.to_doc(p),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: pen_pressure(ui, app, true),
|
||||
};
|
||||
dispatch(app, &ev, view);
|
||||
crate::document_drag::begin(app, ui.ctx(), ev.pos);
|
||||
Drag::Tool
|
||||
@@ -341,7 +353,13 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2)
|
||||
Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => {
|
||||
// Back on the canvas: the move goes on from where it left, with the pointer.
|
||||
egui::DragAndDrop::clear_payload(ui.ctx());
|
||||
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
let ev = PointerEvent {
|
||||
kind: PointerKind::Drag,
|
||||
pos: xf.to_doc(p),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: pen_pressure(ui, app, false),
|
||||
};
|
||||
dispatch(app, &ev, view);
|
||||
ui.data_mut(|dd| {
|
||||
dd.insert_temp(drag_id(), Drag::Tool);
|
||||
@@ -365,9 +383,25 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2)
|
||||
if tool_drag_moved(ui, &xf, p, pointer) || mods_changed || !moves.is_empty() {
|
||||
for (at, force) in moves {
|
||||
let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false));
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view);
|
||||
dispatch(
|
||||
app,
|
||||
&PointerEvent {
|
||||
kind: PointerKind::Drag,
|
||||
pos: xf.to_doc(at),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure,
|
||||
},
|
||||
view,
|
||||
);
|
||||
}
|
||||
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
let ev = PointerEvent {
|
||||
kind: PointerKind::Drag,
|
||||
pos: xf.to_doc(p),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: pen_pressure(ui, app, false),
|
||||
};
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
// Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts
|
||||
@@ -431,10 +465,22 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po
|
||||
if let Some(last) = last_drag
|
||||
&& last != xf.to_doc(p)
|
||||
{
|
||||
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
let ev = PointerEvent {
|
||||
kind: PointerKind::Drag,
|
||||
pos: xf.to_doc(p),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: pen_pressure(ui, app, false),
|
||||
};
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
let ev = PointerEvent {
|
||||
kind: PointerKind::Up,
|
||||
pos: xf.to_doc(p),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: pen_pressure(ui, app, false),
|
||||
};
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
Drag::Art if on_canvas(ui, resp, p) => {
|
||||
@@ -442,15 +488,45 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po
|
||||
egui::DragAndDrop::clear_payload(ui.ctx());
|
||||
let pressure = pen_pressure(ui, app, false);
|
||||
let pos = xf.to_doc(p);
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view);
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view);
|
||||
dispatch(
|
||||
app,
|
||||
&PointerEvent {
|
||||
kind: PointerKind::Drag,
|
||||
pos,
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure,
|
||||
},
|
||||
view,
|
||||
);
|
||||
dispatch(
|
||||
app,
|
||||
&PointerEvent {
|
||||
kind: PointerKind::Up,
|
||||
pos,
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure,
|
||||
},
|
||||
view,
|
||||
);
|
||||
}
|
||||
Drag::Art => {
|
||||
// Released over a panel: the move goes back. (The app ends it before the panels
|
||||
// see the release, so a panel can take the art; this is the headless path.)
|
||||
// With no document open there is nothing to roll back; the tool still gets its up.
|
||||
let _ = app.session.cancel_interaction();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
dispatch(
|
||||
app,
|
||||
&PointerEvent {
|
||||
kind: PointerKind::Up,
|
||||
pos: Point::ZERO,
|
||||
mods: Mods::default(),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: 1.0,
|
||||
},
|
||||
view,
|
||||
);
|
||||
}
|
||||
Drag::Gesture | Drag::Present => {}
|
||||
Drag::Pan { .. } | Drag::RotateView { .. } => {}
|
||||
@@ -463,7 +539,13 @@ fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) {
|
||||
if let Some(i) = artboard_name_at(app, ui, &xf, p) {
|
||||
begin_artboard_name_edit(ui, app, i);
|
||||
} else {
|
||||
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||||
let ev = PointerEvent {
|
||||
kind: PointerKind::DoubleClick,
|
||||
pos: xf.to_doc(p),
|
||||
mods: mods(m, space),
|
||||
backquote: ui.input(|i| i.key_down(egui::Key::Backtick)),
|
||||
pressure: 1.0,
|
||||
};
|
||||
let before = app.session.tool_id().to_string();
|
||||
dispatch(app, &ev, view);
|
||||
// A double-click on type switched a selection tool to the Type tool: the caret goes where
|
||||
|
||||
@@ -580,7 +580,7 @@ fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() {
|
||||
let view = app.view_info();
|
||||
let at = boards[2].center();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view);
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), backquote: false, pressure: 1.0 }, view);
|
||||
}
|
||||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||||
let ids: Vec<vectorcraft_doc::NodeId> = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"]
|
||||
@@ -613,7 +613,7 @@ fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {
|
||||
let view = app.view_info();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, backquote: false, pressure: 1.0 }, view);
|
||||
}
|
||||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");
|
||||
@@ -668,7 +668,7 @@ fn the_active_artboard_is_outlined_in_black() {
|
||||
let view = app.view_info();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0 }, view);
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, backquote: false, pressure: 1.0 }, view);
|
||||
}
|
||||
assert_eq!(app.session.tool_options()["selected"], json!([2, 1]));
|
||||
assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected");
|
||||
@@ -2918,6 +2918,50 @@ fn mirror_axis_tracks_selection_options_and_view_without_editing_art() {
|
||||
assert!(axis(&mut app).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egui_backtick_drag_uses_key_state_at_release() {
|
||||
use egui::Event;
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let tile = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("object.pattern.make", json!({"ids": [tile], "name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
app.run("object.pattern.done", json!({})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.select_tool("selection");
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let from = xf.to_screen(Point::new(125.0, 125.0));
|
||||
let to = xf.to_screen(Point::new(145.0, 125.0));
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let key = |pressed| Event::Key { key: egui::Key::Backtick, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
let node = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
let before = node(&app);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![key(true), Event::PointerMoved(to)]);
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
let after = node(&app);
|
||||
assert_eq!(after.geometric_bounds(), before.geometric_bounds(), "backtick release keeps geometry still");
|
||||
let vectorcraft_color::Paint::Pattern { xf, .. } = after.appearance.fill_paint() else { panic!("pattern fill expected") };
|
||||
assert!((xf.as_coeffs()[4] - 20.0).abs() < 0.001, "egui backtick reaches the active tool");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(node(&app), before, "one Undo restores the pattern transform");
|
||||
frame(&mut app, &ctx, vec![key(false)]);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![key(true), Event::PointerMoved(to)]);
|
||||
frame(&mut app, &ctx, vec![key(false)]);
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
let ordinary = node(&app);
|
||||
assert!(
|
||||
(ordinary.geometric_bounds().unwrap().x0 - before.geometric_bounds().unwrap().x0 - 20.0).abs() < 0.001,
|
||||
"key released before mouse up commits an ordinary move"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! - `ui.menu.list`: flattened menu tree
|
||||
//! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened
|
||||
//! - `ui.tool.select {tool}`, `ui.tool.list`
|
||||
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
|
||||
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`:
|
||||
//! drive the active tool through the same path as the mouse
|
||||
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input
|
||||
//! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`:
|
||||
@@ -528,7 +528,8 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
|
||||
vectorcraft_geom::Point::new(x, y)
|
||||
};
|
||||
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
|
||||
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view);
|
||||
let backquote = e.get("backquote").and_then(Value::as_bool).unwrap_or_else(|| p.get("backquote").and_then(Value::as_bool).unwrap_or(false));
|
||||
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e) }, view);
|
||||
let hold = PointerEvent::json_hold(e);
|
||||
if hold > 0.0 {
|
||||
let r = app.session.tool_tick(hold, view);
|
||||
|
||||
@@ -227,6 +227,37 @@ fn render_takes_the_artboard_asked_for() {
|
||||
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_pointer_backquote_keeps_solid_object_in_place() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
let tile = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("object.pattern.make", json!({"ids": [tile], "name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
app.run("object.pattern.done", json!({})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap();
|
||||
let solid = app.run("shape.rectangle", json!({"x": 220, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [solid], "color": "#3366cc"})).unwrap();
|
||||
app.run("select.set", json!({"ids": [id, solid]})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
let before_solid = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(solid)).unwrap().clone();
|
||||
let r = input_request(
|
||||
&mut app,
|
||||
"ui.pointer",
|
||||
json!({"events": [
|
||||
{"kind": "down", "x": 125, "y": 125},
|
||||
{"kind": "drag", "x": 145, "y": 125},
|
||||
{"kind": "up", "x": 145, "y": 125}
|
||||
], "backquote": true}),
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let after = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap();
|
||||
assert_eq!(after.geometric_bounds(), before.geometric_bounds());
|
||||
assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(solid)).unwrap(), &before_solid);
|
||||
let vectorcraft_color::Paint::Pattern { xf, .. } = after.appearance.fill_paint() else { panic!("pattern fill expected") };
|
||||
assert_eq!(xf.as_coeffs()[4], 20.0, "synthetic backquote drag changes pattern paint");
|
||||
}
|
||||
|
||||
/// `ui.pointer` replies with the key object: a click on one of two selected objects makes it
|
||||
/// the key, and a click on the key again lets it go.
|
||||
#[test]
|
||||
|
||||
@@ -353,7 +353,11 @@ mod tests {
|
||||
let mut app = app();
|
||||
let view = app.view_info();
|
||||
let chip = Point::new(150.0, 150.0 + 10.0 / view.zoom);
|
||||
crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view);
|
||||
crate::canvas::dispatch(
|
||||
&mut app,
|
||||
&PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), backquote: false, pressure: 1.0 },
|
||||
view,
|
||||
);
|
||||
let d = app.ui.dialog.clone().expect("the popover opened");
|
||||
assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into()));
|
||||
assert_eq!(app.session.selected_stop(), Some(1));
|
||||
@@ -453,7 +457,11 @@ mod tests {
|
||||
let (p, i) = point(&app);
|
||||
// Double-clicking the point opens the popover beside it.
|
||||
let view = app.view_info();
|
||||
crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), pressure: 1.0 }, view);
|
||||
crate::canvas::dispatch(
|
||||
&mut app,
|
||||
&PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), backquote: false, pressure: 1.0 },
|
||||
view,
|
||||
);
|
||||
let d = app.ui.dialog.clone().expect("the popover opened");
|
||||
assert_eq!((d.kind.as_str(), d.fields["index"].clone()), ("gradientStop", json!(i)));
|
||||
frame(&mut app, vec![]);
|
||||
|
||||
@@ -113,7 +113,7 @@ fn opens_with_the_artboards_the_artboard_tool_selected_and_exports_only_them() {
|
||||
let click = |app: &mut VectorcraftApp, i: usize, shift: bool| {
|
||||
let mods = Mods { shift, ..Default::default() };
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
crate::canvas::dispatch(app, &PointerEvent { kind, pos: boards[i].center(), mods, pressure: 1.0 }, view);
|
||||
crate::canvas::dispatch(app, &PointerEvent { kind, pos: boards[i].center(), mods, backquote: false, pressure: 1.0 }, view);
|
||||
}
|
||||
};
|
||||
// A click on artboard 2 and a Shift-click on artboard 3 select those two. The dialog opens with
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
|
||||
| `inspect_ui` | `{}` | UI state. Remote mode only. |
|
||||
| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. |
|
||||
| `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. |
|
||||
|
||||
@@ -471,6 +471,7 @@ since 2026-10-01.
|
||||
|
||||
- **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged.
|
||||
- **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`.
|
||||
- **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged.
|
||||
- **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged.
|
||||
- **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`.
|
||||
- **TIFF in screen and asset export:** both format menus expose the existing TIFF encoder and Format Settings. Headless UI regressions choose TIFF and check scaled dimensions, artboard selection, filenames, encoding options, saved settings and collected-asset output. Public reference: [Export for Screens](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/export-for-screens.html), updated 2026-09-28, lists TIFF for both artboards and assets. Adobe staff [confirmed support in 30.2.1](https://illustrator.uservoice.com/forums/333657-illustrator-desktop-feature-requests/suggestions/49081034-add-tiff-format-into-export-for-screens-assets-d) on 2026-02-19; the older 2025 Collect assets guide omits TIFF.
|
||||
|
||||
Reference in New Issue
Block a user