Give a peg handles on the stage

A peg was reachable and not usable. It is a `:group`, so it draws nothing, and
both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the ops,
so a click never found it, and `handles` hangs its box, corners and turn knob off
`pick/bounds-of`, so it got only the pivot cross — which is `pointer-events:
none`. So the pivot `gesture/refusal` tells you to make could be made, and then
only typed at in the inspector, and reselected only from a timeline row.

Every assertion about the maths passed throughout, which is the point: nothing
under `domain/` can see this.

So the handles fall back to a fixed-size rosette about the peg's own origin —
cross to move, knob to turn, four corners to scale. Fixed size, in stage pixels,
because there is no drawing for it to be proportional to. The cross gets a
transparent `.peg-grab` disc rather than taking the handler itself, since
`.pivot` must stay `pointer-events: none` everywhere else: it is a mark on the
picture and must not eat a click meant for the shape under it.

`test/browser/peg.mjs` asserts the handles as DOM and drags through them with
real pointer events, because that is the half a unit test cannot reach: a peg
appears with no drift at all, is selected, renders one knob, four corners and no
box, and dragging its cross moves its child by exactly the drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
This commit is contained in:
Your Name 2026-10-05 19:17:59 -04:00
parent 925c12fc77
commit 00b8ed34ee
3 changed files with 231 additions and 8 deletions

View file

@ -266,10 +266,19 @@
made. On a node that draws nothing — a peg — there is no box and the cross made. On a node that draws nothing — a peg — there is no box and the cross
marks its own origin, which is what it turns about. marks its own origin, which is what it turns about.
AN INDICATOR AND NOT A HANDLE. There is nothing to drag it to: no pivot is THE CROSS IS NOT A HANDLE FOR THE PIVOT. There is nothing to drag it to: no
stored, so moving it could only mean something for the next drag and would be pivot is stored, so moving it could only mean something for the next drag and
gone by the one after. A pivot that has to persist is a peg, which is a node, would be gone by the one after. A pivot that has to persist is a peg.
and is moved and keyed as any other node is."
A PEG GETS A ROSETTE INSTEAD OF A BOX, and it has to get something: it draws
nothing, so it has no bounds to hang handles on — and the stage's other way in,
dragging what is under the pointer, is `pick/choose` on the DRAWN ops, which
never returns a node that draws nothing. Without this a peg could be made and
then only be typed at in the inspector, which is not a pivot anybody would use.
So the handles fall back to a fixed-size cluster about its own origin: the cross
moves it, a knob turns it, four corners scale it. FIXED SIZE, in stage pixels,
because there is no drawing to be proportional to — the same reason the ghost's
cross is always drawn at a fixed size."
[ctx] [ctx]
(let [{:keys [world bounds]} @(rf/subscribe [::sub/selected-placement]) (let [{:keys [world bounds]} @(rf/subscribe [::sub/selected-placement])
[_ _ _ path] @(rf/subscribe [::sub/selection]) [_ _ _ path] @(rf/subscribe [::sub/selection])
@ -287,7 +296,7 @@
(begin! ctx kind path (xy svg event (:w ctx) (:h ctx))))))] (begin! ctx kind path (xy svg event (:w ctx) (:h ctx))))))]
(when world (when world
[:g.handles [:g.handles
(when-let [[x0 y0 x1 y1] bounds] (if-let [[x0 y0 x1 y1] bounds]
(let [corners (partition 2 (through world [x0 y0 x1 y0 x1 y1 x0 y1])) (let [corners (partition 2 (through world [x0 y0 x1 y0 x1 y1 x0 y1]))
[cx cy tx ty] (through world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2) (/ (+ x0 x1) 2) y0]) [cx cy tx ty] (through world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2) (/ (+ x0 x1) 2) y0])
len (max 1e-6 (js/Math.hypot (- tx cx) (- ty cy))) len (max 1e-6 (js/Math.hypot (- tx cx) (- ty cy)))
@ -300,9 +309,25 @@
(for [[i [x y]] (map-indexed vector corners)] (for [[i [x y]] (map-indexed vector corners)]
^{:key i} ^{:key i}
[:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6 [:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6
:on-pointer-down (grab :scale)}]))])) :on-pointer-down (grab :scale)}]))])
[:path.pivot {:d (str "M " (- px 3) " " py " H " (+ px 3) ;; A peg. Same three gestures, hung on nothing.
" M " px " " (- py 3) " V " (+ py 3))}]]))) (let [r 7]
[:<>
[:line.knob-arm {:x1 px :y1 (- py r) :x2 px :y2 (- py r 8)}]
[:circle.knob {:cx px :cy (- py r 8) :r 2.2 :on-pointer-down (grab :turn)}]
(doall
(for [[i [x y]] (map-indexed vector [[(- px r) (- py r)] [(+ px r) (- py r)]
[(+ px r) (+ py r)] [(- px r) (+ py r)]])]
^{:key i}
[:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6
:on-pointer-down (grab :scale)}]))
;; What moves a peg. A transparent disc rather than the cross itself,
;; because `.pivot` is `pointer-events: none` — it is a mark on the
;; picture in every other case and must not eat a click meant for the
;; shape under it.
[:circle.peg-grab {:cx px :cy py :r r :on-pointer-down (grab :move)}]]))
[:path.pivot {:d (str "M " (- px 4) " " py " H " (+ px 4)
" M " px " " (- py 4) " V " (+ py 4))}]])))
(defn- group-handles [ctx placements] (defn- group-handles [ctx placements]
(let [boxes (keep stage-bounds placements)] (let [boxes (keep stage-bounds placements)]

View file

@ -0,0 +1,196 @@
// Browser smoke test for pegs: a peg is made over a shape, nothing moves, and
// the stage gives it handles that actually drag.
//
// WHAT A UNIT TEST CANNOT SEE. A peg is a `:group`, so it draws nothing, and
// both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the
// ops, and `handles` hangs its box, corners and knob off `pick/bounds-of`. So a
// peg was selectable only from a timeline row and had nothing to grab — the
// pivot that `gesture/refusal` tells you to make was unusable on the stage, and
// every assertion about the maths still passed. Hence this: the handles are
// asserted as DOM, with real pointer events through them.
//
// Uses an in-memory fixture and performs no server-side writes.
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
const profile = mkdtempSync(join(tmpdir(), 'arthur-peg-'));
const port = 9341;
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
], { stdio: 'ignore' });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let ws, id = 0;
const pending = new Map();
const send = (method, params) => new Promise(res => {
const n = ++id; pending.set(n, res);
ws.send(JSON.stringify({ id: n, method, params }));
});
const evalJS = async expr => {
const r = await send('Runtime.evaluate',
{ expression: `(function(){${expr}})()`, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
return r.result.value;
};
let failures = 0;
const ok = (cond, what, extra = '') => {
console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}${extra ? ' — ' + extra : ''}`);
if (!cond) failures++;
};
try {
let target;
for (let i = 0; i < 100 && !target; i++) {
await sleep(100);
try {
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
.find(t => t.type === 'page' && t.url.startsWith(url));
} catch {}
}
assert(target, 'editor page');
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r));
ws.addEventListener('message', e => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
});
await send('Runtime.enable');
await send('Page.enable');
for (let i = 0; i < 160; i++) {
if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break;
await sleep(250);
}
// Local route, a turned+scaled shape in :main, selected.
const setup = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector, m = c.hash_map;
c.swap_BANG_(re_frame.db.app_db, db => c.assoc(db, k('route'), k('local-test')));
const blank = arthur.domain.clip.blank();
let doc = arthur.domain.paint.new_shape(blank, k('main'), k('shape'), 0,
v(100, 80, 140, 80, 140, 110, 100, 110), k('brow'));
doc = c.update_in(doc, v(k('symbols'), k('main'), k('nodes'), k('shape'), k('channels')),
ch => c.merge(ch, m(v(k('xform'), k('rot')), arthur.domain.channel.framed(0.4),
v(k('xform'), k('scale')), arthur.domain.channel.framed(v(1.5, 0.75)))));
const entry = m(k('clip'), doc, k('store'), m());
const cid = arthur.footage.store.install_BANG_(entry, 'peg-probe');
c.swap_BANG_(re_frame.db.app_db, db =>
c.assoc(c.assoc_in(db, v(k('ui'), k('open')), k('main')),
k('clip/current'), cid, k('paint/revision'), 0));
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'),
v(k('node'), k('main'), k('shape'), v(k('shape')))));
return {cid: String(cid)};
`);
console.log('setup:', JSON.stringify(setup));
await sleep(600);
const before = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const res = arthur.domain.clip.resolver(c.get(e,k('clip')), k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
const ops = res.call(null, 0);
return Array.from(c.get(c.first(ops), k('pts')));
`);
ok(Array.isArray(before) && before.length === 8, 'the shape draws before the peg', JSON.stringify(before));
// Add the peg, exactly as the menu item does.
const made = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
re_frame.core.dispatch_sync(v(k('arthur.events.ui/peg'), v(k('shape'))));
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const shape = c.get(nodes, k('shape'));
const pegId = c.get(shape, k('parent'));
const peg = c.get(nodes, pegId);
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
const sel = c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection'))));
return {
kind: String(c.get(peg, k('kind'))),
pegPos: c.clj__GT_js(c.get_in(peg, v(k('channels'), v(k('xform'),k('pos')), k('value')))),
hasPinv: !!c.get(shape, k('pinv')),
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
selection: sel ? sel.map(String) : null,
problems: c.clj__GT_js(arthur.domain.clip.problems(doc)),
};
`);
ok(made.kind === ':group', 'the peg is a group', made.kind);
ok(made.hasPinv, 'the child got a :pinv');
ok((made.problems ?? []).length === 0, 'the document still validates', JSON.stringify(made.problems));
const moved = Math.max(...before.map((b, i) => Math.abs(b - made.pts[i])));
ok(moved < 1e-9, 'nothing moved when the peg appeared', `max drift ${moved}`);
ok(String(made.selection?.[2]) !== ':shape', 'the peg is selected afterwards', JSON.stringify(made.selection));
console.log('peg pos:', JSON.stringify(made.pegPos));
await sleep(700);
// THE BIT THAT UNIT TESTS CANNOT SEE: does the stage render grabbable handles?
const dom = await evalJS(`
const q = s => document.querySelector(s);
const g = q('.paint-overlay .handles');
const grab = q('.paint-overlay .handles .peg-grab');
const r = grab && grab.getBoundingClientRect();
return {
handles: !!g,
pegGrab: !!grab,
pegGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null,
knobs: document.querySelectorAll('.paint-overlay .handles .knob').length,
corners: document.querySelectorAll('.paint-overlay .handles .corner').length,
boxes: document.querySelectorAll('.paint-overlay .handles .box').length,
rect: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null,
};
`);
console.log('dom:', JSON.stringify(dom));
ok(dom.handles, 'the stage draws handles for the selected peg');
ok(dom.pegGrab, 'there is a .peg-grab to drag it by');
ok(dom.pegGrabEvents && dom.pegGrabEvents !== 'none', 'and it accepts pointer events', String(dom.pegGrabEvents));
ok(dom.knobs === 1, 'a turn knob', String(dom.knobs));
ok(dom.corners === 4, 'four scale corners', String(dom.corners));
ok(dom.boxes === 0, 'and no box, because a peg draws nothing', String(dom.boxes));
// Drag it, through real pointer events on that element.
if (dom.rect) {
const { x, y } = dom.rect;
for (const [type, dx] of [['mousePressed', 0], ['mouseMoved', 18], ['mouseMoved', 36], ['mouseReleased', 36]]) {
await send('Input.dispatchMouseEvent', {
type, x: x + dx, y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
});
await sleep(80);
}
await sleep(400);
const after = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))),
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts')))};
`);
const dx = after.pts[0] - made.pts[0];
const dy = after.pts[1] - made.pts[1];
console.log('after drag: peg pos', JSON.stringify(after.pos), 'child moved by', dx.toFixed(3), dy.toFixed(3));
ok(dx > 2, 'dragging the peg cross moved its child', `dx=${dx.toFixed(3)}`);
ok(Math.abs(dy) < 1.5, 'and only horizontally, as dragged', `dy=${dy.toFixed(3)}`);
}
console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, selected, and dragged on the stage');
} finally {
if (ws) ws.close();
chrome.kill();
try {
rmSync(profile, { recursive: true, force: true });
} catch (error) {
if (error.code !== 'ENOTEMPTY') throw error;
}
}
if (failures) process.exitCode = 1;

View file

@ -1425,6 +1425,8 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.paint-overlay .handles .knob { fill: #161820; stroke: #fff1be; stroke-width: 0.6; cursor: grab; } .paint-overlay .handles .knob { fill: #161820; stroke: #fff1be; stroke-width: 0.6; cursor: grab; }
.paint-overlay .handles .corner { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: nwse-resize; } .paint-overlay .handles .corner { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: nwse-resize; }
.paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; } .paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; }
/* A peg draws nothing, so the cross is the only thing to grab it by. */
.paint-overlay .handles .peg-grab { fill: transparent; stroke: none; cursor: move; }
.paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; } .paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; }
/* The creation-target outline and its name tag. Solid, no handles, and a tag pinned just /* The creation-target outline and its name tag. Solid, no handles, and a tag pinned just