diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 8a61e0cb4..a3c532026 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -115,7 +115,10 @@ They contain no application state. publishes through `subscribePanel()`, the first layer to hand its rail content to a view, and `MissionRoster` and `MissionDetail` the Space Missions roster and selected mission that the launches layer publishes - the same way), the command dock, + the same way), the command dock (in progress: the style buttons, the + dock's style mini status and the ACTIVE STYLE corner render the style + VisualSettings publishes to the `gev-display` store; the buttons keep + their owner's listeners), the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 6c63406ee..b36a615b7 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -20,16 +20,17 @@ function locationSearchHandler() { test('the ACTIVE STYLE indicator is written from the style name and nothing else', () => { // A free-text location search used to write the searched CITY into the - // top-right style slot, so the corner read "ACTIVE STYLE / TOKYO". - const writes = [...ui.matchAll(/this\._styleIndicator\.textContent\s*=/g)]; + // top-right style slot, so the corner read "ACTIVE STYLE / TOKYO". The + // corner renders the style VisualSettings publishes, from one writer. + const writes = [...ui.matchAll(/\.publishStyle\(/g)]; assert.equal(writes.length, 1, 'the style indicator must have exactly one writer'); assert.match( - ui.slice(writes[0].index, writes[0].index + 160), - /this\._styleIndicator\.textContent = STYLE_STATUS_LABELS\[styleName\]\s*\?\s*t\(STYLE_STATUS_LABELS\[styleName\]\)\s*:\s*styleName\.toUpperCase\(\);/, + ui.slice(writes[0].index, writes[0].index + 200), + /\.publishStyle\(\{\s*name,\s*label: STYLE_STATUS_LABELS\[name\]\s*\?\s*t\(STYLE_STATUS_LABELS\[name\]\)\s*:\s*name\.toUpperCase\(\),/, ); const handler = locationSearchHandler(); - assert.doesNotMatch(handler, /_styleIndicator/, 'location search must not touch the style indicator'); + assert.doesNotMatch(handler, /publishStyle|_publishActiveStyle/, 'location search must not touch the style indicator'); assert.doesNotMatch(handler, /active-style-name/, 'location search must not touch the style indicator'); }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c79ed3834..2a2d47804 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -213,8 +213,6 @@ export class StyleManager extends ShellFacade { _ppToggles: this._ppToggles, _sliderContainer: this._sliderContainer, _sliderPanel: this._sliderPanel, - _styleIndicator: this._styleIndicator, - _styleMiniValue: this._styleMiniValue, }, operations: { _restorePanelState: (...args) => this._restorePanelState(...args), @@ -586,7 +584,7 @@ export class StyleManager extends ShellFacade { this._initRecordingOverlay(); this._startAnimationLoop(); this._startTrafficChipTicker(); - this._updateStyleMiniStatus(); + this._publishActiveStyle(); this._updateLocationMiniStatus(); this._shareRestoration.start(); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs new file mode 100644 index 000000000..0a065558c --- /dev/null +++ b/src/ui/commandDock.test.mjs @@ -0,0 +1,81 @@ +// src/ui/commandDock.test.mjs — the command dock and the scene chrome render +// what VisualSettings publishes, over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, h, nextTick } from 'vue'; +import { setLocale } from '../i18n/index.js'; +import { useDisplayStore } from './stores/display.js'; +import CommandDockView from './views/CommandDockView.vue'; +import SceneChromeView from './views/SceneChromeView.vue'; +import { VisualSettings } from './visualSettings.js'; + +function fixture(t) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp({ + render: () => [h(SceneChromeView), h(CommandDockView)], + }).use(pinia); + app.mount(host); + t.after(() => app.unmount()); + const store = useDisplayStore(pinia); + // VisualSettings publishes through its store; the method needs no more. + const settings = Object.assign(Object.create(VisualSettings.prototype), { + store, + activeStyle: 'normal', + }); + const $ = (selector) => document.querySelector(selector); + const active = () => + [...document.querySelectorAll('.style-btn.active')].map( + (button) => button.dataset.style, + ); + return { $, settings, active }; +} + +test('before VisualSettings publishes, the dock shows NORMAL', (t) => { + const { $, active } = fixture(t); + assert.deepEqual(active(), ['normal']); + assert.equal($('#style-mini-value').textContent, 'NORMAL'); + assert.equal($('#active-style-name').textContent, 'NORMAL'); +}); + +test('the active style marks its button and labels the dock and the corner', async (t) => { + const { $, settings, active } = fixture(t); + settings._publishActiveStyle('thermal'); + await nextTick(); + assert.deepEqual(active(), ['thermal']); + assert.equal($('#style-mini-value').textContent, 'FLIR'); + assert.equal($('#active-style-name').textContent, 'FLIR'); + settings.activeStyle = 'unlisted'; + settings._publishActiveStyle(); + await nextTick(); + assert.deepEqual(active(), []); + assert.equal($('#style-mini-value').textContent, 'UNLISTED'); + settings._publishActiveStyle('normal'); + await nextTick(); + assert.deepEqual(active(), ['normal']); +}); + +test('the style buttons keep classes other code sets', async (t) => { + const { $, settings } = fixture(t); + const noir = $('.style-btn[data-style="noir"]'); + noir.classList.add('voice-highlight'); + settings._publishActiveStyle('noir'); + await nextTick(); + assert.equal(noir.classList.contains('voice-highlight'), true); + assert.equal(noir.classList.contains('active'), true); +}); + +test('the dock and the corner label the style in the UI language', async (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const { $, settings } = fixture(t); + assert.equal($('#style-mini-value').textContent, 'ОБЫЧНЫЙ'); + assert.equal($('#active-style-name').textContent, 'ОБЫЧНЫЙ'); + settings._publishActiveStyle('noir'); + await nextTick(); + assert.equal($('#style-mini-value').textContent, 'НУАР'); + assert.equal($('#active-style-name').textContent, 'НУАР'); +}); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index ca053dd85..c31eeba8c 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -77,7 +77,6 @@ function styleOwner(t, initialVariant = 'cyber') { ), }, services: { governorRequestRender() {}, setDetectionStyle() {} }, - _styleIndicator: {}, _setBloomEnabled: (value) => calls.push(['bloom', value]), _setSharpenEnabled: (value) => calls.push(['sharpen', value]), _applyDetectionPreset: (value) => calls.push(['detection', value]), @@ -86,7 +85,7 @@ function styleOwner(t, initialVariant = 'cyber') { for (const name of [ 'setCelestialRingEnabled', '_startTransition', - '_updateStyleMiniStatus', + '_publishActiveStyle', '_updateSliderPanel', '_updateHudButtonState', '_syncIrBoost', diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 5336e7f13..2ac57a9ef 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -2,7 +2,6 @@ export function readShellElements(document = globalThis.document) { return { _rightPanelStack: document.getElementById('right-context-rail'), - _styleIndicator: document.getElementById('active-style-name'), _sliderPanel: document.getElementById('param-slider-panel'), _sliderContainer: document.getElementById('param-sliders'), _ppToggles: document.getElementById('pp-toggles'), @@ -132,7 +131,6 @@ export function readShellElements(document = globalThis.document) { _locationPills: document.getElementById('location-pills'), _poiRow: document.getElementById('poi-row'), _locationBarDivider: document.getElementById('location-bar-divider'), - _styleMiniValue: document.getElementById('style-mini-value'), _locationMiniCity: document.getElementById('location-mini-city'), _locationMiniPoi: document.getElementById('location-mini-poi'), _safeFrameOverlay: document.getElementById('safe-frame-overlay'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 4f44d3039..89cf2644d 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -1304,8 +1304,8 @@ export class ShellFacade { * @param {string} [styleName=this.activeStyle] - Style name to display. * @returns {void} */ - _updateStyleMiniStatus(styleName = this.activeStyle) { - return this._visualSettings._updateStyleMiniStatus(...arguments); + _publishActiveStyle(styleName = this.activeStyle) { + return this._visualSettings._publishActiveStyle(...arguments); } _initOrbit(...args) { diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index cad6c5dd2..0b3ef39d5 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -5,7 +5,8 @@ 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. + * here; each keeps its listeners on the controls. VisualSettings also + * publishes the active style, which the dock and the scene chrome show. */ export const useDisplayStore = defineStore('gev-display', () => { /** The presented panel; null until VisualSettings first renders. */ @@ -40,6 +41,14 @@ export const useDisplayStore = defineStore('gev-display', () => { geoImport.value = markRaw(next); } + /** The active visual style; null until VisualSettings first presents it. */ + const style = shallowRef(null); + + /** @param {{name: string, label: string}} next The active style and its label. */ + function publishStyle(next) { + style.value = markRaw(next); + } + return { view, publish, @@ -49,5 +58,7 @@ export const useDisplayStore = defineStore('gev-display', () => { publishDraw, geoImport, publishGeoImport, + style, + publishStyle, }; }); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index 954d31389..f60972e43 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -1,3 +1,14 @@ + + @@ -14,32 +25,32 @@ - + ◯ Normal 1 - + ▦ CRT 2 - + 🌙 NVG 3 - + 🌡️ FLIR 4 - + ✦ Anime 5 - + ◐ Noir 6 @@ -55,7 +66,7 @@ Style - NORMAL + {{ style ? style.label : t('NORMAL') }} diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index bdcf8f3b5..b954adaac 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -1,3 +1,12 @@ + + @@ -18,7 +27,7 @@ ACTIVE STYLE - NORMAL + {{ style ? style.label : t('NORMAL') }} diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 884225414..83f41cf0c 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1517,16 +1517,8 @@ export class VisualSettings { this._applyStylePresetDefaults(styleName); } - // Update button UI - document.querySelectorAll('.style-btn').forEach((btn) => { - btn.classList.toggle('active', btn.dataset.style === styleName); - }); - - // Update style indicator - this._styleIndicator.textContent = STYLE_STATUS_LABELS[styleName] - ? t(STYLE_STATUS_LABELS[styleName]) - : styleName.toUpperCase(); - this._updateStyleMiniStatus(styleName); + // The style buttons, the dock's mini status and the ACTIVE STYLE corner. + this._publishActiveStyle(styleName); // Update parameter sliders this._updateSliderPanel(styleName, { reveal: revealParameters }); @@ -1555,11 +1547,15 @@ export class VisualSettings { this._visualEffects.startTransition(styleName, fromValue, toValue); } - _updateStyleMiniStatus(styleName = this.activeStyle) { - if (!this._styleMiniValue) return; - this._styleMiniValue.textContent = STYLE_STATUS_LABELS[styleName] - ? t(STYLE_STATUS_LABELS[styleName]) - : String(styleName || 'normal').toUpperCase(); + /** Publish the active style and its label in the UI language. */ + _publishActiveStyle(styleName = this.activeStyle) { + const name = String(styleName || 'normal'); + this.store.publishStyle({ + name, + label: STYLE_STATUS_LABELS[name] + ? t(STYLE_STATUS_LABELS[name]) + : name.toUpperCase(), + }); } _updateHudButtonState() {