package main // Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13), // the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a // new band for its entity is highlighted where the operator is actually // looking. The verdicts come from the same cluster status cache that colours // the spot grid, so the two windows can never disagree. import ( "fmt" "strconv" "strings" "hamlog/internal/applog" "hamlog/internal/dxcc" udp "hamlog/internal/integrations/udp" ) 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 // decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its // business and is not sent. var wsjtModes = map[string]bool{ "FT8": true, "FT4": true, "JT65": true, "JT9": true, "MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol } // GetWsjtFollowMode reports whether spot clicks retune the decoder's mode. func (a *App) GetWsjtFollowMode() bool { return a.settingOr(keyWsjtFollowMode, "1") == "1" } // SetWsjtFollowMode flips it. func (a *App) SetWsjtFollowMode(on bool) { v := "0" if on { v = "1" } a.setSetting(keyWsjtFollowMode, v) } // ConfigureDecoderMode asks the connected decoders to switch mode — called by // the frontend after a spot click has tuned the radio. A no-op for modes the // decoder does not speak, and when the option is off or nothing is connected. func (a *App) ConfigureDecoderMode(mode string) { mode = strings.ToUpper(strings.TrimSpace(mode)) if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() { return } a.udp.SendConfigureMode(mode) } // 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 hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange hlWhite = udp.RGB{R: 255, G: 255, B: 255} hlBlack = udp.RGB{R: 20, G: 20, B: 20} // 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} ) // 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. // // Its own switch, and off by default. The other three verdicts pick out a // handful of decodes in a period; this one can match most of them on a // well-filled log, and a screen where nearly every line is coloured has stopped // saying anything. It is worth having only for an operator who wants the dupes // struck out rather than the catches picked out. func (a *App) GetWsjtHighlightWorked() bool { return a.settingOr(keyWsjtHLWorked, "0") == "1" } // SetWsjtHighlightWorked flips it, and repaints. // // Turning it OFF has to clear what it painted: those callsigns keep their grey // in the decoder's window otherwise, and nothing would ever say another word // about them — the de-duplication remembers that they were already told. func (a *App) SetWsjtHighlightWorked(on bool) { v := "0" if on { v = "1" } a.setSetting(keyWsjtHLWorked, v) a.wsjtHLWorkedOn.Store(on) a.clearWsjtHighlights() applog.Printf("wsjt highlight: worked stations %v", on) } // clearWsjtHighlights wipes every instruction OpsLog installed, in every // running decoder, and forgets what it had said. func (a *App) clearWsjtHighlights() { if a.udp == nil { return } for _, inst := range a.udp.Instances() { _ = a.udp.SendClearHighlights(inst) } a.wsjtHLMu.Lock() a.wsjtHLSent = map[string]string{} a.wsjtHLMu.Unlock() } // GetWsjtHighlight reports whether decode highlighting is on. func (a *App) GetWsjtHighlight() bool { return a.settingOr(keyWsjtHighlight, "0") == "1" } // SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also // clears every instruction OpsLog installed in the running applications — a // disabled option that leaves stale colours behind looks broken, not disabled. func (a *App) SetWsjtHighlight(on bool) { v := "0" if on { v = "1" } a.setSetting(keyWsjtHighlight, v) a.wsjtHighlightOn.Store(on) if !on { a.clearWsjtHighlights() applog.Printf("wsjt highlight: off — cleared in every instance") } } // maybeHighlightDecode paints one decoded callsign in the instance that heard // it, when the option is on and the verdict is worth a colour. De-duplicated // per instance+call+verdict: a station CQing all evening is decoded four times // a minute, and the instruction only needs to be said once. func (a *App) maybeHighlightDecode(instance, call, band, mode string) { if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" { return } bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode) // The MODE is part of the key: one receiver can be handed FT8 and FT4 in // the same session, and the verdict on a callsign is not the same in both. key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode) a.wsjtHLMu.Lock() if a.wsjtHLSent == nil { a.wsjtHLSent = map[string]string{} } if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few a.wsjtHLSent = map[string]string{} } prev, had := a.wsjtHLSent[key] if had && prev == verdict { a.wsjtHLMu.Unlock() return } a.wsjtHLSent[key] = verdict a.wsjtHLMu.Unlock() if verdict == "" { // Was highlighted under an earlier verdict and no longer deserves it // (the operator just worked them): clear that one callsign. if had && prev != "" { _ = a.udp.SendHighlight(instance, call, nil, nil, false) } return } _ = a.udp.SendHighlight(instance, call, bg, fg, false) } // decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats // new-band, and "already worked here" comes last of all — it is the only // verdict that says do NOT call, so anything worth calling for outranks it. // 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) { 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 { bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)) return &bgc, &fgc, "watchlist" } } 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 := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)) return &bgc, &fgc, "new-dxcc" } if band != "" { if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand { bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand)) return &bgc, &fgc, "new-band" } } } } // 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, "" }