From 39c436584bff13e94070a75b0236e61ea9768323 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 5 Oct 2026 21:48:59 -0400 Subject: [PATCH] =?UTF-8?q?Place=20a=20peg's=20pivot=20with=20=E2=8C=83/?= =?UTF-8?q?=E2=8C=98=20rather=20than=20=E2=8C=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌥-drag does not survive the trip. Most Linux window managers grab Alt-drag to move the window, so the page never sees the pointer and the gesture is simply absent — on the machine it is absent from, with no error and nothing to find. Reported from one. ⌃ (⌘ on a Mac) now places the pivot, which is the modifier the rest of this stage already reaches for. ⌥ keeps working for anyone whose desktop leaves it alone; ⇧ is deliberately not it, since it means CONSTRAIN everywhere else here — uniform scale, 15° turn steps — and is what a snap to the child's corners will want when this drag grows one. The menu row says so, because a modifier nothing mentions is a modifier nobody finds: "add peg · ⌃/⌘-drag its cross to place the pivot". `peg.mjs` now asserts each modifier through real pointer events instead of reading the handler, which is the only way this class of bug shows up — all three place the pivot with a child drift of 0, and an unmodified drag still translates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD --- frontend/src/arthur/ui/stage.cljs | 15 +++- frontend/src/arthur/ui/topbar.cljs | 2 +- frontend/test/browser/peg.mjs | 115 ++++++++++++++++++++--------- 3 files changed, 93 insertions(+), 39 deletions(-) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 7518d0e..eb38d04 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -325,9 +325,20 @@ ;; to 8.8 from the middle, so the disc has to stop short of 5.2. [:circle.peg-grab {:cx px :cy py :r 5 - ;; ⌥ moves the PIVOT instead of the peg, which is the pan-behind + ;; ⌃/⌘ moves the PIVOT instead of the peg, which is the pan-behind ;; split: a plain drag writes `pos` and carries the children with ;; it, and that is a translate, not a new pivot. + ;; + ;; NOT ⌥, which this was and which does not survive the trip. Most + ;; Linux window managers grab Alt-drag to move the window, so the + ;; page never sees the pointer at all and the gesture is simply + ;; missing — on the machine it is missing from, with no error. ⌥ is + ;; still accepted for anyone whose desktop leaves it alone, and the + ;; command key is the one the rest of this stage already reaches for. + ;; + ;; ⇧ is deliberately NOT it: it means CONSTRAIN everywhere else + ;; here — uniform scale, 15° turn steps — and it is what a snap to + ;; the child's corners would want when this drag grows one. :on-pointer-down (fn [^js event] (.stopPropagation event) @@ -335,7 +346,7 @@ (let [svg (.-ownerSVGElement (.-currentTarget event)) p (xy svg event (:w ctx) (:h ctx))] (.setPointerCapture svg (.-pointerId event)) - (if (.-altKey event) + (if (or (.-ctrlKey event) (.-metaKey event) (.-altKey event)) (reset! repivot {:path path :p p}) (begin! ctx :move path p))))}] [:line.knob-arm {:x1 px :y1 (- py r) :x2 px :y2 (- py r 8)}] diff --git a/frontend/src/arthur/ui/topbar.cljs b/frontend/src/arthur/ui/topbar.cljs index 15c9235..5df3b15 100644 --- a/frontend/src/arthur/ui/topbar.cljs +++ b/frontend/src/arthur/ui/topbar.cljs @@ -95,7 +95,7 @@ ;; measured part by hand — which is what `gesture/refusal` tells ;; you to do, so it has to be reachable from somewhere. {:label "add peg" - :sub "A transform node over the selection, to pivot, turn and key from" + :sub "A transform node over the selection · ⌃/⌘-drag its cross to place the pivot" :disabled? (nil? one-node) :on-click #(rf/dispatch [::ui/peg one-node])}]}] [snapshots/view] diff --git a/frontend/test/browser/peg.mjs b/frontend/test/browser/peg.mjs index 6521d5e..517479b 100644 --- a/frontend/test/browser/peg.mjs +++ b/frontend/test/browser/peg.mjs @@ -201,45 +201,88 @@ try { String(dom2.cornerHit)); if (dom2.grab) { - const before2 = 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 res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')), - arthur.domain.palette.index_of, null); - return Array.from(c.get(c.first(res.call(null, 0)), k('pts'))); - `); + // Every modifier that has to place the pivot. CDP modifiers: 1=alt, 2=ctrl, + // 4=meta. ⌥ alone was the whole gesture once, and on a desktop whose window + // manager grabs Alt-drag the page never sees it — so each is asserted + // through real pointer events rather than by reading the handler. const { x, y } = dom2.grab; - const steps = [['mousePressed', 0, 0], ['mouseMoved', -40, 25], ['mouseMoved', -80, 50], - ['mouseReleased', -80, 50]]; - for (const [type, dx, dy] of steps) { - await send('Input.dispatchMouseEvent', { - type, x: x + dx, y: y + dy, button: 'left', buttons: 1, clickCount: 1, - pointerType: 'mouse', modifiers: 1, // alt - }); - await sleep(90); + let after2; + for (const [name, modifiers, sign] of [['alt', 1, -1], ['ctrl', 2, 1], ['meta', 4, -1]]) { + const pre = 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 {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))), + pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))}; + `); + // Where the cross is NOW, since the previous modifier already moved it. + const g = await evalJS(` + const q = document.querySelector('.paint-overlay .handles .peg-grab'); + const r = q && q.getBoundingClientRect(); + return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null; + `); + const dx = 30 * sign, dy = 20 * sign; + for (const [type, fx, fy] of [['mousePressed', 0, 0], ['mouseMoved', dx/2, dy/2], + ['mouseMoved', dx, dy], ['mouseReleased', dx, dy]]) { + await send('Input.dispatchMouseEvent', { + type, x: g.x + fx, y: g.y + fy, button: 'left', buttons: 1, clickCount: 1, + pointerType: 'mouse', modifiers, + }); + await sleep(90); + } + await sleep(450); + const post = 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 {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))), + pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))}; + `); + const drift = Math.max(...pre.pts.map((b, i) => Math.abs(b - post.pts[i]))); + const moved = Math.hypot(post.pos[0] - pre.pos[0], post.pos[1] - pre.pos[1]); + ok(drift < 1e-6, `${name}-drag places the pivot and moves NOTHING under the peg`, + `drift ${drift}`); + ok(moved > 2, `${name}-drag actually moved the pivot`, `by ${moved.toFixed(2)}`); + after2 = post; + } + // And a plain drag still translates, which is the other half of the split. + { + const g = await evalJS(` + const q = document.querySelector('.paint-overlay .handles .peg-grab'); + const r = q && q.getBoundingClientRect(); + return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null; + `); + const pre = after2; + for (const [type, fx] of [['mousePressed', 0], ['mouseMoved', 12], ['mouseReleased', 12]]) { + await send('Input.dispatchMouseEvent', { + type, x: g.x + fx, y: g.y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse', + }); + await sleep(90); + } + await sleep(450); + const post = 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); + return Array.from(c.get(c.first(res.call(null, 0)), k('pts'))); + `); + ok(Math.abs(post[0] - pre.pts[0]) > 2, 'and an UNMODIFIED drag still translates the child', + `dx=${(post[0] - pre.pts[0]).toFixed(2)}`); } - await sleep(500); - const after2 = 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'))), - refusal: c.clj__GT_js(c.get_in(db, v(k('ui'), k('refusal'))))}; - `); - const drift = Math.max(...before2.map((b, i) => Math.abs(b - after2.pts[i]))); - console.log('after alt-drag: peg pos', JSON.stringify(after2.pos), 'child drift', drift); - ok(drift < 1e-6, 'alt-dragging the cross moved NOTHING under the peg', `drift ${drift}`); - ok(Math.abs(after2.pos[0] - made.pegPos[0]) > 2, 'but the pivot did move', - `${JSON.stringify(made.pegPos)} -> ${JSON.stringify(after2.pos)}`); } + console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, moved, and have its pivot placed'); } finally { if (ws) ws.close();