From b6c7c130f6738fcef132956c8d2f5b11381cf0fe Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 00:43:27 -0700 Subject: [PATCH] feat(ui): render the local RTL-SDR card from the store LocalSdrControls publishes the card to the radio store and SdrCard renders it. The new v-field-value directive never overwrites a focused field, including one a render disables. --- docs/VUE-MIGRATION.md | 8 +- scripts/package-boundaries.json | 4 +- src/ui/localSdrControls.js | 92 +++++------------- src/ui/localSdrControls.test.mjs | 8 +- src/ui/radioPanel.test.mjs | 146 +++++++++++++++++++++++++++++ src/ui/radioPresentation.js | 7 +- src/ui/rail/fieldValue.js | 23 +++++ src/ui/rail/fieldValue.test.mjs | 81 ++++++++++++++++ src/ui/stores/radio.js | 13 ++- src/ui/views/panels/RadioPanel.vue | 90 +++--------------- src/ui/views/panels/SdrCard.vue | 88 +++++++++++++++++ 11 files changed, 402 insertions(+), 158 deletions(-) create mode 100644 src/ui/rail/fieldValue.js create mode 100644 src/ui/rail/fieldValue.test.mjs create mode 100644 src/ui/views/panels/SdrCard.vue diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index cc7d11622..5e173c829 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -104,9 +104,11 @@ They contain no application state. component compiler; the other groups follow one owner at a time), Context and Radio (in progress: `RadioPanel` renders the directory row, station card, transport, volume and playback line from the `gev-radio` - store that RadioControls publishes to; the tuner, the local SDR card and - the Cockpit radio controls keep their owners' element bindings), the - command dock, + store that RadioControls publishes to, and `SdrCard` the local RTL-SDR + card that LocalSdrControls publishes beside it; fields a person edits take + their value through `v-field-value`, which never writes a focused field; + the tuner and the Cockpit radio controls keep their owners' element + bindings), the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a78030afc..361cebe60 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -25,6 +25,7 @@ "src/ui/views/CommandDockView.vue", "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", + "src/ui/views/panels/SdrCard.vue", "src/ui/views/DisplayControlsView.vue", "src/ui/views/LayerPanelsView.vue", "src/ui/views/ProviderSettingsView.vue", @@ -57,7 +58,8 @@ "src/ui/views/panels/DisplayPanel.vue", "src/ui/stores/display.js", "src/ui/rail/rangeValue.js", - "src/ui/rail/classToggle.js" + "src/ui/rail/classToggle.js", + "src/ui/rail/fieldValue.js" ], "external": ["vue", "pinia"] }, diff --git a/src/ui/localSdrControls.js b/src/ui/localSdrControls.js index 51c0175dd..1d805e271 100644 --- a/src/ui/localSdrControls.js +++ b/src/ui/localSdrControls.js @@ -6,6 +6,8 @@ import { localSdrFmAudioActive, localSdrGainOptionLabel, } from './localSdrPresentation.js'; +import { useRadioStore } from './stores/radio.js'; +import { applicationPinia } from './vueRuntime.js'; const ELEMENT_IDS = Object.freeze({ card: 'sdr-radio-card', @@ -31,15 +33,12 @@ const ELEMENT_IDS = Object.freeze({ feedStatus: 'sdr-feed-status', }); -function setText(element, text) { - if (element && element.textContent !== text) element.textContent = text; -} - /** * Local RTL-SDR card in the Radio panel. * - * Owns the card's listeners and its receiver/radio subscriptions. The Radio - * panel is one listening surface: starting local FM stops internet radio, and + * Owns the card's listeners and its receiver/radio subscriptions; the Radio + * view renders the card from what this publishes (`gev-radio` store). The + * Radio panel is one listening surface: starting local FM stops internet radio, and * starting internet radio stops local FM. */ export class LocalSdrControls { @@ -53,6 +52,7 @@ export class LocalSdrControls { * @param {object} [options.radio] Internet-radio port (subscribe, stopPlayback). * @param {object} [options.actions] Application actions: * setLocalAdsbEnabled(enabled), isLocalAdsbEnabled(), scheduleLayout(). + * @param {object} [options.store] Store the card's view is published to. */ constructor({ document: doc, @@ -60,7 +60,9 @@ export class LocalSdrControls { feeds = null, radio = null, actions = {}, + store = useRadioStore(applicationPinia()), }) { + this.store = store; this.receiver = receiver; this.feeds = feeds; this.radio = radio; @@ -78,6 +80,7 @@ export class LocalSdrControls { this._radioAudioActive = false; this._renderFrame = null; this._destroyed = false; + this._view = null; if (!this.elements.card) return; this._labelGainOptions(); this._bind(); @@ -180,7 +183,7 @@ export class LocalSdrControls { this._listen(el.volume, 'input', () => { const value = Number(el.volume.value); this.receiver.setVolume(value / 100); - setText(el.volumeValue, `${value}%`); + if (this._view) this._publish({ ...this._view, volumeText: `${value}%` }); }); } @@ -217,69 +220,24 @@ export class LocalSdrControls { }); } + _publish(view) { + this._view = view; + this.store?.publishSdr(view); + } + _render() { - const el = this.elements; const view = localSdrCardView(this._state); - const doc = el.card?.ownerDocument; - setText(el.connectionState, view.connectionLabel); - el.connectionState?.classList.toggle('active', view.connectionActive); - if (el.connect) { - setText(el.connect, view.connectLabel); - el.connect.disabled = view.connectDisabled; - el.connect.classList.toggle('active', view.connectPressed); - el.connect.setAttribute('aria-pressed', String(view.connectPressed)); - } - for (const [button, active] of [ - [el.modeFm, view.fmActive], - [el.modeAdsb, view.adsbActive], - ]) { - if (!button) continue; - button.classList.toggle('active', active); - button.setAttribute('aria-pressed', String(active)); - button.disabled = view.modeDisabled; - } - if (el.locate) { - setText(el.locate, view.locateLabel); - el.locate.disabled = view.locateDisabled; - el.locate.classList.toggle('active', view.locateActive); - } - if (el.changeDevice) el.changeDevice.disabled = view.changeDeviceDisabled; - if (el.gain) { - if (doc?.activeElement !== el.gain && el.gain.value !== view.gainValue) - el.gain.value = view.gainValue; - el.gain.disabled = view.gainDisabled; - } - if (el.stats) el.stats.hidden = view.statsHidden; - setText(el.statRate, view.stats.rate); - setText(el.statHeard, view.stats.heard); - setText(el.statPositioned, view.stats.positioned); - setText(el.statIq, view.stats.iq); - if (el.frequency) { - el.frequency.disabled = view.frequencyDisabled; - if (doc?.activeElement !== el.frequency && view.frequencyValue !== null) - el.frequency.value = view.frequencyValue; - } - if (el.tune) el.tune.disabled = view.tuneDisabled; - if (el.seekBack) el.seekBack.disabled = view.seekDisabled; - if (el.seekForward) el.seekForward.disabled = view.seekDisabled; - if (el.volume) { - if (doc?.activeElement !== el.volume) - el.volume.value = String(view.volumePercent); - el.volume.disabled = view.volumeDisabled; - } - setText(el.volumeValue, `${view.volumePercent}%`); - if (el.status) { - setText(el.status, view.statusText); - el.status.classList.toggle('error', view.statusError); - } const feedLine = localSdrFeedLine(this.feeds?.getState?.() || null); - if (el.feedStatus) { - el.feedStatus.hidden = feedLine === null; - setText(el.feedStatus, feedLine || ''); - } - el.card - ?.closest?.('#radio-panel') - ?.classList.toggle('sdr-connected', view.connectionActive); + // The card leaves a focused field, and the FM frequency outside FM, as + // they are. + this._publish({ + ...view, + gain: view.gainValue, + frequency: view.frequencyValue, + volume: view.volumePercent, + volumeText: `${view.volumePercent}%`, + feedLine, + }); // Only structural changes move the rail; per-block text updates do not. const layoutKey = `${view.statsHidden}|${view.connectionActive}|${feedLine === null}`; if (layoutKey !== this._layoutKey) { diff --git a/src/ui/localSdrControls.test.mjs b/src/ui/localSdrControls.test.mjs index 77d44af7a..4d8ad17ee 100644 --- a/src/ui/localSdrControls.test.mjs +++ b/src/ui/localSdrControls.test.mjs @@ -184,15 +184,16 @@ test('the card shows one read-only decoder-feed line after a single probe', asyn for (const listener of listeners) listener(state); }, }; + let shown = null; const controls = new LocalSdrControls({ document: doc, receiver: stubReceiver(), feeds, + store: { publishSdr: (view) => (shown = view) }, }); t.after(() => controls.destroy()); - const line = doc.getElementById('sdr-feed-status'); assert.equal(probes, 1); - assert.equal(line.hidden, true); + assert.equal(shown.feedLine, null); feeds.set({ configured: true, polling: true, @@ -202,6 +203,5 @@ test('the card shows one read-only decoder-feed line after a single probe', asyn ], }); await new Promise((resolve) => setTimeout(resolve, 30)); - assert.equal(line.hidden, false); - assert.equal(line.textContent, 'Decoder feeds: 1090 live · 978 live'); + assert.equal(shown.feedLine, 'Decoder feeds: 1090 live · 978 live'); }); diff --git a/src/ui/radioPanel.test.mjs b/src/ui/radioPanel.test.mjs index 345ada823..733f0196c 100644 --- a/src/ui/radioPanel.test.mjs +++ b/src/ui/radioPanel.test.mjs @@ -13,6 +13,7 @@ import { showRadioVolume, } from './radioPresentation.js'; import { setLocale } from '../i18n/index.js'; +import { LocalSdrControls } from './localSdrControls.js'; function fixture(t) { document.body.replaceChildren(); @@ -217,3 +218,148 @@ test('a toggle that ends mid-transition does not leave Enable marked idle', asyn assert.equal(enable.getAttribute('aria-busy'), 'true', 'the next render reports it'); assert.equal(enable.getAttribute('aria-disabled'), 'true'); }); + +function sdrReceiver() { + const listeners = new Set(); + let state = { + webUsbSupported: true, + connected: false, + status: 'idle', + message: 'Connect an RTL-SDR to begin.', + mode: 'fm', + frequencyHz: 98_500_000, + volume: 0.8, + gain: 'auto', + locationStatus: 'unknown', + }; + return { + volumes: [], + getState: () => ({ ...state }), + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emit(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener({ ...state }); + }, + setVolume(value) { + this.volumes.push(value); + }, + }; +} + +test('the RTL-SDR card renders what LocalSdrControls presents', async (t) => { + const { $, owner } = fixture(t); + const receiver = sdrReceiver(); + const controls = new LocalSdrControls({ document, receiver, store: owner.store }); + t.after(() => controls.destroy()); + const frame = () => new Promise((resolve) => setTimeout(resolve, 40)); + await nextTick(); + assert.equal($('sdr-connection-state').getAttribute('class'), null); + assert.equal($('sdr-connect-btn').textContent, 'CONNECT'); + assert.equal($('sdr-connect-btn').disabled, false); + assert.equal($('sdr-mode-fm-btn').className, 'active'); + assert.equal($('sdr-mode-adsb-btn').getAttribute('aria-pressed'), 'false'); + assert.equal($('sdr-adsb-stats').hidden, true); + assert.equal($('sdr-frequency-input').value, '98.5'); + assert.equal($('sdr-feed-status').hidden, true); + + receiver.emit({ + connected: true, + status: 'streaming', + mode: 'adsb', + gain: 20.7, + aircraftHeard: 3, + aircraftPositioned: 2, + messagesPerSecond: 41.25, + }); + await frame(); + const panel = $('radio-panel'); + assert.equal(panel.classList.contains('sdr-connected'), true); + assert.equal(panel.classList.contains('collapsed'), true, 'chrome keeps its class'); + assert.equal($('sdr-connection-state').className, 'active'); + assert.equal($('sdr-connect-btn').textContent, 'DISCONNECT'); + assert.equal($('sdr-connect-btn').className, 'panel-layer-toggle active'); + assert.equal($('sdr-mode-fm-btn').className, ''); + assert.equal($('sdr-mode-adsb-btn').className, 'active'); + assert.equal($('sdr-adsb-stats').hidden, false); + assert.equal($('sdr-stat-heard').textContent, '3'); + assert.equal($('sdr-stat-positioned').textContent, '2'); + assert.equal($('sdr-gain-select').value, '20.7'); + assert.equal($('sdr-frequency-input').disabled, true); + + // A focused field keeps the value the person is setting (FM volume). + receiver.emit({ mode: 'fm' }); + await frame(); + const volume = $('sdr-volume'); + assert.equal(volume.disabled, false); + volume.focus(); + volume.value = '35'; + volume.dispatchEvent(new Event('input')); + await nextTick(); + assert.deepEqual(receiver.volumes, [0.35]); + assert.equal($('sdr-volume-value').textContent, '35%'); + receiver.emit({ volume: 0.6 }); + await frame(); + assert.equal(volume.value, '35'); + volume.blur(); + receiver.emit({ volume: 0.6, connected: false, status: 'idle' }); + await frame(); + assert.equal(volume.value, '60'); + assert.equal($('sdr-volume-value').textContent, '60%'); + assert.equal(panel.classList.contains('sdr-connected'), false); +}); + +test('focused RTL-SDR fields keep what the person typed or picked', async (t) => { + const { $, owner, render } = fixture(t); + const receiver = sdrReceiver(); + let feeds = { configured: null, polling: false, feeds: [] }; + const feedListeners = new Set(); + const controls = new LocalSdrControls({ + document, + receiver, + store: owner.store, + feeds: { + getState: () => feeds, + subscribe(listener) { + feedListeners.add(listener); + return () => feedListeners.delete(listener); + }, + probe: async () => {}, + }, + }); + t.after(() => controls.destroy()); + const frame = () => new Promise((resolve) => setTimeout(resolve, 40)); + receiver.emit({ connected: true, status: 'streaming' }); + await frame(); + const frequency = $('sdr-frequency-input'); + frequency.focus(); + frequency.value = '101.1'; + receiver.emit({ frequencyHz: 98_700_000 }); + await frame(); + assert.equal(frequency.value, '101.1', 'typing is not undone'); + await render({}); + assert.equal(frequency.value, '101.1', 'nor by a Radio render'); + frequency.blur(); + receiver.emit({ frequencyHz: 98_700_000 }); + await frame(); + assert.equal(frequency.value, '98.7'); + + const gain = $('sdr-gain-select'); + gain.focus(); + gain.value = '20.7'; + receiver.emit({ gain: 'auto' }); + await frame(); + assert.equal(gain.value, '20.7', 'the pick stands while focused'); + + feeds = { + configured: true, + polling: true, + feeds: [{ band: '1090', status: 'live' }], + }; + for (const listener of feedListeners) listener(feeds); + await frame(); + assert.equal($('sdr-feed-status').hidden, false); + assert.equal($('sdr-feed-status').textContent, 'Decoder feeds: 1090 live'); +}); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index f0674e212..90359ed06 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -164,9 +164,10 @@ export function renderRadioState(state) { this._cockpitRadioPlayBtn.title = playbackAction; } // A slider the person is dragging keeps the value they are setting. - if (document.activeElement !== this._radioVolume) - this._radioVolumeShown = Math.round(state.volume * 100); - else this._radioVolumeShown ??= Number(this._radioVolume?.value ?? 80); + this._radioVolumeShown = + this._radioVolume && document.activeElement === this._radioVolume + ? Number(this._radioVolume.value) + : Math.round(state.volume * 100); if ( this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume diff --git a/src/ui/rail/fieldValue.js b/src/ui/rail/fieldValue.js new file mode 100644 index 000000000..1b7841feb --- /dev/null +++ b/src/ui/rail/fieldValue.js @@ -0,0 +1,23 @@ +/** + * `v-field-value`: a form field's live value, written only when it differs + * and the field is not focused, so a render never overwrites what a person + * is typing, picking or dragging. Focus is read before the render patches + * the field too, since disabling it drops focus. A null value leaves the + * field as it is. Unlike a `:value` binding, the value attribute is never + * rewritten. + */ +const focused = (el) => el.ownerDocument?.activeElement === el; +const apply = (el, { value }) => { + const held = focused(el) || el.__fieldValueFocused; + el.__fieldValueFocused = false; + if (value == null || held) return; + const next = String(value); + if (el.value !== next) el.value = next; +}; +export const vFieldValue = { + mounted: apply, + beforeUpdate(el) { + el.__fieldValueFocused = focused(el); + }, + updated: apply, +}; diff --git a/src/ui/rail/fieldValue.test.mjs b/src/ui/rail/fieldValue.test.mjs new file mode 100644 index 000000000..ba8173020 --- /dev/null +++ b/src/ui/rail/fieldValue.test.mjs @@ -0,0 +1,81 @@ +// src/ui/rail/fieldValue.test.mjs — v-field-value never writes over a field +// that has focus, and leaves a field alone for a null value. +import '../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, h, nextTick, shallowRef, withDirectives } from 'vue'; +import { vFieldValue } from './fieldValue.js'; + +test('a render writes the value unless the field has focus or the value is null', async (t) => { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const value = shallowRef('98.5'); + const app = createApp({ + render: () => + withDirectives(h('input', { type: 'number', id: 'field' }), [ + [vFieldValue, value.value], + ]), + }); + app.mount(host); + t.after(() => app.unmount()); + const field = document.getElementById('field'); + assert.equal(field.value, '98.5'); + field.focus(); + field.value = '101.1'; + value.value = '98.7'; + await nextTick(); + assert.equal(field.value, '101.1', 'typing stands'); + field.blur(); + value.value = null; + await nextTick(); + assert.equal(field.value, '101.1', 'null leaves it'); + value.value = '98.9'; + await nextTick(); + assert.equal(field.value, '98.9'); +}); + +test('a render that disables a focused field leaves its value', async (t) => { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const state = shallowRef({ value: 'auto', disabled: false }); + const app = createApp({ + render: () => + withDirectives( + h('select', { id: 'gain', disabled: state.value.disabled }, [ + h('option', { value: 'auto' }, 'AUTO'), + h('option', { value: '20.7' }, '20.7 dB'), + ]), + [[vFieldValue, state.value.value]], + ), + }); + app.mount(host); + t.after(() => app.unmount()); + const gain = document.getElementById('gain'); + // Browsers drop focus from a field that becomes disabled; happy-dom does + // not, so focus moves to the page here. + const page = document.body.appendChild(document.createElement('button')); + const drop = () => page.focus(); + const disabled = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(gain), + 'disabled', + ); + Object.defineProperty(gain, 'disabled', { + get: () => disabled.get.call(gain), + set: (on) => { + disabled.set.call(gain, on); + if (on) drop(); + }, + }); + const setAttribute = gain.setAttribute.bind(gain); + gain.setAttribute = (name, value) => { + setAttribute(name, value); + if (name === 'disabled') drop(); + }; + gain.focus(); + gain.value = '20.7'; + state.value = { value: 'auto', disabled: true }; + await nextTick(); + assert.equal(gain.disabled, true); + assert.equal(document.activeElement === gain, false, 'focus left the field'); + assert.equal(gain.value, '20.7'); +}); diff --git a/src/ui/stores/radio.js b/src/ui/stores/radio.js index 7b77abc2d..d8e32fc1e 100644 --- a/src/ui/stores/radio.js +++ b/src/ui/stores/radio.js @@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue'; * The Radio panel as its view renders it. RadioControls presents the * directory row, station card, transport, volume and playback line * (`renderRadioState()`) and publishes them here; it keeps its listeners, - * the tuner and the Cockpit radio controls. + * the tuner and the Cockpit radio controls. LocalSdrControls publishes the + * local RTL-SDR card beside it. */ export const useRadioStore = defineStore('gev-radio', () => { /** The presented panel; null until the controls first render. */ @@ -16,5 +17,13 @@ export const useRadioStore = defineStore('gev-radio', () => { view.value = markRaw(next); } - return { view, publish }; + /** The local RTL-SDR card; null until its controls first render. */ + const sdr = shallowRef(null); + + /** @param {object} next A fresh presentation of the RTL-SDR card. */ + function publishSdr(next) { + sdr.value = markRaw(next); + } + + return { view, publish, sdr, publishSdr }; }); diff --git a/src/ui/views/panels/RadioPanel.vue b/src/ui/views/panels/RadioPanel.vue index f7f02f745..58c4f9bb4 100644 --- a/src/ui/views/panels/RadioPanel.vue +++ b/src/ui/views/panels/RadioPanel.vue @@ -1,19 +1,25 @@ diff --git a/src/ui/views/panels/SdrCard.vue b/src/ui/views/panels/SdrCard.vue new file mode 100644 index 000000000..a55327177 --- /dev/null +++ b/src/ui/views/panels/SdrCard.vue @@ -0,0 +1,88 @@ + + +