diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 44c33afc0..f70201771 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -5178,6 +5178,7 @@ "src/render/stub/geodesic.js", "src/render/stub/viewRectangle.js", "src/render/stub/orbits.js", + "src/render/input/router.js", "src/render/conformance/goldens/camera.json", "src/render/conformance/checks/camera.js" ], diff --git a/src/render/input/router.js b/src/render/input/router.js new file mode 100644 index 000000000..c27cf65b2 --- /dev/null +++ b/src/render/input/router.js @@ -0,0 +1,244 @@ +/** + * @module render/input/router + * @description Who hears each pointer event: the contract's input rules + * (`src/render/contract/input.d.ts`) for any renderer. A renderer feeds it + * the events its engine sees, already shaped as `PointerEv` (its clicks with + * their pick); the router decides, from the leases and the presses under + * way, which listeners get each one, and keeps the input port's leases and + * behaviour holds. Engine-free. + */ + +const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= 4; + +/** + * @param {object} options + * @param {(error: unknown) => void} options.report Where a listener's or a + * `grab`'s error goes. + * @param {(reason: string) => () => void} options.suspendCamera The + * renderer's counted camera-input hold. + * @param {string[]} [options.behaviours] The stock behaviours the renderer + * has, beyond `select` and `dblclickFollow` (always listed). + * @param {(name: string, enabled: boolean) => void} [options.onBehaviour] + * Told when a behaviour turns off (first hold) or back on (last release). + * @param {(enabled: boolean) => void} [options.onCtrlWheelZoom] Told when + * `ctrlWheelZoom` is set. + * @param {Record} [options.supported] Behaviours the + * renderer cannot do, as `false`: listed, but reported disabled. + */ +export function createInputRouter({ + report, + suspendCamera, + behaviours: extra = [], + onBehaviour = () => {}, + onCtrlWheelZoom = () => {}, + supported = {}, +}) { + const listeners = []; + let currentLease = null; + + const holds = {}; + // The renderer hears a behaviour turn off and on only if it has it; its + // error is reported, and the hold stands either way. + const tell = (name, enabled) => { + if (supported[name] === false) return; + try { + onBehaviour(name, enabled); + } catch (error) { + report(error); + } + }; + function makeBehaviour(name) { + holds[name] = 0; + return Object.freeze({ + get enabled() { + return supported[name] !== false && holds[name] === 0; + }, + suppress() { + holds[name] += 1; + if (holds[name] === 1) tell(name, false); + let released = false; + return () => { + if (released) return; + released = true; + holds[name] -= 1; + if (holds[name] === 0) tell(name, true); + }; + }, + }); + } + const behaviours = Object.freeze( + Object.fromEntries( + ['select', 'dblclickFollow', ...extra].map((name) => [ + name, + makeBehaviour(name), + ]), + ), + ); + + /** Each lease's `grab`, or null when it takes every press. */ + const leaseGrab = new WeakMap(); + /** + * The presses under way, by button, from their `down`: `owner` is the + * lease each went to, or null when it went to everyone else. A press's + * click and double-click go where its down went (an ended owner's to no + * one). + */ + const presses = new Map(); + + const leaseOf = (entry) => + typeof entry.lease === 'function' ? entry.lease() : entry.lease; + + let ctrlWheelZoom = false; + const port = { + on(type, cb, opts = {}) { + const entry = { type, cb, lease: opts.lease ?? null, live: true }; + listeners.push(entry); + return () => { + entry.live = false; + const i = listeners.indexOf(entry); + if (i >= 0) listeners.splice(i, 1); + }; + }, + claimPointer(owner, opts = {}) { + if (currentLease) currentLease.release(); + let released = false; + const grab = typeof opts.grab === 'function' ? opts.grab : null; + const lease = { + owner: String(owner), + get current() { + return !released && currentLease === lease; + }, + release() { + if (released) return; + released = true; + if (currentLease === lease) currentLease = null; + }, + }; + currentLease = lease; + leaseGrab.set(lease, grab); + return lease; + }, + isPointerFree: () => currentLease === null, + suspendCameraControls: (reason) => suspendCamera(String(reason)), + behaviours, + get ctrlWheelZoom() { + return ctrlWheelZoom; + }, + set ctrlWheelZoom(value) { + ctrlWheelZoom = Boolean(value); + onCtrlWheelZoom(ctrlWheelZoom); + }, + }; + + /** Who a new press belongs to, decided from its `down`. */ + function pressFor(down) { + const lease = currentLease; + if (!lease) return { owner: null, clicked: false }; + const grab = leaseGrab.get(lease); + let grabbed = !grab; + if (grab) + try { + grabbed = grab(down) === true; + } catch (error) { + report(error); + } + return { owner: grabbed ? lease : null, clicked: false }; + } + + /** + * Where an event goes: to one lease's listeners (`lease`), to everyone + * without a lease (`ambient`), or both (`move` and `up`). + */ + function recipients(ev) { + const { type, button } = ev; + // A click comes straight after its press's up, and a double-click + // straight after that click, by the same button at the same spot; + // anything else after the up ends the press. + for (const [b, p] of presses) { + if (!p.up) continue; + const continues = + b === button && + near(p.at, ev.position) && + ((type === 'click' && !p.clicked) || + (type === 'dblclick' && p.clicked)); + if (!continues) presses.delete(b); + } + if (type === 'up' && presses.has(button)) { + const p = presses.get(button); + p.up = true; + p.at = ev.position; + } + if (type === 'move' || type === 'up') + return { lease: currentLease, ambient: true }; + if (type === 'wheel' || type === 'pinch') { + const exclusive = currentLease && !leaseGrab.get(currentLease); + return exclusive + ? { lease: currentLease, ambient: false } + : { lease: null, ambient: true }; + } + // `grab` sees a click with no press of its own as the down it stands + // for: no pick, no gesture. + const asDown = () => ({ + ...pressFor({ + type: 'down', + position: ev.position, + button: ev.button, + modifiers: ev.modifiers, + timestamp: ev.timestamp, + pointerType: ev.pointerType, + }), + up: true, + }); + let current; + if (type === 'down') { + current = pressFor(ev); + presses.set(button, current); + } else { + // Only a press the loop above kept, at the right step, continues; + // anything else is a press of its own and leaves a held one alone. + const held = presses.get(button); + const continues = + held?.up && (type === 'click' ? !held.clicked : held.clicked); + current = continues ? held : asDown(); + if (type === 'click' && (continues || !held)) { + current.clicked = true; + presses.set(button, current); + } else if (continues) presses.delete(button); + } + current.at = ev.position; + return current.owner + ? { lease: current.owner, ambient: false } + : { lease: null, ambient: true }; + } + + /** + * Delivers one event to the listeners it belongs to. + * @param {import('../contract/input').PointerEv} ev + * @returns {{lease: object|null, ambient: boolean}} Who it went to, so a + * renderer can leave a grabbed press out of its stock behaviours. A + * `move` or `up` always reads as going to both, grabbed press or not. + */ + function dispatch(ev) { + const to = recipients(ev); + for (const entry of listeners.slice()) { + if (!entry.live || entry.type !== ev.type) continue; + if (entry.lease) { + const lease = leaseOf(entry); + if (!to.lease || lease !== to.lease || !lease.current) continue; + } else if (!to.ambient) continue; + try { + entry.cb(ev); + } catch (error) { + report(error); + } + } + return to; + } + + return { + port, + dispatch, + /** Live listeners, for leak checks. */ + listenerCount: () => listeners.length, + }; +} diff --git a/src/render/input/router.test.mjs b/src/render/input/router.test.mjs new file mode 100644 index 000000000..f9cc23622 --- /dev/null +++ b/src/render/input/router.test.mjs @@ -0,0 +1,116 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createInputRouter } from './router.js'; + +const router = (options = {}) => + createInputRouter({ + report: (error) => { + throw error; + }, + suspendCamera: () => () => {}, + ...options, + }); + +const ev = (type, x = 10, y = 10, button = 0) => ({ + type, + position: { x, y }, + button, + modifiers: { shift: false, ctrl: false, alt: false, meta: false }, + timestamp: 0, + pointerType: 'mouse', +}); + +test('a behaviour turns off at its first hold and on at its last release', () => { + const told = []; + const { port } = router({ + onBehaviour: (name, enabled) => told.push([name, enabled]), + }); + const a = port.behaviours.select.suppress(); + const b = port.behaviours.select.suppress(); + assert.equal(port.behaviours.select.enabled, false); + a(); + a(); + assert.deepEqual(told, [['select', false]], 'one hold still out'); + b(); + assert.equal(port.behaviours.select.enabled, true); + assert.deepEqual(told, [ + ['select', false], + ['select', true], + ]); + assert.equal(port.behaviours.dblclickFollow.enabled, true, 'separate'); +}); + +test('extra behaviours are listed; unsupported ones read disabled', () => { + const { port } = router({ + behaviours: ['dblclickZoom'], + supported: { dblclickFollow: false }, + }); + assert.deepEqual(Object.keys(port.behaviours), [ + 'select', + 'dblclickFollow', + 'dblclickZoom', + ]); + assert.equal(port.behaviours.dblclickFollow.enabled, false); + assert.equal(port.behaviours.dblclickZoom.enabled, true); +}); + +test('an unsupported behaviour is never told; a throwing hook is reported', () => { + const told = []; + const errors = []; + const { port } = router({ + report: (error) => errors.push(error), + supported: { dblclickFollow: false }, + onBehaviour: (name, enabled) => { + told.push([name, enabled]); + if (name === 'select') throw new Error('hook'); + }, + }); + port.behaviours.dblclickFollow.suppress()(); + assert.deepEqual(told, []); + const release = port.behaviours.select.suppress(); + assert.equal(typeof release, 'function', 'the hold is still handed out'); + assert.equal(port.behaviours.select.enabled, false); + release(); + assert.equal(port.behaviours.select.enabled, true); + assert.equal(errors.length, 2); +}); + +test('ctrlWheelZoom is told to the renderer', () => { + const told = []; + const { port } = router({ onCtrlWheelZoom: (on) => told.push(on) }); + assert.equal(port.ctrlWheelZoom, false); + port.ctrlWheelZoom = 1; + assert.equal(port.ctrlWheelZoom, true); + assert.deepEqual(told, [true]); +}); + +test('dispatch says who an event went to', () => { + const { port, dispatch, listenerCount } = router(); + const off = port.on('down', () => {}); + assert.equal(listenerCount(), 1); + assert.deepEqual(dispatch(ev('down')), { lease: null, ambient: true }); + dispatch(ev('up')); + const lease = port.claimPointer('tool', { + grab: (down) => down.button === 2, + }); + assert.deepEqual(dispatch(ev('down')), { lease: null, ambient: true }); + dispatch(ev('up')); + assert.deepEqual(dispatch(ev('down', 10, 10, 2)), { + lease, + ambient: false, + }); + off(); + assert.equal(listenerCount(), 0); +}); + +test('camera holds go to the renderer, reason as a string', () => { + const reasons = []; + const { port } = router({ + suspendCamera: (reason) => { + reasons.push(reason); + return () => {}; + }, + }); + port.suspendCameraControls(7); + assert.deepEqual(reasons, ['7']); +}); diff --git a/src/render/noCesiumOutsideRender.test.mjs b/src/render/noCesiumOutsideRender.test.mjs index 9c75b12d8..67cda53b4 100644 --- a/src/render/noCesiumOutsideRender.test.mjs +++ b/src/render/noCesiumOutsideRender.test.mjs @@ -264,10 +264,10 @@ test('no runtime file is in a language the ratchet cannot read', () => { assert.deepEqual(unread, [], 'Teach the ratchet to read these first.'); }); -test('the renderer contract, stub and conformance suite stay Cesium-free', () => { +test('the renderer contract, stub, input router and conformance suite stay Cesium-free', () => { const engineFree = tracked.filter( (file) => - /^src\/render\/(?:contract|stub|conformance)\//.test(file) && + /^src\/render\/(?:contract|stub|input|conformance)\//.test(file) && /\.[cm]?js$/.test(file) && !/\.test\.[cm]?js$/.test(file), ); diff --git a/src/render/stub/index.js b/src/render/stub/index.js index ec77d45ad..7a90e4ec5 100644 --- a/src/render/stub/index.js +++ b/src/render/stub/index.js @@ -9,6 +9,7 @@ * Nothing runs on its own: `testing.tick()` and `testing.advance()` drive the * loop on a virtual clock, one frame at a time, in the contract's order. */ +import { createInputRouter } from '../input/router.js'; import { CAPABILITIES, CONTRACT_VERSION } from '../contract/index.js'; import { createStubCamera } from './camera.js'; import { motionPosition } from './motion.js'; @@ -715,164 +716,11 @@ export function createStubRenderer(options = {}) { // --- Input ----------------------------------------------------------------- - const pointerListeners = []; - let currentLease = null; - const cameraHolds = new Map(); - const behaviourHolds = { select: 0, dblclickFollow: 0 }; - - function makeBehaviour(name) { - return { - get enabled() { - return behaviourHolds[name] === 0; - }, - suppress() { - behaviourHolds[name] += 1; - let released = false; - return () => { - if (released) return; - released = true; - behaviourHolds[name] -= 1; - }; - }, - }; - } - - /** Each lease's `grab`, or null when it takes every press. */ - const leaseGrab = new WeakMap(); - /** - * The presses under way, by button, from their `down`: `owner` is the - * lease each went to, or null when it went to everyone else. A press's - * click and double-click go where its down went (an ended owner's to no - * one). - */ - const presses = new Map(); - - const leaseOf = (entry) => - typeof entry.lease === 'function' ? entry.lease() : entry.lease; - - const input = { - on(type, cb, opts = {}) { - const entry = { type, cb, lease: opts.lease ?? null, live: true }; - pointerListeners.push(entry); - return () => { - entry.live = false; - const i = pointerListeners.indexOf(entry); - if (i >= 0) pointerListeners.splice(i, 1); - }; - }, - claimPointer(owner, opts = {}) { - if (currentLease) currentLease.release(); - let released = false; - const grab = typeof opts.grab === 'function' ? opts.grab : null; - const lease = { - owner: String(owner), - get current() { - return !released && currentLease === lease; - }, - release() { - if (released) return; - released = true; - if (currentLease === lease) currentLease = null; - }, - }; - currentLease = lease; - leaseGrab.set(lease, grab); - return lease; - }, - isPointerFree: () => currentLease === null, - suspendCameraControls(reason) { - return camera.port.controls.suspend(String(reason)); - }, - behaviours: { - select: makeBehaviour('select'), - dblclickFollow: makeBehaviour('dblclickFollow'), - }, - ctrlWheelZoom: false, - }; - - /** Who a new press belongs to, decided from its `down`. */ - function pressFor(down) { - const lease = currentLease; - if (!lease) return { owner: null, clicked: false }; - const grab = leaseGrab.get(lease); - let grabbed = !grab; - if (grab) - try { - grabbed = grab(down) === true; - } catch (error) { - report(error); - } - return { owner: grabbed ? lease : null, clicked: false }; - } - - /** - * Where an event goes: to one lease's listeners (`lease`), to everyone - * without a lease (`ambient`), or both (`move` and `up`). - */ - function recipients(type, ev) { - const button = ev.button; - // A click comes straight after its press's up, and a double-click - // straight after that click, by the same button at the same spot; - // anything else after the up ends the press. - for (const [b, p] of presses) { - if (!p.up) continue; - const continues = - b === button && - ((type === 'click' && - !p.clicked && - Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4) || - (type === 'dblclick' && - p.clicked && - Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4)); - if (!continues) presses.delete(b); - } - if (type === 'up' && presses.has(button)) { - const p = presses.get(button); - p.up = true; - p.at = ev.position; - } - if (type === 'move' || type === 'up') - return { lease: currentLease, ambient: true }; - if (type === 'wheel' || type === 'pinch') { - const exclusive = currentLease && !leaseGrab.get(currentLease); - return exclusive - ? { lease: currentLease, ambient: false } - : { lease: null, ambient: true }; - } - // `grab` sees a click with no press of its own as the down it stands - // for: no pick, no gesture. - const asDown = () => ({ - ...pressFor({ - type: 'down', - position: ev.position, - button: ev.button, - modifiers: ev.modifiers, - timestamp: ev.timestamp, - pointerType: ev.pointerType, - }), - up: true, - }); - let current; - if (type === 'down') { - current = pressFor(ev); - presses.set(button, current); - } else { - // Only a press the loop above kept, at the right step, continues; - // anything else is a press of its own and leaves a held one alone. - const held = presses.get(button); - const continues = - held?.up && (type === 'click' ? !held.clicked : held.clicked); - current = continues ? held : asDown(); - if (type === 'click' && (continues || !held)) { - current.clicked = true; - presses.set(button, current); - } else if (continues) presses.delete(button); - } - current.at = ev.position; - return current.owner - ? { lease: current.owner, ambient: false } - : { lease: null, ambient: true }; - } + const router = createInputRouter({ + report, + suspendCamera: (reason) => camera.port.controls.suspend(reason), + }); + const input = router.port; function dispatchPointer(type, x, y, extra = {}) { let picked; @@ -900,19 +748,7 @@ export function createStubRenderer(options = {}) { opts.limit === undefined ? hits().slice() : hits().slice(0, opts.limit); ev.ground = () => rayEllipsoid(pickRay(x, y)); } - const to = recipients(type, ev); - for (const entry of pointerListeners.slice()) { - if (!entry.live || entry.type !== type) continue; - if (entry.lease) { - const lease = leaseOf(entry); - if (!to.lease || lease !== to.lease || !lease.current) continue; - } else if (!to.ambient) continue; - try { - entry.cb(ev); - } catch (error) { - report(error); - } - } + router.dispatch(ev); requestRender('input'); }