Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 3 additions & 13 deletions src/lib/modules/device/components/slot-dialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
</script>

<Dialog
open={$open}
title="The watch is full"
onClose={() => slotDialogClosed()}
>
<Dialog open={$open} title="The watch is full" onClose={() => slotDialogClosed()}>
<p class="desc">
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.
</p>
<div class="dials">
{#each $dials ?? [] as id, i (id)}
Expand Down
16 changes: 4 additions & 12 deletions src/lib/modules/device/components/watch-popover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -64,11 +62,7 @@
<span
title="Clear Chrome's device permission and the saved auth key — does not affect pairing state on the watch itself"
>
<Button
kind="ghost"
onClick={() => forgetRequested()}
disabled={$forgetting}
>
<Button kind="ghost" onClick={() => forgetRequested()} disabled={$forgetting}>
<Icon name="eraser" size={16} />
{$forgetting ? "Forgetting…" : "Forget device"}
</Button>
Expand All @@ -88,12 +82,10 @@
Battery: {$bleInfo.battery ?? "?"}%
</p>
<p>
<Icon name="cpu" size={16} color={muted} /> Firmware: {$bleInfo.firmware ??
"?"}
<Icon name="cpu" size={16} color={muted} /> Firmware: {$bleInfo.firmware ?? "?"}
</p>
<p>
<Icon name="hash" size={16} color={muted} /> Serial: {$bleInfo.serial ??
"?"}
<Icon name="hash" size={16} color={muted} /> Serial: {$bleInfo.serial ?? "?"}
</p>
</div>
</div>
Expand Down
52 changes: 52 additions & 0 deletions src/lib/modules/editor/lib/snap.ts
Original file line number Diff line number Diff line change
@@ -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<FaceNode>()): Set<FaceNode> => {
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;
}
60 changes: 57 additions & 3 deletions src/lib/modules/editor/pages/editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 } });
Expand Down Expand Up @@ -387,7 +439,8 @@
});
rz = null;
}
drag = null;
drag = box0 = snapT = null;
guides = { x: [], y: [] };
}

function onKey(e: KeyboardEvent) {
Expand Down Expand Up @@ -580,7 +633,8 @@
></canvas>
</div>
<p class="hint">
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
</p>
</section>

Expand Down
3 changes: 3 additions & 0 deletions src/lib/modules/editor/shared/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const SNAP_THRESHOLD = 5;
export const GUIDE_WIDTH = 1;
export const GUIDE_COLOR = "#ff44ff88";
7 changes: 1 addition & 6 deletions src/lib/shared/components/button/button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,7 @@
}: Props = $props();
</script>

<button
class="btn kind__{kind} size__{size}"
{type}
{disabled}
onclick={onClick}
>
<button class="btn kind__{kind} size__{size}" {type} {disabled} onclick={onClick}>
{@render children?.()}
</button>

Expand Down
8 changes: 1 addition & 7 deletions src/lib/shared/components/dialog/dialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDialogElement>();

Expand Down
88 changes: 88 additions & 0 deletions tests/editor-snap.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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)));
});
Loading