feat: wheel over RST; PSK Reporter keeps the band's window

The mouse wheel steps the RST fields — one S-unit on an RST or RS, one
decibel on a digital report, up for a better one — in the entry strip and
in the QSO editor. The dropdown beside them lists the reports worth
having to hand, not all 41 decibels, so the wheel works on the value
rather than walking the list. The listener is native and non-passive:
React attaches onWheel passively, where preventDefault does nothing and
the panel scrolls away under the field being adjusted.

PSK Reporter, whole-band scope: clicking a decode reset the report count
to zero. The window there is the BAND's — every FTx report on it,
filtered by target only when the analysis is drawn — and it was emptied
on every target change, throwing away an hour of evidence at the moment
the operator asked the question it answers. The narrow scope still clears
it: there the window is one station's, and keeping it would answer the
new question with the old station's evidence.
This commit is contained in:
2026-09-06 13:58:25 +02:00
parent 8b59954ce0
commit b421da165a
7 changed files with 120 additions and 13 deletions
+6 -2
View File
@@ -8,7 +8,9 @@
"Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).", "Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
"Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA.", "Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA.",
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.", "Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
"Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Managers console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast." "Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Managers console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast.",
"[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor: one S-unit on an RST or RS report, one decibel on a digital one, up for a better report. The dropdown beside them lists the reports worth having to hand, not all 41 decibels — so the wheel works on the value rather than walking the list.",
"PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations."
], ],
"fr": [ "fr": [
"Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.", "Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.",
@@ -16,7 +18,9 @@
"Upload Cloudlog / Wavelog : un contact simplex nest plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que sils diffèrent. Lenregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).", "Upload Cloudlog / Wavelog : un contact simplex nest plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que sils diffèrent. Lenregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
"Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». Cest le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA.", "Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». Cest le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA.",
"Lauto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries dappels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et cest précisément pour ça quil est sur la liste. Le repos entre séries sapplique toujours.", "Lauto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries dappels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et cest précisément pour ça quil est sur la liste. Le repos entre séries sapplique toujours.",
"Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui nest pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH sannoncent aussi sous leur nom dans le message." "Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui nest pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH sannoncent aussi sous leur nom dans le message.",
"[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO : un point S sur un RST ou un RS, un décibel sur un report numérique, vers le haut pour un meilleur report. La liste déroulante à côté contient les reports quon veut sous la main, pas les 41 décibels — la molette agit donc sur la valeur plutôt que de parcourir la liste.",
"Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station."
] ]
}, },
{ {
+10 -2
View File
@@ -150,6 +150,7 @@ import { cn } from '@/lib/utils';
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead'; import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { LogViewer } from '@/components/LogViewer'; import { LogViewer } from '@/components/LogViewer';
import { stepRST } from '@/lib/rst';
type QSO = QSOForm; type QSO = QSOForm;
type ImportResult = adifModels.ImportResult; type ImportResult = adifModels.ImportResult;
@@ -5480,12 +5481,19 @@ export default function App() {
); );
const rstTxBlock = ( const rstTxBlock = (
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label> <div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} /> {/* The wheel steps the report an S-unit on RST, a decibel on a digital
one. Wheeling is the same gesture as saying "he is a bit stronger than
that", and it beats retyping three characters between overs. */}
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType
onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }}
onWheelStep={(d) => { setRstSent((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
</div> </div>
); );
const rstRxBlock = ( const rstRxBlock = (
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label> <div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} /> <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
</div> </div>
); );
// DX country flag, shown large next to RST (moved here from the Country field). // DX country flag, shown large next to RST (moved here from the Country field).
+7 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react'; import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App'; import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, stepRST, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -598,9 +598,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} /> value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div> </div>
<div className="flex flex-col w-20"><Label>S</Label> <div className="flex flex-col w-20"><Label>S</Label>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div> <Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_sent', v)}
onWheelStep={(d) => set('rst_sent', stepRST(draft.rst_sent ?? '', d, draft.mode ?? ''))} /></div>
<div className="flex flex-col w-20"><Label>R</Label> <div className="flex flex-col w-20"><Label>R</Label>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div> <Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_rcvd', v)}
onWheelStep={(d) => set('rst_rcvd', stepRST(draft.rst_rcvd ?? '', d, draft.mode ?? ''))} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking} <Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}> title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')} {looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
+20 -1
View File
@@ -9,7 +9,7 @@ import { cn } from '@/lib/utils';
// can't hold a typo'd value that isn't in the list. // can't hold a typo'd value that isn't in the list.
export function Combobox({ export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false, value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
showToggle = false, showToggle = false, onWheelStep,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
@@ -21,6 +21,10 @@ export function Combobox({
// fields read live by other actions — e.g. RST, so a CW macro sent without // fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed. // leaving the field uses the value just typed.
commitOnType?: boolean; commitOnType?: boolean;
// Wheel over the field steps the value. The control cannot know what a step
// means — a decibel here, an S-unit there — so it reports the direction and
// the owner decides.
onWheelStep?: (dir: 1 | -1) => void;
// Draw a chevron that opens the full list on click. // Draw a chevron that opens the full list on click.
// //
// Without it this control is indistinguishable from a plain text box: it opens // Without it this control is indistinguishable from a plain text box: it opens
@@ -42,6 +46,21 @@ export function Combobox({
// leave the list floating over the wrong row. // leave the list floating over the wrong row.
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
// A NATIVE, non-passive listener: React attaches onWheel passively, where
// preventDefault does nothing at all and the panel scrolls away under the
// field being adjusted.
useEffect(() => {
const el = ref.current;
if (!el || !onWheelStep) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
onWheelStep(e.deltaY < 0 ? 1 : -1); // up is a better report
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheelStep]);
useEffect(() => { useEffect(() => {
function onDoc(e: MouseEvent) { function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+31
View File
@@ -36,3 +36,34 @@ export function rstOptions(mode: string, lists: RSTLists): string[] {
if (l && l.length) return l; if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20']; return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
} }
// stepRST moves a report one step up or down, the way an operator would say it.
//
// The dropdown beside these fields is a list of the values worth having to hand,
// not of every legal report: nobody keeps 41 dB figures in it. So the wheel
// works on the VALUE, not on the list — a digital report moves by a decibel and
// an RST by one S-unit, which is what the hand on the wheel is asking for.
//
// dir is +1 for wheel up (a better report) and -1 for down.
export function stepRST(value: string, dir: number, mode: string): string {
const v = (value || '').trim();
if (v === '') return v;
if (rstCategory(mode) === 'digital') {
// A dB report: "-12", "+05", "0". Kept in its own shape — signed and two
// digits — because that is how every decoder writes it and how the operator
// reads it back off the screen.
const n = parseInt(v, 10);
if (!Number.isFinite(n)) return v;
// The range WSJT-X itself reports in, with room either side. Beyond it the
// number stops meaning anything.
const next = Math.max(-30, Math.min(35, n + dir));
return (next < 0 ? '-' : '+') + String(Math.abs(next)).padStart(2, '0');
}
// RST/RS: the S digit is the one that moves. R and T are judgements about
// readability and tone that a wheel has no business changing, and S is the
// figure an operator actually adjusts.
const m = /^(\d)(\d)(\d?)((?:\+\d+)?)$/.exec(v);
if (!m) return v;
const s = Math.max(1, Math.min(9, parseInt(m[2], 10) + dir));
return m[1] + String(s) + m[3] + m[4];
}
+23 -5
View File
@@ -279,13 +279,9 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error {
return nil return nil
} }
w.mu.Lock()
// The window belongs to the target it was collected for. Keeping it across a
// change would answer the new question with the old station's evidence.
if changed { if changed {
w.spots = w.spots[:0] w.dropWindowOfPreviousTarget()
} }
w.mu.Unlock()
if err := w.resubscribe(client); err != nil { if err := w.resubscribe(client); err != nil {
return err return err
@@ -301,6 +297,28 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error {
return nil return nil
} }
// dropWindowOfPreviousTarget empties the collected window when it belonged to
// the station being left behind.
//
// WHOSE WINDOW IS IT? Under the narrow scope the subscription IS the target —
// three filters about one station — so the window is his, and keeping it across
// a change would answer the new question with the old station's evidence.
//
// Under the band-wide scope it is the BAND's: every FTx report on it, filtered
// by target only when the analysis is drawn. Clearing it there threw away an
// hour of accumulated evidence on every click, and a panel that had been showing
// hundreds of reports read zero — the operator clicking a decode to ask "can he
// hear me" is the very moment that history is worth something, and it was being
// deleted in order to answer the question.
func (w *Watcher) dropWindowOfPreviousTarget() {
w.mu.Lock()
defer w.mu.Unlock()
if w.cfg.Scope == ScopeBand {
return
}
w.spots = w.spots[:0]
}
// resubscribe replaces every filter with the ones the current target and scope // resubscribe replaces every filter with the ones the current target and scope
// want. Takes the old ones down first: a target change that only ADDED filters // want. Takes the old ones down first: a target change that only ADDED filters
// would leave the previous station's reports arriving for ever. // would leave the previous station's reports arriving for ever.
+23
View File
@@ -156,3 +156,26 @@ func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) {
t.Errorf("suggested %d Hz, want the quietest slot around 2400", got) t.Errorf("suggested %d Hz, want the quietest slot around 2400", got)
} }
} }
// Under the band-wide scope the window is the BAND's, not the target's: it is
// every FTx report on it, filtered by target only when the analysis is drawn.
// Clearing it on a target change threw away an hour of accumulated evidence at
// the exact moment it was worth something — the operator clicking a decode to
// ask "can he hear me" saw the report count drop to zero.
func TestBandScopeKeepsItsWindowAcrossATargetChange(t *testing.T) {
for _, tc := range []struct {
scope Scope
keep bool
}{{ScopeBand, true}, {ScopeTarget, false}} {
w := &Watcher{cfg: Config{Scope: tc.scope, Logf: func(string, ...any) {}}}
w.target, w.mode = "OLD", "FT8"
w.spots = []spot{{TxCall: "OLD", RxCall: "F4BPO", at: time.Now()}}
w.dropWindowOfPreviousTarget()
w.target = "NEW"
if got := len(w.spots) > 0; got != tc.keep {
t.Errorf("%s scope: window kept = %v, want %v", tc.scope, got, tc.keep)
}
}
}