mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 03:01:11 +00:00
Merge upstream/main into feat/tidy-up-figma-rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user