diff --git a/CHANGELOG.md b/CHANGELOG.md index 344d4f4ee..0125b1f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,12 +46,13 @@ SCENE bar through a store that the scene panels view renders, so a page that omits `layer-panels` shows neither (samehkhamis). -- `./ui/display`'s `bindDisplayControls` takes the keyhole fade and outside - opacity sliders as `fadeSlider` and `outsideOpacitySlider`, and their - actions receive whole percentages; `fadeSliders` still works. - `syncScopeControls` (`./ui/display`) and `shouldHideCollapsedRightPanels` - (`./ui/layout`) are deprecated: the Display view and the rail now own what - they did. `./ui/input` imports Vue (samehkhamis). +- `./ui/display`'s `bindDisplayControls` is gone: the Display panel's + controls and the dock's style buttons call their owner through the display + store's `connect`/`act`, and its allocation and 3D model choices are the + kit's `UiSegmented`. `syncScopeControls` (`./ui/display`) and + `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display + view and the rail now own what they did. `./ui/input` imports Vue + (samehkhamis). - `./ui/cctv` and `./ui/radio` import Vue and Pinia: their controllers publish the panel's state to a store that the CCTV and Radio views render, diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 1cb7fdd52..fde88d562 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -37,8 +37,8 @@ one-panel rule too. Share links continue to record which panels are open. ## Component kit `src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`, -`UiButton` and `UiPanel` so far, with more (`UiSegmented`, `UiSelect` and -so on) joining as the controls they serve are converted. One component +`UiButton`, `UiPanel` and `UiSegmented` so far, with more (`UiSlider`, +`UiSelect` and so on) joining as the controls they serve are converted. One component serves every use of a control, with props for the differences (`UiPanel`'s `sideways`, `glow`, `scroll`), rather than a copy per panel. Names, props, events and class names follow Halfpixel's `ui-vue` kit so the two can merge @@ -50,6 +50,8 @@ its own classes beside it. They contain no application state. `collapsed` drives only the button and `ui-panel--collapsed`: the panel's owner hides the body by writing the root's `collapsed` class at once, and there is no `closable`, since Core's panels collapse rather than close. +`UiSegmented` adds `keyboard` (`none`, `arrows` as in ui-vue, or `radio` with +Home and End), since each of Core's groups keeps its own keyboard policy. Controls share one skin, so every panel reads the same. Every scrolling surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 927a58c41..e44dd9a1a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -201,7 +201,6 @@ "src/ui/displayControls.js", "src/ui/segmentedControl.test.mjs", "src/ui/styles/segmented-control.css", - "src/ui/displayControls.test.mjs", "src/ui/cameraOrientationControls.js", "src/ui/cameraOrientationControls.test.mjs", "src/ui/effects.js", @@ -1126,6 +1125,9 @@ "src/ui/shareButton.test.mjs", "src/share/shareLinkPath.test.mjs", "src/ui/kit/UiRail.test.mjs", + "src/ui/kit/UiSegmented.vue", + "src/ui/kit/UiSegmented.test.mjs", + "src/ui/displayPanelActions.test.mjs", "src/testSupport/vueDom.mjs", "build/vueLoader.js", "build/registerVue.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f1287db14..133cde26b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -121,6 +121,10 @@ "src/ui/stores/firstRun.js", "src/firstRunCopy.js", "src/ui/kit/UiButton.vue", + "src/ui/kit/UiSegmented.vue", + "src/ui/rail/RailPanel.vue", + "src/ui/kit/UiPanel.vue", + "src/ui/kit/UiPanelToggle.vue", "src/ui/stores/panelChrome.js", "src/ui/rail/PanelCollapseButton.vue", "src/ui/rail/panelState.js" @@ -564,7 +568,7 @@ }, "display-controls": { "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 545c50b88..3c096acdc 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -265,7 +265,7 @@ try { 'destroyed Display controls cannot change settings', await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; - manager._displayControls.destroy(); + manager._displayBindings._detachDisplayControls(); const before = manager.bloomEnabled; const contrast = manager._bloomStage.uniforms.contrast; manager._bloomBtn.click(); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index f093a3c37..0c6ce129c 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -753,7 +753,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an ); assert.match( panel, - /id="models3d-toggle" :aria-pressed="String\(models3dOn\)"/, + /id="models3d-toggle" @click="act\('toggleModels'\)" :aria-pressed="String\(models3dOn\)"/, 'DisplayPanel.vue: the view renders aria-pressed with the lit state', ); assert.match( diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index c1ad536eb..9f90ba1be 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -263,7 +263,7 @@ 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 displayActions = uiSource.slice(uiSource.indexOf('this._displayControls =')); + const displayActions = uiSource.slice(uiSource.indexOf('const token = store.connect(')); 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\.claimDetection\(\);/, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 105b8a573..70716f3fe 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -535,7 +535,7 @@ test('every explicit visual UI gesture claims restore authority before it mutate assertClaimsBefore(initUi.slice(startIndex, endIndex), mutation, label); } - const displayActions = initUi.slice(initUi.indexOf('this._displayControls =')); + const displayActions = initUi.slice(initUi.indexOf('const token = store.connect(')); 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'); @@ -792,7 +792,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/); assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.destroy\(\)/); assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/); - assert.match(DisplayBindings.prototype.destroy.toString(), /this\._displayControls\?\.destroy\(\)/); + assert.match(DisplayBindings.prototype.destroy.toString(), /this\._detachDisplayControls\?\.\(\)/); assert.match(DisplayBindings.prototype.destroy.toString(), /this\._applicationShortcuts\?\.destroy\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/); diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 46af726ec..7ab6a8fa3 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -9,7 +9,8 @@ import { detectOn, } from '../../scripts/qa-transit-controls.mjs'; import { createStyleParameters } from '../ui/styleParameters.js'; -import { bindDisplayControls } from '../ui/displayControls.js'; +import { createPinia } from 'pinia'; +import { useDisplayStore } from '../ui/stores/display.js'; import { thermalShader } from '../styles/thermal.js'; import { cycleMode, @@ -189,20 +190,26 @@ test('DETECT uses the real OFF/restore button and density input to reach DENSE', setMode('BALANCED'); setMode('OFF'); const modes = []; - const controls = bindDisplayControls({ - elements: { detectionButton: button, densitySlider: slider }, - actions: { - cycleDetection() { - cycleMode(); - modes.push(getMode()); - }, - setDensity(value) { - setDetectionTuning({ densityPct: Number(value) }); - }, + // As DisplayPanel does: the toggle and the slider call the Display + // controls' actions through the display store. + const store = useDisplayStore(createPinia()); + const token = store.connect({ + cycleDetection() { + cycleMode(); + modes.push(getMode()); + }, + setDensity(value) { + setDetectionTuning({ densityPct: Number(value) }); }, }); + const onClick = () => store.act('cycleDetection'); + const onInput = () => store.act('setDensity', slider.value); + button.addEventListener('click', onClick); + slider.addEventListener('input', onInput); t.after(() => { - controls.destroy(); + button.removeEventListener('click', onClick); + slider.removeEventListener('input', onInput); + store.detach(token); setMode('OFF'); }); assert.equal(await detectOn(page, pause), 'DENSE'); diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js index bd7cce089..a6e2219c7 100644 --- a/src/ui/aircraftDisplay.js +++ b/src/ui/aircraftDisplay.js @@ -23,10 +23,6 @@ export class AircraftDisplay { this._models3dPressedShown = null; this._models3dRowShown = null; this._models3dModeShown = null; - this._models3dModeBtns = [ - document.getElementById('models3d-mode-proximity'), - document.getElementById('models3d-mode-all'), - ]; } get _dataManager() { return this.readDataManager(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 42bb65587..61cbdafbe 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -526,29 +526,6 @@ export class StyleManager extends ShellFacade { }, elements: { _locationSearch: this._locationSearch, - _bloomBtn: this._bloomBtn, - _bloomSlider: this._bloomSlider, - _sharpenBtn: this._sharpenBtn, - _sharpenSlider: this._sharpenSlider, - _scopeBtn: this._scopeBtn, - _scopeFeatherSlider: this._scopeFeatherSlider, - _hudLayoutSelect: this._hudLayoutSelect, - _hudBtn: this._hudBtn, - _cyberSonarBtn: this._cyberSonarBtn, - _cyberSonarRings: this._cyberSonarRings, - _cyberSonarRange: this._cyberSonarRange, - _cyberSonarIntensity: this._cyberSonarIntensity, - _cyberSonarOpacity: this._cyberSonarOpacity, - _cyberSonarSector: this._cyberSonarSector, - _cleanViewBtn: this._cleanViewBtn, - _cleanViewExitBtn: this._cleanViewExitBtn, - _detectionDensitySlider: this._detectionDensitySlider, - _detectionBtn: this._detectionBtn, - _detectionFadeSlider: this._detectionFadeSlider, - _detectionOpacitySlider: this._detectionOpacitySlider, - _detectionAllocationBtns: this._detectionAllocationBtns, - _celestialBtn: this._celestialBtn, - _models3dBtn: this._models3dBtn, }, operations: { setStyle: (...args) => this.setStyle(...args), @@ -583,7 +560,6 @@ export class StyleManager extends ShellFacade { celestialRing: this.celestialRing, celestialRingEnabled: this.celestialRingEnabled, _models3dEnabled: this._models3dEnabled, - _models3dModeBtns: this._models3dModeBtns, }), claimDetection: () => { this._visualSettings._detectionUserOverridden = true; diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index adde0637a..8ac35747c 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -12,7 +12,8 @@ import { import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js'; import { createGevActionRunner } from '../voice/gevActions.js'; import { StyleManager } from './applicationShell.js'; -import { bindDisplayControls } from './displayControls.js'; +import { createPinia } from 'pinia'; +import { useDisplayStore } from './stores/display.js'; import { VisualSettings } from './visualSettings.js'; import { STYLES } from './visualPresets.js'; @@ -231,28 +232,22 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author }; const display = makeOwner(); - const hudLayout = new EventTarget(); - hudLayout.value = 'cyber'; - const animeButton = new EventTarget(); - animeButton.dataset = { style: 'anime' }; - const controls = bindDisplayControls({ - elements: { hudLayout, styleButtons: [animeButton] }, - actions: { - setHudLayout: (value) => display.owner.setHudLayout(value), - setStyle: (value) => - display.owner.setStyle(value, { userInitiated: true }), - }, + // The Display panel's layout select and the dock's style buttons call + // these actions through the display store. + const store = useDisplayStore(createPinia()); + const token = store.connect({ + setHudLayout: (value) => display.owner.setHudLayout(value), + setStyle: (value) => display.owner.setStyle(value, { userInitiated: true }), }); - hudLayout.dispatchEvent(new Event('change')); + store.act('setHudLayout', 'cyber'); assert.equal(display.hud.getVariant(), 'cyber'); assert.equal(display.owner.activeStyle, 'thermal'); - animeButton.dispatchEvent(new Event('click')); + store.act('setStyle', 'anime'); assert.equal(display.owner.activeStyle, 'anime'); - hudLayout.value = 'minimal'; - hudLayout.dispatchEvent(new Event('change')); + store.act('setHudLayout', 'minimal'); assert.equal(display.hud.getVariant(), 'minimal'); assert.equal(display.owner.activeStyle, 'anime'); - controls.destroy(); + store.detach(token); const voice = makeOwner(); const eventSource = { addEventListener() {}, removeEventListener() {} }; diff --git a/src/ui/displayBindings.js b/src/ui/displayBindings.js index e2f213c02..7c67f65c4 100644 --- a/src/ui/displayBindings.js +++ b/src/ui/displayBindings.js @@ -1,6 +1,7 @@ import { createFrameRateMonitor } from './frameRateMonitor.js'; import { bindApplicationShortcuts } from './visualInput.js'; -import { bindDisplayControls } from './displayControls.js'; +import { useDisplayStore } from './stores/display.js'; +import { applicationPinia } from './vueRuntime.js'; /** Own keyboard/display event subscriptions; settings remain with their state owners. */ export class DisplayBindings { @@ -11,10 +12,11 @@ export class DisplayBindings { operations, readState, claimDetection, + displayStore, }) { Object.assign( this, - { viewer, services, readState, claimDetection }, + { viewer, services, readState, claimDetection, displayStore }, elements, operations, ); @@ -40,9 +42,6 @@ export class DisplayBindings { get _models3dEnabled() { return this.readState()._models3dEnabled; } - get _models3dModeBtns() { - return this.readState()._models3dModeBtns; - } _initUI() { const { cycleDetectionMode, isScopeMaskEnabled } = this.services; this._applicationShortcuts?.destroy(); @@ -84,132 +83,106 @@ export class DisplayBindings { }); this._setScope({ enabled: isScopeMaskEnabled() }, { apply: false }); - this._displayControls?.destroy(); - this._displayControls = bindDisplayControls({ - elements: { - styleButtons: document.querySelectorAll('.style-btn'), - bloomButton: this._bloomBtn, - bloomSlider: this._bloomSlider, - sharpenButton: this._sharpenBtn, - sharpenSlider: this._sharpenSlider, - scopeToggle: this._scopeBtn, - scopeFeatherSlider: this._scopeFeatherSlider, - hudLayout: this._hudLayoutSelect, - hudButton: this._hudBtn, - sonarButton: this._cyberSonarBtn, - sonarRingsSlider: this._cyberSonarRings, - sonarRangeSlider: this._cyberSonarRange, - sonarIntensitySlider: this._cyberSonarIntensity, - sonarOpacitySlider: this._cyberSonarOpacity, - sonarSectorSlider: this._cyberSonarSector, - cleanViewButton: this._cleanViewBtn, - cleanViewExitButton: this._cleanViewExitBtn, - densitySlider: this._detectionDensitySlider, - detectionButton: this._detectionBtn, - allocationButtons: this._detectionAllocationBtns, - fadeSlider: this._detectionFadeSlider, - outsideOpacitySlider: this._detectionOpacitySlider, - celestialButton: this._celestialBtn, - modelsButton: this._models3dBtn, - modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [], + // The Display controls (and the dock's style buttons) answer through + // the display store; DisplayPanel's events call these actions. + this._detachDisplayControls?.(); + const store = this.displayStore ?? useDisplayStore(applicationPinia()); + const token = store.connect({ + setStyle: (style) => this.setStyle(style, { userInitiated: true }), + toggleBloom: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setBloomEnabled(!this.bloomEnabled); }, - actions: { - setStyle: (style) => this.setStyle(style, { userInitiated: true }), - 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); - }, - setScopeEnabled: (enabled) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setScope({ enabled }); - this._syncShareState(); - }, - setScopeFeather: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setScope({ featherPct: value }); - this._syncShareState(); - }, - setSharpenIntensity: (pct) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setSharpenIntensity(pct); - }, - setHudLayout: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setHudVariant(value, { applyVisualDefaults: true }); - }, - toggleCleanView: () => this.toggleCleanView(), - exitCleanView: () => this.toggleCleanView(false), - setDensity: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.claimDetection(); - this._setDetectionDensity(value); - this._syncShareState(); - }, - setAllocation: (value) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.claimDetection(); - this._setDetectionAllocation(value); - }, - setFade: (fadePct) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setDetectionFade({ fadePct }); - this._syncShareState(); - }, - setOutsideOpacity: (outsideOpacityPct) => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this._setDetectionFade({ outsideOpacityPct }); - 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(); - }, - toggleSonar: () => this._setCyberSonarEnabled(), - setSonarRings: (value) => this._setCyberSonarSetting('rings', value), - setSonarRange: (value) => this._setCyberSonarSetting('range', value), - setSonarIntensity: (value) => - this._setCyberSonarSetting('intensity', value), - setSonarOpacity: (value) => - this._setCyberSonarSetting('opacity', value), - setSonarSector: (value) => this._setCyberSonarSetting('sector', value), - cycleDetection: () => { - this.shareLinkManager?.claimRestoreLane?.('visual'); - this.claimDetection(); - cycleDetectionMode(); - this._syncShareState(); - }, - toggleModels: () => { - this._setModels3dEnabled(!this._models3dEnabled); - this._syncModels3dModeRow(); - }, - setModelsMode: (mode) => this._setModels3dMode(mode), + setBloomIntensity: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setBloomIntensity(value); }, + toggleSharpen: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setSharpenEnabled(!this.sharpenEnabled); + }, + setScopeEnabled: (enabled) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setScope({ enabled }); + this._syncShareState(); + }, + setScopeFeather: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setScope({ featherPct: value }); + this._syncShareState(); + }, + setSharpenIntensity: (pct) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setSharpenIntensity(pct); + }, + setHudLayout: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setHudVariant(value, { applyVisualDefaults: true }); + }, + toggleCleanView: () => this.toggleCleanView(), + exitCleanView: () => this.toggleCleanView(false), + setDensity: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this.claimDetection(); + this._setDetectionDensity(value); + this._syncShareState(); + }, + setAllocation: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this.claimDetection(); + this._setDetectionAllocation(value); + }, + setFade: (fadePct) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setDetectionFade({ fadePct }); + this._syncShareState(); + }, + setOutsideOpacity: (outsideOpacityPct) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setDetectionFade({ outsideOpacityPct }); + 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(); + }, + toggleSonar: () => this._setCyberSonarEnabled(), + setSonarRings: (value) => this._setCyberSonarSetting('rings', value), + setSonarRange: (value) => this._setCyberSonarSetting('range', value), + setSonarIntensity: (value) => + this._setCyberSonarSetting('intensity', value), + setSonarOpacity: (value) => this._setCyberSonarSetting('opacity', value), + setSonarSector: (value) => this._setCyberSonarSetting('sector', value), + cycleDetection: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this.claimDetection(); + cycleDetectionMode(); + this._syncShareState(); + }, + toggleModels: () => { + this._setModels3dEnabled(!this._models3dEnabled); + this._syncModels3dModeRow(); + }, + setModelsMode: (mode) => this._setModels3dMode(mode), }); + this._detachDisplayControls = () => store.detach(token); } destroy() { this._applicationShortcuts?.destroy(); this._applicationShortcuts = null; this._frameRateMonitor?.destroy(); this._frameRateMonitor = null; - this._displayControls?.destroy(); - this._displayControls = null; + this._detachDisplayControls?.(); + this._detachDisplayControls = null; } } diff --git a/src/ui/displayControls.js b/src/ui/displayControls.js index 6994b933c..437b296fe 100644 --- a/src/ui/displayControls.js +++ b/src/ui/displayControls.js @@ -1,86 +1,3 @@ -import { createSegmentedControl } from './segmentedControl.js'; - -/** - * 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'], - ['cleanViewButton', 'toggleCleanView'], - ['cleanViewExitButton', 'exitCleanView'], - ['celestialButton', 'toggleCelestial'], - ['hudButton', 'toggleHud'], - ['sonarButton', 'toggleSonar'], - ['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.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked); - listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value); - listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value); - for (const [name, action] of [ - ['sonarRingsSlider', 'setSonarRings'], - ['sonarRangeSlider', 'setSonarRange'], - ['sonarIntensitySlider', 'setSonarIntensity'], - ['sonarOpacitySlider', 'setSonarOpacity'], - ['sonarSectorSlider', 'setSonarSector'], - ]) - listen(elements[name], 'input', action, integer); - for (const el of elements.styleButtons || []) - listen(el, 'click', 'setStyle', (el) => el.dataset.style); - for (const [buttons, valueOf, action] of [ - [ - elements.allocationButtons || [], - (el) => el.dataset.allocation, - 'setAllocation', - ], - [ - elements.modelModeButtons || [], - (el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'), - 'setModelsMode', - ], - ]) { - const control = createSegmentedControl({ - buttons, - valueOf, - onChange: (value) => actions[action](value), - }); - removers.push(() => control.destroy()); - } - listen(elements.fadeSlider, 'input', 'setFade', integer); - listen(elements.outsideOpacitySlider, 'input', 'setOutsideOpacity', integer); - // Hosts written before the two sliders were told apart pass `fadeSliders` - // and read both values in `setFade` themselves. - for (const el of elements.fadeSliders || []) { - if (el === elements.fadeSlider || el === elements.outsideOpacitySlider) - continue; - listen(el, 'input', 'setFade'); - } - return { - destroy() { - for (const remove of removers.splice(0)) remove(); - }, - }; -} - /** * Keep a native Scope checkbox and feather availability in step with the * mask. The Display view renders both from VisualSettings now; this stays diff --git a/src/ui/displayControls.test.mjs b/src/ui/displayControls.test.mjs deleted file mode 100644 index d91b1bc5b..000000000 --- a/src/ui/displayControls.test.mjs +++ /dev/null @@ -1,200 +0,0 @@ -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('sonar on/off control cannot fire after disposal', () => { - const sonarButton = element(); - const calls = []; - const control = bindDisplayControls({ - elements: { sonarButton }, - actions: { toggleSonar: () => calls.push('toggle') }, - }); - sonarButton.dispatchEvent(new Event('click')); - control.destroy(); - sonarButton.dispatchEvent(new Event('click')); - assert.deepEqual(calls, ['toggle']); -}); - -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('the Cyber sonar control is an optional owned display action', () => { - const sonarButton = element(); - const sonarRingsSlider = element('9'); - const sonarRangeSlider = element('110'); - const sonarIntensitySlider = element('65'); - const sonarOpacitySlider = element('84'); - const sonarSectorSlider = element('32'); - const calls = []; - const control = bindDisplayControls({ - elements: { - sonarButton, - sonarRingsSlider, - sonarRangeSlider, - sonarIntensitySlider, - sonarOpacitySlider, - sonarSectorSlider, - }, - actions: { - toggleSonar: () => calls.push('toggle'), - setSonarRings: (value) => calls.push(['rings', value]), - setSonarRange: (value) => calls.push(['range', value]), - setSonarIntensity: (value) => calls.push(['intensity', value]), - setSonarOpacity: (value) => calls.push(['opacity', value]), - setSonarSector: (value) => calls.push(['sector', value]), - }, - }); - sonarButton.dispatchEvent(new Event('click')); - sonarRingsSlider.dispatchEvent(new Event('input')); - sonarRangeSlider.dispatchEvent(new Event('input')); - sonarIntensitySlider.dispatchEvent(new Event('input')); - sonarOpacitySlider.dispatchEvent(new Event('input')); - sonarSectorSlider.dispatchEvent(new Event('input')); - assert.deepEqual(calls, [ - 'toggle', - ['rings', 9], - ['range', 110], - ['intensity', 65], - ['opacity', 84], - ['sector', 32], - ]); - control.destroy(); - sonarButton.dispatchEvent(new Event('click')); - assert.equal(calls.length, 6); -}); - -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: { fadeSlider: null, outsideOpacitySlider: a }, - actions: { setOutsideOpacity: () => calls.push('a') }, - }); - const two = bindDisplayControls({ - elements: { fadeSlider: b }, - actions: { setFade: () => calls.push('b') }, - }); - one.destroy(); - a.dispatchEvent(new Event('input')); - b.dispatchEvent(new Event('input')); - assert.deepEqual(calls, ['b']); - two.destroy(); -}); - -test('legacy fadeSliders bind once and skip sliders passed by name', () => { - const calls = []; - const fade = element('40'); - const legacy = element('25'); - const control = bindDisplayControls({ - elements: { fadeSlider: fade, fadeSliders: [fade, legacy] }, - actions: { setFade: (value) => calls.push(value) }, - }); - fade.dispatchEvent(new Event('input')); - legacy.dispatchEvent(new Event('input')); - assert.deepEqual(calls, [40, undefined]); - control.destroy(); -}); - -test('the native Scope checkbox reads checked state on change and disposes its listener', () => { - const scopeToggle = element(); - scopeToggle.checked = true; - const scopeFeatherSlider = element('200'); - const calls = []; - const controls = bindDisplayControls({ - elements: { scopeToggle, scopeFeatherSlider }, - actions: { - setScopeEnabled: (value) => calls.push(['enabled', value]), - setScopeFeather: (value) => calls.push(['feather', value]), - }, - }); - scopeToggle.checked = false; - scopeToggle.dispatchEvent(new Event('change')); - scopeToggle.checked = true; - scopeToggle.dispatchEvent(new Event('change')); - scopeFeatherSlider.dispatchEvent(new Event('input')); - controls.destroy(); - scopeToggle.dispatchEvent(new Event('change')); - assert.deepEqual(calls, [ - ['enabled', false], - ['enabled', true], - ['feather', 200], - ]); -}); diff --git a/src/ui/displayPanelActions.test.mjs b/src/ui/displayPanelActions.test.mjs new file mode 100644 index 000000000..7ea5e4ea3 --- /dev/null +++ b/src/ui/displayPanelActions.test.mjs @@ -0,0 +1,173 @@ +// src/ui/displayPanelActions.test.mjs — the Display panel's controls, the +// clean-view exit and the dock's style buttons call the Display controls' +// owner through the display store, and only while it is connected. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, h, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useDisplayStore } from './stores/display.js'; +import { DisplayBindings } from './displayBindings.js'; +import CommandDockView from './views/CommandDockView.vue'; +import DisplayControlsView from './views/DisplayControlsView.vue'; + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const store = useDisplayStore(pinia); + const app = createApp({ + render: () => [h(DisplayControlsView), h(CommandDockView)], + }).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + return store; +} +const $ = (id) => document.getElementById(id); +function recorder(store) { + const calls = []; + const actions = new Proxy( + {}, + { + get: + (_, name) => + (...args) => + calls.push([name, ...args]), + }, + ); + return { calls, token: store.connect(actions) }; +} +function input(el, value, type = 'input') { + el.value = String(value); + el.dispatchEvent(new Event(type, { bubbles: true })); +} + +test('buttons, sliders, the layout select and the Scope checkbox call their actions', async (t) => { + const store = mount(t); + const { calls } = recorder(store); + for (const id of [ + 'hud-toggle', + 'cyber-sonar-toggle', + 'detection-toggle', + 'models3d-toggle', + 'celestial-toggle', + 'clean-view-toggle', + 'bloom-toggle', + 'sharpen-toggle', + 'clean-view-exit', + ]) + $(id).click(); + input($('hud-layout-select'), 'minimal', 'change'); + input($('cyber-sonar-rings'), 5); + input($('cyber-sonar-range'), 90); + input($('cyber-sonar-intensity'), 40); + input($('cyber-sonar-opacity'), 60); + input($('cyber-sonar-sector'), 30); + input($('detection-density-slider'), 75); + input($('detection-fade-slider'), 12); + input($('detection-opacity-slider'), 3); + input($('scope-feather-slider'), 150); + input($('bloom-intensity-slider'), 24); + input($('sharpen-intensity-slider'), 37); + $('scope-toggle').checked = false; + $('scope-toggle').dispatchEvent(new Event('change', { bubbles: true })); + assert.deepEqual(calls, [ + ['toggleHud'], + ['toggleSonar'], + ['cycleDetection'], + ['toggleModels'], + ['toggleCelestial'], + ['toggleCleanView'], + ['toggleBloom'], + ['toggleSharpen'], + ['exitCleanView'], + ['setHudLayout', 'minimal'], + ['setSonarRings', 5], + ['setSonarRange', 90], + ['setSonarIntensity', 40], + ['setSonarOpacity', 60], + ['setSonarSector', 30], + // Density passes the slider's own value, as it always has. + ['setDensity', '75'], + ['setFade', 12], + ['setOutsideOpacity', 3], + ['setScopeFeather', 150], + ['setBloomIntensity', 24], + ['setSharpenIntensity', 37], + ['setScopeEnabled', false], + ]); +}); + +test('style, allocation and model choices keep their ids and data attributes', async (t) => { + const store = mount(t); + const { calls } = recorder(store); + document.querySelector('.style-btn[data-style="thermal"]').click(); + $('detection-allocation-weighted').click(); + $('models3d-mode-all').click(); + $('models3d-mode-proximity').click(); + assert.deepEqual(calls, [ + ['setStyle', 'thermal'], + ['setAllocation', 'WEIGHTED'], + ['setModelsMode', 'all'], + ['setModelsMode', 'proximity'], + ]); + assert.equal( + $('detection-allocation-weighted').dataset.allocation, + 'WEIGHTED', + ); + assert.equal($('models3d-mode-all').dataset.mode, 'all'); + // Their selection follows what the owners present. + assert.equal( + $('models3d-mode-proximity').getAttribute('aria-checked'), + 'true', + ); + store.publishModels3d({ pressed: true, mode: 'all', rowVisible: true }); + await nextTick(); + assert.equal($('models3d-mode-all').getAttribute('aria-checked'), 'true'); + assert.ok($('models3d-mode-all').classList.contains('active')); +}); + +test('a detached or replaced owner hears nothing', (t) => { + const store = mount(t); + const first = recorder(store); + $('bloom-toggle').click(); + store.detach(first.token); + $('bloom-toggle').click(); + const second = recorder(store); + store.detach(first.token); + $('bloom-toggle').click(); + assert.deepEqual(first.calls, [['toggleBloom']]); + assert.deepEqual(second.calls, [['toggleBloom']]); +}); + +test('DisplayBindings answers the controls from init until destroy', (t) => { + const store = mount(t); + const calls = []; + const bindings = new DisplayBindings({ + viewer: null, + services: { isScopeMaskEnabled: () => false, cycleDetectionMode: () => {} }, + elements: { _locationSearch: document.createElement('input') }, + operations: { + _setScope: () => {}, + _setBloomEnabled: (enabled) => calls.push(['bloom', enabled]), + }, + readState: () => ({ bloomEnabled: false }), + claimDetection: () => {}, + displayStore: store, + }); + bindings._initUI(); + // Initialising again replaces its own connection rather than stacking one. + bindings._initUI(); + $('bloom-toggle').click(); + assert.deepEqual(calls, [['bloom', true]]); + // A newer owner takes over; the old one's destroy leaves it connected. + const next = recorder(store); + bindings.destroy(); + $('bloom-toggle').click(); + assert.deepEqual(calls, [['bloom', true]]); + assert.deepEqual(next.calls, [['toggleBloom']]); + store.detach(next.token); + bindings._initUI(); + bindings.destroy(); + $('bloom-toggle').click(); + assert.deepEqual(calls, [['bloom', true]]); +}); diff --git a/src/ui/kit/UiSegmented.test.mjs b/src/ui/kit/UiSegmented.test.mjs new file mode 100644 index 000000000..2dbb5bf26 --- /dev/null +++ b/src/ui/kit/UiSegmented.test.mjs @@ -0,0 +1,97 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../../testSupport/vueDom.mjs'; + +let mount; +let UiSegmented; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ mount } = await import('@vue/test-utils')); + UiSegmented = (await import('./UiSegmented.vue')).default; +}); +after(() => uninstall()); + +const options = [ + { value: 'a', label: 'Alpha', attrs: { id: 'seg-a', 'data-v': 'a' } }, + { value: 'b', label: 'Beta', disabled: true }, + { value: 'c', label: 'Gamma' }, +]; + +function mountGroup(props = {}) { + return mount(UiSegmented, { + attachTo: document.body, + props: { options, label: 'Pick', modelValue: 'a', ...props }, + }); +} +const tabStops = (wrapper) => + wrapper.findAll('button').map((b) => b.attributes('tabindex') ?? null); + +test('a named radio group with Core and kit classes on every option', () => { + const wrapper = mountGroup({ itemClass: 'pp-mode-btn' }); + const group = wrapper.get('[role="radiogroup"]'); + assert.equal(group.attributes('aria-label'), 'Pick'); + assert.ok(group.classes().includes('gev-segmented')); + const [a, b, c] = wrapper.findAll('button'); + assert.equal(a.text(), 'Alpha'); + assert.equal(a.attributes('id'), 'seg-a'); + assert.equal(a.attributes('data-v'), 'a'); + assert.equal(a.attributes('type'), 'button'); + assert.equal(a.attributes('aria-checked'), 'true'); + assert.ok(a.classes().includes('active')); + assert.ok(a.classes().includes('ui-segmented__item--on')); + assert.ok(a.classes().includes('gev-segment')); + assert.ok(a.classes().includes('pp-mode-btn')); + assert.equal(b.attributes('disabled'), ''); + assert.equal(a.attributes('disabled'), undefined); + assert.equal(c.attributes('aria-checked'), 'false'); + assert.equal(c.classes().includes('active'), false); + wrapper.unmount(); +}); + +test('a click asks for its option; a disabled one asks for nothing', async () => { + const wrapper = mountGroup(); + const [, b, c] = wrapper.findAll('button'); + await c.trigger('click'); + await b.trigger('click'); + assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]); + wrapper.unmount(); +}); + +test('keyboard none: every option is a tab stop and arrows do nothing', async () => { + const wrapper = mountGroup({ keyboard: 'none', activeClass: 'is-active' }); + assert.deepEqual(tabStops(wrapper), [null, null, null]); + assert.ok(wrapper.get('#seg-a').classes().includes('is-active')); + await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' }); + assert.equal(wrapper.emitted('update:modelValue'), undefined); + wrapper.unmount(); +}); + +test('keyboard arrows: one roving stop, arrows skip disabled options and wrap', async () => { + const wrapper = mountGroup(); + assert.deepEqual(tabStops(wrapper), ['0', '-1', '-1']); + await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' }); + await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' }); + await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' }); + assert.deepEqual(wrapper.emitted('update:modelValue'), [['c'], ['c']]); + await wrapper.setProps({ modelValue: 'c' }); + assert.deepEqual(tabStops(wrapper), ['-1', '-1', '0']); + wrapper.unmount(); +}); + +test('keyboard radio: Home and End reach the ends', async () => { + const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'c' }); + await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' }); + await wrapper.get('#seg-a').trigger('keydown', { key: 'End' }); + assert.deepEqual(wrapper.emitted('update:modelValue'), [['a'], ['c']]); + wrapper.unmount(); +}); + +test('a disabled group answers nothing and leaves the tab order', async () => { + const wrapper = mountGroup({ disabled: true }); + assert.deepEqual(tabStops(wrapper), ['-1', '-1', '-1']); + await wrapper.get('#seg-a').trigger('click'); + await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' }); + assert.equal(wrapper.emitted('update:modelValue'), undefined); + wrapper.unmount(); +}); diff --git a/src/ui/kit/UiSegmented.vue b/src/ui/kit/UiSegmented.vue new file mode 100644 index 000000000..13b690eeb --- /dev/null +++ b/src/ui/kit/UiSegmented.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index c1944b995..9ff12e75f 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -7,32 +7,8 @@ export function readShellElements(document = globalThis.document) { _ppToggles: document.getElementById('pp-toggles'), _bloomBtn: document.getElementById('bloom-toggle'), _bloomSlider: document.getElementById('bloom-intensity-slider'), - _sharpenBtn: document.getElementById('sharpen-toggle'), - _sharpenSlider: document.getElementById('sharpen-intensity-slider'), _hudBtn: document.getElementById('hud-toggle'), - _hudLayoutSelect: document.getElementById('hud-layout-select'), - _cyberSonarBtn: document.getElementById('cyber-sonar-toggle'), - _cyberSonarRings: document.getElementById('cyber-sonar-rings'), - _cyberSonarRange: document.getElementById('cyber-sonar-range'), - _cyberSonarIntensity: document.getElementById('cyber-sonar-intensity'), - _cyberSonarOpacity: document.getElementById('cyber-sonar-opacity'), - _cyberSonarSector: document.getElementById('cyber-sonar-sector'), - _detectionDensitySlider: document.getElementById( - 'detection-density-slider', - ), - _detectionAllocationBtns: [ - document.getElementById('detection-allocation-elastic'), - document.getElementById('detection-allocation-weighted'), - ].filter(Boolean), - _detectionFadeSlider: document.getElementById('detection-fade-slider'), - _detectionOpacitySlider: document.getElementById( - 'detection-opacity-slider', - ), - _celestialBtn: document.getElementById('celestial-toggle'), - _scopeBtn: document.getElementById('scope-toggle'), - _scopeFeatherSlider: document.getElementById('scope-feather-slider'), _cleanViewBtn: document.getElementById('clean-view-toggle'), - _cleanViewExitBtn: document.getElementById('clean-view-exit'), _dataPanel: document.getElementById('data-panel'), _scenePanel: document.getElementById('scene-panel'), _cctvPanel: document.getElementById('cctv-panel'), @@ -102,7 +78,6 @@ export function readShellElements(document = globalThis.document) { _clearSelectedLayersBtn: document.getElementById('clear-selected-layers'), _resetGlobeBtn: document.getElementById('reset-globe-view'), _cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'), - _styleButtons: document.getElementById('style-buttons'), _locationSearch: document.getElementById('location-search'), _searchToggle: document.getElementById('search-toggle'), _safeFrameOverlay: document.getElementById('safe-frame-overlay'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index ce12d3211..133f9ec0c 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -9,14 +9,6 @@ export class ShellFacade { return this._navigation.subscribeCameraHandoff(listener); } - get _models3dModeBtns() { - return this._aircraftDisplay._models3dModeBtns; - } - - set _models3dModeBtns(value) { - this._aircraftDisplay._models3dModeBtns = value; - } - get _models3dEnabled() { return this._aircraftDisplay._models3dEnabled; } @@ -137,14 +129,6 @@ export class ShellFacade { this._displayBindings._frameRateMonitor = value; } - get _displayControls() { - return this._displayBindings?._displayControls; - } - - set _displayControls(value) { - this._displayBindings._displayControls = value; - } - get _activeLocationId() { return this._locationNavigation._activeLocationId; } diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index 8b94b6f3f..7f06bf4cb 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -5,7 +5,9 @@ import { markRaw, shallowRef } from 'vue'; * The Display panel as its view renders it. VisualSettings presents its * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the * draw tool its controls and the file import its list, and each publishes - * here; each keeps its listeners on the controls. VisualSettings also + * here. The Display controls' changes reach their owner (DisplayBindings) + * through act(); the draw tool and the file import keep their listeners on + * their controls. VisualSettings also * publishes the active style, which the dock and the scene chrome show, and * the map source controls publish their chip row beside it. */ @@ -66,6 +68,34 @@ export const useDisplayStore = defineStore('gev-display', () => { mapSourceControls.value = controls && markRaw(controls); } + /** @type {{actions: object}|null} The Display controls' owner. */ + let owner = null; + + /** + * Answer the Display controls (and the dock's style buttons) with + * `actions`, replacing any earlier owner. + * @param {Record} actions + * @returns {object} Token for detach(). + */ + function connect(actions) { + owner = { actions }; + return owner; + } + + /** Stop answering for the owner holding `token`; a later owner stays. */ + function detach(token) { + if (token === owner) owner = null; + } + + /** + * A control's change: call the owner's action, if one is connected. + * @param {string} name + * @param {...unknown} args + */ + function act(name, ...args) { + owner?.actions[name]?.(...args); + } + /** The phone shell's tab bar; null until the shell first presents it. */ const phoneShell = shallowRef(null); @@ -91,5 +121,8 @@ export const useDisplayStore = defineStore('gev-display', () => { publishMapSource, mapSourceControls, connectMapSource, + connect, + detach, + act, }; }); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index c6553720e..c34e37286 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -14,9 +14,10 @@ import PhoneTabBar from './panels/PhoneTabBar.vue'; import VoiceControl from './panels/VoiceControl.vue'; // VisualSettings publishes the active style and the map source controls -// their chip row; until they do, the dock shows its initial markup. The -// style buttons keep their owner's listeners; a chip calls the controls. -const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore()); +// their chip row; until they do, the dock shows its initial markup. A style +// button calls the Display controls' owner, a chip the map source controls. +const display = useDisplayStore(); +const { style, mapSource, mapSourceControls } = storeToRefs(display); // LocationControls publishes the location tray; pills call the controls, // and the search field keeps their listeners. const { view: loc, controls: locationControls } = storeToRefs(useLocationStore()); @@ -80,32 +81,32 @@ function selectMapSource(chip) {
- - - - - -
-
Layout - @@ -66,60 +77,57 @@ const geoListed = computed(() => geoRows.value.length > 0);
Sonar - +
- + {{ sonar?.rings ?? 8 }}
- + {{ pct(sonar?.range ?? 100) }}
- + {{ pct(sonar?.intensity ?? 70) }}
- + {{ pct(sonar?.opacity ?? 84) }}
- + {{ `${sonar?.sector ?? 24}°` }}
-
- + {{ pct(detection?.densityPct ?? 50) }}
Allocation -
- - -
+
- + {{ pct(detection?.fadePct ?? 7) }}
- + {{ pct(detection?.outsideOpacityPct ?? 1) }}
@@ -137,27 +145,24 @@ const geoListed = computed(() => geoRows.value.length > 0); button ships lit and the mode row ships open — the same markup-carries-the-default convention as #scope-toggle. ui.js re-syncs both from durable layer state, which is what flips them back for a share link or a stored session carrying 3D off. --> -
Models -
- - -
+
Feather - + {{ pct(scopeFeather) }}
@@ -197,32 +202,32 @@ const geoListed = computed(() => geoRows.value.length > 0); {{ geoImport?.hint ?? '' }}
-
-
-
- + {{ pct(view?.bloom.intensity ?? 0) }}
-
- + {{ pct(view?.sharpen.intensity ?? 49) }}