diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b6d77b912..f5df3c51a 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1197,6 +1197,8 @@ export class StyleManager extends ShellFacade { toggleEnabled: (...args) => this._toggleCctvEnabled(...args), runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + preservePanelStateDuringClear: () => + this._preservePanelStateDuringLayerClear, 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..65c5c724d 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -513,3 +513,81 @@ 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]); + // Later notifications while off leave a panel the user reopened alone. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(calls.length, 2); +}); + +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 context-mode layer clear keeps the panel open', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls.actions.preservePanelStateDuringClear = () => true; + controls._renderCctvState(activeState); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls, [['cctv-panel', false]]); +}); + +test('an already collapsed panel is not collapsed again', (t) => { + const { controls } = panel(t); + controls._renderCctvState(activeState); + const calls = collapseCalls(controls); + controls._cctvPanel.classList.add('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls, []); +}); + +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..eff9d528b 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,19 @@ 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, and a context-mode layer clear keeps + // the panel for the layers it restores. + if ( + this._wasCctvEnabled && + !enabled && + !this.actions.preservePanelStateDuringClear?.() && + !this._cctvPanel?.classList?.contains('collapsed') + ) { + this.actions.setPanelCollapsed('cctv-panel', true); + } + this._wasCctvEnabled = enabled; this._updateCctvSyncChip(state?.loading, enabled);