diff --git a/changelog.d/1723-cctv-panel-collapse-on-disable.md b/changelog.d/1723-cctv-panel-collapse-on-disable.md new file mode 100644 index 000000000..ae5a49445 --- /dev/null +++ b/changelog.d/1723-cctv-panel-collapse-on-disable.md @@ -0,0 +1,5 @@ +- Collapse the CCTV panel when the layer is switched off. It stayed open with + an empty frame and a meta line that still described the last camera; the + meta line of a disabled layer now reads the catalog count. Contacts, Space + Missions and Clear selected data layers leave the panel as it is + (asturksever, #1723). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 80e7b8860..ce37fa0fc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2229,6 +2229,17 @@ shown without a fade once it is decoded (or after one second if its decode never settles), so a new camera's preview is blank only while its frame loads. Disposal releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. +The panel opens when a camera becomes active and collapses when the layer is +switched off, from its own switch or anywhere else. The panel chrome applies +the collapse, so a panel the desktop accordion only folded has its saved and +shared state closed too. Clear selected data layers and Context isolation +(Contacts, Space Missions) switch the layer off without the user asking: the +panel and its share preference stay as they are, and the layer comes back to an +open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true +while `_clearLayersOutsideContextMode` switches the layer off): the user +switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its +meta line reads the catalog count, not the last active camera. + ## UI disposal UI disposal releases the CCTV subscription, command-dock observer, legacy drag diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index c2d2b5646..b16a793df 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -339,6 +339,45 @@ try { '#cctv-frame', (image) => !image.getAttribute('src') && image.dataset.cameraId === '', ); + // The address bar follows the share state after a short delay: wait for the + // CCTV panel token (1 collapsed, 0 open) the step expects. + const panelState = async (expectedToken) => { + const settled = await page + .waitForFunction( + (token) => + new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test( + new URLSearchParams(location.hash.slice(1)).get('ui') || '', + ), + { timeout: 5000 }, + expectedToken, + ) + .then( + () => true, + () => false, + ); + return page.evaluate( + (shared) => ({ + collapsed: document + .getElementById('cctv-panel') + .classList.contains('collapsed'), + shared, + ui: new URLSearchParams(location.hash.slice(1)).get('ui'), + }), + settled ? expectedToken : null, + ); + }; + const closedOnDisable = await panelState('1'); + check( + 'disable collapses the panel and its share preference', + closedOnDisable.collapsed && closedOnDisable.shared === '1', + JSON.stringify(closedOnDisable), + ); + // The switch sits inside the collapsed panel: reopen it from the right rail. + await page.click('[data-rail-item="cctv-panel"]'); + await page.waitForFunction( + () => + !document.getElementById('cctv-panel').classList.contains('collapsed'), + ); await page.click('#cctv-enable-btn'); await page.waitForFunction( () => @@ -351,6 +390,42 @@ try { 'disable clears the preview and re-enable reacquires the camera frame', disabledCleanly, ); + // Contacts switches CCTV off to isolate itself and restores it on the way + // out: the open panel and its share preference survive the round trip. + const cameraBeforeContacts = await page.evaluate( + () => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId, + ); + const entered = await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode('flights'), + ); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const duringContacts = await panelState('0'); + await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode(null), + ); + await page.waitForFunction(() => + window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const afterContacts = await panelState('0'); + check( + 'Contacts isolation keeps the open CCTV panel and restores its camera', + entered?.ok === true && + !duringContacts.collapsed && + duringContacts.shared === '0' && + !afterContacts.collapsed && + afterContacts.shared === '0' && + (await page.evaluate( + (cameraId) => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId === cameraId, + cameraBeforeContacts, + )), + JSON.stringify({ entered, duringContacts, afterContacts }), + ); await page.$eval('#cctv-frame', (image) => image.scrollIntoView({ block: 'nearest' }), ); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b6d77b912..8122bda29 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1197,6 +1197,11 @@ export class StyleManager extends ShellFacade { toggleEnabled: (...args) => this._toggleCctvEnabled(...args), runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + // Clear selected data layers and Context isolation switch the layer + // off without the user asking: the panel keeps its state for both. + preservePanelState: () => + this._preservePanelStateDuringLayerClear || + this._contextSuspendsLayer('cctv'), showToast: (message) => this._showToast(message), syncViewport: () => this._syncCctvPanelViewport(), }, diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 4e8911695..1e227c92e 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -54,6 +54,7 @@ export class CctvControls { this._cctvSummaryTypingTimer = null; this._lastCctvSummaryText = ''; this._lastSeenCctvActiveId = null; + this._wasCctvEnabled = false; this._cctvChipHideTimer = null; this._cctvChipWasBusy = false; // The camera grid chip as last published; its label stays while hidden. diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index a3dbef786..bc9c83c9b 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -513,3 +513,96 @@ test('the panel meta line reads a bearing that rounds to north as HDG 0°', () = assert.match(meta(359.4), /· HDG 359° ·/); assert.match(meta(359.6), /· HDG 0° ·/); }); + +/** The panel collapses the test asked for, as `[panelId, collapsed]` pairs. */ +function collapseCalls(controls) { + const calls = []; + controls.actions.setPanelCollapsed = (panelId, collapsed) => { + calls.push([panelId, collapsed]); + // The panel chrome owns the class the controls read back. + controls._cctvPanel.classList.toggle('collapsed', collapsed); + }; + return calls; +} + +const activeState = { + enabled: true, + count: 1, + cameraIndex: [], + activeCameraId: camera.id, + activeCamera: camera, +}; + +test('switching the layer off collapses the panel it opened', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState(activeState); + assert.deepEqual(calls, [['cctv-panel', false]]); + // The layer keeps its active camera while off. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); + // A panel reopened by hand stays open through later notifications while + // the layer is off: only the on → off change collapses. + controls._cctvPanel.classList.remove('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(calls.length, 2); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); +}); + +test('a layer that was never on leaves an open panel alone', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + assert.deepEqual(calls, []); +}); + +test('the data manager switching the layer off collapses the panel too', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + let on = true; + controls.actions.isEnabled = () => on; + controls._renderCctvState(activeState); + on = false; + controls._renderCctvState(activeState); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); +}); + +test('a layer the application suspends keeps its panel through the round trip', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + // Clear selected data layers and Context isolation both answer true. + let suspended = true; + controls.actions.preservePanelState = () => suspended; + controls._renderCctvState(activeState); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + suspended = false; + controls._renderCctvState(activeState); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + assert.ok(calls.every(([, collapsed]) => collapsed === false)); + // The next switch-off is the user's again. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); +}); + +test('a panel the accordion folded still has its saved state closed', (t) => { + const { controls } = panel(t); + controls._renderCctvState(activeState); + const calls = []; + controls.actions.setPanelCollapsed = (...args) => calls.push(args); + // Folded for layout only: the class is there, the saved state is open. The + // panel chrome tells the two apart, so the controls always ask it. + controls._cctvPanel.classList.add('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls, [['cctv-panel', true]]); +}); + +test('the meta line of a disabled layer does not describe its last camera', () => { + const off = { ...activeState, enabled: false, count: 3 }; + assert.equal( + presentCctvPanel(off, false).meta, + '3 cameras loaded · enable CCTV to activate', + ); + assert.match(presentCctvPanel(activeState, true).meta, /^Austin · HDG /); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 7eab9bc2d..2f4cb8584 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -90,7 +90,9 @@ export function presentCctvPanel(state, enabled) { } function metaText(state, enabled, activeCamera, catalogCount) { - if (activeCamera) { + // The layer keeps its active camera while off: a disabled panel must not + // describe a feed it no longer shows. + if (enabled && activeCamera) { const provider = cctvSourceLabelText( activeCamera.sourceLabel || activeCamera.provider || 'Configured Source', ); @@ -188,6 +190,20 @@ export function _renderCctvState(state) { }); } this._lastSeenCctvActiveId = effectiveActiveId; + // Switching the layer off closes the panel it opened: nothing is left to + // show. Only the on → off change collapses, so a panel the user opens + // while the layer is off stays open. Clear selected data layers and + // Context isolation keep the panel: the layer comes back with it. The + // panel chrome decides whether anything changes: a panel the desktop + // accordion only folded still has an open saved state to close. + if ( + this._wasCctvEnabled && + !enabled && + !this.actions.preservePanelState?.() + ) { + this.actions.setPanelCollapsed('cctv-panel', true); + } + this._wasCctvEnabled = enabled; this._updateCctvSyncChip(state?.loading, enabled); diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 81da5f20e..7acb31ef5 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -51,6 +51,8 @@ export class ContextControls { this._contextRestoreState = null; this._contextLayerReactionPromises = new Set(); this._preservePanelStateDuringLayerClear = false; + /** Layers Context isolation is switching off right now. */ + this._isolatingLayerIds = new Set(); this._userFacingContextNotificationTokens = new Set(); this._clearSelectedLayersPromise = null; this._clearSelectedLayersManagerPromise = null; @@ -59,6 +61,16 @@ export class ContextControls { showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } + /** + * Whether Context isolation is switching this layer off right now: it is + * suspended, and leaving the mode restores it. A switch-off by the user, + * inside the mode or out of it, is not. + * @param {string} layerId Layer id. + * @returns {boolean} + */ + suspendsLayer(layerId) { + return this._isolatingLayerIds.has(layerId); + } setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index 33b3c4d10..2bceb7e75 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -153,3 +153,56 @@ test('a rejected tracked reaction settles without an unhandled rejection', async await controls._waitForContextLayerSettlement(); assert.equal(controls._contextLayerReactionPromises.size, 0); }); + +test('only a layer Context isolation is switching off is suspended', async (t) => { + const { controls } = fixture(t); + const enabled = new Set(['cctv']); + // What the CCTV panel is told at each visibility change of its layer. + const seen = []; + controls._dataManager = { + layers: new Map([['cctv', {}]]), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => null, + setLayerParams() {}, + isEnabled: (layerId) => enabled.has(layerId), + isEffectivelyEnabled: (layerId) => enabled.has(layerId), + async setEnabled(layerId, next) { + // The layer tells its panel it is off while it is being disabled. + if (next) enabled.add(layerId); + else enabled.delete(layerId); + seen.push([layerId, next, controls.suspendsLayer(layerId)]); + return true; + }, + async restoreEnabledLayerIds(layerIds) { + for (const layerId of layerIds) await this.setEnabled(layerId, true); + }, + }; + assert.equal(controls.suspendsLayer('cctv'), false); + controls._captureContextSessionSnapshot(); + await controls._clearLayersOutsideContextMode('flights'); + assert.deepEqual(seen, [['cctv', false, true]]); + assert.equal(controls.suspendsLayer('cctv'), false, 'only while isolating'); + // Inside the mode the user turns CCTV back on, then off: their own. + await controls._dataManager.setEnabled('cctv', true); + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); + await controls._restoreContextSession(); + assert.equal(enabled.has('cctv'), true); + // A switch-off after the mode is left is the user's own too. + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); +}); + +test('a failed isolation leaves no layer suspended', async (t) => { + const { controls } = fixture(t); + controls._dataManager = { + layers: new Map([['cctv', {}]]), + isEffectivelyEnabled: () => true, + isEnabled: () => true, + setEnabled: async () => { + throw new Error('disable failed'); + }, + }; + await assert.rejects(controls._clearLayersOutsideContextMode('flights')); + assert.equal(controls.suspendsLayer('cctv'), false); +}); diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index e8a69b5fc..1f17faeef 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -240,25 +240,35 @@ export async function _clearLayersOutsideContextMode( ) { const allowed = contextAllowedLayerIds(mode); const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if ( - !allowed.has(layerId) && - this._dataManager.isEffectivelyEnabled(layerId) - ) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); + // Panels read this while their layer goes off (suspendsLayer): a layer + // isolation switches off is suspended, not switched off by the user. + const isolating = []; + let results; + try { + for (const [layerId] of this._dataManager.layers || []) { + // Effective visibility: a disallowed layer still mid-ENABLING must be + // isolated too, or it settles ON inside the exclusive mode. + if ( + !allowed.has(layerId) && + this._dataManager.isEffectivelyEnabled(layerId) + ) { + if (!this._isolatingLayerIds.has(layerId)) { + this._isolatingLayerIds.add(layerId); + isolating.push(layerId); + } + pending.push({ + layerId, + transition: this._dataManager.setEnabled(layerId, false, { + notificationToken, + ...(signal ? { signal } : {}), + }), + }); + } } + results = await Promise.all(pending.map(({ transition }) => transition)); + } finally { + for (const layerId of isolating) this._isolatingLayerIds.delete(layerId); } - const results = await Promise.all( - pending.map(({ transition }) => transition), - ); const failed = pending .filter( ({ layerId }, index) => diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 678b69c79..276a95547 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -891,6 +891,10 @@ export class ShellFacade { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } + _contextSuspendsLayer(layerId) { + return this._contextControls?.suspendsLayer(layerId) ?? false; + } + _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); }