Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/grida-canvas-hud/__tests__/apply-resize-aspect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// `applyResize` — aspect-lock under `{ aspect: true }` (Shift). A corner takes
// the max-magnitude axis factor; a side edge drives the perpendicular axis by
// the same factor about the perpendicular center. This variant drives the
// dashed resize *preview* so it matches an aspect-locking host (the emitted
// intent stays opposite-anchored / free — see state.ts). Composes with
// `fromCenter` (Alt) for uniform-about-center.

import { describe, it, expect } from "vitest";
import { applyResize } from "../event/gesture";
import type { SelectionShape } from "../event";

function rect(x: number, y: number, w: number, h: number): SelectionShape {
return { kind: "rect", rect: { x, y, width: w, height: h } };
}
function bbox(s: SelectionShape) {
if (s.kind !== "rect") throw new Error("expected rect");
return s.rect;
}
function center(r: { x: number; y: number; width: number; height: number }) {
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}

describe("applyResize — aspect off is unchanged (regression guard)", () => {
it("e edge stays free (perpendicular unchanged) without aspect", () => {
expect(bbox(applyResize(rect(0, 0, 100, 50), "e", 20, 0))).toEqual({
x: 0,
y: 0,
width: 120,
height: 50,
});
});
});

describe("applyResize — aspect (Shift) on a side edge follows the perpendicular", () => {
it("e: left edge pinned, height scales with width about the vertical center", () => {
const r = bbox(
applyResize(rect(0, 0, 100, 50), "e", 20, 0, { aspect: true })
);
expect(r).toEqual({ x: 0, y: -5, width: 120, height: 60 }); // s = 1.2
expect(r.y + r.height / 2).toBe(25); // vertical center preserved
expect(r.x).toBe(0); // left edge pinned
});

it("n: bottom edge pinned, width scales with height about the horizontal center", () => {
const r = bbox(
applyResize(rect(0, 0, 100, 50), "n", 0, -10, { aspect: true })
);
expect(r).toEqual({ x: -10, y: -10, width: 120, height: 60 }); // s = 1.2
expect(r.x + r.width / 2).toBe(50); // horizontal center preserved
expect(r.y + r.height).toBe(50); // bottom edge pinned
});
});

describe("applyResize — aspect (Shift) on a corner is max-magnitude uniform", () => {
it("se collapses to the larger axis factor, origin pinned", () => {
// sx = 1.2, sy = 1.1 → mag 1.2.
const r = bbox(
applyResize(rect(0, 0, 100, 50), "se", 20, 5, { aspect: true })
);
expect(r).toEqual({ x: 0, y: 0, width: 120, height: 60 });
});
});

describe("applyResize — Shift+Alt is uniform about the bbox center", () => {
it("e edge: both opposite edges move, center stays put", () => {
const r = bbox(
applyResize(rect(0, 0, 100, 50), "e", 20, 0, {
fromCenter: true,
aspect: true,
})
);
expect(r).toEqual({ x: -20, y: -10, width: 140, height: 70 }); // s = 1.4
expect(center(r)).toEqual({ x: 50, y: 25 }); // initial center
});
});
68 changes: 63 additions & 5 deletions packages/grida-canvas-hud/event/gesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,13 +410,21 @@ export function applyResize(
direction: ResizeDirection,
dx: number,
dy: number,
opts?: { fromCenter?: boolean }
opts?: { fromCenter?: boolean; aspect?: boolean }
): SelectionShape {
const fromCenter = opts?.fromCenter ?? false;
const aspect = opts?.aspect ?? false;
if (initial.kind === "rect") {
return {
kind: "rect",
rect: applyResizeRect(initial.rect, direction, dx, dy, fromCenter),
rect: applyResizeRect(
initial.rect,
direction,
dx,
dy,
fromCenter,
aspect
),
};
}
if (initial.kind === "transformed") {
Expand All @@ -435,7 +443,14 @@ export function applyResize(
// for rotated / sheared selections.
return {
kind: "transformed",
local: applyResizeRect(initial.local, direction, ldx, ldy, fromCenter),
local: applyResizeRect(
initial.local,
direction,
ldx,
ldy,
fromCenter,
aspect
),
matrix: m,
};
}
Expand All @@ -450,13 +465,19 @@ export function applyResize(
* `fromCenter` (Alt), the resize is symmetric about the rect's center:
* the dragged edge moves by the delta and the opposite edge mirrors it,
* so the size delta doubles and the center stays put.
*
* With `aspect` (Shift), the resize is uniform: a corner takes the
* max-magnitude axis factor; a side edge drives the perpendicular axis by
* the same factor about the perpendicular center. This mirrors the host's
* core `compute_factors`, so the dashed preview tracks what gets written.
*/
function applyResizeRect(
initial: Rect,
direction: ResizeDirection,
dx: number,
dy: number,
fromCenter = false
fromCenter = false,
aspect = false
): Rect {
let { x, y, width, height } = initial;

Expand Down Expand Up @@ -486,5 +507,42 @@ function applyResizeRect(
height -= k * dy;
}

return { x, y, width, height };
if (!aspect) return { x, y, width, height };

// Aspect-lock (Shift). Take the signed per-axis factors the free math
// produced, lock them to one magnitude, then rebuild the box about the
// anchor the free math pins — the opposite edge/corner, or the bbox
// center under `fromCenter`. A side edge has only one driven axis, so the
// perpendicular follows it about the perpendicular center.
const affectsX = hasE || hasW;
const affectsY = hasN || hasS;
// Per-axis anchor: bbox center under Alt or on the free (perpendicular)
// axis; else the pinned far edge — `pinLo` (moving the high edge) pins the
// low coordinate, otherwise the high one.
const anchorAxis = (
affected: boolean,
lo: number,
size: number,
pinLo: boolean
) => (fromCenter || !affected ? lo + size / 2 : pinLo ? lo : lo + size);
const ax = anchorAxis(affectsX, initial.x, initial.width, hasE);
const ay = anchorAxis(affectsY, initial.y, initial.height, hasS);

const sxRaw = initial.width !== 0 ? width / initial.width : 1;
const syRaw = initial.height !== 0 ? height / initial.height : 1;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let sx: number;
let sy: number;
if (affectsX && affectsY) {
const mag = Math.max(Math.abs(sxRaw), Math.abs(syRaw));
sx = sxRaw >= 0 ? mag : -mag;
sy = syRaw >= 0 ? mag : -mag;
} else if (affectsX) {
sx = sxRaw;
sy = sxRaw;
} else {
sx = syRaw;
sy = syRaw;
}

return cmath.rect.scale(initial, [ax, ay], [sx, sy]);
}
28 changes: 17 additions & 11 deletions packages/grida-canvas-hud/event/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,12 @@ export class SurfaceState {
return this.onPointerUp(event.x, event.y, event.button, deps);
case "modifiers": {
this.modifiers = event.mods;
// A mid-drag Alt toggle (no pointer move) switches the resize anchor
// opposite<->center, so the dashed preview must refresh now —
// otherwise it stays stale until the next move. `current_shape` (the
// intent dims) is anchor-independent, so only `preview_shape` changes.
// A mid-drag Alt toggle (anchor opposite<->center) or Shift toggle
// (aspect-lock on/off) — both with no pointer move — change the
// dashed preview, so it must refresh now or it stays stale until the
// next move. `current_shape` (the intent dims) is modifier-independent
// (the host applies anchor/aspect itself), so only `preview_shape`
// changes.
if (this.gesture.kind === "resize") {
const g = this.gesture;
const dx = g.last_doc[0] - g.anchor_doc[0];
Expand All @@ -509,19 +511,23 @@ export class SurfaceState {
}
}

/** The dashed-preview shape for a resize: center-symmetric under Alt,
* else the opposite-anchored `opposite` shape (which the intent carries).
* One rule, shared by the pointer-move handler and the modifier-toggle
* refresh so the preview can't go stale on a mid-drag Alt flip. */
/** The dashed-preview shape for a resize: aspect-locked under Shift
* and/or center-symmetric under Alt (the two compose), else the
* opposite-anchored `opposite` shape (which the intent carries). One
* rule, shared by the pointer-move handler and the modifier-toggle
* refresh so the preview can't go stale on a mid-drag Shift/Alt flip. */
private resizePreviewShape(
g: Extract<SurfaceGesture, { kind: "resize" }>,
dx: number,
dy: number,
opposite: SelectionShape
): SelectionShape {
return this.modifiers.alt
? applyResize(g.initial_shape, g.direction, dx, dy, { fromCenter: true })
: opposite;
const { alt, shift } = this.modifiers;
if (!alt && !shift) return opposite;
return applyResize(g.initial_shape, g.direction, dx, dy, {
fromCenter: alt,
aspect: shift,
});
}

// ── Pointer move ─────────────────────────────────────────────────────────
Expand Down
Loading
Loading