From a8c231458e1698bebcacfe9771d1f7fa7a3564cf Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Tue, 28 Jul 2026 13:12:55 +0300 Subject: [PATCH 1/2] Snap dragged widgets to the other boxes' edges, with guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a widget or group is dragged, its box's start/centre/end lines are pulled onto the same lines of every other hitbox plus the canvas edges and centre, and the line that caught it is drawn across the canvas. ⌥ holds the drag off the guides. Thresholds are in screen pixels and converted at the use site: the canvas is drawn in 466-space but displayed much smaller, so a tolerance in canvas units shrinks to about two pixels of pointer slack and never catches. Closes #4 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011ZdiFtcW3mifwmYtspMcB7 --- src/lib/modules/editor/lib/snap.ts | 52 +++++++++++++ src/lib/modules/editor/pages/editor.svelte | 60 ++++++++++++++- src/lib/modules/editor/shared/constants.ts | 3 + tests/editor-snap.browser.test.ts | 88 ++++++++++++++++++++++ tests/editor-snap.test.ts | 61 +++++++++++++++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 src/lib/modules/editor/lib/snap.ts create mode 100644 src/lib/modules/editor/shared/constants.ts create mode 100644 tests/editor-snap.browser.test.ts create mode 100644 tests/editor-snap.test.ts diff --git a/src/lib/modules/editor/lib/snap.ts b/src/lib/modules/editor/lib/snap.ts new file mode 100644 index 0000000..37f9801 --- /dev/null +++ b/src/lib/modules/editor/lib/snap.ts @@ -0,0 +1,52 @@ +// Drag snapping: the moved box's start/centre/end lines are pulled onto the same three lines +// of every other hitbox, plus the canvas edges and centre. Pure — the canvas page owns when +// to collect targets and how to draw the guides. +// ponytail: proximity snap only, no equal-spacing / distance guides — add if anyone asks. +import type { Hit } from "./canvas"; +import type { FaceNode } from "./wf"; + +export interface SnapTargets { + xs: number[]; + ys: number[]; +} + +const nodesUnder = (n: FaceNode, out = new Set()): Set => { + out.add(n); + n.subs?.forEach((s) => nodesUnder(s, out)); + return out; +}; + +/** Lines the drag can snap to: every other hitbox's edges/centre + the canvas's own. */ +export function snapTargets(hits: Hit[], dragged: FaceNode): SnapTargets { + const skip = nodesUnder(dragged); // a group carries its children's boxes along with it + const xs = [0, 233, 466], + ys = [0, 233, 466]; + + for (const h of hits) { + if (skip.has(h.node)) continue; + xs.push(h.x, h.x + h.w / 2, h.x + h.w); + ys.push(h.y, h.y + h.h / 2, h.y + h.h); + } + return { xs, ys }; +} + +/** Nearest target line to this box's start/centre/end, or null if nothing is within `tol`. */ +export function snapAxis( + lo: number, + size: number, + targets: number[], + tol: number, +): { corr: number; line: number } | null { + let best = tol, + hit: { corr: number; line: number } | null = null; + + for (const e of [lo, lo + size / 2, lo + size]) { + for (const t of targets) { + if (Math.abs(t - e) < best) { + best = Math.abs(t - e); + hit = { corr: t - e, line: t }; + } + } + } + return hit; +} diff --git a/src/lib/modules/editor/pages/editor.svelte b/src/lib/modules/editor/pages/editor.svelte index b47b9ad..c75c4f9 100644 --- a/src/lib/modules/editor/pages/editor.svelte +++ b/src/lib/modules/editor/pages/editor.svelte @@ -10,6 +10,8 @@ import { TAG, unhex, hex, type FaceNode } from "../lib/wf"; import { render, parseFrame } from "../lib/render"; import type { Hit } from "../lib/canvas"; + import { snapAxis, snapTargets, type SnapTargets } from "../lib/snap"; + import { SNAP_THRESHOLD, GUIDE_WIDTH, GUIDE_COLOR } from "../shared/constants"; import { editorModel } from "../model"; import TreePanel from "../components/TreePanel.svelte"; import PropsPanel from "../components/PropsPanel.svelte"; @@ -125,6 +127,7 @@ if (s.face) { hits = render(ctx, s.face, s.screenTag, s.sim); drawSelection(ctx, s.sel); + drawGuides(ctx); } else { ctx.clearRect(0, 0, 466, 466); } @@ -239,6 +242,32 @@ return null; }; + // ---- snapping ---- + // Targets are collected once on pointerdown: hits is rebuilt every frame, and the dragged + // node's own box would otherwise snap to itself. ⌥ holds the drag off the guides. + let snapT: SnapTargets | null = null; + let cvScale = 1; // canvas units per screen px — the thresholds are in screen px + let box0: Hit | null = null; + let guides: { x: number[]; y: number[] } = { x: [], y: [] }; + + function drawGuides(ctx: CanvasRenderingContext2D) { + if (!guides.x.length && !guides.y.length) return; + ctx.save(); + ctx.strokeStyle = GUIDE_COLOR; + ctx.lineWidth = GUIDE_WIDTH * cvScale; + ctx.beginPath(); + for (const x of guides.x) { + ctx.moveTo(x + 0.5, 0); + ctx.lineTo(x + 0.5, 466); + } + for (const y of guides.y) { + ctx.moveTo(0, y + 0.5); + ctx.lineTo(466, y + 0.5); + } + ctx.stroke(); + ctx.restore(); + } + // ---- selection and drag ---- type XY = { x: number; y: number }; type Drag = @@ -321,6 +350,14 @@ if (fr) drag = { p, fr: h.node, x0: fr.x, y0: fr.y, moved: false }; else if (st && st.x != null) drag = { p, st, x0: st.x, y0: st.y!, moved: false }; + if (drag) { + // the hitbox, not st.x/st.y — a NUMBER's box is its composed digits, a HAND's the + // rotated AABB. Both move by the same delta, so snapping the box is enough. + box0 = h; + snapT = snapTargets(hits, h.node); + // the canvas doesn't resize mid-drag, so one layout read is enough for the whole gesture + cvScale = 466 / (canvas?.getBoundingClientRect().width || 466); + } canvas?.setPointerCapture(e.pointerId); } function onMove(e: PointerEvent) { @@ -349,9 +386,24 @@ d.moved = true; } const p = canvasXY(e); - const dx = Math.round(p.x - d.p.x), + let dx = Math.round(p.x - d.p.x), dy = Math.round(p.y - d.p.y); + guides = { x: [], y: [] }; + if (box0 && snapT && !e.altKey) { + const tol = SNAP_THRESHOLD * cvScale; + const sx = snapAxis(box0.x + dx, box0.w, snapT.xs, tol); + const sy = snapAxis(box0.y + dy, box0.h, snapT.ys, tol); + + if (sx) { + dx += Math.round(sx.corr); + guides.x = [sx.line]; + } + if (sy) { + dy += Math.round(sy.corr); + guides.y = [sy.line]; + } + } // widget x/y are int16 — negatives are legal (and used by stock faces), so no clamp here; // group frames stay >=0, their x/y round-trip through the file as unsigned if (d.st) patched({ node: d.st, patch: { x: d.x0 + dx, y: d.y0 + dy } }); @@ -387,7 +439,8 @@ }); rz = null; } - drag = null; + drag = box0 = snapT = null; + guides = { x: [], y: [] }; } function onKey(e: KeyboardEvent) { @@ -580,7 +633,8 @@ >

- click — select · drag / arrow keys (⇧ ×10) — move · corners — resize · ⌘Z undo + click — select · drag / arrow keys (⇧ ×10) — move · ⌥ drag — no snap · corners — resize · ⌘Z + undo

diff --git a/src/lib/modules/editor/shared/constants.ts b/src/lib/modules/editor/shared/constants.ts new file mode 100644 index 0000000..a760b7d --- /dev/null +++ b/src/lib/modules/editor/shared/constants.ts @@ -0,0 +1,3 @@ +export const SNAP_THRESHOLD = 5; +export const GUIDE_WIDTH = 1; +export const GUIDE_COLOR = "#ff44ff88"; diff --git a/tests/editor-snap.browser.test.ts b/tests/editor-snap.browser.test.ts new file mode 100644 index 0000000..4b7513e --- /dev/null +++ b/tests/editor-snap.browser.test.ts @@ -0,0 +1,88 @@ +// Drag snapping through the real pointer path: snap.ts is unit tested on its own, the wiring +// here (targets taken on pointerdown, tolerance converted from screen px) is what breaks. +import { test, expect } from "vitest"; +import { render as mount } from "vitest-browser-svelte"; +import { EditorPage } from "$lib/modules/editor/pages"; +import { editorModel } from "$lib/modules/editor/model"; +import { render } from "$lib/modules/editor/lib/render"; +import { structOf } from "$lib/modules/editor/lib/tree"; +import { TAG } from "$lib/modules/editor/lib/wf"; +import type { Hit } from "$lib/modules/editor/lib/canvas"; +import { SNAP_THRESHOLD } from "$lib/modules/editor/shared/constants"; +import url from "./__fixtures__/Analog__287__Simple_Dial.bin?url"; + +const boxes = (): Hit[] => { + const s = editorModel.$editor.getState(); + const c = document.createElement("canvas"); + + c.width = c.height = 466; + return render(c.getContext("2d")!, s.face!, TAG.main, s.sim); +}; + +test("dragging a widget snaps its edge onto another widget's edge", async () => { + const buf = await fetch(url).then((r) => r.arrayBuffer()); + + await new Promise((resolve) => { + const unwatch = editorModel.loadDone.watch(() => { + unwatch(); + resolve(); + }); + + editorModel.loadRequested({ buf, label: "snap-test" }); + }); + // frozen clock: the hands are what this face is made of, and their boxes are rotated AABBs + editorModel.simPatched({ + live: false, + time: new Date("2026-01-09T10:09:30").getTime(), + }); + mount(EditorPage); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + + const canvas = document.querySelector("canvas.canvas") as HTMLCanvasElement; + + canvas.setPointerCapture = () => {}; // synthetic events have no active pointer to capture + + const hits = boxes(); + const me = hits[hits.length - 1]; // topmost — pointerdown picks it wherever they overlap + const other = hits.find((h) => h.node.tag === 0x60)!; // the one small widget on this face + + expect(structOf(me.node)?.x).toBeTypeOf("number"); + + const r = canvas.getBoundingClientRect(); + const k = r.width / 466; // canvas units -> client px + const tol = (SNAP_THRESHOLD * 466) / r.width; + const off = 3; // inside the snap window, so the drag must land flush instead + + expect(off).toBeLessThan(tol); + const dx = other.x + off - me.x; // aim my left edge just short of the other box's left edge + const at = (cx: number, cy: number) => ({ + clientX: r.left + cx * k, + clientY: r.top + cy * k, + pointerId: 1, + bubbles: true, + }); + const start = { x: me.x + me.w / 2, y: me.y + me.h / 2 }; + + canvas.dispatchEvent(new PointerEvent("pointerdown", at(start.x, start.y))); + expect(editorModel.$editor.getState().sel).toBe(me.node); + canvas.dispatchEvent(new PointerEvent("pointermove", at(start.x + dx, start.y))); + const moved = boxes().findLast((h) => h.node === me.node)!; + + // x/y are integers, so a fractional box (a rotated hand) lands within a unit of the line + expect(Math.abs(moved.x - other.x)).toBeLessThan(1); + + // …and the guide is on screen while the pointer is still down + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const px = canvas.getContext("2d")!.getImageData(Math.round(other.x), 0, 1, 466).data; + // magenta-ish rather than exactly GUIDE_COLOR: the guide is drawn over the face and may carry + // alpha, so what identifies it is red+blue far above green + let magenta = false; + + for (let i = 0; i < px.length; i += 4) { + if (px[i] > 120 && px[i + 2] > 120 && px[i] - px[i + 1] > 90 && px[i + 2] - px[i + 1] > 90) + magenta = true; + } + + expect(magenta).toBe(true); + canvas.dispatchEvent(new PointerEvent("pointerup", at(start.x + dx, start.y))); +}); diff --git a/tests/editor-snap.test.ts b/tests/editor-snap.test.ts new file mode 100644 index 0000000..a0c3c7d --- /dev/null +++ b/tests/editor-snap.test.ts @@ -0,0 +1,61 @@ +// Snapping is what a drag lands on, so the two rules that matter: only lines within the +// threshold pull, and the nearest one wins across all three edges. Runs in the "unit" project. +import { describe, test, expect } from "vitest"; +import { snapAxis, snapTargets } from "$lib/modules/editor/lib/snap"; +import { SNAP_THRESHOLD } from "$lib/modules/editor/shared/constants"; +import type { Hit } from "$lib/modules/editor/lib/canvas"; +import type { FaceNode } from "$lib/modules/editor/lib/wf"; + +const hit = (node: FaceNode, x: number, y: number, w: number, h: number): Hit => ({ + node, + x, + y, + w, + h, +}); + +const TOL = SNAP_THRESHOLD; + +describe("snapAxis", () => { + test("nothing within tolerance — no pull", () => { + expect(snapAxis(100, 40, [200], TOL)).toBeNull(); + expect(snapAxis(100, 40, [100 + TOL], TOL)).toBeNull(); // exactly tol away is already too far + }); + + test("centre snaps to the canvas centre", () => { + // box 100..140, centre 120; target 122 is 2 away from the centre, 18 from the near edge + expect(snapAxis(100, 40, [122], TOL)).toEqual({ corr: 2, line: 122 }); + }); + + test("nearest of several targets wins, edges included", () => { + // left edge 100 is 1 from 101; centre 120 is 3 from 123 — the edge is closer + expect(snapAxis(100, 40, [101, 123], TOL)).toEqual({ corr: 1, line: 101 }); + // right edge 140 pulls backwards + expect(snapAxis(100, 40, [138], TOL)).toEqual({ corr: -2, line: 138 }); + }); + + test("butts up against a neighbour: right edge to that box's left edge", () => { + const other = snapTargets([hit({ tag: 0x30 }, 200, 0, 60, 40)], { + tag: 0x33, + }); + + // box 158..198 lands flush at 160..200 — the neighbour's left edge, not a shared centre + expect(snapAxis(158, 40, other.xs, TOL)).toEqual({ corr: 2, line: 200 }); + }); +}); + +describe("snapTargets", () => { + const child: FaceNode = { tag: 0x30 }; + const dragged: FaceNode = { tag: 0x33, subs: [child] }; + const other: FaceNode = { tag: 0x30 }; + + test("canvas lines always, other boxes' edges/centre, never the dragged subtree", () => { + const t = snapTargets( + [hit(dragged, 10, 10, 100, 100), hit(child, 20, 20, 20, 20), hit(other, 200, 300, 60, 40)], + dragged, + ); + + expect(t.xs).toEqual([0, 233, 466, 200, 230, 260]); + expect(t.ys).toEqual([0, 233, 466, 300, 320, 340]); + }); +}); From 87c059f3278c1e87b606191bf58f2c13cca39dec Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Tue, 28 Jul 2026 15:23:44 +0300 Subject: [PATCH 2/2] Run oxfmt over the five files a prettier pass had rewrapped Cosmetic only: prettier's 80-column default had broken up lines the repo's oxfmt keeps on one, so `oxfmt --check` failed on files nothing had touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011ZdiFtcW3mifwmYtspMcB7 --- .../modules/device/components/slot-dialog.svelte | 16 +++------------- .../device/components/watch-popover.svelte | 16 ++++------------ src/lib/shared/components/button/button.svelte | 7 +------ src/lib/shared/components/dialog/dialog.svelte | 8 +------- tests/facer-import.browser.test.ts | 4 +++- 5 files changed, 12 insertions(+), 39 deletions(-) diff --git a/src/lib/modules/device/components/slot-dialog.svelte b/src/lib/modules/device/components/slot-dialog.svelte index a239fbd..fc3b6ed 100644 --- a/src/lib/modules/device/components/slot-dialog.svelte +++ b/src/lib/modules/device/components/slot-dialog.svelte @@ -6,22 +6,12 @@ import { dialLabel, dialPreview, dialTitle } from "../lib/catalog-names"; import WatchSelector from "./watch-selector.svelte"; - const { - $slotDialogOpen: open, - $dials: dials, - slotPicked, - slotDialogClosed, - } = bleModel; + const { $slotDialogOpen: open, $dials: dials, slotPicked, slotDialogClosed } = bleModel; - slotDialogClosed()} -> + slotDialogClosed()}>

- Pick the watchface to replace — its slot number matches the order the watch - shows them in. + Pick the watchface to replace — its slot number matches the order the watch shows them in.

{#each $dials ?? [] as id, i (id)} diff --git a/src/lib/modules/device/components/watch-popover.svelte b/src/lib/modules/device/components/watch-popover.svelte index e549694..d1ea18c 100644 --- a/src/lib/modules/device/components/watch-popover.svelte +++ b/src/lib/modules/device/components/watch-popover.svelte @@ -9,9 +9,7 @@ interface Props { // like Menu's trigger, but the panel doesn't auto-close on inside clicks - trigger: Snippet< - [{ open: boolean; toggle: () => void; connected: boolean }] - >; + trigger: Snippet<[{ open: boolean; toggle: () => void; connected: boolean }]>; placement?: "bottom" | "top"; } const { trigger, placement = "bottom" }: Props = $props(); @@ -64,11 +62,7 @@ - @@ -88,12 +82,10 @@ Battery: {$bleInfo.battery ?? "?"}%

- Firmware: {$bleInfo.firmware ?? - "?"} + Firmware: {$bleInfo.firmware ?? "?"}

- Serial: {$bleInfo.serial ?? - "?"} + Serial: {$bleInfo.serial ?? "?"}

diff --git a/src/lib/shared/components/button/button.svelte b/src/lib/shared/components/button/button.svelte index 3218429..8a7e18b 100644 --- a/src/lib/shared/components/button/button.svelte +++ b/src/lib/shared/components/button/button.svelte @@ -19,12 +19,7 @@ }: Props = $props(); - diff --git a/src/lib/shared/components/dialog/dialog.svelte b/src/lib/shared/components/dialog/dialog.svelte index a83ba8a..bdcb800 100644 --- a/src/lib/shared/components/dialog/dialog.svelte +++ b/src/lib/shared/components/dialog/dialog.svelte @@ -9,13 +9,7 @@ onClose?: () => void; children?: Snippet; } - const { - open = false, - title, - side = false, - onClose, - children, - }: Props = $props(); + const { open = false, title, side = false, onClose, children }: Props = $props(); let el = $state(); diff --git a/tests/facer-import.browser.test.ts b/tests/facer-import.browser.test.ts index c1cacb6..ad69b76 100644 --- a/tests/facer-import.browser.test.ts +++ b/tests/facer-import.browser.test.ts @@ -126,7 +126,9 @@ async function fakeExport(): Promise { new File([await sprite("#000")], "h"), // one distinguishable sprite per weekday value: red channel = value * 20 ...(await Promise.all( - [1, 2, 3, 4, 5, 6, 7].map(async (d) => new File([await sprite(`rgb(${d * 20},0,0)`)], `d${d}`)), + [1, 2, 3, 4, 5, 6, 7].map( + async (d) => new File([await sprite(`rgb(${d * 20},0,0)`)], `d${d}`), + ), )), ]; }