From fd4b69ff5294c35d697bf7b8cbe9d151ed0b2afb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 15:01:41 -0800 Subject: [PATCH] feat(cockpit): teleport the shared Display groups into Cockpit's popover The Cockpit display portal publishes the Display popover's slots to the gev-display store while Cockpit is on, and the Display panel teleports its HUD, Detection, Parameters and 3D groups into them and back, each only while its own slot is published. The portal no longer moves the groups: it keeps each surface's scroll position, puts back focus the move dropped, and lays the surfaces out once Vue has moved them. The popover's uses-shared-display-controls class renders from the store, and the right rail no longer re-homes the parameters panel the markup already places. --- CHANGELOG.md | 5 + docs/VUE-MIGRATION.md | 7 +- src/cockpitMarkup.test.mjs | 14 ++- src/ui/applicationShell.js | 2 + src/ui/cockpitCoordinator.js | 5 + src/ui/cockpitDisplayPortal.js | 62 ++++++---- src/ui/cockpitDisplayPortal.test.mjs | 41 +++++++ src/ui/displayPanel.test.mjs | 116 +++++++++++++++++- src/ui/panelLayoutController.js | 12 -- src/ui/stores/display.js | 14 +++ .../views/panels/CockpitUtilityControls.vue | 10 +- src/ui/views/panels/DisplayPanel.vue | 11 +- 12 files changed, 250 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bff59f00..b96b65570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,11 @@ host constructing `CockpitController` passes `publish.utilityLayout` beside its other publishers (samehkhamis). +- Cockpit's Display popover shows the Display panel's own HUD, Detection, + Parameters and 3D groups through Vue teleports: `CockpitDisplayPortal` + publishes the popover's slots (`publish`, with `afterRender` to lay out + once they have moved) instead of moving the groups itself (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index fa341ec50..dfeace952 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -329,8 +329,11 @@ a feature supplying only its accent. `CockpitUtilityControls` renders the Display and Radio launchers, which popover is open and the compact Radio controls from the `gev-radio` store that RadioControls publishes to and - answers through; the Display popover's slots stay static for the Cockpit - display portal). + answers through. The Cockpit display portal publishes the Display + popover's slots to the `gev-display` store while Cockpit is on, and + `DisplayPanel` teleports its HUD, Detection, Parameters and 3D groups into + them and back; the portal keeps each surface's scroll position and puts + back focus the move dropped). Layers that write into the right rail directly gain a slot instead. 4. **Cleanup.** The build-time template expansion is retired: `index.html` diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index f47403e73..e03b6304b 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -543,9 +543,14 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below 1, 'Parameters must have one DOM owner', ); + // The markup places Parameters below Detection; nothing re-homes it. assert.match( + html, + /id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"/, + ); + assert.doesNotMatch( PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(), - /const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/, + /_sliderPanel/, ); assert.match( ui, @@ -1055,15 +1060,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls assert.doesNotMatch(ui, /\['presets',/); assert.match( CockpitDisplayPortal.toString(), - /group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/, + /window\.addEventListener\(\s*'gev:cockpit-mode-changed'/, ); + // The portal publishes the slots; the Display panel teleports its groups. assert.match( CockpitDisplayPortal.prototype.setActive.toString(), - /record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/, + /this\.publish\(nextActive \? this\.slots : null\)/, ); assert.match( CockpitDisplayPortal.prototype.destroy.toString(), - /this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/, + /this\.stop\(\)[\s\S]*?this\.publish\(null\)/, ); assert.doesNotMatch( ui, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 46dd3d04c..ee8544b65 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -403,6 +403,8 @@ export class StyleManager extends ShellFacade { utilityLayout: (layout) => useCockpitStore(applicationPinia()).publishUtilityLayout(layout), }, + publishCockpitPortal: (slots) => + useDisplayStore(applicationPinia()).publishCockpitPortal(slots), _layoutRightPanels: (...args) => this._layoutRightPanels(...args), _setCockpitVision: (...args) => this._setCockpitVision(...args), _stampNavigation: (...args) => this._stampNavigation(...args), diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index afa938e2f..b71889b4c 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js'; import { cockpitEntryAllowed } from '../contextModePolicy.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; @@ -380,6 +381,10 @@ export class CockpitCoordinator { this._layoutRightPanels(); this.cockpitView?.scheduleContextLayout(); }, + publish: (slots) => this.publishCockpitPortal?.(slots), + afterRender: (callback) => { + void nextTick(callback); + }, }); } diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js index fb0ce3285..4e297f25b 100644 --- a/src/ui/cockpitDisplayPortal.js +++ b/src/ui/cockpitDisplayPortal.js @@ -1,11 +1,25 @@ import { displayPanelScroller } from './displayPanelScroll.js'; -/** Move the existing Display groups between their two presentation surfaces. */ +/** + * Show the existing Display groups in Cockpit's Display popover while Cockpit + * is on. The portal publishes the popover's slots; the Display panel + * teleports its groups into them and back. The portal keeps each surface's + * scroll position and puts back focus the move dropped. + */ export class CockpitDisplayPortal { - constructor({ standardPanel, cockpitPanel, groups, layout }) { + constructor({ + standardPanel, + cockpitPanel, + groups, + layout, + publish = () => {}, + afterRender = (callback) => callback(), + }) { this.standardPanel = standardPanel; this.cockpitPanel = cockpitPanel; this.layout = layout; + this.publish = publish; + this.afterRender = afterRender; this.active = false; this.destroyed = false; this.stopped = false; @@ -17,11 +31,12 @@ export class CockpitDisplayPortal { const slot = cockpitPanel?.querySelector( `[data-cockpit-display-slot="${name}"]`, ); - if (!group || !slot || !group.parentNode) return []; - const anchor = document.createComment(`cockpit-display-home:${name}`); - group.before(anchor); - return [{ name, group, slot, anchor }]; + if (!group || !slot) return []; + return [{ name, group, slot }]; }); + this.slots = Object.freeze( + Object.fromEntries(this.records.map(({ name, slot }) => [name, slot])), + ); this.standardScrollTop = displayPanelScroller(standardPanel)?.scrollTop || 0; this.cockpitScrollTop = cockpitPanel?.scrollTop || 0; @@ -78,14 +93,7 @@ export class CockpitDisplayPortal { const focusedElement = focusedRecord ? document.activeElement : null; this.restoreOwner = nextActive ? 'cockpit' : 'standard'; this.active = nextActive; - for (const record of this.records) { - if (nextActive) record.slot.append(record.group); - else if (record.anchor.parentNode) record.anchor.after(record.group); - } - this.cockpitPanel?.classList.toggle( - 'uses-shared-display-controls', - nextActive, - ); + this.publish(nextActive ? this.slots : null); const restoreScroll = () => { if (nextActive && this.cockpitPanel) this.cockpitPanel.scrollTop = this.cockpitScrollTop; @@ -94,8 +102,11 @@ export class CockpitDisplayPortal { this.standardScrollTop; }; if (!settle) { - restoreScroll(); - this.restoreOwner = null; + this.afterRender(() => { + if (generation !== this.generation) return; + restoreScroll(); + this.restoreOwner = null; + }); return; } this._frame(generation, () => { @@ -114,7 +125,10 @@ export class CockpitDisplayPortal { this.restoreOwner = null; }); }); - this.layout(); + // The surfaces are measured once the groups have moved. + this.afterRender(() => { + if (!this.stopped && generation === this.generation) this.layout(); + }); } stop() { @@ -129,14 +143,12 @@ export class CockpitDisplayPortal { this.stop(); this.destroyed = true; this.active = false; - for (const record of this.records) { - if (record.anchor.parentNode) record.anchor.after(record.group); - record.anchor.remove(); - } - this.cockpitPanel?.classList.remove('uses-shared-display-controls'); - if (this.standardPanel) - displayPanelScroller(this.standardPanel).scrollTop = - this.standardScrollTop; + this.publish(null); + const { standardPanel, standardScrollTop } = this; + this.afterRender(() => { + if (standardPanel) + displayPanelScroller(standardPanel).scrollTop = standardScrollTop; + }); this.records = []; this.restoreOwner = null; } diff --git a/src/ui/cockpitDisplayPortal.test.mjs b/src/ui/cockpitDisplayPortal.test.mjs index 3e3c57733..4e861ccaf 100644 --- a/src/ui/cockpitDisplayPortal.test.mjs +++ b/src/ui/cockpitDisplayPortal.test.mjs @@ -79,6 +79,14 @@ function fixture() { return id; }; globalThis.cancelAnimationFrame = (id) => frames.delete(id); + const published = []; + // The Display panel's Teleport: the group moves to the published slot and + // back to its home ahead of its sibling. + const publish = (slots) => { + published.push(slots); + if (slots) slots.hud.append(group); + else if (group.parentNode !== standard) standard.children[0].before(group); + }; const create = () => new CockpitDisplayPortal({ standardPanel: standard, @@ -87,6 +95,7 @@ function fixture() { layout: () => { layouts += 1; }, + publish, }); return { standard, @@ -96,6 +105,7 @@ function fixture() { slot, frames, create, + published, get focusCalls() { return focusCalls; }, @@ -120,6 +130,7 @@ test('Display groups return to their exact home with independent scroll position try { const owner = f.create(); owner.setActive(true); + assert.deepEqual(f.published, [{ hud: f.slot }]); assert.equal(f.group.parentNode, f.slot); f.cockpit.scrollTop = 0; f.flush(); @@ -130,6 +141,7 @@ test('Display groups return to their exact home with independent scroll position f.flush(); assert.equal(f.standard.scrollTop, 71); owner.destroy(); + assert.deepEqual(f.published, [{ hud: f.slot }, null, null]); assert.deepEqual(f.standard.children, [f.group, f.sibling]); assert.equal(f.frames.size, 0); } finally { @@ -137,6 +149,35 @@ test('Display groups return to their exact home with independent scroll position } }); +test('the surfaces are laid out and scrolled once the groups have rendered', () => { + const f = fixture(); + try { + const rendered = []; + const owner = new CockpitDisplayPortal({ + standardPanel: f.standard, + cockpitPanel: f.cockpit, + groups: [['hud', f.group]], + layout: () => rendered.push('layout'), + publish: (slots) => rendered.push(slots ? 'cockpit' : 'standard'), + afterRender: (callback) => rendered.push(callback), + }); + owner.setActive(true, { settle: false }); + // Published first; the scroll waits for the render. + assert.deepEqual(rendered.slice(0, 1), ['cockpit']); + f.cockpit.scrollTop = 0; + rendered.splice(1).forEach((callback) => callback()); + assert.equal(f.cockpit.scrollTop, 19); + owner.setActive(false); + const pending = rendered.filter((item) => typeof item === 'function'); + assert.equal(rendered.includes('layout'), false); + pending.forEach((callback) => callback()); + assert.equal(rendered.at(-1), 'layout'); + owner.destroy(); + } finally { + f.restore(); + } +}); + test('focus moved elsewhere before the frame is not taken back', () => { const f = fixture(); try { diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index 3231ead0f..9efed9111 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -10,6 +10,7 @@ import DisplayPanel from './views/panels/DisplayPanel.vue'; import { AircraftDisplay } from './aircraftDisplay.js'; import { VisualSettings } from './visualSettings.js'; import { initGeoFileImport } from './geoFileImport.js'; +import { CockpitDisplayPortal } from './cockpitDisplayPortal.js'; // Core's classes on an element, without the kit's `ui-` classes. const coreClasses = (el) => [...el.classList].filter((name) => !name.startsWith('ui-')).join(' '); @@ -20,9 +21,14 @@ function fixture(t) { const pinia = createPinia(); const app = createApp(DisplayPanel).use(pinia); app.mount(host); - t.after(() => app.unmount()); + let mounted = true; + const unmount = () => { + if (mounted) app.unmount(); + mounted = false; + }; + t.after(unmount); const $ = (id) => document.getElementById(id); - return { store: useDisplayStore(pinia), $ }; + return { store: useDisplayStore(pinia), $, unmount }; } /** A VisualSettings over stand-in effects, publishing to `store`. */ @@ -575,3 +581,109 @@ test('VisualSettings publishes a style’s parameters and writes a moved slider owner.stop(); assert.equal(store.parameters, newer); }); + +test('the four Cockpit groups teleport to the published slots and back to their places', async (t) => { + const { store, $ } = fixture(t); + const groupOf = (id) => $(id).closest('.pp-toggle-group') ?? $(id); + const ids = ['hud-toggle', 'detection-toggle', 'param-slider-panel', 'models3d-toggle']; + const body = $('pp-toggles').querySelector('.pp-panel-body'); + const order = () => + [...body.children].filter((el) => el.matches('.pp-toggle-group, #param-slider-panel')) + .map((el) => el.id || el.querySelector('[id]')?.id); + const home = order(); + const popover = document.body.appendChild(document.createElement('div')); + const slots = {}; + for (const name of ['hud', 'detection', 'parameters', 'models3d']) { + slots[name] = popover.appendChild(document.createElement('div')); + } + store.publishCockpitPortal(slots); + await nextTick(); + assert.equal(slots.hud.contains($('hud-toggle')), true); + assert.equal(slots.detection.contains($('detection-toggle')), true); + assert.equal(slots.parameters.firstElementChild, $('param-slider-panel')); + assert.equal(slots.models3d.contains($('models3d-toggle')), true); + // The teleported controls still render from the store. + store.publishParameters({ style: 'noir', rows: [] }); + await nextTick(); + assert.equal($('param-slider-panel').classList.contains('active'), true); + store.publishCockpitPortal(null); + await nextTick(); + for (const id of ids) assert.equal(body.contains(groupOf(id)), true, id); + assert.deepEqual(order(), home); + // A second trip uses the same slots. + store.publishCockpitPortal(slots); + await nextTick(); + assert.equal(slots.hud.contains($('hud-toggle')), true); + store.publishCockpitPortal(null); + await nextTick(); + assert.deepEqual(order(), home); +}); + +test('a group without a published slot stays home; a replaced slot and an unmount leave nothing behind', async (t) => { + const { store, $, unmount } = fixture(t); + const body = $('pp-toggles').querySelector('.pp-panel-body'); + const popover = document.body.appendChild(document.createElement('div')); + const slot = () => popover.appendChild(document.createElement('div')); + const first = { hud: slot(), detection: slot(), parameters: slot() }; + store.publishCockpitPortal(first); + await nextTick(); + // No models3d slot: 3D stays in the Display panel, not in body. + assert.equal(body.contains($('models3d-toggle')), true); + assert.equal(first.hud.contains($('hud-toggle')), true); + // A new popover replaces the slots while the groups are out. + const second = { ...first, hud: slot() }; + store.publishCockpitPortal(second); + await nextTick(); + assert.equal(second.hud.contains($('hud-toggle')), true); + assert.equal(first.hud.childElementCount, 0); + // Unmounting while teleported removes the groups from the slots too. + unmount(); + for (const target of Object.values(second)) + assert.equal(target.childElementCount, 0); +}); + +test('the real portal lays out once the groups have moved and puts back the focus the move dropped', async (t) => { + const { store, $ } = fixture(t); + const popover = document.body.appendChild(document.createElement('div')); + for (const name of ['hud', 'detection', 'parameters', 'models3d']) { + const slot = popover.appendChild(document.createElement('div')); + slot.dataset.cockpitDisplaySlot = name; + } + const frames = []; + const priorFrame = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (callback) => frames.push(callback); + t.after(() => { + globalThis.requestAnimationFrame = priorFrame; + }); + const layouts = []; + const portal = new CockpitDisplayPortal({ + standardPanel: $('pp-toggles'), + cockpitPanel: popover, + groups: [ + ['hud', $('hud-toggle').closest('.pp-toggle-group')], + ['detection', $('detection-toggle').closest('.pp-toggle-group')], + ['parameters', $('param-slider-panel')], + ['models3d', $('models3d-toggle').closest('.pp-toggle-group')], + ], + layout: () => layouts.push(popover.contains($('hud-toggle'))), + publish: (slots) => store.publishCockpitPortal(slots), + afterRender: (callback) => { + void nextTick(callback); + }, + }); + t.after(() => portal.destroy()); + $('hud-toggle').focus(); + portal.setActive(true); + // Superseded before the flush: one layout, for the last state. + portal.setActive(false); + portal.setActive(true); + await nextTick(); + assert.deepEqual(layouts, [true]); + while (frames.length) frames.shift()(); + assert.equal(document.activeElement, $('hud-toggle')); + // A stopped portal lays out nothing. + portal.stop(); + portal.setActive(false); + await nextTick(); + assert.deepEqual(layouts, [true]); +}); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index d75ae3580..dddedf575 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -97,8 +97,6 @@ export class PanelLayoutController { this._weatherPanel = document.getElementById('weather-panel'); this._recentImageryPanel = document.getElementById('recent-imagery-panel'); this._streetLevelPanel = document.getElementById('street-level-panel'); - this._sliderPanel = document.getElementById('param-slider-panel'); - this._detectionBtn = document.getElementById('detection-toggle'); } _scheduleAdaptivePanelLayout({ settle = false } = {}) { if (this.destroyed) return; @@ -199,16 +197,6 @@ export class PanelLayoutController { if (!panel) continue; stack.insertBefore(panel, globalContextPanel); } - if (this._sliderPanel) { - this._sliderPanel.style.removeProperty('top'); - this._sliderPanel.style.removeProperty('right'); - this._sliderPanel.style.removeProperty('bottom'); - this._sliderPanel.style.removeProperty('left'); - this._sliderPanel.style.removeProperty('max-height'); - const detectionGroup = this._detectionBtn?.closest('.pp-toggle-group'); - if (detectionGroup) detectionGroup.after(this._sliderPanel); - else this._ppToggles.append(this._sliderPanel); - } if (typeof ResizeObserver !== 'undefined') { this._rightStackResizeObserver = new ResizeObserver(() => { this._scheduleRightPanelLayout(); diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index 5dd63580c..42d5b51fa 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -83,6 +83,18 @@ export const useDisplayStore = defineStore('gev-display', () => { parameters.value = next && markRaw(next); } + /** + * The Cockpit's Display popover slots while Cockpit shows the Display + * controls, `{hud, detection, parameters, models3d}` (elements); null while + * they stay in the Display panel. The panel teleports its groups there. + */ + const cockpitPortal = shallowRef(null); + + /** @param {?object} next The slots, or null to bring the groups home. */ + function publishCockpitPortal(next) { + cockpitPortal.value = next && markRaw(next); + } + /** The phone shell's tab bar; null until the shell first presents it. */ const phoneShell = shallowRef(null); @@ -104,6 +116,8 @@ export const useDisplayStore = defineStore('gev-display', () => { publishStyle, parameters, publishParameters, + cockpitPortal, + publishCockpitPortal, phoneShell, publishPhoneShell, mapSource, diff --git a/src/ui/views/panels/CockpitUtilityControls.vue b/src/ui/views/panels/CockpitUtilityControls.vue index d10b29ab9..f631e6408 100644 --- a/src/ui/views/panels/CockpitUtilityControls.vue +++ b/src/ui/views/panels/CockpitUtilityControls.vue @@ -6,14 +6,15 @@ import { vClassToggle } from '../../rail/classToggle.js'; import UiSlider from '../../kit/UiSlider.js'; import { useRadioStore } from '../../stores/radio.js'; import { useCockpitStore } from '../../stores/cockpit.js'; +import { useDisplayStore } from '../../stores/display.js'; import UiButton from '../../kit/UiButton.vue'; // The Cockpit's Display and Radio launchers and their popovers. RadioControls // presents which popover is open and the compact Radio controls; until it // does, both are closed and the controls show their initial state. The // controls call RadioControls through the store. The Display popover's slots -// are static: the Cockpit display portal moves the real Display controls into -// them. +// are static: the Display panel teleports its real controls into them while +// the Cockpit display portal publishes them. const store = useRadioStore(); const { act } = store; const { view: radio, utility } = storeToRefs(store); @@ -22,6 +23,9 @@ const cockpit = computed(() => radio.value?.cockpit ?? null); // The Cockpit's layout pass measures which controls fit; until it runs, the // controls carry no aria-hidden. const { utilityLayout: layout } = storeToRefs(useCockpitStore()); +// The Display panel's groups sit in the popover's slots while the Cockpit +// display portal publishes them. +const { cockpitPortal } = storeToRefs(useDisplayStore()); const ariaHidden = (expanded) => layout.value ? String( @@ -50,7 +54,7 @@ const radioLabel = computed(() => -
+
diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index 259b64106..a810ffb59 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -19,7 +19,7 @@ import { StyleParameterRows } from '../../styleParameters.js'; // the draw tool its controls, and each publishes them; until they do, the // panel shows its initial markup. GeoImport renders the file import. const display = useDisplayStore(); -const { view, models3d, draw, parameters } = storeToRefs(display); +const { view, models3d, draw, parameters, cockpitPortal } = storeToRefs(display); // A control's change goes to its owner (DisplayBindings or the draw tool). const act = (name, ...args) => display.act(name, ...args); const pct = (value) => `${value}%`; @@ -64,6 +64,8 @@ const allocationOptions = [
+ +
+ +
▣ @@ -133,7 +137,9 @@ const allocationOptions = [ {{ pct(detection?.outsideOpacityPct ?? 1) }}
+ +
PARAMETERS @@ -142,6 +148,8 @@ const allocationOptions = [
+
+