Merge upstream/main into feat/tidy-up-figma-rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-13 12:36:27 -05:00
co-authored by Claude Opus 5.5
8 changed files with 66 additions and 45 deletions
+3 -2
View File
@@ -586,8 +586,9 @@ pub struct ToolState {
/// Vector edit mode's pen: where the last press that started something new (no path being
/// drawn) was, and the undo depth before it, so a double-click there takes that edit back.
pub(crate) pen_press: Option<(Point, usize)>,
/// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the
/// selection box that stays put (`tool.scaleAnchor`).
/// The Scale tool's origin: column and row (0 start, 1 middle, 2 end) of the point of the
/// selection box that stays put when the Scale field sets a factor (`tool.scaleAnchor`; the
/// centre, `(1, 1)`, until set). Handle drags ignore it and pivot on the opposite handle.
pub scale_anchor: (u8, u8),
/// The last drag move handled (a held button repeats it every frame).
pub(crate) last_move: Option<PointerEvent>,
+1 -1
View File
@@ -640,7 +640,7 @@ impl Session {
/// text included, children by the same factor whatever their constraints.
fn scale_drag(&mut self, ev: &PointerEvent, r: &ResizeStart, q: Point, guides: Vec<Guide>) -> Result<()> {
let (frame, w0, h0) = r.frame;
let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, self.tool.scale_anchor, ev.alt) else { return Ok(()) };
let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, ev.alt) else { return Ok(()) };
let nodes = r.nodes.clone();
let inv = frame.inverse();
// A layer's place in the box scales about the pivot; a negative factor turns it over.
+5 -13
View File
@@ -392,30 +392,22 @@ fn opposite(h: Handle) -> Handle {
}
/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box
/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the
/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis
/// that moved the most; a negative factor flips. None when the box has no size to scale by.
pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> {
/// to `q`. The pivot is the opposite handle's point, or the centre (`from_center`); the origin grid
/// never decides it (measured in Figma's web app, 2026-11-09). Corners follow the axis that moved
/// the most; a negative factor flips. None when the box has no size to scale by.
pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, from_center: bool) -> Option<(f64, Point)> {
const EPS: f64 = 1e-9;
if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) {
return None;
}
let h = handle_point(handle, w0, h0);
let anchor_pt = anchor_in_box(anchor, w0, h0);
// The axes the handle moves along.
let (ax_x, ax_y) = match handle {
Handle::Edge(0) | Handle::Edge(2) => (false, true),
Handle::Edge(_) => (true, false),
_ => (true, true),
};
let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS);
let pivot = if from_center {
Point::new(w0 / 2.0, h0 / 2.0)
} else if on_side {
handle_point(opposite(handle), w0, h0)
} else {
anchor_pt
};
let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) };
let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p));
let kx = ratio(ax_x, q.x, h.x, pivot.x);
let ky = ratio(ax_y, q.y, h.y, pivot.y);
+1 -1
View File
@@ -650,7 +650,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"tool.scaleAnchor",
"Scale anchor",
"{anchor?: tl|t|tr|l|c|r|bl|b|br} (the point of the selection that stays put when the Scale tool scales; omit to read it)",
"{anchor?: tl|t|tr|l|c|r|bl|b|br} (the origin of the Scale tool's factor field: the point of the selection that stays put; defaults to c, handle drags pivot on the opposite handle whatever it is; omit to read it)",
false,
|s, p| {
if let Some(a) = string(p, "anchor") {
+47 -21
View File
@@ -57,29 +57,54 @@ fn anchors_round_trip() {
}
#[test]
fn the_pivot_is_the_anchor_unless_it_is_on_the_dragged_side() {
fn the_pivot_is_the_opposite_handle_or_the_centre_with_alt() {
let (w, h) = (100.0, 50.0);
let tl = (0, 0);
// Bottom-right corner from the top-left anchor: classic, by the axis that moved the most.
assert_eq!(scale_factor(Handle::Corner(2), Point::new(200.0, 60.0), w, h, tl, false), Some((2.0, Point::new(0.0, 0.0))));
// Top-right corner: the anchor is on its top side, so the opposite corner stays put.
assert_eq!(scale_factor(Handle::Corner(1), Point::new(150.0, -25.0), w, h, tl, false), Some((1.5, Point::new(0.0, 50.0))));
// Right edge: from the anchor; left edge: the anchor is on it, so from the right edge.
assert_eq!(scale_factor(Handle::Edge(1), Point::new(50.0, 999.0), w, h, tl, false), Some((0.5, Point::new(0.0, 0.0))));
assert_eq!(scale_factor(Handle::Edge(3), Point::new(-100.0, 0.0), w, h, tl, false), Some((2.0, Point::new(100.0, 25.0))));
// A centre anchor, or Alt, scales about the centre.
assert_eq!(scale_factor(Handle::Corner(2), Point::new(150.0, 75.0), w, h, (1, 1), false), Some((2.0, Point::new(50.0, 25.0))));
assert_eq!(scale_factor(Handle::Edge(2), Point::new(0.0, 75.0), w, h, tl, true), Some((2.0, Point::new(50.0, 25.0))));
// Bottom-right corner: the top-left corner stays put, by the axis that moved the most.
assert_eq!(scale_factor(Handle::Corner(2), Point::new(200.0, 60.0), w, h, false), Some((2.0, Point::new(0.0, 0.0))));
// Top-right corner: the bottom-left corner stays put.
assert_eq!(scale_factor(Handle::Corner(1), Point::new(150.0, -25.0), w, h, false), Some((1.5, Point::new(0.0, 50.0))));
// Right edge: the left edge's middle; left edge: the right edge's middle.
assert_eq!(scale_factor(Handle::Edge(1), Point::new(50.0, 999.0), w, h, false), Some((0.5, Point::new(0.0, 25.0))));
assert_eq!(scale_factor(Handle::Edge(3), Point::new(-100.0, 0.0), w, h, false), Some((2.0, Point::new(100.0, 25.0))));
// Alt scales about the centre.
assert_eq!(scale_factor(Handle::Edge(2), Point::new(0.0, 75.0), w, h, true), Some((2.0, Point::new(50.0, 25.0))));
// Past the pivot flips; it never collapses to nothing.
assert_eq!(scale_factor(Handle::Corner(2), Point::new(-100.0, -50.0), w, h, tl, false).map(|k| k.0), Some(-1.0));
assert_eq!(scale_factor(Handle::Corner(2), Point::new(0.0, 0.0), w, h, tl, false).map(|k| k.0), Some(0.01));
assert_eq!(scale_factor(Handle::Corner(2), Point::new(-100.0, -50.0), w, h, false).map(|k| k.0), Some(-1.0));
assert_eq!(scale_factor(Handle::Corner(2), Point::new(0.0, 0.0), w, h, false).map(|k| k.0), Some(0.01));
// Lines (no height) scale along their length.
assert_eq!(scale_factor(Handle::Corner(1), Point::new(300.0, 7.0), 100.0, 0.0, tl, false).map(|k| k.0), Some(3.0));
assert_eq!(scale_factor(Handle::Corner(1), Point::new(300.0, 7.0), 100.0, 0.0, false).map(|k| k.0), Some(3.0));
for bad in [f64::NAN, f64::INFINITY] {
assert_eq!(scale_factor(Handle::Corner(2), Point::new(bad, 0.0), w, h, tl, false), None);
assert_eq!(scale_factor(Handle::Corner(2), Point::new(bad, 0.0), w, h, false), None);
}
assert_eq!(scale_factor(Handle::Rotate(0), Point::new(1.0, 1.0), w, h, tl, false), None);
assert_eq!(scale_factor(Handle::Corner(2), Point::new(5.0, 5.0), 0.0, 0.0, tl, false), None);
assert_eq!(scale_factor(Handle::Rotate(0), Point::new(1.0, 1.0), w, h, false), None);
assert_eq!(scale_factor(Handle::Corner(2), Point::new(5.0, 5.0), 0.0, 0.0, false), None);
}
/// Measured in Figma's web app, 2026-11-09: a drag ignores the origin grid and pivots on the
/// opposite handle (a 100 x 100 layer at 9000, 5000: the bottom-right corner keeps the top left;
/// the right edge keeps the left edge's middle and scales both ways).
#[test]
fn a_scale_handle_drag_pivots_on_the_opposite_handle_whatever_the_origin() {
for origin in ["c", "tl"] {
let mut s = Session::new();
let r = rect(&mut s, 9000.0, 5000.0, 100.0, 100.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.scale", json!({}));
ex(&mut s, "tool.scaleAnchor", json!({"anchor": origin}));
drag(&mut s, (9100.0, 5100.0), (9144.0, 5144.0), false);
assert_rect(bounds(&s, r), [9000.0, 5000.0, 9144.0, 5144.0]);
s.undo().unwrap();
// The right edge's middle: the left edge's middle stays, the layer scales evenly.
drag(&mut s, (9100.0, 5050.0), (9150.0, 5050.0), false);
assert_rect(bounds(&s, r), [9000.0, 4975.0, 9150.0, 5125.0]);
}
}
#[test]
fn the_scale_origin_starts_at_the_centre() {
let mut s = Session::new();
assert_eq!(ex(&mut s, "tool.scaleAnchor", json!({}))["anchor"], json!("c"));
assert_eq!(s.tool.scale_anchor, (1, 1));
}
#[test]
@@ -118,7 +143,7 @@ fn the_live_readout_shows_the_factor_and_pivot() {
}
#[test]
fn the_anchor_and_alt_choose_what_stays_put() {
fn the_anchor_is_read_back_and_alt_scales_from_the_centre() {
let mut s = Session::new();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
@@ -126,12 +151,13 @@ fn the_anchor_and_alt_choose_what_stays_put() {
assert_eq!(ex(&mut s, "tool.scaleAnchor", json!({"anchor": "c"}))["anchor"], json!("c"));
assert_eq!(ex(&mut s, "tool.scaleAnchor", json!({}))["anchor"], json!("c"), "reads it back");
assert!(s.execute("tool.scaleAnchor", &json!({"anchor": "nowhere"})).is_err());
// A drag pivots on the opposite corner whatever the anchor.
drag(&mut s, (100.0, 50.0), (150.0, 75.0), false);
assert_rect(bounds(&s, r), [-50.0, -25.0, 150.0, 75.0]);
assert_rect(bounds(&s, r), [0.0, 0.0, 150.0, 75.0]);
// Alt scales from the centre whatever the anchor.
ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"}));
drag(&mut s, (150.0, 75.0), (125.0, 62.5), true);
assert_rect(bounds(&s, r), [-25.0, -12.5, 125.0, 62.5]);
assert_rect(bounds(&s, r), [25.0, 12.5, 125.0, 62.5]);
}
#[test]
+1 -1
View File
@@ -17,7 +17,7 @@ impl Session {
default_props: Default::default(),
prefs: Prefs::default(),
prefs_unsaved: false,
tool: tools::ToolState::default(),
tool: tools::ToolState { scale_anchor: (1, 1), ..tools::ToolState::default() },
ui_requests: Vec::new(),
ui_dark: None,
journal: Vec::new(),
+5 -4
View File
@@ -941,7 +941,7 @@ fn enter_in_another_field_after_tab_keeps_the_scale_tool() {
assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Scale);
}
/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the panel's origin, the tool is Move, one undo step.
/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the panel's origin (the centre), the tool is Move, one undo step.
#[test]
fn k_two_enter_scales_the_selection_by_two() {
let (mut u, r) = scale_setup();
@@ -951,8 +951,8 @@ fn k_two_enter_scales_the_selection_by_two() {
u.frame(vec![Event::Text("2".to_string())]);
key(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
// About the panel's origin, FigCraft's top-left default (Figma's grid starts at the centre).
assert_eq!(geom(&u, r), (0.0, 0.0, 200.0, 200.0));
// About the panel's origin, which starts at the centre as Figma's grid does.
assert_eq!(geom(&u, r), (-50.0, -50.0, 200.0, 200.0));
assert!(!scale_field_focused(&u), "the field let go of the keyboard");
assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
@@ -971,7 +971,8 @@ fn the_scale_field_takes_2x_and_1_5() {
u.frame(vec![Event::Text(text.to_string())]);
key(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
assert_eq!(geom(&u, r), (0.0, 0.0, size, size), "{text}");
let at = 50.0 - size / 2.0;
assert_eq!(geom(&u, r), (at, at, size, size), "{text}");
}
}
+3 -2
View File
File diff suppressed because one or more lines are too long