Merge pull request #390 from bilawalsidhu/refactor/visual-input

refactor(ui): extract application shortcuts and parameter controls
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-12 20:54:09 -07:00
commit 795ed68192
16 files changed
+696 -95

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Extract application shortcuts and shader-parameter controls into reusable UI
components, preserving inputs and cleaning up listeners on rebuild/disposal.
- Extract adaptive panel rail placement and measurement into reusable UI modules,
preserving obstacle clearance, responsive allocation, disclosure and scroll behavior.
+12
View File
@@ -231,3 +231,15 @@ or observers; construction/import does no work. Scheduling, preference writes,
share restoration and movement of controls between containers remain caller-owned.
Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain
compatible through re-exports.
## Visual input
`gods-eye-view/ui/input` exports `bindApplicationShortcuts` and
`createStyleParameters`. The shortcut binder owns one bubbling keydown listener
and receives the document, editing target and explicit action callbacks.
Parameter controls own only the supplied container's generated rows/listeners;
uniform metadata and read/write/change operations come from the caller.
Clearing permits reuse; destruction is final. Neither module imports the app,
renderer, persistence or services. The facade retains panel visibility, share
restore claims and render scheduling. Both controls are destroyed before the
facade's asynchronous teardown can yield.
+13
View File
@@ -1,5 +1,18 @@
# God's Eye View Current State
## Application shortcuts and shader parameter controls
`ui/input` supplies the bubbling application shortcut listener and generated
shader-parameter rows. Number/style keys, H/O/V/F/D/C actions, native form-control
typing and Escape behavior retain their existing mappings. Capture-phase
surfaces continue to arbitrate their own keyboard events first.
The UI facade retains shader values, share-restore authority, render requests,
search dismissal, visibility and Cockpit portal policy. Parameter rows preserve
labels, bounds, steps and precision. Rebuilding rows removes their previous
listeners; disposal removes shortcuts and parameter listeners synchronously
before asynchronous application teardown.
## Adaptive panel rail layout
`ui/layout` supplies the left/right rail layout passes, natural-height
+2 -1
View File
@@ -121,6 +121,7 @@
"./search": "./src/search/index.js",
"./ui/panels": "./src/ui/panelDisclosure.js",
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
"./ui/layout": "./src/ui/panelRails.js"
"./ui/layout": "./src/ui/panelRails.js",
"./ui/input": "./src/ui/visualInput.js"
}
}
+6 -1
View File
@@ -129,5 +129,10 @@
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs",
"src/panelStackLayout.js"
"src/panelStackLayout.js",
"src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js",
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs"
]
+9
View File
@@ -285,5 +285,14 @@
"src/panelStackLayout.js"
],
"external": []
},
"visual-input": {
"exports": ["./ui/input"],
"modules": [
"src/ui/visualInput.js",
"src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js"
],
"external": []
}
}
+21
View File
@@ -62,6 +62,27 @@ try {
await page.setRequestInterception(true);
const routeQaRequest = (request) => {
const url = new URL(request.url());
// These scenarios exercise Context lifecycle and keyboard ownership, not
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
// CelesTrak outages cannot invalidate an otherwise clean UI run.
if (url.origin === new URL(appUrl).origin
&& ['/api/celestrak/active', '/api/celestrak/starlink'].includes(url.pathname)) {
const dense = url.pathname.endsWith('/starlink');
request.respond({
status: 200,
contentType: 'text/plain',
body: (dense ? [
'STARLINK-1007',
'1 44713U 19074A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
'2 44713 53.0000 247.4627 0006703 130.5360 325.0288 15.06000000 12345',
] : [
'ISS (ZARYA)',
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49814310 12345',
]).join('\n') + '\n',
});
return;
}
// This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
// unrelated upstream terrain outage out of the rendered interaction gate.
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
+18 -4
View File
@@ -569,7 +569,13 @@ try {
trusted: event.isTrusted, detail: event.detail ?? null,
style: event.target.closest('.style-btn')?.dataset.style || null,
});
for (const type of ['keydown', 'keyup', 'click']) grid.addEventListener(type, record, true);
// A claimed hold deliberately blurs the button. Repeats and release then
// target the document body, so observe Space beyond the original grid.
const recordSpace = (event) => {
if (event.code === 'Space') record(event);
};
for (const type of ['keydown', 'keyup']) document.addEventListener(type, recordSpace, true);
grid.addEventListener('click', record, true);
manager.setStyle = function (...args) {
probe.activations.push(args[0]);
return originalSetStyle.apply(this, args);
@@ -597,6 +603,11 @@ try {
voiceBefore: probe.voiceBefore, voiceNow: voiceState(),
selectedStyle: manager.activeStyle, selectedMap: manager.mapStackController.getActiveId(),
focusedStyle: document.activeElement?.dataset.style || null,
holdObservation: {
pageFocused: document.hasFocus(), visibility: document.visibilityState,
timerPending: Boolean(voice.pushToTalkHoldTimer),
focusOwnerMatches: document.activeElement === voice.pushToTalkHoldFocusOwner,
},
});
probe.reset = () => {
probe.events.length = 0;
@@ -609,7 +620,8 @@ try {
manager.setStyle = originalSetStyle;
voice.start = originalVoiceStart;
observer.disconnect();
for (const type of ['keydown', 'keyup', 'click']) grid.removeEventListener(type, record, true);
for (const type of ['keydown', 'keyup']) document.removeEventListener(type, recordSpace, true);
grid.removeEventListener('click', record, true);
};
window.__qaStyleKeyProbe = probe;
});
@@ -638,9 +650,11 @@ try {
await page.keyboard.down('Space');
styleSpaceIsDown = true;
longSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
await new Promise((resolve) => setTimeout(resolve, 250));
// Use the browser's clock, like the production hold timer. Runner-side
// delays can finish while Chromium's timer is still pending under load.
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 250)));
await page.keyboard.down('Space'); // exercise repeat without resetting the hold deadline
await new Promise((resolve) => setTimeout(resolve, 400));
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 400)));
longSpaceHeld = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
await page.keyboard.up('Space');
styleSpaceIsDown = false;
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/** Browser acceptance for application shortcuts and generated parameter controls. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, passed) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures += 1;
};
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setStyle('normal');
document.activeElement?.blur();
});
await page.keyboard.press('2');
check(
'native number shortcut selects CRT',
await page.evaluate(
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
),
);
const initial = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager._sliderPanel.classList.remove('collapsed');
const slider = manager._sliderContainer.querySelector('input');
window.__qaOldSlider = slider;
const name = 'pixelation';
window.__qaParameter = {
name,
before: manager.stages.retro.uniforms[name],
};
slider.focus();
return {
before: Number(slider.value),
step: Number(slider.step),
label: slider.getAttribute('aria-label'),
};
});
check(
'generated range has an accessible label and a numeric step',
initial.label === 'Pixelation' && initial.step > 0,
);
await page.keyboard.press('ArrowRight');
const changed = await page.evaluate(() => {
const slider = window.__qaOldSlider;
const manager = window.__godsEyeView.styleManager;
return {
value: Number(slider.value),
readout: slider.nextElementSibling.textContent,
uniform: manager.stages.retro.uniforms[window.__qaParameter.name],
};
});
check(
'native range input changes the shader and formatted readout',
changed.value > initial.before &&
changed.uniform === changed.value &&
Number(changed.readout) === changed.value,
);
await page.keyboard.press('3');
check(
'focused range retains native input instead of changing style',
await page.evaluate(
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
),
);
check(
'replacing a style revokes its detached slider',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const before = manager.stages.retro.uniforms[window.__qaParameter.name];
manager.setStyle('thermal');
window.__qaOldSlider.value = '0';
window.__qaOldSlider.dispatchEvent(new Event('input', { bubbles: true }));
return (
manager.stages.retro.uniforms[window.__qaParameter.name] === before
);
}),
);
check(
'normal style clears rows; reopening restores current shader values',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setStyle('normal');
const cleared = manager._sliderContainer.children.length === 0;
manager.setStyle('retro');
return (
cleared &&
Number(manager._sliderContainer.querySelector('input').value) ===
manager.stages.retro.uniforms[window.__qaParameter.name]
);
}),
);
const hudBefore = await page.evaluate(() => {
document.getElementById('hud-layout-select').focus();
return window.__godsEyeView.styleManager.hud.visible;
});
await page.keyboard.press('h');
check(
'HUD select keeps its native typing and selection',
await page.evaluate(
(visible) =>
document.activeElement?.id === 'hud-layout-select' &&
window.__godsEyeView.styleManager.hud.visible === visible,
hudBefore,
),
);
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
await page.waitForFunction(
() =>
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
'mobile',
);
await page.evaluate(() => {
const slider =
window.__godsEyeView.styleManager._sliderContainer.querySelector('input');
slider.focus();
slider.scrollIntoView({ block: 'center', behavior: 'instant' });
});
await page.screenshot({ path: 'qa-shots/visual-input/narrow.png' });
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
window.__qaCurrentSlider = manager._sliderContainer.querySelector('input');
manager._applicationShortcuts.destroy();
manager._styleParameters.destroy();
document.activeElement?.blur();
});
await page.keyboard.press('3');
check(
'destroyed application shortcuts cannot change the selected style',
await page.evaluate(
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
),
);
check(
'destroyed parameter controls cannot write through a retained element',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const before = manager.stages.retro.uniforms[window.__qaParameter.name];
window.__qaCurrentSlider.value = '0';
window.__qaCurrentSlider.dispatchEvent(
new Event('input', { bubbles: true }),
);
return (
manager._sliderContainer.children.length === 0 &&
manager.stages.retro.uniforms[window.__qaParameter.name] === before
);
}),
);
check('runtime has no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+3 -3
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8');
// Focused markup guards; actual computed names are checked in Chromium.
// Native labels and hidden inputs must not be treated as missing aria-labels.
@@ -25,6 +25,6 @@ test('the first-run checkbox keeps its native visible label', () => {
});
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(ui, /label\.textContent\s*=\s*uMeta\.label;/);
assert.match(ui, /slider\.setAttribute\(['"]aria-label['"],\s*uMeta\.label\)/);
assert.match(parameters, /label\.textContent\s*=\s*metadata\.label;/);
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*metadata\.label\)/);
});
+12 -2
View File
@@ -417,8 +417,8 @@ test('newer visual, map, and individual panel actions suppress only their owned
test('every explicit visual UI gesture claims restore authority before it mutates state', () => {
const initUi = sourceBlock(' _initUI() {', ' _initMapStackControl() {');
const gestureRoutes = [
["if (e.key.toLowerCase() === 'h')", "if (e.key.toLowerCase() === 'o')", 'this.hud.toggle()', 'HUD hotkey'],
["if (e.key.toLowerCase() === 'd')", "if (e.key.toLowerCase() === 'c')", 'cycleDetectionMode()', 'detection hotkey'],
['toggleHud: () => {', 'toggleOrbit: () =>', 'this.hud.toggle()', 'HUD hotkey'],
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
['// Bloom toggle', '// Bloom intensity slider', 'this._setBloomEnabled(', 'bloom button'],
['// Bloom intensity slider', '// Sharpen toggle', 'this._setBloomIntensity(', 'bloom slider'],
['// Sharpen toggle', '// Scope mask', 'this._setSharpenEnabled(', 'sharpen button'],
@@ -695,3 +695,13 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
newerManager.destroy();
assert.equal(cancellations, 1, 'newer navigation must not be cancelled');
});
test('visual input listeners are revoked before asynchronous UI teardown', () => {
const disposal = uiSource.slice(uiSource.indexOf(' async dispose() {'));
const firstAwait = disposal.indexOf('await ');
assert.ok(firstAwait > 0);
const synchronous = disposal.slice(0, firstAwait);
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
});
+45 -84
View File
@@ -1,3 +1,4 @@
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
import * as Cesium from 'cesium';
@@ -3399,51 +3400,37 @@ export class StyleManager {
btn.addEventListener('click', () => this.setStyle(btn.dataset.style));
});
// Keyboard shortcuts: 1-7, H, Escape
this._globalKeydownHandler = (e) => {
// Ignore when interacting with a form control (except Escape). Global
// hotkeys ('1'-'7', 'h', 'o', 'v', 'd', 'c', 'f') otherwise fire while a
// <select> dropdown (e.g. HUD layout) is focused and its native
// type-ahead is in use, or while typing in a text field (M9).
const isFormControl = e.target?.matches?.('select, input, textarea')
|| e.target === this._locationSearch;
if (isFormControl && e.key !== 'Escape') return;
const keyMap = {
'1': 'normal', '2': 'retro', '3': 'surveillance',
'4': 'thermal', '5': 'anime', '6': 'noir',
'7': 'snow',
};
if (keyMap[e.key]) this.setStyle(keyMap[e.key]);
if (e.key === 'Escape') {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
}
if (e.key.toLowerCase() === 'h') {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.toggle();
this._updateHudButtonState();
this._syncShareState();
}
if (e.key.toLowerCase() === 'o') this._toggleOrbit();
if (e.key.toLowerCase() === 'v') this.toggleCleanView();
if (e.key.toLowerCase() === 'f') {
document.getElementById('data-panel').classList.toggle('active');
}
if (e.key.toLowerCase() === 'd') {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._detectionUserOverridden = true;
cycleDetectionMode();
this._syncShareState();
}
if (e.key.toLowerCase() === 'c') {
this._toggleCctvEnabled();
}
};
document.addEventListener('keydown', this._globalKeydownHandler);
this._applicationShortcuts?.destroy();
this._applicationShortcuts = bindApplicationShortcuts({
documentRef: document,
searchInput: this._locationSearch,
actions: {
setStyle: (style) => this.setStyle(style),
dismissSearch: () => {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
},
toggleHud: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.toggle();
this._updateHudButtonState();
this._syncShareState();
},
toggleOrbit: () => this._toggleOrbit(),
toggleCleanView: () => this.toggleCleanView(),
toggleLayers: () => document.getElementById('data-panel').classList.toggle('active'),
cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._detectionUserOverridden = true;
cycleDetectionMode();
this._syncShareState();
},
toggleCctv: () => this._toggleCctvEnabled(),
},
});
// Bloom toggle
this._bloomBtn.addEventListener('click', () => {
@@ -8507,7 +8494,8 @@ export class StyleManager {
* @returns {void}
*/
_updateSliderPanel(styleName, { reveal = false } = {}) {
this._sliderContainer.innerHTML = '';
this._styleParameters ||= createStyleParameters({ container: this._sliderContainer });
this._styleParameters.clear();
const shader = STYLES[styleName];
if (!shader || !shader.uniforms || styleName === 'normal') {
@@ -8516,44 +8504,19 @@ export class StyleManager {
return;
}
for (const [uName, uMeta] of Object.entries(shader.uniforms)) {
const row = document.createElement('div');
row.className = 'param-slider-row';
const label = document.createElement('span');
label.className = 'param-label';
label.textContent = uMeta.label;
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', uMeta.label);
slider.min = uMeta.min;
slider.max = uMeta.max;
slider.step = uMeta.max <= 1 ? '0.01' : '0.1';
slider.value = this.stages[styleName].uniforms[uName];
const valueDisplay = document.createElement('span');
valueDisplay.className = 'param-value';
valueDisplay.textContent = parseFloat(slider.value).toFixed(uMeta.max <= 1 ? 2 : 1);
slider.addEventListener('input', () => {
this._styleParameters.render({
uniforms: shader.uniforms,
readValue: (uName) => this.stages[styleName].uniforms[uName],
writeValue: (uName, val) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const val = parseFloat(slider.value);
this.stages[styleName].uniforms[uName] = val;
valueDisplay.textContent = val.toFixed(uMeta.max <= 1 ? 2 : 1);
// Uniform writes don't auto-render under the idle governor —
// without this the slider visibly does nothing until the next
// camera move (browser finding). (perf wave 2)
},
onChange: () => {
// Uniform writes need an explicit render under the idle governor.
governorRequestRender('style-param-slider');
this._syncShareState();
});
row.appendChild(label);
row.appendChild(slider);
row.appendChild(valueDisplay);
this._sliderContainer.appendChild(row);
}
},
});
this._sliderPanel.classList.add('active');
this._scheduleRightPanelLayout();
@@ -9726,6 +9689,8 @@ export class StyleManager {
this._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._disposed = true;
this._applicationShortcuts?.destroy();
this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy();
this._panelDisclosureControls = [];
this._hoverPanelControls?.forEach((control) => control.destroy());
@@ -9835,10 +9800,6 @@ export class StyleManager {
this._loadingVisibilityHandler = null;
}
this._stopLoadingFeedbackTicker();
if (this._globalKeydownHandler) {
document.removeEventListener('keydown', this._globalKeydownHandler);
this._globalKeydownHandler = null;
}
if (this._poiKeydownHandler) {
document.removeEventListener('keydown', this._poiKeydownHandler);
this._poiKeydownHandler = null;
+48
View File
@@ -0,0 +1,48 @@
const STYLE_KEYS = Object.freeze({
1: 'normal',
2: 'retro',
3: 'surveillance',
4: 'thermal',
5: 'anime',
6: 'noir',
7: 'snow',
});
/**
* Bind the application's existing bubbling keyboard shortcuts.
* Capture-phase surfaces keep first refusal. The caller owns each action and
* persistence; form controls keep native typing except for Escape.
* @param {object} options
* @param {Document} options.documentRef Keyboard event target.
* @param {HTMLElement} options.searchInput Additional editing target.
* @param {object} options.actions Existing application operations.
* @returns {{destroy: Function}} Synchronous, idempotent listener cleanup.
*/
export function bindApplicationShortcuts({
documentRef,
searchInput,
actions,
}) {
const onKeyDown = (event) => {
const isFormControl =
event.target?.matches?.('select, input, textarea') ||
event.target === searchInput;
if (isFormControl && event.key !== 'Escape') return;
if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]);
if (event.key === 'Escape') actions.dismissSearch();
const key = event.key.toLowerCase();
if (key === 'h') actions.toggleHud();
if (key === 'o') actions.toggleOrbit();
if (key === 'v') actions.toggleCleanView();
if (key === 'f') actions.toggleLayers();
if (key === 'd') actions.cycleDetection();
if (key === 'c') actions.toggleCctv();
};
documentRef.addEventListener('keydown', onKeyDown);
return {
destroy() {
documentRef.removeEventListener('keydown', onKeyDown);
},
};
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Own generated parameter rows and their input listeners.
* The caller supplies uniform metadata/value access and rendering/persistence
* callbacks. Replacing or destroying rows revokes all old input handlers.
* @param {object} options
* @param {HTMLElement} options.container Container owned by this component.
* @param {Document} [options.documentRef] Document used to construct rows.
* @returns {{render: Function, clear: Function, destroy: Function}}
*/
export function createStyleParameters({
container,
documentRef = container.ownerDocument,
}) {
let listeners = [];
let destroyed = false;
const clear = () => {
if (destroyed) return;
for (const [slider, listener] of listeners)
slider.removeEventListener('input', listener);
listeners = [];
container.replaceChildren();
};
return {
/** Render uniform metadata with readValue, writeValue and onChange callbacks. */
render({ uniforms, readValue, writeValue, onChange }) {
if (destroyed) return;
clear();
for (const [name, metadata] of Object.entries(uniforms)) {
const row = documentRef.createElement('div');
row.className = 'param-slider-row';
const label = documentRef.createElement('span');
label.className = 'param-label';
label.textContent = metadata.label;
const slider = documentRef.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', metadata.label);
slider.min = metadata.min;
slider.max = metadata.max;
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
slider.value = readValue(name);
const digits = metadata.max <= 1 ? 2 : 1;
const valueDisplay = documentRef.createElement('span');
valueDisplay.className = 'param-value';
valueDisplay.textContent = parseFloat(slider.value).toFixed(digits);
const onInput = () => {
const value = parseFloat(slider.value);
writeValue(name, value);
valueDisplay.textContent = value.toFixed(digits);
onChange();
};
slider.addEventListener('input', onInput);
listeners.push([slider, onInput]);
row.append(label, slider, valueDisplay);
container.appendChild(row);
}
},
clear,
destroy() {
if (destroyed) return;
clear();
destroyed = true;
},
};
}
+2
View File
@@ -0,0 +1,2 @@
export { bindApplicationShortcuts } from './applicationShortcuts.js';
export { createStyleParameters } from './styleParameters.js';
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
bindApplicationShortcuts,
createStyleParameters,
} from './visualInput.js';
class Element {
constructor(tagName = 'DIV', ownerDocument) {
this.tagName = tagName;
this.ownerDocument = ownerDocument;
this.children = [];
this.listeners = new Map();
this.attributes = new Map();
}
addEventListener(type, listener, capture) {
assert.notEqual(
capture,
true,
'these controls must not preempt capture-phase surfaces',
);
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
}
removeEventListener(type, listener) {
this.listeners.get(type)?.delete(listener);
}
emit(type, event = {}) {
for (const listener of [...(this.listeners.get(type) || [])])
listener(event);
}
setAttribute(name, value) {
this.attributes.set(name, value);
}
append(...children) {
this.children.push(...children);
}
appendChild(child) {
this.children.push(child);
}
replaceChildren(...children) {
this.children = children;
}
matches() {
return ['INPUT', 'SELECT', 'TEXTAREA'].includes(this.tagName);
}
}
function shortcuts() {
const documentRef = new Element();
const searchInput = new Element();
const calls = [];
const names = [
'setStyle',
'dismissSearch',
'toggleHud',
'toggleOrbit',
'toggleCleanView',
'toggleLayers',
'cycleDetection',
'toggleCctv',
];
const actions = Object.fromEntries(
names.map((name) => [name, (...args) => calls.push([name, ...args])]),
);
const controller = bindApplicationShortcuts({
documentRef,
searchInput,
actions,
});
const press = (key, target = new Element(), rest = {}) =>
documentRef.emit('keydown', { key, target, ...rest });
return { documentRef, searchInput, calls, controller, press };
}
test('number keys retain the seven style mappings', () => {
const f = shortcuts();
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space'])
f.press(key);
assert.deepEqual(
f.calls,
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map(
(style) => ['setStyle', style],
),
);
});
test('letter shortcuts retain uppercase handling and existing actions', () => {
const f = shortcuts();
for (const key of ['H', 'o', 'V', 'f', 'D', 'c']) f.press(key);
assert.deepEqual(
f.calls,
[
'toggleHud',
'toggleOrbit',
'toggleCleanView',
'toggleLayers',
'cycleDetection',
'toggleCctv',
].map((name) => [name]),
);
});
for (const tag of ['INPUT', 'SELECT', 'TEXTAREA']) {
test(`${tag} retains native editing, while Escape reaches search dismissal`, () => {
const f = shortcuts();
for (const key of ['1', 'h', 'o', 'v', 'f', 'd', 'c'])
f.press(key, new Element(tag));
assert.deepEqual(f.calls, []);
f.press('Escape', new Element(tag));
assert.deepEqual(f.calls, [['dismissSearch']]);
});
}
test('the supplied search target retains editing even without form markup', () => {
const f = shortcuts();
f.press('1', f.searchInput);
f.press('Escape', f.searchInput);
assert.deepEqual(f.calls, [['dismissSearch']]);
});
test('shortcut extraction does not change repeat or modifier policy', () => {
const f = shortcuts();
f.press('h', new Element(), { repeat: true, ctrlKey: true });
assert.deepEqual(f.calls, [['toggleHud']]);
});
test('destroy synchronously removes shortcuts and a replacement binds once', () => {
const f = shortcuts();
f.controller.destroy();
f.controller.destroy();
f.press('h');
assert.deepEqual(f.calls, []);
assert.equal(f.documentRef.listeners.get('keydown').size, 0);
const replacement = bindApplicationShortcuts({
documentRef: f.documentRef,
searchInput: f.searchInput,
actions: { toggleHud: () => f.calls.push('new') },
});
f.press('h');
assert.deepEqual(f.calls, ['new']);
replacement.destroy();
});
function parameters() {
const documentRef = {
createElement: (tag) => new Element(tag.toUpperCase(), documentRef),
};
const container = new Element('DIV', documentRef);
const controller = createStyleParameters({ container });
const values = { amount: 0.25, scale: 2 };
const uniforms = {
amount: { label: '<Amount>', min: 0, max: 1 },
scale: { label: 'Scale', min: 0, max: 10 },
};
const order = [];
const options = {
uniforms,
readValue: (name) => values[name],
writeValue: (name, value) => {
order.push(['write', name, value]);
values[name] = value;
},
onChange: () => {
order.push(['change', container.children[0]?.children[2].textContent]);
},
};
controller.render(options);
return { container, controller, values, options, order };
}
test('parameter rows preserve labels, bounds, precision and initial values', () => {
const f = parameters();
assert.equal(f.container.children.length, 2);
const [label, slider, value] = f.container.children[0].children;
assert.equal(label.textContent, '<Amount>');
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
assert.equal(slider.type, 'range');
assert.equal(slider.className, 'param-slider');
assert.equal(slider.min, 0);
assert.equal(slider.max, 1);
assert.equal(slider.step, '0.01');
assert.equal(value.textContent, '0.25');
assert.equal(f.container.children[1].children[1].step, '0.1');
assert.equal(f.container.children[1].children[2].textContent, '2.0');
assert.deepEqual(
f.order,
[],
'rendering must not write shader values or publish changes',
);
});
test('input writes numeric values, updates the readout, then requests a change', () => {
const f = parameters();
const slider = f.container.children[0].children[1];
slider.value = '0.75';
slider.emit('input');
assert.equal(f.values.amount, 0.75);
assert.deepEqual(f.order, [
['write', 'amount', 0.75],
['change', '0.75'],
]);
});
test('rebuilding the parameter panel revokes detached slider listeners', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.render({
...f.options,
uniforms: { scale: f.options.uniforms.scale },
});
oldSlider.value = '0.8';
oldSlider.emit('input');
assert.equal(f.values.amount, 0.25);
assert.equal(oldSlider.listeners.get('input').size, 0);
assert.equal(f.container.children.length, 1);
f.container.children[0].children[1].value = '3.5';
f.container.children[0].children[1].emit('input');
assert.equal(f.values.scale, 3.5);
});
test('clearing removes rows and listeners but allows another style', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.clear();
oldSlider.emit('input');
assert.equal(f.container.children.length, 0);
assert.deepEqual(f.order, []);
f.controller.render(f.options);
assert.equal(f.container.children.length, 2);
});
test('destroy is final and cannot clear a subsequent owner’s DOM', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.destroy();
const nextOwner = new Element();
f.container.appendChild(nextOwner);
f.controller.render(f.options);
f.controller.clear();
f.controller.destroy();
oldSlider.emit('input');
assert.deepEqual(f.container.children, [nextOwner]);
assert.deepEqual(f.order, []);
});