diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 6237972..14598ac 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -266,10 +266,19 @@ 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. - AN INDICATOR AND NOT A HANDLE. There is nothing to drag it to: no pivot is - stored, so moving it could only mean something for the next drag and would be - gone by the one after. A pivot that has to persist is a peg, which is a node, - and is moved and keyed as any other node is." + THE CROSS IS NOT A HANDLE FOR THE PIVOT. There is nothing to drag it to: no + pivot is stored, so moving it could only mean something for the next drag and + would be gone by the one after. A pivot that has to persist is a peg. + + 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] (let [{:keys [world bounds]} @(rf/subscribe [::sub/selected-placement]) [_ _ _ path] @(rf/subscribe [::sub/selection]) @@ -287,7 +296,7 @@ (begin! ctx kind path (xy svg event (:w ctx) (:h ctx))))))] (when world [: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])) [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))) @@ -300,9 +309,25 @@ (for [[i [x y]] (map-indexed vector corners)] ^{:key i} [:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6 - :on-pointer-down (grab :scale)}]))])) - [:path.pivot {:d (str "M " (- px 3) " " py " H " (+ px 3) - " M " px " " (- py 3) " V " (+ py 3))}]]))) + :on-pointer-down (grab :scale)}]))]) + ;; A peg. Same three gestures, hung on nothing. + (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] (let [boxes (keep stage-bounds placements)] diff --git a/frontend/test/browser/peg.mjs b/frontend/test/browser/peg.mjs new file mode 100644 index 0000000..2bd1543 --- /dev/null +++ b/frontend/test/browser/peg.mjs @@ -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; diff --git a/static/arthur/app.css b/static/arthur/app.css index 4ae29f5..6a75457 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -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 .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; } +/* 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; } /* The creation-target outline and its name tag. Solid, no handles, and a tag pinned just