feat: scroll-tune the top frequency readout (100/10/1 kHz per digit)

Rolling the mouse wheel over the hundreds / tens / units-of-kHz digit of the
header (and compact top-bar) frequency steps the frequency by 100 / 10 / 1 kHz
(wheel up = higher). The display updates optimistically on every notch and the
rig QSYs over CAT, debounced so a fast scroll doesn't flood the link. Only the
kHz digits are wheel-sensitive; MHz and Hz stay static.

New FreqWheelDisplay component (replaces the plain fmtFreqDots span in both the
full header and the compact top bar) + nudgeFreqHz handler.
This commit is contained in:
2026-07-25 12:22:17 +02:00
parent 9e86d57dac
commit e5ff30823d
2 changed files with 58 additions and 2 deletions
+56 -2
View File
@@ -211,6 +211,36 @@ function fmtFreqDots(mhzStr: string): string {
const frac = (fracRaw + '000000').slice(0, 6);
return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
}
// FreqWheelDisplay renders a frequency (MHz string) like fmtFreqDots — MHz.kHz.Hz
// — but makes the three kHz digits scroll-sensitive: rolling the mouse wheel over
// the hundreds / tens / units-of-kHz digit steps the frequency by 100 / 10 / 1 kHz
// (up = wheel up). onNudge receives the delta in Hz. The MHz and Hz digits are
// static (only kHz stepping was requested). Used in the header + compact top bar.
function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.———.———' }: {
mhz: string; onNudge: (deltaHz: number) => void; className?: string; placeholder?: string;
}) {
if (!mhz) return <span className={className}>{placeholder}</span>;
const [intPart, fracRaw = ''] = mhz.split('.');
const frac = (fracRaw + '000000').slice(0, 6);
const khz = frac.slice(0, 3); // [hundreds, tens, units] of kHz
const hz = frac.slice(3, 6);
const stepHz = [100_000, 10_000, 1_000]; // per kHz digit: 100 / 10 / 1 kHz
return (
<span className={className}>
{intPart}.
{khz.split('').map((d, i) => (
<span key={i}
onWheel={(e) => { e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHz[i] : -stepHz[i]); }}
className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
title="Scroll to change frequency">
{d}
</span>
))}
.{hz}
</span>
);
}
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
@@ -3543,6 +3573,30 @@ export default function App() {
noteManualEdit();
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
};
// Mouse-wheel over a kHz digit of the top frequency readout: step the frequency
// and (if CAT is connected) QSY the rig. The display updates optimistically on
// every notch; the actual radio tune is debounced so a fast scroll doesn't flood
// the CAT link. nudgeAccumRef holds the live value across a burst (setFreqMhz is
// async, so we can't re-read it between notches).
const nudgeAccumRef = useRef<number | null>(null);
const nudgeCatTimer = useRef<number | null>(null);
const nudgeFreqHz = (deltaHz: number) => {
let cur = nudgeAccumRef.current;
if (cur == null) cur = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
if (!cur) return;
const newHz = Math.max(0, cur + deltaHz);
nudgeAccumRef.current = newHz;
setFreqMhz((newHz / 1_000_000).toFixed(6));
noteManualEdit();
const b = bandForMHz(newHz / 1_000_000); if (b) setBand(b);
if (nudgeCatTimer.current) window.clearTimeout(nudgeCatTimer.current);
nudgeCatTimer.current = window.setTimeout(() => {
const hz = nudgeAccumRef.current;
nudgeAccumRef.current = null;
nudgeCatTimer.current = null;
if (hz && catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
}, 150);
};
const freqBlock = (
<div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
@@ -3936,7 +3990,7 @@ export default function App() {
<span className="font-bold text-xs tracking-tight">OpsLog</span>
</div>
<div className="flex items-baseline gap-1.5 font-mono ml-2">
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-sm font-semibold text-primary" />
<span className="text-[9px] text-muted-foreground">MHz</span>
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
<Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
@@ -3966,7 +4020,7 @@ export default function App() {
{/* Toasts and errors live in the STATUS BAR at the bottom now the
header band was too narrow and long messages were cut off. */}
<div className="flex flex-col items-end leading-none">
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-2xl font-semibold text-primary tracking-wide" />
{catState.split && rxFreqMhz && (
<span className="text-[10px] text-muted-foreground mt-0.5">
<span className="text-danger font-semibold mr-1">RX</span>