diff --git a/app.go b/app.go index 1769f0c..8913161 100644 --- a/app.go +++ b/app.go @@ -13688,6 +13688,7 @@ func defaultWKMacros() []WKMacro { {Label: "73", Text: " TU 73 DE "}, {Label: "QRL?", Text: "QRL? "}, {Label: "AGN", Text: "AGN "}, + {Label: "QRZ?", Text: "QRZ? DE "}, } } diff --git a/changelog.json b/changelog.json index 340ab8c..c27b2ed 100644 --- a/changelog.json +++ b/changelog.json @@ -3,6 +3,9 @@ "version": "0.21.1", "date": "2026-07-24", "en": [ + "Ctrl + mouse wheel zoom is now remembered across restarts (Ctrl+0 resets to 100%).", + "Log grid: award column widths are now saved like the other columns, so a width you set survives a restart.", + "CW keyer widget: added an F9 macro slot, and empty macros are now hidden (like the voice keyer) — fill them in Settings → CW Keyer.", "New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs the QSO if the macro contains .", "The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.", "New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.", @@ -15,6 +18,9 @@ "Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references." ], "fr": [ + "Le zoom Ctrl + molette est maintenant conservé après un redémarrage (Ctrl+0 remet à 100 %).", + "Grille du log : les largeurs des colonnes de diplômes sont désormais sauvegardées comme les autres colonnes, une largeur réglée survit au redémarrage.", + "Widget keyer CW : ajout d'un emplacement de macro F9, et les macros vides sont maintenant masquées (comme le keyer vocal) — remplis-les dans Réglages → Keyer CW.", "Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue le QSO si le macro contient .", "L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.", "Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5bb5ed5..cfc44f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -231,7 +231,7 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.—— {intPart}. {khz.split('').map((d, i) => ( { e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHz[i] : -stepHz[i]); }} + onWheel={(e) => { if (e.ctrlKey || e.metaKey) return; 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} @@ -843,6 +843,30 @@ export default function App() { const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow) const wkEsmRef = useRef(false); useEffect(() => { wkEsmRef.current = wkEsm; }, [wkEsm]); + + // Persistent Ctrl+wheel zoom. The native WebView2 Ctrl+wheel zoom isn't saved, + // so we run our own: CSS `zoom` on the root element, factor stored in + // localStorage and restored at startup. Ctrl+0 resets to 100%. + useEffect(() => { + const KEY = 'opslog.uiZoom'; + let z = parseFloat(localStorage.getItem(KEY) || '1'); + if (!Number.isFinite(z) || z <= 0) z = 1; + const apply = () => { (document.documentElement.style as any).zoom = String(z); }; + apply(); + const onWheel = (e: WheelEvent) => { + if (!e.ctrlKey && !e.metaKey) return; // plain wheel is left alone (scroll / freq nudge) + e.preventDefault(); + z = Math.min(2.5, Math.max(0.5, Math.round((z + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10)); + localStorage.setItem(KEY, String(z)); + apply(); + }; + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(); } + }; + window.addEventListener('wheel', onWheel, { passive: false }); + window.addEventListener('keydown', onKey); + return () => { window.removeEventListener('wheel', onWheel); window.removeEventListener('keydown', onKey); }; + }, []); // CW keyer output engine (persisted in the WinKeyer settings, chosen in // Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros, @@ -2237,7 +2261,9 @@ export default function App() { setWkEnabled(!!s.enabled); setWkPort(s.port ?? ''); setWkWpm(s.wpm ?? 25); - setWkMacros((s.macros ?? []) as WKMacro[]); + // Pad to 9 slots (F1–F9) so an F9 macro always exists to fill; empty ones + // are hidden in the widget. + { const mac = ((s.macros ?? []) as WKMacro[]).slice(); while (mac.length < 9) mac.push({ label: '', text: '' }); setWkMacros(mac); } setWkEscClears(s.esc_clears_call !== false); setWkSendOnType(!!s.send_on_type); setWkEsm(!!s.esm); diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 57fc62d..980cd71 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -328,6 +328,39 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag saveState(AWARD_SHOWN_KEY, [...next]); }, []); + // Award-column WIDTHS are persisted separately too, for the same reason as + // visibility: award columns are stripped from AG Grid's saved column-state + // round-trip, so their width would otherwise reset to the default on reopen. + // Stored as an array of { code, width } (code upper-cased); mirrored to the DB. + const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths'; + const awardWidthsRef = useRef>({}); + const awardWidthsInit = useRef(false); + if (!awardWidthsInit.current) { + awardWidthsInit.current = true; + for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) { + if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width); + } + } + // Fresh machine: hydrate widths from the portable DB copy, seed the cache, and + // apply them to any award columns already on screen. + useEffect(() => { + if (loadLocal(AWARD_WIDTH_KEY)) return; + loadRemote(AWARD_WIDTH_KEY).then((remote) => { + if (!remote || !remote.length) return; + for (const it of remote as any[]) { + if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width); + } + seedLocal(AWARD_WIDTH_KEY, remote); + const api = gridRef.current?.api; + if (api && awardCols?.length) { + const ups = awardCols + .map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] })) + .filter((u) => !!u.newWidth); + if (ups.length) api.setColumnWidths(ups); + } + }); + }, []); + const columnDefs = useMemo[]>(() => { restoringRef.current = true; const base = COL_CATALOG.map((c) => { @@ -354,7 +387,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag colId: `award_${a.code}`, headerName: a.code, headerTooltip: t('rqg.awardTip', { name: a.name }), - width: 110, + width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110, cellClass: 'text-[11px]', // Visibility comes from the persisted award-code set, so a column the user // showed reappears on reopen and one they didn't stays hidden. @@ -370,11 +403,17 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag useEffect(() => { const api = gridRef.current?.api; if (!api || !awardCols?.length) return; + const widthUps: { key: string; newWidth: number }[] = []; for (const a of awardCols) { const want = awardShown.has(a.code.toUpperCase()); const col = api.getColumn(`award_${a.code}`); if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want); + // Re-apply the saved width — AG Grid keeps an existing column's width across + // a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide). + const w = awardWidthsRef.current[a.code.toUpperCase()]; + if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w }); } + if (widthUps.length) api.setColumnWidths(widthUps); }, [awardCols, awardShown]); const defaultColDef = useMemo(() => ({ @@ -421,8 +460,24 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag const saveColumnState = useCallback(() => { if (restoringRef.current) return; // ignore the events fired by a column rebuild const state = gridRef.current?.api?.getColumnState(); - if (state) saveState(colStateKey, stripAwardCols(state)); - }, []); + if (!state) return; + saveState(colStateKey, stripAwardCols(state)); + // Award columns are stripped above, so persist their widths on the side. + let changed = false; + for (const s of state) { + const id = String((s as any)?.colId ?? ''); + if (id.startsWith('award_') && (s as any).width) { + const code = id.slice('award_'.length).toUpperCase(); + if (awardWidthsRef.current[code] !== (s as any).width) { + awardWidthsRef.current[code] = (s as any).width; + changed = true; + } + } + } + if (changed) { + saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width }))); + } + }, [colStateKey, AWARD_WIDTH_KEY]); // columnDefs is rebuilt whenever the award columns load OR the user toggles an // award column (both change the memo → restoringRef flips true at line 316). Each diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 03d17a1..62d017b 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1407,7 +1407,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const locs: any = await ListTQSLStationLocations(); setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); } catch { /* TQSL not installed — leave the dropdown empty */ } - try { setWk(await GetWinkeyerSettings() as any); } catch {} + try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {} try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {} try { setAudioCfg(await GetAudioSettings() as any); } catch {} try { setEmailCfg(await GetEmailSettings() as any); } catch {} @@ -1441,7 +1441,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan try { setBackupCfg(await GetBackupSettings() as any); } catch {} try { setQslDefaults(await GetQSLDefaults() as any); } catch {} try { setExtSvc(await GetExternalServices() as any); } catch {} - try { setWk(await GetWinkeyerSettings() as any); } catch {} + try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {} try { setAudioCfg(await GetAudioSettings() as any); } catch {} try { setEmailCfg(await GetEmailSettings() as any); } catch {} try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {} diff --git a/frontend/src/components/WinkeyerPanel.tsx b/frontend/src/components/WinkeyerPanel.tsx index 816500e..dfafd70 100644 --- a/frontend/src/components/WinkeyerPanel.tsx +++ b/frontend/src/components/WinkeyerPanel.tsx @@ -230,9 +230,14 @@ export function WinkeyerPanel({ {autoCall && {t('wkp.loopHint')}} - {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */} + {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. + Empty macros (no label AND no text) are hidden, like the voice keyer; + the F-number stays tied to the macro's real index so shortcuts match. */}
- {macros.map((m, i) => ( + {macros + .map((m, i) => ({ m, i })) + .filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '') + .map(({ m, i }) => (