feat(ui): render the Display panel's HUD and Sonar controls from the store

This commit is contained in:
Sameh Khamis committed 2026-10-30 23:01:09 -07:00
1 parent c99c0e8000
commit 3aa5a3e5e3
11 files changed
+118 -94

No files matched your search

+1 -1
View File
@@ -94,7 +94,7 @@ They contain no application state.
renders `LayerRows` synchronously; keyed chips and list items keep focus
through the refresh their own click triggers, and clicks re-read the live
descriptor before acting), Display (in progress: `DisplayPanel` renders
bloom, sharpen and detection from the `gev-display` store, which VisualSettings
the HUD, Sonar, detection, bloom and sharpen from the `gev-display` store, which VisualSettings
publishes to; the other groups follow one owner at a time), Context and
Radio, the command dock,
the phone shell, then Cockpit. Layers that write into the right rail
+1 -17
View File
@@ -116,6 +116,7 @@ export class StyleManager extends ShellFacade {
this._lifetime = new UiLifetime();
this._recording = new RecordingControls({
syncShareState: () => this._syncShareState(),
syncHudControls: () => this._visualSettings._syncHudControls(),
});
Object.assign(this, readShellElements());
this._panelChrome = new PanelChrome({
@@ -209,20 +210,6 @@ export class StyleManager extends ShellFacade {
elements: {
_celestialBtn: this._celestialBtn,
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRingsValue: this._cyberSonarRingsValue,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarRangeValue: this._cyberSonarRangeValue,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarIntensityValue: this._cyberSonarIntensityValue,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarOpacityValue: this._cyberSonarOpacityValue,
_cyberSonarSector: this._cyberSonarSector,
_cyberSonarSectorValue: this._cyberSonarSectorValue,
_hudLayoutRow: this._hudLayoutRow,
_hudLayoutSelect: this._hudLayoutSelect,
_ppToggles: this._ppToggles,
_scopeBtn: this._scopeBtn,
_scopeFeatherSlider: this._scopeFeatherSlider,
@@ -1693,9 +1680,6 @@ export class StyleManager extends ShellFacade {
*/
_initHUDToggle() {
if (this._hudLayoutSelect) {
this._hudLayoutSelect.value = 'tactical';
}
this._setHudVariant('tactical');
this.hud.setMode('on');
this._updateHudButtonState();
+2
View File
@@ -48,6 +48,8 @@ test('real runner and facade use visual settings, preserve visibility and return
const renders = [];
const visual = Object.create(VisualSettings.prototype);
visual.services = { governorRequestRender: (reason) => renders.push(reason) };
// No Display panel here: the controls' render is not under test.
visual._publishDisplay = () => {};
const owner = Object.create(StyleManager.prototype);
owner._visualSettings = visual;
Object.defineProperty(owner, 'hud', { value: f.owner.hud });
+1 -2
View File
@@ -65,6 +65,7 @@ function styleOwner(t, initialVariant = 'cyber') {
const owner = Object.create(VisualSettings.prototype);
Object.assign(owner, {
activeStyle: 'normal',
store: { publish() {} },
readHud: () => hud,
readShareLinks: () => ({ onStyleChange() {} }),
_visualEffects: {
@@ -76,7 +77,6 @@ function styleOwner(t, initialVariant = 'cyber') {
),
},
services: { governorRequestRender() {}, setDetectionStyle() {} },
_hudLayoutSelect: { value: initialVariant },
_styleIndicator: {},
_setBloomEnabled: (value) => calls.push(['bloom', value]),
_setSharpenEnabled: (value) => calls.push(['sharpen', value]),
@@ -116,7 +116,6 @@ test('visual styles retain Cyber through real preset application without droppin
assert.equal(owner.activeStyle, style);
assert.equal(hud.getVariant(), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(owner._hudLayoutSelect.value, 'cyber');
assert.deepEqual(calls.at(-1), ['share', 'cyber']);
}
assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85);
+48
View File
@@ -205,3 +205,51 @@ test('detection renders its mode, rows, sliders and allocation', async (t) => {
assert.equal(button.className, 'pp-toggle-btn');
assert.equal($('detection-slider-row').classList.contains('visible'), false);
});
test('the HUD and Sonar controls render what VisualSettings shows', async (t) => {
const { store, $ } = fixture(t);
const { owner } = settings(store);
let visible = true;
let variant = 'minimal';
owner.readHud = () => ({
get visible() {
return visible;
},
getVariant: () => variant,
});
owner._scheduleAdaptivePanelLayout = () => {};
assert.equal($('cyber-sonar-toggle').textContent, 'ON');
assert.equal($('cyber-sonar-toggle').getAttribute('aria-pressed'), 'true');
owner._syncHudControls();
await nextTick();
assert.equal($('hud-toggle').classList.contains('active'), true);
assert.equal($('hud-layout-row').classList.contains('visible'), true);
assert.equal($('hud-layout-select').value, 'minimal');
visible = false;
owner._updateHudButtonState();
await nextTick();
assert.equal($('hud-toggle').classList.contains('active'), false);
assert.equal($('hud-layout-row').classList.contains('visible'), false);
owner._sonarShown = {
enabled: false,
rings: 5,
range: 90,
intensity: 40,
opacity: 60,
sector: 30,
};
owner._publishDisplay();
await nextTick();
const toggle = $('cyber-sonar-toggle');
assert.equal(toggle.textContent, 'OFF');
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
assert.equal(toggle.classList.contains('active'), false);
assert.equal($('cyber-sonar-rings').value, '5');
assert.equal($('cyber-sonar-rings-value').textContent, '5');
assert.equal($('cyber-sonar-range-value').textContent, '90%');
assert.equal($('cyber-sonar-sector').value, '30');
assert.equal($('cyber-sonar-sector-value').textContent, '30°');
});
+2 -1
View File
@@ -1,5 +1,6 @@
/**
* `v-range-value`: a range input's live value, written only when it differs.
* `v-range-value`: a range input's (or select's) live value, written only
* when it differs.
* A `:value` binding would also rewrite the value attribute on every patch;
* the directive runs after the element's min, max and step are patched, so
* the value is never clamped to a stale range.
+4 -8
View File
@@ -1,7 +1,9 @@
/** Own recording chrome, safe frames and the pre-recording HUD snapshot. */
export class RecordingControls {
constructor({ syncShareState }) {
constructor({ syncShareState, syncHudControls }) {
this._syncShareState = syncShareState;
// Shows the HUD's visibility and layout on the Display controls.
this._syncHudControls = syncHudControls;
this.destroyed = false;
this._recordingMode = false;
this._recordingConfig = {
@@ -12,8 +14,6 @@ export class RecordingControls {
this._preRecordingHudState = null;
this._safeFrameOverlay = document.getElementById('safe-frame-overlay');
this._safeFrameBox = document.getElementById('safe-frame-box');
this._hudLayoutSelect = document.getElementById('hud-layout-select');
this._hudBtn = document.getElementById('hud-toggle');
this.hud = null;
}
_initRecordingOverlay() {
@@ -63,8 +63,6 @@ export class RecordingControls {
} else if (hudMode === 'full' || hudMode === 'minimal') {
this.hud.setMode('on');
this.hud.setVariant(hudMode === 'minimal' ? 'minimal' : 'tactical');
if (this._hudLayoutSelect)
this._hudLayoutSelect.value = this.hud.getVariant();
} else {
this.hud.setMode('auto');
}
@@ -73,8 +71,6 @@ export class RecordingControls {
this._preRecordingHudState = null;
if (saved) {
this.hud.setVariant(saved.variant);
if (this._hudLayoutSelect)
this._hudLayoutSelect.value = this.hud.getVariant();
}
this.hud.setMode(saved ? saved.mode : 'auto');
if (this._safeFrameOverlay) {
@@ -85,7 +81,7 @@ export class RecordingControls {
);
}
}
this._hudBtn.classList.toggle('active', this.hud.visible);
this._syncHudControls();
this._syncShareState();
}
destroy() {
-10
View File
@@ -11,23 +11,13 @@ export function readShellElements(document = globalThis.document) {
_sharpenBtn: document.getElementById('sharpen-toggle'),
_sharpenSlider: document.getElementById('sharpen-intensity-slider'),
_hudBtn: document.getElementById('hud-toggle'),
_hudLayoutRow: document.getElementById('hud-layout-row'),
_hudLayoutSelect: document.getElementById('hud-layout-select'),
_cyberSonarBtn: document.getElementById('cyber-sonar-toggle'),
_cyberSonarRings: document.getElementById('cyber-sonar-rings'),
_cyberSonarRingsValue: document.getElementById('cyber-sonar-rings-value'),
_cyberSonarRange: document.getElementById('cyber-sonar-range'),
_cyberSonarRangeValue: document.getElementById('cyber-sonar-range-value'),
_cyberSonarIntensity: document.getElementById('cyber-sonar-intensity'),
_cyberSonarIntensityValue: document.getElementById(
'cyber-sonar-intensity-value',
),
_cyberSonarOpacity: document.getElementById('cyber-sonar-opacity'),
_cyberSonarOpacityValue: document.getElementById(
'cyber-sonar-opacity-value',
),
_cyberSonarSector: document.getElementById('cyber-sonar-sector'),
_cyberSonarSectorValue: document.getElementById('cyber-sonar-sector-value'),
_detectionDensitySlider: document.getElementById(
'detection-density-slider',
),
+10 -1
View File
@@ -200,7 +200,11 @@ test('recording restores the original HUD after repeated entry and revokes retai
try {
let mode = 'auto',
variant = 'tactical';
const owner = new RecordingControls({ syncShareState() {} });
const hudSyncs = [];
const owner = new RecordingControls({
syncShareState() {},
syncHudControls: () => hudSyncs.push(variant),
});
owner.hud = {
getMode: () => mode,
getVariant: () => variant,
@@ -218,6 +222,11 @@ test('recording restores the original HUD after repeated entry and revokes retai
owner.setRecordingMode(true);
assert.equal(mode, 'auto');
assert.equal(variant, 'tactical');
assert.deepEqual(
hudSyncs,
['minimal', 'minimal', 'tactical'],
'each change shows the HUD layout it leaves',
);
assert.equal(document.body.classList.contains('recording-mode'), false);
assert.equal(
f.nodes.get('safe-frame-overlay').classList.contains('active'),
+20 -17
View File
@@ -5,11 +5,14 @@ import { t } from '../../../i18n/index.js';
import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js';
// The Display panel. VisualSettings presents bloom, sharpen and detection
// and publishes them; until it does, the panel shows its initial markup.
// The other groups keep their owners' element bindings.
// The Display panel. VisualSettings presents the HUD, Sonar, detection,
// bloom and sharpen and publishes them; until it does, the panel shows its
// initial markup. The other groups keep their owners' element bindings.
const { view } = storeToRefs(useDisplayStore());
const pct = (value) => `${value}%`;
const hud = computed(() => view.value?.hud ?? null);
const sonar = computed(() => view.value?.sonar ?? null);
const sonarOn = computed(() => sonar.value?.enabled ?? true);
const detection = computed(() => view.value?.detection ?? null);
const detectButton = computed(() => detection.value?.button ?? null);
const allocation = computed(() => detection.value?.allocation ?? 'ELASTIC');
@@ -29,13 +32,13 @@ const allocationSegment = (value) => ({
</div>
<div class="pp-panel-body" data-rail-scroller>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label" translate="no">HUD</span>
</button>
<div class="pp-slider-row" id="hud-layout-row">
<div class="pp-slider-row" :class="{ visible: hud?.visible }" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n>Layout</span>
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" v-range-value="hud?.variant ?? 'tactical'" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option>
<option value="minimal" data-i18n>Minimal</option>
<option value="cyber" data-i18n>Cyber</option>
@@ -43,33 +46,33 @@ const allocationSegment = (value) => ({
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label" data-i18n>Sonar</span>
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true" data-i18n-title data-i18n-aria-label data-i18n>ON</button>
<button class="pp-mode-btn" :class="{ active: sonarOn }" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" :aria-pressed="String(sonarOn)" data-i18n-title data-i18n-aria-label>{{ sonarOn ? t('ON') : t('OFF') }}</button>
</div>
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" v-range-value="sonar?.rings ?? 8" aria-label="Sonar ring count" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-rings-value">{{ sonar?.rings ?? 8 }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" v-range-value="sonar?.range ?? 100" aria-label="Sonar ring range" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-range-value">{{ pct(sonar?.range ?? 100) }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" v-range-value="sonar?.intensity ?? 70" aria-label="Sonar intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">{{ pct(sonar?.intensity ?? 70) }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" v-range-value="sonar?.opacity ?? 84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">{{ pct(sonar?.opacity ?? 84) }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" v-range-value="sonar?.sector ?? 24" aria-label="Sonar sector width" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-sector-value">{{ `${sonar?.sector ?? 24}°` }}</span>
</div>
</div>
<div class="pp-toggle-group">
+29 -37
View File
@@ -213,6 +213,11 @@ export class VisualSettings {
this._detectionOutsidePct = 1;
this._detectionModeLabel = null;
this._detectionAllocationShown = null;
// What the HUD and Sonar controls show; null until first synced, so the
// initial markup stands.
this._hudVisibleShown = null;
this._hudVariantShown = null;
this._sonarShown = null;
this.activeStyle = 'normal';
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
@@ -476,6 +481,8 @@ export class VisualSettings {
bloom: { active: this.bloomEnabled, intensity: this.bloomIntensityPct },
sharpen: { active: this.sharpenEnabled, intensity: this._sharpenPct },
detection: this._presentDetection(),
hud: { visible: this._hudVisibleShown, variant: this._hudVariantShown },
sonar: this._sonarShown,
};
}
@@ -666,9 +673,8 @@ export class VisualSettings {
this._preCyberStyle = this.activeStyle;
this._cyberStyleUserSelected = false;
}
if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) {
this._hudLayoutSelect.value = nextVariant;
}
this._hudVariantShown = nextVariant;
this._publishDisplay();
const visualDefaults = cyberVisualDefaultsForHudTransition(
previousVariant,
nextVariant,
@@ -709,37 +715,16 @@ export class VisualSettings {
}
_syncCyberSonarControl() {
if (!this._cyberSonarBtn) return;
const enabled = isCyberSonarEnabled();
const settings = applyCyberSonarSettings(readCyberSonarSettings());
this._cyberSonarBtn.classList.toggle('active', enabled);
this._cyberSonarBtn.setAttribute('aria-pressed', String(enabled));
this._cyberSonarBtn.textContent = enabled ? t('ON') : t('OFF');
for (const [input, output, value, suffix] of [
[this._cyberSonarRings, this._cyberSonarRingsValue, settings.rings, ''],
[this._cyberSonarRange, this._cyberSonarRangeValue, settings.range, '%'],
[
this._cyberSonarIntensity,
this._cyberSonarIntensityValue,
settings.intensity,
'%',
],
[
this._cyberSonarOpacity,
this._cyberSonarOpacityValue,
settings.opacity,
'%',
],
[
this._cyberSonarSector,
this._cyberSonarSectorValue,
settings.sector,
'°',
],
]) {
if (input) input.value = String(value);
if (output) output.textContent = `${value}${suffix}`;
}
this._sonarShown = {
enabled: isCyberSonarEnabled(),
rings: settings.rings,
range: settings.range,
intensity: settings.intensity,
opacity: settings.opacity,
sector: settings.sector,
};
this._publishDisplay();
}
_setCyberSonarEnabled(enabled = !isCyberSonarEnabled()) {
@@ -1587,10 +1572,17 @@ export class VisualSettings {
}
_updateHudButtonState() {
this._hudBtn.classList.toggle('active', this.hud.visible);
if (this._hudLayoutRow) {
this._hudLayoutRow.classList.toggle('visible', this.hud.visible);
}
this._syncHudControls();
}
/**
* Show the HUD's current visibility and layout on its controls; the layout
* row's visibility changes the panel's height, so the rails settle again.
*/
_syncHudControls() {
this._hudVisibleShown = Boolean(this.hud.visible);
this._hudVariantShown = this.hud.getVariant();
this._publishDisplay();
this._scheduleAdaptivePanelLayout({ settle: true });
}