mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #391 from bilawalsidhu/refactor/display-controls
refactor(ui): give Display controls an explicit listener lifecycle
This commit is contained in:
12 files changed
+365
-167
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior.
|
||||||
|
|
||||||
- Extract application shortcuts and shader-parameter controls into reusable UI
|
- Extract application shortcuts and shader-parameter controls into reusable UI
|
||||||
components, preserving inputs and cleaning up listeners on rebuild/disposal.
|
components, preserving inputs and cleaning up listeners on rebuild/disposal.
|
||||||
|
|
||||||
|
|||||||
@@ -243,3 +243,10 @@ Clearing permits reuse; destruction is final. Neither module imports the app,
|
|||||||
renderer, persistence or services. The facade retains panel visibility, share
|
renderer, persistence or services. The facade retains panel visibility, share
|
||||||
restore claims and render scheduling. Both controls are destroyed before the
|
restore claims and render scheduling. Both controls are destroyed before the
|
||||||
facade's asynchronous teardown can yield.
|
facade's asynchronous teardown can yield.
|
||||||
|
|
||||||
|
## Display controls
|
||||||
|
|
||||||
|
`ui/display` owns Display button, selector and slider subscriptions. It receives
|
||||||
|
DOM elements and explicit actions, imports no application or effect singleton,
|
||||||
|
and releases every listener on destruction. Settings and rendering remain with
|
||||||
|
the caller.
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
## Display control ownership
|
||||||
|
|
||||||
|
Display button, selector and slider listeners have a single destroyable owner.
|
||||||
|
The application supplies actions and retains effect settings, restore claims and
|
||||||
|
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
||||||
|
Destroying the UI removes these listeners before asynchronous teardown.
|
||||||
|
|
||||||
## Application shortcuts and shader parameter controls
|
## Application shortcuts and shader parameter controls
|
||||||
|
|
||||||
`ui/input` supplies the bubbling application shortcut listener and generated
|
`ui/input` supplies the bubbling application shortcut listener and generated
|
||||||
|
|||||||
+2
-1
@@ -122,6 +122,7 @@
|
|||||||
"./ui/panels": "./src/ui/panelDisclosure.js",
|
"./ui/panels": "./src/ui/panelDisclosure.js",
|
||||||
"./ui/surfaces": "./src/ui/surfaceKeyboard.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"
|
"./ui/input": "./src/ui/visualInput.js",
|
||||||
|
"./ui/display": "./src/ui/displayControls.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,5 +134,7 @@
|
|||||||
"src/ui/styleParameters.js",
|
"src/ui/styleParameters.js",
|
||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/visualInput.test.mjs",
|
"src/ui/visualInput.test.mjs",
|
||||||
"scripts/qa-visual-input.mjs"
|
"scripts/qa-visual-input.mjs",
|
||||||
|
"src/ui/displayControls.js",
|
||||||
|
"src/ui/displayControls.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -294,5 +294,10 @@
|
|||||||
"src/ui/styleParameters.js"
|
"src/ui/styleParameters.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"display-controls": {
|
||||||
|
"exports": ["./ui/display"],
|
||||||
|
"modules": ["src/ui/displayControls.js"],
|
||||||
|
"external": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +140,49 @@ try {
|
|||||||
hudBefore,
|
hudBefore,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const displayBefore = await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
manager._setBloomEnabled(true);
|
||||||
|
manager._bloomSlider.value = '80';
|
||||||
|
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
manager._bloomSlider.focus();
|
||||||
|
return {
|
||||||
|
value: Number(manager._bloomSlider.value),
|
||||||
|
contrast: manager._bloomStage.uniforms.contrast,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
check(
|
||||||
|
'native Display slider updates effect state',
|
||||||
|
await page.evaluate((before) => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
return (
|
||||||
|
Number(manager._bloomSlider.value) > before.value &&
|
||||||
|
manager._bloomStage.uniforms.contrast < before.contrast
|
||||||
|
);
|
||||||
|
}, displayBefore),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Display buttons toggle current state once',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
const before = manager.bloomEnabled;
|
||||||
|
manager._bloomBtn.click();
|
||||||
|
const changed = manager.bloomEnabled !== before;
|
||||||
|
manager._bloomBtn.click();
|
||||||
|
return changed && manager.bloomEnabled === before;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Display rebind removes old listeners',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
manager._initUI();
|
||||||
|
const before = manager.bloomEnabled;
|
||||||
|
manager._bloomBtn.click();
|
||||||
|
return manager.bloomEnabled !== before;
|
||||||
|
}),
|
||||||
|
);
|
||||||
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
|
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
|
||||||
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
|
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
|
||||||
await page.setViewport({ width: 620, height: 900 });
|
await page.setViewport({ width: 620, height: 900 });
|
||||||
@@ -184,6 +227,22 @@ try {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
check(
|
||||||
|
'destroyed Display controls cannot change settings',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
manager._displayControls.destroy();
|
||||||
|
const before = manager.bloomEnabled;
|
||||||
|
const contrast = manager._bloomStage.uniforms.contrast;
|
||||||
|
manager._bloomBtn.click();
|
||||||
|
manager._bloomSlider.value = '15';
|
||||||
|
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
return (
|
||||||
|
manager.bloomEnabled === before &&
|
||||||
|
manager._bloomStage.uniforms.contrast === contrast
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
check('runtime has no uncaught browser errors', errors.length === 0);
|
check('runtime has no uncaught browser errors', errors.length === 0);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ test('detection-on-by-default is a default, not an operator override', () => {
|
|||||||
// preset consults the flag, and the detection button sets it.
|
// preset consults the flag, and the detection button sets it.
|
||||||
assert.match(uiSource, /if \(preset\.detection && !this\._detectionUserOverridden\) \{/,
|
assert.match(uiSource, /if \(preset\.detection && !this\._detectionUserOverridden\) \{/,
|
||||||
'a style preset still yields to an operator who changed detection by hand');
|
'a style preset still yields to an operator who changed detection by hand');
|
||||||
const detectionButton = uiBlock("this._detectionBtn.addEventListener('click'", 'cycleDetectionMode()');
|
const displayActions = uiSource.slice(uiSource.indexOf('this._displayControls ='));
|
||||||
|
const detectionButton = displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:'));
|
||||||
|
assert.match(detectionButton, /cycleDetectionMode\(\)/, 'the button still invokes the detection action');
|
||||||
assert.match(detectionButton, /this\._detectionUserOverridden = true;/,
|
assert.match(detectionButton, /this\._detectionUserOverridden = true;/,
|
||||||
'and the detection control still claims the override when the operator uses it');
|
'and the detection control still claims the override when the operator uses it');
|
||||||
|
|
||||||
|
|||||||
@@ -419,16 +419,16 @@ test('every explicit visual UI gesture claims restore authority before it mutate
|
|||||||
const gestureRoutes = [
|
const gestureRoutes = [
|
||||||
['toggleHud: () => {', 'toggleOrbit: () =>', 'this.hud.toggle()', 'HUD hotkey'],
|
['toggleHud: () => {', 'toggleOrbit: () =>', 'this.hud.toggle()', 'HUD hotkey'],
|
||||||
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
|
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
|
||||||
['// Bloom toggle', '// Bloom intensity slider', 'this._setBloomEnabled(', 'bloom button'],
|
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
|
||||||
['// Bloom intensity slider', '// Sharpen toggle', 'this._setBloomIntensity(', 'bloom slider'],
|
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
|
||||||
['// Sharpen toggle', '// Scope mask', 'this._setSharpenEnabled(', 'sharpen button'],
|
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
|
||||||
["this._scopeBtn?.addEventListener('click'", "this._scopeFeatherSlider?.addEventListener('input'", 'setScopeMaskEnabled(', 'scope button'],
|
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'],
|
||||||
["this._scopeFeatherSlider?.addEventListener('input'", 'if (this._sharpenSlider)', 'setScopeMaskFeather(', 'scope feather slider'],
|
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
|
||||||
["this._sharpenSlider.addEventListener('input'", 'if (this._hudLayoutSelect)', 'this._applySharpenIntensity(', 'sharpen slider'],
|
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
|
||||||
["this._hudLayoutSelect.addEventListener('change'", 'if (this._cleanViewBtn)', 'this._setHudVariant(', 'HUD layout select'],
|
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
|
||||||
["this._detectionDensitySlider.addEventListener('input'", 'for (const button of this._detectionAllocationBtns)', 'this._applyDetectionDensityFromUi()', 'detection density slider'],
|
['setDensity:', 'setAllocation:', 'this._applyDetectionDensityFromUi()', 'setDensity control'],
|
||||||
["button.addEventListener('click'", 'for (const slider of [this._detectionFadeSlider', 'this._setDetectionAllocation(', 'detection allocation button'],
|
['setAllocation:', 'setFade:', 'this._setDetectionAllocation(', 'setAllocation control'],
|
||||||
["slider?.addEventListener('input'", 'if (this._celestialBtn)', 'this._applyDetectionFadeFromUi()', 'detection fade controls'],
|
['setFade:', 'toggleCelestial:', 'this._applyDetectionFadeFromUi()', 'setFade control'],
|
||||||
];
|
];
|
||||||
for (const [start, end, mutation, label] of gestureRoutes) {
|
for (const [start, end, mutation, label] of gestureRoutes) {
|
||||||
const startIndex = initUi.indexOf(start);
|
const startIndex = initUi.indexOf(start);
|
||||||
@@ -437,23 +437,10 @@ test('every explicit visual UI gesture claims restore authority before it mutate
|
|||||||
assertClaimsBefore(initUi.slice(startIndex, endIndex), mutation, label);
|
assertClaimsBefore(initUi.slice(startIndex, endIndex), mutation, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hudToggle = sourceBlock(' _initHUDToggle() {', ' _initCockpitDisplayPortal() {');
|
const displayActions = initUi.slice(initUi.indexOf('this._displayControls ='));
|
||||||
assertClaimsBefore(
|
assertClaimsBefore(displayActions.slice(displayActions.indexOf('toggleHud:'), displayActions.indexOf('cycleDetection:')), 'this.hud.toggle()', 'HUD button');
|
||||||
hudToggle.slice(
|
assertClaimsBefore(displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:')), 'cycleDetectionMode()', 'detection button');
|
||||||
hudToggle.indexOf("this._hudBtn.addEventListener('click'"),
|
|
||||||
hudToggle.indexOf('if (this._hudLayoutSelect)'),
|
|
||||||
),
|
|
||||||
'this.hud.toggle()',
|
|
||||||
'HUD button',
|
|
||||||
);
|
|
||||||
assertClaimsBefore(
|
|
||||||
hudToggle.slice(
|
|
||||||
hudToggle.indexOf("this._detectionBtn.addEventListener('click'"),
|
|
||||||
hudToggle.indexOf('this._cockpitDisplayToggleBtn'),
|
|
||||||
),
|
|
||||||
'cycleDetectionMode()',
|
|
||||||
'detection button',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Contacts OWNS detection while it is active (forced Dense @ 75%). That makes
|
// Contacts OWNS detection while it is active (forced Dense @ 75%). That makes
|
||||||
@@ -703,5 +690,6 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
|||||||
assert.ok(firstAwait > 0);
|
assert.ok(firstAwait > 0);
|
||||||
const synchronous = disposal.slice(0, firstAwait);
|
const synchronous = disposal.slice(0, firstAwait);
|
||||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||||
|
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindDisplayControls } from './ui/displayControls.js';
|
||||||
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
|
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
|
||||||
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
|
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
|
||||||
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
||||||
@@ -3395,11 +3396,6 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_initUI() {
|
_initUI() {
|
||||||
// Style buttons
|
|
||||||
document.querySelectorAll('.style-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => this.setStyle(btn.dataset.style));
|
|
||||||
});
|
|
||||||
|
|
||||||
this._applicationShortcuts?.destroy();
|
this._applicationShortcuts?.destroy();
|
||||||
this._applicationShortcuts = bindApplicationShortcuts({
|
this._applicationShortcuts = bindApplicationShortcuts({
|
||||||
documentRef: document,
|
documentRef: document,
|
||||||
@@ -3432,108 +3428,109 @@ export class StyleManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bloom toggle
|
this._displayControls?.destroy();
|
||||||
this._bloomBtn.addEventListener('click', () => {
|
this._displayControls = bindDisplayControls({
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
elements: {
|
||||||
this._setBloomEnabled(!this.bloomEnabled);
|
styleButtons: document.querySelectorAll('.style-btn'),
|
||||||
|
bloomButton: this._bloomBtn, bloomSlider: this._bloomSlider,
|
||||||
|
sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider,
|
||||||
|
scopeButton: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider,
|
||||||
|
hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn,
|
||||||
|
cleanViewButton: this._cleanViewBtn, cleanViewExitButton: this._cleanViewExitBtn,
|
||||||
|
densitySlider: this._detectionDensitySlider, detectionButton: this._detectionBtn,
|
||||||
|
allocationButtons: this._detectionAllocationBtns,
|
||||||
|
fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider],
|
||||||
|
celestialButton: this._celestialBtn,
|
||||||
|
modelsButton: this._models3dBtn,
|
||||||
|
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setStyle: (style) => this.setStyle(style),
|
||||||
|
toggleBloom: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setBloomEnabled(!this.bloomEnabled);
|
||||||
|
},
|
||||||
|
setBloomIntensity: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setBloomIntensity(value);
|
||||||
|
},
|
||||||
|
toggleSharpen: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||||
|
},
|
||||||
|
toggleScope: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
const next = !isScopeMaskEnabled();
|
||||||
|
setScopeMaskEnabled(next);
|
||||||
|
this._scopeBtn.classList.toggle('active', next);
|
||||||
|
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setScopeFeather: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
const pct = Math.max(0, Math.min(100, value || 0));
|
||||||
|
if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`;
|
||||||
|
setScopeMaskFeather(pct / 100);
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setSharpenIntensity: (pct) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`;
|
||||||
|
this._applySharpenIntensity(pct / 100);
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setHudLayout: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setHudVariant(value);
|
||||||
|
},
|
||||||
|
toggleCleanView: () => this.toggleCleanView(),
|
||||||
|
exitCleanView: () => this.toggleCleanView(false),
|
||||||
|
setDensity: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._detectionUserOverridden = true;
|
||||||
|
const pct = canonicalizeDensity(value);
|
||||||
|
this._detectionDensitySlider.value = String(pct);
|
||||||
|
if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`;
|
||||||
|
this._applyDetectionDensityFromUi();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setAllocation: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._detectionUserOverridden = true;
|
||||||
|
this._setDetectionAllocation(value);
|
||||||
|
},
|
||||||
|
setFade: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._applyDetectionFadeFromUi();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
toggleCelestial: () => {
|
||||||
|
const ringIsVisible = !!this.celestialRing?.visible;
|
||||||
|
if (!this.celestialRingEnabled || !ringIsVisible) {
|
||||||
|
this.setCelestialRingEnabled(true, { focus: true });
|
||||||
|
} else {
|
||||||
|
this.setCelestialRingEnabled(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleHud: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this.hud.toggle();
|
||||||
|
this._updateHudButtonState();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
cycleDetection: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._detectionUserOverridden = true;
|
||||||
|
cycleDetectionMode();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
toggleModels: () => {
|
||||||
|
this._setModels3dEnabled(!this._models3dEnabled);
|
||||||
|
this._syncModels3dModeRow();
|
||||||
|
},
|
||||||
|
setModelsMode: (mode) => this._setModels3dMode(mode),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bloom intensity slider
|
|
||||||
this._bloomSlider.addEventListener('input', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this._setBloomIntensity(parseInt(this._bloomSlider.value, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sharpen toggle
|
|
||||||
this._sharpenBtn.addEventListener('click', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scope mask — the explicit circular viewport treatment (owner ask:
|
|
||||||
// standalone toggle + featherable edge; see src/scopeMask.js).
|
|
||||||
this._scopeBtn?.addEventListener('click', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
const next = !isScopeMaskEnabled();
|
|
||||||
setScopeMaskEnabled(next);
|
|
||||||
this._scopeBtn.classList.toggle('active', next);
|
|
||||||
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
|
||||||
this._syncShareState();
|
|
||||||
});
|
|
||||||
this._scopeFeatherSlider?.addEventListener('input', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
const pct = Math.max(0, Math.min(100, parseInt(this._scopeFeatherSlider.value, 10) || 0));
|
|
||||||
if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`;
|
|
||||||
setScopeMaskFeather(pct / 100);
|
|
||||||
this._syncShareState();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this._sharpenSlider) {
|
|
||||||
this._sharpenSlider.addEventListener('input', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
const pct = parseInt(this._sharpenSlider.value, 10);
|
|
||||||
if (this._sharpenSliderValue) {
|
|
||||||
this._sharpenSliderValue.textContent = `${pct}%`;
|
|
||||||
}
|
|
||||||
this._applySharpenIntensity(pct / 100);
|
|
||||||
this._syncShareState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._hudLayoutSelect) {
|
|
||||||
this._hudLayoutSelect.addEventListener('change', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this._setHudVariant(this._hudLayoutSelect.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._cleanViewBtn) {
|
|
||||||
this._cleanViewBtn.addEventListener('click', () => this.toggleCleanView());
|
|
||||||
}
|
|
||||||
if (this._cleanViewExitBtn) {
|
|
||||||
this._cleanViewExitBtn.addEventListener('click', () => this.toggleCleanView(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._detectionDensitySlider) {
|
|
||||||
this._detectionDensitySlider.addEventListener('input', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this._detectionUserOverridden = true;
|
|
||||||
const pct = canonicalizeDensity(this._detectionDensitySlider.value);
|
|
||||||
this._detectionDensitySlider.value = String(pct);
|
|
||||||
if (this._detectionDensityValue) {
|
|
||||||
this._detectionDensityValue.textContent = `${pct}%`;
|
|
||||||
}
|
|
||||||
this._applyDetectionDensityFromUi();
|
|
||||||
this._syncShareState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const button of this._detectionAllocationBtns) {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this._detectionUserOverridden = true;
|
|
||||||
this._setDetectionAllocation(button.dataset.allocation);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const slider of [this._detectionFadeSlider, this._detectionOpacitySlider]) {
|
|
||||||
slider?.addEventListener('input', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this._applyDetectionFadeFromUi();
|
|
||||||
this._syncShareState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._celestialBtn) {
|
|
||||||
this._celestialBtn.addEventListener('click', () => {
|
|
||||||
const ringIsVisible = !!this.celestialRing?.visible;
|
|
||||||
if (!this.celestialRingEnabled || !ringIsVisible) {
|
|
||||||
this.setCelestialRingEnabled(true, { focus: true });
|
|
||||||
} else {
|
|
||||||
this.setCelestialRingEnabled(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9395,26 +9392,15 @@ export class StyleManager {
|
|||||||
this._layoutRightPanels();
|
this._layoutRightPanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_syncModels3dModeRow() {
|
||||||
|
if (this._models3dModeRow) this._models3dModeRow.classList.toggle('visible', this._models3dEnabled);
|
||||||
|
this._layoutRightPanels();
|
||||||
|
}
|
||||||
|
|
||||||
_initModels3dToggle() {
|
_initModels3dToggle() {
|
||||||
if (!this._models3dBtn) return;
|
if (!this._models3dBtn) return;
|
||||||
// The Proximity/All mode row is revealed only while 3D is on (mirrors the DETECT slider row).
|
|
||||||
const syncModeRow = () => {
|
|
||||||
if (this._models3dModeRow) this._models3dModeRow.classList.toggle('visible', this._models3dEnabled);
|
|
||||||
this._layoutRightPanels();
|
|
||||||
};
|
|
||||||
this._models3dBtn.addEventListener('click', () => {
|
|
||||||
this._setModels3dEnabled(!this._models3dEnabled);
|
|
||||||
syncModeRow();
|
|
||||||
});
|
|
||||||
for (const btn of this._models3dModeBtns) {
|
|
||||||
if (!btn) continue;
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const mode = btn.dataset.mode === 'all' ? 'all' : 'proximity';
|
|
||||||
this._setModels3dMode(mode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this._syncModels3dButtonState();
|
this._syncModels3dButtonState();
|
||||||
syncModeRow();
|
this._syncModels3dModeRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
_setModels3dEnabled(enabled) {
|
_setModels3dEnabled(enabled) {
|
||||||
@@ -9447,13 +9433,6 @@ export class StyleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_initHUDToggle() {
|
_initHUDToggle() {
|
||||||
this._hudBtn.addEventListener('click', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this.hud.toggle();
|
|
||||||
this._updateHudButtonState();
|
|
||||||
this._syncShareState();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this._hudLayoutSelect) {
|
if (this._hudLayoutSelect) {
|
||||||
this._hudLayoutSelect.value = 'tactical';
|
this._hudLayoutSelect.value = 'tactical';
|
||||||
}
|
}
|
||||||
@@ -9461,13 +9440,6 @@ export class StyleManager {
|
|||||||
this.hud.setMode('on');
|
this.hud.setMode('on');
|
||||||
this._updateHudButtonState();
|
this._updateHudButtonState();
|
||||||
|
|
||||||
// Detection toggle button
|
|
||||||
this._detectionBtn.addEventListener('click', () => {
|
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
||||||
this._detectionUserOverridden = true;
|
|
||||||
cycleDetectionMode();
|
|
||||||
this._syncShareState();
|
|
||||||
});
|
|
||||||
this._cockpitDisplayToggleBtn?.addEventListener('click', () => {
|
this._cockpitDisplayToggleBtn?.addEventListener('click', () => {
|
||||||
const open = this._cockpitDisplayToggleBtn.getAttribute('aria-expanded') === 'true';
|
const open = this._cockpitDisplayToggleBtn.getAttribute('aria-expanded') === 'true';
|
||||||
this._setCockpitDisclosure?.('display', !open);
|
this._setCockpitDisclosure?.('display', !open);
|
||||||
@@ -9690,6 +9662,7 @@ export class StyleManager {
|
|||||||
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
|
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
|
||||||
this._disposed = true;
|
this._disposed = true;
|
||||||
this._applicationShortcuts?.destroy();
|
this._applicationShortcuts?.destroy();
|
||||||
|
this._displayControls?.destroy();
|
||||||
this._styleParameters?.destroy();
|
this._styleParameters?.destroy();
|
||||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||||
this._panelDisclosureControls = [];
|
this._panelDisclosureControls = [];
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Bind Display controls to application actions. Inputs remain native DOM controls;
|
||||||
|
* the caller owns settings and effect state. Destroy before replacing the view.
|
||||||
|
* @param {{ elements: object, actions: object }} options
|
||||||
|
* @returns {{ destroy(): void }}
|
||||||
|
*/
|
||||||
|
export function bindDisplayControls({ elements, actions }) {
|
||||||
|
const removers = [];
|
||||||
|
const listen = (element, type, action, read) => {
|
||||||
|
if (!element) return;
|
||||||
|
const handler = () => actions[action](...(read ? [read(element)] : []));
|
||||||
|
element.addEventListener(type, handler);
|
||||||
|
removers.push(() => element.removeEventListener(type, handler));
|
||||||
|
};
|
||||||
|
const integer = (element) => parseInt(element.value, 10);
|
||||||
|
for (const [name, action] of [
|
||||||
|
['bloomButton', 'toggleBloom'],
|
||||||
|
['sharpenButton', 'toggleSharpen'],
|
||||||
|
['scopeButton', 'toggleScope'],
|
||||||
|
['cleanViewButton', 'toggleCleanView'],
|
||||||
|
['cleanViewExitButton', 'exitCleanView'],
|
||||||
|
['celestialButton', 'toggleCelestial'],
|
||||||
|
['hudButton', 'toggleHud'],
|
||||||
|
['detectionButton', 'cycleDetection'],
|
||||||
|
['modelsButton', 'toggleModels'],
|
||||||
|
])
|
||||||
|
listen(elements[name], 'click', action);
|
||||||
|
for (const [name, action] of [
|
||||||
|
['bloomSlider', 'setBloomIntensity'],
|
||||||
|
['sharpenSlider', 'setSharpenIntensity'],
|
||||||
|
['scopeFeatherSlider', 'setScopeFeather'],
|
||||||
|
])
|
||||||
|
listen(elements[name], 'input', action, integer);
|
||||||
|
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
||||||
|
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
||||||
|
for (const el of elements.styleButtons || [])
|
||||||
|
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
||||||
|
for (const el of elements.allocationButtons || [])
|
||||||
|
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
|
||||||
|
for (const el of elements.modelModeButtons || [])
|
||||||
|
listen(el, 'click', 'setModelsMode', (el) =>
|
||||||
|
el.dataset.mode === 'all' ? 'all' : 'proximity',
|
||||||
|
);
|
||||||
|
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
for (const remove of removers.splice(0)) remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { bindDisplayControls } from './displayControls.js';
|
||||||
|
|
||||||
|
function element(value = '') {
|
||||||
|
const el = new EventTarget();
|
||||||
|
el.value = value;
|
||||||
|
el.dataset = {};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('controls read current values without preventing native input behavior', () => {
|
||||||
|
const bloomSlider = element('24');
|
||||||
|
const hudLayout = element('tactical');
|
||||||
|
const calls = [];
|
||||||
|
const control = bindDisplayControls({
|
||||||
|
elements: { bloomSlider, hudLayout },
|
||||||
|
actions: {
|
||||||
|
setBloomIntensity: (value) => calls.push(value),
|
||||||
|
setHudLayout: (value) => calls.push(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const event = new Event('input', { cancelable: true });
|
||||||
|
bloomSlider.dispatchEvent(event);
|
||||||
|
bloomSlider.value = '37';
|
||||||
|
bloomSlider.dispatchEvent(new Event('input'));
|
||||||
|
hudLayout.value = 'minimal';
|
||||||
|
hudLayout.dispatchEvent(new Event('change'));
|
||||||
|
assert.deepEqual(calls, [24, 37, 'minimal']);
|
||||||
|
assert.equal(event.defaultPrevented, false);
|
||||||
|
control.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroyed and replaced controls cannot issue stale actions', () => {
|
||||||
|
const bloomButton = element();
|
||||||
|
let oldCalls = 0;
|
||||||
|
let newCalls = 0;
|
||||||
|
const first = bindDisplayControls({
|
||||||
|
elements: { bloomButton },
|
||||||
|
actions: { toggleBloom: () => oldCalls++ },
|
||||||
|
});
|
||||||
|
bloomButton.dispatchEvent(new Event('click'));
|
||||||
|
first.destroy();
|
||||||
|
const second = bindDisplayControls({
|
||||||
|
elements: { bloomButton },
|
||||||
|
actions: { toggleBloom: () => newCalls++ },
|
||||||
|
});
|
||||||
|
first.destroy();
|
||||||
|
bloomButton.dispatchEvent(new Event('click'));
|
||||||
|
assert.equal(oldCalls, 1);
|
||||||
|
assert.equal(newCalls, 1);
|
||||||
|
second.destroy();
|
||||||
|
bloomButton.dispatchEvent(new Event('click'));
|
||||||
|
assert.equal(newCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style, allocation and model choices retain their current data attributes', () => {
|
||||||
|
const style = element();
|
||||||
|
const allocation = element();
|
||||||
|
const mode = element();
|
||||||
|
style.dataset.style = 'thermal';
|
||||||
|
allocation.dataset.allocation = 'balanced';
|
||||||
|
mode.dataset.mode = 'all';
|
||||||
|
const calls = [];
|
||||||
|
const control = bindDisplayControls({
|
||||||
|
elements: {
|
||||||
|
styleButtons: [style],
|
||||||
|
allocationButtons: [allocation],
|
||||||
|
modelModeButtons: [mode],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setStyle: (value) => calls.push(value),
|
||||||
|
setAllocation: (value) => calls.push(value),
|
||||||
|
setModelsMode: (value) => calls.push(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const el of [style, allocation, mode])
|
||||||
|
el.dispatchEvent(new Event('click'));
|
||||||
|
mode.dataset.mode = 'unknown';
|
||||||
|
mode.dispatchEvent(new Event('click'));
|
||||||
|
assert.deepEqual(calls, ['thermal', 'balanced', 'all', 'proximity']);
|
||||||
|
control.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optional controls are absent safely and subscriptions stay instance-owned', () => {
|
||||||
|
const calls = [];
|
||||||
|
const a = element();
|
||||||
|
const b = element();
|
||||||
|
const one = bindDisplayControls({
|
||||||
|
elements: { fadeSliders: [null, a] },
|
||||||
|
actions: { setFade: () => calls.push('a') },
|
||||||
|
});
|
||||||
|
const two = bindDisplayControls({
|
||||||
|
elements: { fadeSliders: [b] },
|
||||||
|
actions: { setFade: () => calls.push('b') },
|
||||||
|
});
|
||||||
|
one.destroy();
|
||||||
|
a.dispatchEvent(new Event('input'));
|
||||||
|
b.dispatchEvent(new Event('input'));
|
||||||
|
assert.deepEqual(calls, ['b']);
|
||||||
|
two.destroy();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user