mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24: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]
|
||||
|
||||
- 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
|
||||
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
|
||||
restore claims and render scheduling. Both controls are destroyed before the
|
||||
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
|
||||
|
||||
## 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
|
||||
|
||||
`ui/input` supplies the bubbling application shortcut listener and generated
|
||||
|
||||
+2
-1
@@ -122,6 +122,7 @@
|
||||
"./ui/panels": "./src/ui/panelDisclosure.js",
|
||||
"./ui/surfaces": "./src/ui/surfaceKeyboard.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/visualInput.js",
|
||||
"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"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"display-controls": {
|
||||
"exports": ["./ui/display"],
|
||||
"modules": ["src/ui/displayControls.js"],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,49 @@ try {
|
||||
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 });
|
||||
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
|
||||
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);
|
||||
} finally {
|
||||
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.
|
||||
assert.match(uiSource, /if \(preset\.detection && !this\._detectionUserOverridden\) \{/,
|
||||
'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;/,
|
||||
'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 = [
|
||||
['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'],
|
||||
["this._scopeBtn?.addEventListener('click'", "this._scopeFeatherSlider?.addEventListener('input'", 'setScopeMaskEnabled(', 'scope button'],
|
||||
["this._scopeFeatherSlider?.addEventListener('input'", 'if (this._sharpenSlider)', 'setScopeMaskFeather(', 'scope feather slider'],
|
||||
["this._sharpenSlider.addEventListener('input'", 'if (this._hudLayoutSelect)', 'this._applySharpenIntensity(', 'sharpen slider'],
|
||||
["this._hudLayoutSelect.addEventListener('change'", 'if (this._cleanViewBtn)', 'this._setHudVariant(', 'HUD layout select'],
|
||||
["this._detectionDensitySlider.addEventListener('input'", 'for (const button of this._detectionAllocationBtns)', 'this._applyDetectionDensityFromUi()', 'detection density slider'],
|
||||
["button.addEventListener('click'", 'for (const slider of [this._detectionFadeSlider', 'this._setDetectionAllocation(', 'detection allocation button'],
|
||||
["slider?.addEventListener('input'", 'if (this._celestialBtn)', 'this._applyDetectionFadeFromUi()', 'detection fade controls'],
|
||||
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
|
||||
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
|
||||
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
|
||||
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'],
|
||||
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
|
||||
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
|
||||
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
|
||||
['setDensity:', 'setAllocation:', 'this._applyDetectionDensityFromUi()', 'setDensity control'],
|
||||
['setAllocation:', 'setFade:', 'this._setDetectionAllocation(', 'setAllocation control'],
|
||||
['setFade:', 'toggleCelestial:', 'this._applyDetectionFadeFromUi()', 'setFade control'],
|
||||
];
|
||||
for (const [start, end, mutation, label] of gestureRoutes) {
|
||||
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);
|
||||
}
|
||||
|
||||
const hudToggle = sourceBlock(' _initHUDToggle() {', ' _initCockpitDisplayPortal() {');
|
||||
assertClaimsBefore(
|
||||
hudToggle.slice(
|
||||
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',
|
||||
);
|
||||
const displayActions = initUi.slice(initUi.indexOf('this._displayControls ='));
|
||||
assertClaimsBefore(displayActions.slice(displayActions.indexOf('toggleHud:'), displayActions.indexOf('cycleDetection:')), 'this.hud.toggle()', 'HUD button');
|
||||
assertClaimsBefore(displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:')), 'cycleDetectionMode()', 'detection button');
|
||||
|
||||
});
|
||||
|
||||
// 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);
|
||||
const synchronous = disposal.slice(0, firstAwait);
|
||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._displayControls\?\.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 { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
|
||||
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
||||
@@ -3395,11 +3396,6 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_initUI() {
|
||||
// Style buttons
|
||||
document.querySelectorAll('.style-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.setStyle(btn.dataset.style));
|
||||
});
|
||||
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._applicationShortcuts = bindApplicationShortcuts({
|
||||
documentRef: document,
|
||||
@@ -3432,108 +3428,109 @@ export class StyleManager {
|
||||
},
|
||||
});
|
||||
|
||||
// Bloom toggle
|
||||
this._bloomBtn.addEventListener('click', () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setBloomEnabled(!this.bloomEnabled);
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = bindDisplayControls({
|
||||
elements: {
|
||||
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();
|
||||
}
|
||||
|
||||
_syncModels3dModeRow() {
|
||||
if (this._models3dModeRow) this._models3dModeRow.classList.toggle('visible', this._models3dEnabled);
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
|
||||
_initModels3dToggle() {
|
||||
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();
|
||||
syncModeRow();
|
||||
this._syncModels3dModeRow();
|
||||
}
|
||||
|
||||
_setModels3dEnabled(enabled) {
|
||||
@@ -9447,13 +9433,6 @@ export class StyleManager {
|
||||
}
|
||||
|
||||
_initHUDToggle() {
|
||||
this._hudBtn.addEventListener('click', () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.hud.toggle();
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
});
|
||||
|
||||
if (this._hudLayoutSelect) {
|
||||
this._hudLayoutSelect.value = 'tactical';
|
||||
}
|
||||
@@ -9461,13 +9440,6 @@ export class StyleManager {
|
||||
this.hud.setMode('on');
|
||||
this._updateHudButtonState();
|
||||
|
||||
// Detection toggle button
|
||||
this._detectionBtn.addEventListener('click', () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._detectionUserOverridden = true;
|
||||
cycleDetectionMode();
|
||||
this._syncShareState();
|
||||
});
|
||||
this._cockpitDisplayToggleBtn?.addEventListener('click', () => {
|
||||
const open = this._cockpitDisplayToggleBtn.getAttribute('aria-expanded') === 'true';
|
||||
this._setCockpitDisclosure?.('display', !open);
|
||||
@@ -9690,6 +9662,7 @@ export class StyleManager {
|
||||
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
|
||||
this._disposed = true;
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._displayControls?.destroy();
|
||||
this._styleParameters?.destroy();
|
||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||
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