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(() => -