diff --git a/app.go b/app.go index 6d7f8e6..1a96a5a 100644 --- a/app.go +++ b/app.go @@ -18342,7 +18342,13 @@ func (a *App) applyMotorInhibit(on bool) { // OR within a grace window after a commanded move — and always releases the // inhibit when it stops (so a settings change / shutdown never leaves TX blocked). func (a *App) motorTXInhibitLoop(c motorAntenna, bands []string, stop <-chan struct{}) { - const grace = 3 * time.Second // cover the poll latency at the very start of a move + // Just long enough to bridge ONE fast poll at the start of a move, when the + // antenna has been told to go but has not yet said it is going. It was three + // seconds, from when the status poll ran every two: a move that took a second + // left the transmitter gagged for two more, with the elements already in + // place. The antenna is now polled four times a second while it travels, so + // this only has to cover the command itself. + const grace = 900 * time.Millisecond ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() defer a.applyMotorInhibit(false) // never leave TX blocked when the loop ends diff --git a/app_wsjt_highlight.go b/app_wsjt_highlight.go index 0628884..c3681ca 100644 --- a/app_wsjt_highlight.go +++ b/app_wsjt_highlight.go @@ -7,6 +7,8 @@ package main // the spot grid, so the two windows can never disagree. import ( + "fmt" + "strconv" "strings" "hamlog/internal/applog" @@ -18,6 +20,13 @@ const ( keyWsjtHighlight = "udp.wsjt.highlight" keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode keyWsjtHLWorked = "udp.wsjt.highlight_worked" + // One key per verdict, holding "#RRGGBB". Only the BACKGROUND is stored: the + // text colour is computed from it, so a chosen colour can never come out + // unreadable in somebody else's window. + keyWsjtColWatchlist = "udp.wsjt.colour.watchlist" + keyWsjtColNewDXCC = "udp.wsjt.colour.new_dxcc" + keyWsjtColNewBand = "udp.wsjt.colour.new_band" + keyWsjtColWorked = "udp.wsjt.colour.worked" ) // wsjtModes are the modes a Configure message can meaningfully ask for — the @@ -53,8 +62,9 @@ func (a *App) ConfigureDecoderMode(mode string) { a.udp.SendConfigureMode(mode) } -// The palette. Fixed colours, not theme tokens — they are painted into another -// application's window, which has no idea what theme OpsLog wears. +// The DEFAULT palette. Fixed colours, not theme tokens — they are painted into +// another application's window, which has no idea what theme OpsLog wears, and +// the operator can change each of them (see WsjtHighlightColours). var ( hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green @@ -64,10 +74,82 @@ var ( // Worked already, on this band and in this mode. Grey on purpose, and the // only DIM colour of the four: the others say "look at this", and this one // says the opposite — it has to recede, not compete with them. - hlWorked = udp.RGB{R: 75, G: 85, B: 99} - hlWorkedFg = udp.RGB{R: 203, G: 213, B: 225} + hlWorked = udp.RGB{R: 75, G: 85, B: 99} ) +// WsjtHighlightColours is the operator's palette, one background per verdict. +type WsjtHighlightColours struct { + Watchlist string `json:"watchlist"` + NewDXCC string `json:"new_dxcc"` + NewBand string `json:"new_band"` + Worked string `json:"worked"` +} + +// GetWsjtHighlightColours returns the palette in "#RRGGBB", defaults included. +func (a *App) GetWsjtHighlightColours() WsjtHighlightColours { + return WsjtHighlightColours{ + Watchlist: a.settingOr(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)), + NewDXCC: a.settingOr(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)), + NewBand: a.settingOr(keyWsjtColNewBand, hexOfRGB(hlNewBand)), + Worked: a.settingOr(keyWsjtColWorked, hexOfRGB(hlWorked)), + } +} + +// SetWsjtHighlightColours stores the palette and repaints. +// +// The repaint is the whole point of clearing: the de-duplication remembers what +// it has already told each decoder, so without this a callsign keeps yesterday's +// colour until it changes verdict — and the operator, having just picked a new +// one, sees nothing happen. +func (a *App) SetWsjtHighlightColours(c WsjtHighlightColours) { + set := func(key, v, def string) { + if _, ok := parseHexRGB(v); !ok { + v = def + } + a.setSetting(key, strings.ToUpper(strings.TrimSpace(v))) + } + set(keyWsjtColWatchlist, c.Watchlist, hexOfRGB(hlWatchlist)) + set(keyWsjtColNewDXCC, c.NewDXCC, hexOfRGB(hlNewDXCC)) + set(keyWsjtColNewBand, c.NewBand, hexOfRGB(hlNewBand)) + set(keyWsjtColWorked, c.Worked, hexOfRGB(hlWorked)) + a.clearWsjtHighlights() + applog.Printf("wsjt highlight: palette changed — repainting") +} + +// colourFor reads one verdict's background and picks a legible foreground. +// +// The text colour is DERIVED, never stored: an operator choosing a dark blue +// would otherwise get black text on it in somebody else's window and conclude +// the feature is broken. Rec. 601 luma, the same rule a browser's contrast +// checker uses, with the threshold where black stops being readable. +func (a *App) colourFor(key, def string) (udp.RGB, udp.RGB) { + bg, ok := parseHexRGB(a.settingOr(key, def)) + if !ok { + bg, _ = parseHexRGB(def) + } + luma := (299*int(bg.R) + 587*int(bg.G) + 114*int(bg.B)) / 1000 + if luma < 140 { + return bg, hlWhite + } + return bg, hlBlack +} + +func hexOfRGB(c udp.RGB) string { return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) } + +// parseHexRGB reads "#RRGGBB" (or "RRGGBB"). Anything else is refused rather +// than half-read: a colour that silently becomes black is worse than a default. +func parseHexRGB(s string) (udp.RGB, bool) { + s = strings.TrimPrefix(strings.TrimSpace(s), "#") + if len(s) != 6 { + return udp.RGB{}, false + } + v, err := strconv.ParseUint(s, 16, 32) + if err != nil { + return udp.RGB{}, false + } + return udp.RGB{R: byte(v >> 16), G: byte(v >> 8), B: byte(v)}, true +} + // GetWsjtHighlightWorked reports whether stations already worked on this band // and mode are greyed out as well. // @@ -174,42 +256,52 @@ func (a *App) maybeHighlightDecode(instance, call, band, mode string) { // Anything else is "no colour", and the empty verdict doubles as the clear // signal in maybeHighlightDecode. func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) { - if a.watchlist != nil { + c := a.clusterStatusMaps() + // ALREADY WORKED HERE, whatever else the station is. + // + // Settled first because it is the one fact that cancels the others. A watch + // list entry worked on this band and mode stayed pink for the rest of the + // session — the list is a statement of intent, not of what is left to do, and + // the colour that means "call this one" was being shown for a station already + // in the log. From the operator's side there was no way to tell the two + // apart, which is the only thing the colours are for. + workedHere := false + if band != "" && mode != "" && c.workedCallSlots != nil { + m := strings.ToUpper(strings.TrimSpace(mode)) + if c.normMode != nil { + m = c.normMode(m) + } + _, workedHere = c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m] + } + if a.watchlist != nil && !workedHere { if _, ok := a.watchlist.Match(call); ok { - c := hlWatchlist - f := hlBlack - return &c, &f, "watchlist" + bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)) + return &bgc, &fgc, "watchlist" } } - c := a.clusterStatusMaps() if a.dxcc != nil { if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil { num := dxcc.EntityDXCC(m.Entity.Name) ent := c.entities[num] if ent == nil { - bgc, fgc := hlNewDXCC, hlWhite + bgc, fgc := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)) return &bgc, &fgc, "new-dxcc" } if band != "" { if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand { - bgc, fgc := hlNewBand, hlBlack + bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand)) return &bgc, &fgc, "new-band" } } } } - // Worked already, this exact callsign on this band in this mode — a dupe, - // judged by the same ledger and the same digital-mode grouping the cluster - // uses, so the two windows cannot disagree about what "worked" means. - if a.wsjtHLWorkedOn.Load() && band != "" && mode != "" { - m := strings.ToUpper(strings.TrimSpace(mode)) - if c.normMode != nil { - m = c.normMode(m) - } - if _, ok := c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]; ok { - bgc, fgc := hlWorked, hlWorkedFg - return &bgc, &fgc, "worked" - } + // A dupe, judged by the same ledger and the same digital-mode grouping the + // cluster uses, so the two windows cannot disagree about what "worked" means. + // Its own option: on a well-filled log this matches most of a period, and a + // screen where nearly every line is coloured has stopped saying anything. + if workedHere && a.wsjtHLWorkedOn.Load() { + bgc, fgc := a.colourFor(keyWsjtColWorked, hexOfRGB(hlWorked)) + return &bgc, &fgc, "worked" } return nil, nil, "" } diff --git a/changelog.json b/changelog.json index 25d43d3..fcc8303 100644 --- a/changelog.json +++ b/changelog.json @@ -11,7 +11,10 @@ "Club Log uploads are no longer refused as “not configured”. The check added for services with no credentials demanded a Club Log API key, which nobody has ever set — OpsLog carries its own application key — so an operator whose live upload had worked for months was turned away when sending QSOs by hand. Each service’s requirements now live beside the uploader that enforces them, and the message names the fields that are actually missing.", "Audio: the Listening device now says when it cannot be opened. A device unplugged, renamed by Windows or unable to run at 16 kHz failed silently while everything upstream reported success — the stream up, the packets arriving, the monitor started — which is the whole of “I turned the sound on and nothing comes out”. The log also says, once, whether the network RX audio is reaching the speakers or arriving with nobody listening.", "Icom network audio starts at once instead of half a minute later. The message that authorises the stream is sent during the login, before the audio socket exists — so the rig was told to send audio to a port nothing was bound to, got a port-unreachable back, and only resumed when its own retry timer came round. It is sent once more as soon as the port is listening.", - "Rotor dial: a circular scale and a beam instead of an arrow. The square ring made a marker at 45° sit further from the centre than one at north — a dial is read by angle, so the ring it is read against is now the same distance away all the way round. The antenna is drawn as a sector that fades outwards, which is the shape of the thing it stands for; where the mouse would send it appears in the same shape in orange, and its azimuth in place of the current heading while you aim. Green for where the antenna is, orange for where it would go, yellow for what was ordered — the second lobe of a bidirectional Ultrabeam and the dashed boom are unchanged. Design from EC1KD again." + "Rotor dial: a circular scale and a beam instead of an arrow. The square ring made a marker at 45° sit further from the centre than one at north — a dial is read by angle, so the ring it is read against is now the same distance away all the way round. The antenna is drawn as a sector that fades outwards, which is the shape of the thing it stands for; where the mouse would send it appears in the same shape in orange, and its azimuth in place of the current heading while you aim. Green for where the antenna is, orange for where it would go, yellow for what was ordered — the second lobe of a bidirectional Ultrabeam and the dashed boom are unchanged. Design from EC1KD again.", + "Motorised antenna: the transmitter is released as soon as the elements stop. Three delays were stacked between the antenna finishing and the operator being allowed to call — the antenna polled every two seconds, the transmit gag held for three after the command whatever the antenna said, and the screen refreshed every three. The antenna is now polled four times a second WHILE IT MOVES (and left at two seconds when it is still, where nothing changes), the gag only bridges the command itself, and the widget follows at half a second.", + "[NEW] The WSJT-X / JTDX highlight colours are yours to choose (Settings → UDP), one per verdict — watch list, new DXCC, new band, worked. Only the background is set: the text colour is worked out from it, so a chosen colour cannot come back unreadable in the decoder’s window.", + "WSJT-X highlighting: a watch-list station already worked on this band and mode is no longer painted as one to call. The list is a statement of intent, not of what is left to do, and its pink outranked everything — including the log — so a station already worked stayed pink for the session with no way to tell it from one still needed." ], "fr": [ "Carte FTx : l’indicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée qu’au point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables.", @@ -22,7 +25,10 @@ "Les envois vers Club Log ne sont plus refusés comme « non configuré ». Le contrôle ajouté pour les services sans identifiants réclamait une clé API Club Log que personne n’a jamais saisie — OpsLog embarque la sienne — et un opérateur dont l’envoi automatique fonctionnait depuis des mois se voyait éconduit au moment d’envoyer des QSO à la main. Les exigences de chaque service vivent désormais à côté du code qui les applique, et le message nomme les champs réellement manquants.", "Audio : le périphérique d’écoute signale désormais quand il ne peut pas s’ouvrir. Un périphérique débranché, renommé par Windows ou incapable de fonctionner en 16 kHz échouait en silence pendant que tout en amont annonçait le succès — flux ouvert, paquets reçus, moniteur démarré — ce qui est exactement le « j’ai remis le son et rien ne sort ». Le journal dit aussi, une fois, si l’audio réseau atteint les haut-parleurs ou arrive sans que personne n’écoute.", "L’audio réseau Icom démarre tout de suite au lieu d’une demi-minute plus tard. Le message qui autorise le flux part pendant la connexion, avant que la prise audio n’existe : le poste se voyait donc demander d’émettre vers un port où personne n’écoutait, recevait un « port injoignable » en retour, et ne reprenait qu’au tour suivant de son propre minuteur. Il est renvoyé dès que le port écoute.", - "Cadran rotor : échelle circulaire et faisceau au lieu d’une flèche. L’anneau carré plaçait un repère à 45° plus loin du centre qu’un repère au nord — un cadran se lit par l’angle, donc l’anneau qui sert de référence est désormais à la même distance tout autour. L’antenne est dessinée comme un secteur qui s’estompe vers l’extérieur, ce qui est la forme de ce qu’il représente ; là où la souris l’enverrait apparaît dans la même forme en orange, et son azimut à la place du cap courant pendant qu’on vise. Vert pour où l’antenne est, orange pour où elle irait, jaune pour ce qui a été demandé — le deuxième lobe d’un Ultrabeam bidirectionnel et le boom en pointillés sont inchangés. Design d’EC1KD, encore." + "Cadran rotor : échelle circulaire et faisceau au lieu d’une flèche. L’anneau carré plaçait un repère à 45° plus loin du centre qu’un repère au nord — un cadran se lit par l’angle, donc l’anneau qui sert de référence est désormais à la même distance tout autour. L’antenne est dessinée comme un secteur qui s’estompe vers l’extérieur, ce qui est la forme de ce qu’il représente ; là où la souris l’enverrait apparaît dans la même forme en orange, et son azimut à la place du cap courant pendant qu’on vise. Vert pour où l’antenne est, orange pour où elle irait, jaune pour ce qui a été demandé — le deuxième lobe d’un Ultrabeam bidirectionnel et le boom en pointillés sont inchangés. Design d’EC1KD, encore.", + "Antenne motorisée : l’émission est rendue dès que les éléments s’arrêtent. Trois délais s’ajoutaient entre la fin du mouvement et le droit d’appeler — l’antenne interrogée toutes les deux secondes, le blocage d’émission maintenu trois secondes après la commande quoi qu’en dise l’antenne, et l’écran rafraîchi toutes les trois. L’antenne est désormais interrogée quatre fois par seconde PENDANT qu’elle bouge (et laissée à deux secondes à l’arrêt, où rien ne change), le blocage ne couvre plus que la commande elle-même, et le widget suit à la demi-seconde.", + "[NEW] Les couleurs de mise en évidence WSJT-X / JTDX sont au choix (Réglages → UDP), une par verdict — watchlist, nouveau DXCC, nouvelle bande, contactée. Seul le fond se règle : la couleur du texte en est déduite, pour qu’une couleur choisie ne revienne jamais illisible dans la fenêtre du décodeur.", + "Mise en évidence WSJT-X : une station de la watchlist déjà contactée sur cette bande et ce mode n’est plus peinte comme une station à appeler. La liste dit une intention, pas ce qu’il reste à faire, et son rose passait devant tout — y compris le carnet — si bien qu’une station déjà faite restait rose toute la session, sans moyen de la distinguer d’une station encore à faire." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 08b1528..1a9ac71 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3100,15 +3100,22 @@ export default function App() { const pokeUbStatus = useCallback(async () => { try { const s: any = await GetUltrabeamStatus(); if (s) setUbStatus(s); } catch { /* transient */ } }, []); + // Three seconds while it sits still, half a second while it moves. + // + // The moving flag gags the transmitter, and this poll is the last of three + // delays between the elements stopping and the operator being allowed to call + // again — the antenna's own poll and the inhibit loop are the other two. At + // three seconds it was the largest of them: the antenna had finished, the + // radio was free, and the screen still said orange. useEffect(() => { let alive = true; const tick = async () => { try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {} }; tick(); - const id = window.setInterval(tick, 3000); + const id = window.setInterval(tick, ubStatus.moving ? 500 : 3000); return () => { alive = false; window.clearInterval(id); }; - }, []); + }, [ubStatus.moving]); // Poll the Antenna Genius switch for active antenna per port + the list. // Re-read the enabled flag each tick so toggling it in Settings makes the diff --git a/frontend/src/components/UDPIntegrationsPanel.tsx b/frontend/src/components/UDPIntegrationsPanel.tsx index 6a5d148..cb33464 100644 --- a/frontend/src/components/UDPIntegrationsPanel.tsx +++ b/frontend/src/components/UDPIntegrationsPanel.tsx @@ -3,6 +3,7 @@ import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } fro import { ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations, GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode, + GetWsjtHighlightColours, SetWsjtHighlightColours, } from '../../wailsjs/go/main/App'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -161,9 +162,13 @@ type Props = { onError: (msg: string) => void }; export function UDPIntegrationsPanel({ onError }: Props) { const [highlightOn, setHighlightOn] = useState(false); const [hlWorked, setHlWorked] = useState(false); + // The palette, as chosen. Background per verdict; the text colour is the + // backend’s business (see colourFor). + const [colours, setColours] = useState({ watchlist: '#F472B6', new_dxcc: '#16823C', new_band: '#E27A18', worked: '#4B5563' }); const [followMode, setFollowMode] = useState(true); useEffect(() => { GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {}); + GetWsjtHighlightColours().then((c: any) => { if (c) setColours(c); }).catch(() => {}); GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {}); GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {}); GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {}); @@ -249,6 +254,45 @@ export function UDPIntegrationsPanel({ onError }: Props) { {t('udpp.highlightHint')} + {/* The palette, nested under the switch for the same reason as the box + below it. One colour per verdict, and the background only: the text + colour is worked out from it, so a chosen colour cannot come back + unreadable in the decoder's window. */} + {highlightOn && ( +
+
{t('udpp.hlColours')}
+
+ {([ + ['watchlist', t('udpp.hlWatchlist')], + ['new_dxcc', t('udpp.hlNewDxcc')], + ['new_band', t('udpp.hlNewBand')], + ['worked', t('udpp.hlWorkedC')], + ] as const).map(([k, label]) => ( + + ))} + +
+
+ )} {/* Nested under the switch above: the same feature, and meaningless while that one is off. */} {highlightOn && ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 3503dab..3938bd6 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -508,7 +508,7 @@ const en: Dict = { 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET', 'udpp.relayInstead': 'For an antenna switch or a relay board, use Station Control → relays instead: it holds the state, reads the boards at startup and does not re-switch while you tune inside a band. A home-made switch is the “HTTP relay” type there.', 'udpp.svcCustomLabel': 'Custom message', 'udpp.svcCustomHint': 'You choose what fires it and what it says. A UDP datagram or an HTTP request — the latter is how most antenna switches are driven.', 'udpp.trigger': 'Fires on', 'udpp.trgBand': 'Band change (radio)', 'udpp.trgQso': 'QSO logged', 'udpp.trgRotator': 'Rotator command', 'udpp.trgLookup': 'Callsign lookup', 'udpp.transport': 'Sends as', 'udpp.transportUdp': 'UDP message', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Values are URL-encoded. Credentials may be included as http://user:pass@host/… — stored as typed, so keep it to your own network.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Line end', 'udpp.lineEndNone': 'None', 'udpp.fieldsAvailable': 'Fields for this trigger', 'udpp.fieldsHint': 'Anything else renders empty.', - 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcWsjtRelayLabel': 'Relay the WSJT-X stream', 'udpp.svcWsjtRelayHint': 'Re-sends every datagram received from WSJT-X / JTDX / MSHV, byte for byte, to another program — JTAlert, GridTracker, a second logger. The sender only talks to one address, so this is what lets them run alongside OpsLog. Point it at the OTHER program’s port, never at one of OpsLog’s own.', 'udpp.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32’s additional UDP sockets, for one).', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'Connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.highlight': 'Highlight decodes in WSJT-X / JTDX', 'udpp.hlWorked': 'Grey out stations already worked', 'udpp.hlWorkedHint': 'The same callsign already in your log on this band AND in this mode — a duplicate. Grey, not a colour: the other three say look at this, and this one says the opposite. Off by default, because on a well-filled log it can match most of a period.', 'udpp.highlightHint': 'Colours callsigns in the decoder’s own Band Activity window from your log: watchlist members pink, a new DXCC green, a new band for its entity orange. Applied live as decodes arrive.', 'udpp.followMode': 'Switch the decoder\u2019s mode from spots', 'udpp.followModeHint': 'Clicking an FT4 spot while WSJT-X / JTDX sits in FT8 switches its mode too (Configure message).', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', + 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcWsjtRelayLabel': 'Relay the WSJT-X stream', 'udpp.svcWsjtRelayHint': 'Re-sends every datagram received from WSJT-X / JTDX / MSHV, byte for byte, to another program — JTAlert, GridTracker, a second logger. The sender only talks to one address, so this is what lets them run alongside OpsLog. Point it at the OTHER program’s port, never at one of OpsLog’s own.', 'udpp.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32’s additional UDP sockets, for one).', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'Connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.highlight': 'Highlight decodes in WSJT-X / JTDX', 'udpp.hlWorked': 'Grey out stations already worked', 'udpp.hlColours': 'Colours — the background of each verdict; the text colour follows it.', 'udpp.hlWatchlist': 'Watch list', 'udpp.hlNewDxcc': 'New DXCC', 'udpp.hlNewBand': 'New band', 'udpp.hlWorkedC': 'Worked', 'udpp.hlReset': 'Reset to the defaults', 'udpp.hlWorkedHint': 'The same callsign already in your log on this band AND in this mode — a duplicate. Grey, not a colour: the other three say look at this, and this one says the opposite. Off by default, because on a well-filled log it can match most of a period.', 'udpp.highlightHint': 'Colours callsigns in the decoder’s own Band Activity window from your log: watchlist members pink, a new DXCC green, a new band for its entity orange. Applied live as decodes arrive.', 'udpp.followMode': 'Switch the decoder\u2019s mode from spots', 'udpp.followModeHint': 'Clicking an FT4 spot while WSJT-X / JTDX sits in FT8 switches its mode too (Configure message).', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', 'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Added to the log on', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL sent via', 'fltb.fQslRcvdVia': 'QSL rcvd via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fClublogRcvd': 'Club Log match status', 'fltb.fClublogRcvdDate': 'Club Log match date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamqthSent': 'HamQTH sent status', 'fltb.fHamqthSentDate': 'HamQTH sent date', 'bulk.fHamqthSent': 'HamQTH sent status', 'bulk.fHamqthSentDate': 'HamQTH sent date', 'rqg.c.hamqth_sent': 'HamQTH sent', 'rqg.h.hamqth_sent': 'HamQTH sent', 'rqg.c.hamqth_sent_date': 'HamQTH sent date', 'rqg.h.hamqth_sent_date': 'HamQTH S date', 'fltb.fHamlogSent': 'HAMLOG.online sent', 'fltb.fHamlogSentDate': 'HAMLOG.online sent date', 'fltb.fHamlogRcvd': 'HAMLOG.online received', 'fltb.fHamlogRcvdDate': 'HAMLOG.online received date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opIn': 'is one of', 'fltb.opNotIn': 'is none of', 'fltb.listPh': '2m, 70cm — comma separated', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.presetSaved': 'Filter “{name}” saved', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', 'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Detected — this contact will count for:', 'detp.ambiguous': 'Ambiguous — pick one:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', 'detp.contactedWeb': 'Website', // Awards (ref picker / ref selector / awards panel / award editor) @@ -1050,7 +1050,7 @@ const fr: Dict = { 'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET', 'udpp.relayInstead': 'Pour un commutateur d’antennes ou une carte de relais, préférez Station Control → relais : il tient l’état, relit les cartes au démarrage et ne recommute pas quand vous bougez dans la même bande. Un commutateur fait main s’y déclare en type « Relais HTTP ».', 'udpp.svcCustomLabel': 'Message personnalisé', 'udpp.svcCustomHint': 'Vous choisissez ce qui le déclenche et ce qu’il dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs d’antennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche d’indicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour l’URL. Les identifiants peuvent s’écrire http://user:pass@hôte/… — stockés tels quels, à réserver à votre réseau local.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Fin de ligne', 'udpp.lineEndNone': 'Aucune', 'udpp.fieldsAvailable': 'Champs de ce déclencheur', 'udpp.fieldsHint': 'Tout autre champ rendra du vide.', - 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcWsjtRelayLabel': 'Relayer le flux WSJT-X', 'udpp.svcWsjtRelayHint': 'Réémet chaque datagramme reçu de WSJT-X / JTDX / MSHV, octet pour octet, vers un autre logiciel — JTAlert, GridTracker, un second carnet. L’émetteur ne parle qu’à une seule adresse : c’est ce qui permet de les faire tourner à côté d’OpsLog. À pointer sur le port de l’AUTRE logiciel, jamais sur un port d’écoute d’OpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.highlight': 'Surligner les décodages dans WSJT-X / JTDX', 'udpp.hlWorked': 'Griser les stations déjà travaillées', 'udpp.hlWorkedHint': 'Le même indicatif déjà au log sur cette bande ET dans ce mode — un doublon. En gris, pas en couleur : les trois autres disent « regarde », celle-ci dit l’inverse. Désactivé par défaut, car sur un log bien rempli elle peut concerner la majorité d’une période.', 'udpp.highlightHint': 'Colore les indicatifs dans la fenêtre Band Activity du décodeur selon votre log : watchlist en rose, nouveau DXCC en vert, nouvelle bande pour son entité en orange. Appliqué en direct à l’arrivée des décodages.', 'udpp.followMode': 'Changer le mode du décodeur depuis les spots', 'udpp.followModeHint': 'Cliquer un spot FT4 pendant que WSJT-X / JTDX est en FT8 change aussi son mode.', 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', + 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcWsjtRelayLabel': 'Relayer le flux WSJT-X', 'udpp.svcWsjtRelayHint': 'Réémet chaque datagramme reçu de WSJT-X / JTDX / MSHV, octet pour octet, vers un autre logiciel — JTAlert, GridTracker, un second carnet. L’émetteur ne parle qu’à une seule adresse : c’est ce qui permet de les faire tourner à côté d’OpsLog. À pointer sur le port de l’AUTRE logiciel, jamais sur un port d’écoute d’OpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.highlight': 'Surligner les décodages dans WSJT-X / JTDX', 'udpp.hlWorked': 'Griser les stations déjà travaillées', 'udpp.hlColours': 'Couleurs — le fond de chaque verdict ; la couleur du texte en découle.', 'udpp.hlWatchlist': 'Watchlist', 'udpp.hlNewDxcc': 'Nouveau DXCC', 'udpp.hlNewBand': 'Nouvelle bande', 'udpp.hlWorkedC': 'Contactée', 'udpp.hlReset': 'Rétablir les couleurs d’origine', 'udpp.hlWorkedHint': 'Le même indicatif déjà au log sur cette bande ET dans ce mode — un doublon. En gris, pas en couleur : les trois autres disent « regarde », celle-ci dit l’inverse. Désactivé par défaut, car sur un log bien rempli elle peut concerner la majorité d’une période.', 'udpp.highlightHint': 'Colore les indicatifs dans la fenêtre Band Activity du décodeur selon votre log : watchlist en rose, nouveau DXCC en vert, nouvelle bande pour son entité en orange. Appliqué en direct à l’arrivée des décodages.', 'udpp.followMode': 'Changer le mode du décodeur depuis les spots', 'udpp.followModeHint': 'Cliquer un spot FT4 pendant que WSJT-X / JTDX est en FT8 change aussi son mode.', 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', 'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Ajouté au journal le', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL envoyée via', 'fltb.fQslRcvdVia': 'QSL reçue via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fClublogRcvd': 'Statut match Club Log', 'fltb.fClublogRcvdDate': 'Date match Club Log', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamqthSent': 'Statut envoi HamQTH', 'fltb.fHamqthSentDate': 'Date d’envoi HamQTH', 'bulk.fHamqthSent': 'Statut envoi HamQTH', 'bulk.fHamqthSentDate': 'Date d’envoi HamQTH', 'rqg.c.hamqth_sent': 'HamQTH env', 'rqg.h.hamqth_sent': 'HamQTH env.', 'rqg.c.hamqth_sent_date': 'Date env HamQTH', 'rqg.h.hamqth_sent_date': 'HamQTH env.', 'fltb.fHamlogSent': 'HAMLOG.online envoyé', 'fltb.fHamlogSentDate': "HAMLOG.online date d'envoi", 'fltb.fHamlogRcvd': 'HAMLOG.online reçu', 'fltb.fHamlogRcvdDate': 'HAMLOG.online date de réception', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opIn': 'est parmi', 'fltb.opNotIn': 'n est pas parmi', 'fltb.listPh': '2m, 70cm — séparés par des virgules', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.presetSaved': 'Filtre « {name} » enregistré', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.contactedWeb': 'Site web', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 82b4831..fea8d0c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -648,6 +648,8 @@ export function GetWsjtFollowMode():Promise; export function GetWsjtHighlight():Promise; +export function GetWsjtHighlightColours():Promise; + export function GetWsjtHighlightWorked():Promise; export function GetYaesuBandAntennas():Promise>; @@ -1308,6 +1310,8 @@ export function SetWsjtFollowMode(arg1:boolean):Promise; export function SetWsjtHighlight(arg1:boolean):Promise; +export function SetWsjtHighlightColours(arg1:main.WsjtHighlightColours):Promise; + export function SetWsjtHighlightWorked(arg1:boolean):Promise; export function SetYaesuAFGain(arg1:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 4f4713b..60fded2 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1230,6 +1230,10 @@ export function GetWsjtHighlight() { return window['go']['main']['App']['GetWsjtHighlight'](); } +export function GetWsjtHighlightColours() { + return window['go']['main']['App']['GetWsjtHighlightColours'](); +} + export function GetWsjtHighlightWorked() { return window['go']['main']['App']['GetWsjtHighlightWorked'](); } @@ -2550,6 +2554,10 @@ export function SetWsjtHighlight(arg1) { return window['go']['main']['App']['SetWsjtHighlight'](arg1); } +export function SetWsjtHighlightColours(arg1) { + return window['go']['main']['App']['SetWsjtHighlightColours'](arg1); +} + export function SetWsjtHighlightWorked(arg1) { return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 084ad4b..4675484 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -4592,6 +4592,24 @@ export namespace main { return a; } } + export class WsjtHighlightColours { + watchlist: string; + new_dxcc: string; + new_band: string; + worked: string; + + static createFrom(source: any = {}) { + return new WsjtHighlightColours(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.watchlist = source["watchlist"]; + this.new_dxcc = source["new_dxcc"]; + this.new_band = source["new_band"]; + this.worked = source["worked"]; + } + } } diff --git a/internal/ultrabeam/ultrabeam.go b/internal/ultrabeam/ultrabeam.go index ff77a22..44c2ac5 100644 --- a/internal/ultrabeam/ultrabeam.go +++ b/internal/ultrabeam/ultrabeam.go @@ -134,6 +134,23 @@ type Client struct { // clears rather than latching the TX-inhibit on for ever. const ubMoveOptimisticWindow = 3 * time.Second +// Poll cadences: what an idle antenna is worth, and what a moving one is worth. +// See the poll loop. +const ( + ubPollIdle = 2 * time.Second + ubPollMoving = 250 * time.Millisecond +) + +// movingOptimistically reports whether a move was commanded so recently that the +// antenna cannot have answered yet — the same window GetStatus reports motion +// for, so the fast poll starts with the movement rather than one interval into +// it. +func (c *Client) movingOptimistically() bool { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow +} + // LastSetKHz returns the frequency (kHz) most recently commanded to the antenna, // or 0 if none yet. func (c *Client) LastSetKHz() int { @@ -312,8 +329,22 @@ func (c *Client) pollLoop() { close(c.done) } }() - ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s + // TWO CADENCES, and the fast one is what matters. + // + // An idle antenna has nothing to say, so two seconds is generous. A MOVING + // one has one thing to say and it is urgent: transmit is inhibited while the + // elements travel, so every poll interval between the motors stopping and + // this loop noticing is a second the operator cannot call — with the antenna + // already in place and the rig still gagged. Reported from the air as "it + // stays orange one or two seconds after it has finished". + // + // While the motors run — or a move has just been commanded — the antenna is + // asked four times a second. The controller answers a status query in + // milliseconds; this is nothing to it, and it lasts only as long as the + // movement does. + ticker := time.NewTicker(ubPollIdle) defer ticker.Stop() + fast := false pollCount := 0 pollFails := 0 // consecutive failed status polls (transient timeouts tolerated) @@ -436,6 +467,18 @@ func (c *Client) pollLoop() { c.lastStatus = status c.statusMu.Unlock() + // Follow the motors with the poll rate. Changed only when it actually + // changes: Reset on a ticker is cheap but not free, and the antenna is + // polled for hours on end. + if moving := status.MotorsMoving != 0 || c.movingOptimistically(); moving != fast { + fast = moving + if fast { + ticker.Reset(ubPollMoving) + } else { + ticker.Reset(ubPollIdle) + } + } + case <-c.stopChan: return } diff --git a/wsjthighlight_test.go b/wsjthighlight_test.go new file mode 100644 index 0000000..46ac41d --- /dev/null +++ b/wsjthighlight_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "testing" + + udp "hamlog/internal/integrations/udp" +) + +// The text colour is derived from the background, never stored: an operator who +// picks a dark blue must not end up with black text on it in somebody else's +// window and conclude the feature is broken. +func TestHighlightForegroundFollowsTheBackground(t *testing.T) { + var a App + for _, tc := range []struct { + bg string + want udp.RGB + }{ + {"#111827", hlWhite}, // near-black + {"#16823C", hlWhite}, // the new-DXCC green + {"#F472B6", hlBlack}, // the watchlist pink + {"#FFFFFF", hlBlack}, + {"#E27A18", hlBlack}, // orange + } { + bg, fg := a.colourFor("", tc.bg) + if got, ok := parseHexRGB(tc.bg); !ok || got != bg { + t.Errorf("%s: background came back as %v", tc.bg, bg) + } + if fg != tc.want { + t.Errorf("%s: foreground %v, want %v", tc.bg, fg, tc.want) + } + } +} + +// A colour that cannot be read is refused rather than half-read: silently +// becoming black is worse than keeping the default. +func TestHighlightColourParsing(t *testing.T) { + for _, s := range []string{"#F472B6", "f472b6", " #F472B6 "} { + if c, ok := parseHexRGB(s); !ok || c != (udp.RGB{R: 244, G: 114, B: 182}) { + t.Errorf("%q → %v ok=%v", s, c, ok) + } + } + for _, s := range []string{"", "#FFF", "pink", "#GGGGGG", "#F472B6F"} { + if _, ok := parseHexRGB(s); ok { + t.Errorf("%q was accepted", s) + } + } +}