241 lines
9.0 KiB
Go
241 lines
9.0 KiB
Go
package main
|
|
|
|
// Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does
|
|
// and the reason an operator can tell at a glance what still needs sending.
|
|
//
|
|
// Rules are ORDERED and the first match wins, because a contact is usually
|
|
// several things at once: one confirmed on LoTW and by card is confirmed, not
|
|
// "sent, awaiting reply". Putting the order in the data rather than in a chain
|
|
// of ifs is what lets the settings panel show it in the same order it applies.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// RowColorRule is one status and the colour it paints.
|
|
type RowColorRule struct {
|
|
ID string `json:"id"`
|
|
Color string `json:"color"`
|
|
Enabled bool `json:"enabled"`
|
|
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz.
|
|
//
|
|
// Empty means ALL of them — what an operator expects from a rule they have
|
|
// not narrowed, and what keeps a config written before this field meaningful.
|
|
Channels []string `json:"channels"`
|
|
}
|
|
|
|
// The QSL channels a rule can be scoped to.
|
|
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"}
|
|
|
|
// RowColorSettings is the whole appearance block.
|
|
type RowColorSettings struct {
|
|
Enabled bool `json:"enabled"`
|
|
// Style is how the colour is shown: "bar" paints a stripe down the left edge,
|
|
// "tint" washes the row, "both" does each.
|
|
//
|
|
// A bar is the default because a filled row is a poor signal in a log where
|
|
// nearly every contact has SOME QSL state: colour that is always present
|
|
// stops being information and becomes a striped background, with the data
|
|
// behind it. The stripe says the same thing and costs nothing to read.
|
|
Style string `json:"style"`
|
|
// Intensity is the tint strength in percent. Only used by "tint"/"both".
|
|
Intensity int `json:"intensity"`
|
|
// BandMapLotw marks stations that upload to LoTW on the band map, with the
|
|
// same "L" badge the cluster list uses — one visual vocabulary across the two
|
|
// views rather than a second invention.
|
|
BandMapLotw bool `json:"bandmap_lotw"`
|
|
// Configured distinguishes "saved with this off" from "saved before the
|
|
// option existed", so a new marker can default ON without silently turning
|
|
// itself back on for an operator who switched it off.
|
|
// RecentZebra decides whether the Recent QSOs grid alternates its row
|
|
// background. "" (or "on") keeps the banding, which is what the grid has
|
|
// always done; "off" makes every row the same colour.
|
|
//
|
|
// A string with an empty default rather than a bool: a bool would have to
|
|
// mean "true when absent", and every reader would have to remember that.
|
|
RecentZebra string `json:"recent_zebra,omitempty"`
|
|
// RecentZebraColor is the alternate row's colour. Empty follows the theme,
|
|
// which is the only answer that stays right across all four of them.
|
|
RecentZebraColor string `json:"recent_zebra_color,omitempty"`
|
|
Configured bool `json:"configured"`
|
|
Rules []RowColorRule `json:"rules"`
|
|
}
|
|
|
|
// The rule ids, in priority order. The frontend matches on these and holds the
|
|
// labels, so a translated name never has to travel through the settings.
|
|
// The four categories, in the order they are tested — first match wins.
|
|
//
|
|
// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and
|
|
// still owe a paper card, and the colour an operator scans for is the one that
|
|
// means "there is something left to do here". Put after "confirmed", that row
|
|
// would go green and the card would never be printed.
|
|
//
|
|
// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and
|
|
// nothing back. Off by default — with it on, every remaining row is painted.
|
|
var rowColorOrder = []string{
|
|
"to_send", // requested or queued on a watched channel, not gone out
|
|
"confirmed", // a watched channel has a confirmation back
|
|
"sent", // gone out on a watched channel, nothing back yet
|
|
"worked", // none of the above
|
|
}
|
|
|
|
// Defaults: green for done, amber for waiting, blue for owed. Deliberately
|
|
// muted — they are composited at low opacity over a dark grid, and a saturated
|
|
// value there reads as an error state rather than a status.
|
|
var rowColorDefaults = map[string]string{
|
|
"to_send": "#a855f7",
|
|
"confirmed": "#16a34a",
|
|
"sent": "#f59e0b",
|
|
"worked": "#64748b",
|
|
}
|
|
|
|
// hexColor guards what reaches the stylesheet. The value is interpolated into a
|
|
// CSS color-mix() by the grid, so anything that is not plainly a hex colour is
|
|
// refused rather than passed through.
|
|
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
|
|
func normRowColors(s RowColorSettings) RowColorSettings {
|
|
byID := map[string]RowColorRule{}
|
|
for _, r := range s.Rules {
|
|
byID[r.ID] = r
|
|
}
|
|
out := RowColorSettings{
|
|
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
|
|
BandMapLotw: s.BandMapLotw, Configured: true,
|
|
RecentZebra: strings.ToLower(strings.TrimSpace(s.RecentZebra)),
|
|
}
|
|
if out.RecentZebra != "off" {
|
|
out.RecentZebra = "" // anything unrecognised means the default banding
|
|
}
|
|
if c := strings.TrimSpace(s.RecentZebraColor); hexColor.MatchString(c) {
|
|
out.RecentZebraColor = c
|
|
}
|
|
if !s.Configured {
|
|
out.BandMapLotw = true // new option, on unless the operator says otherwise
|
|
}
|
|
switch out.Style {
|
|
case "bar", "tint", "both":
|
|
default:
|
|
out.Style = "bar"
|
|
}
|
|
// Clamped rather than rejected: the value only shapes a colour-mix, and a
|
|
// number outside the range is a slider that got away, not a fault worth
|
|
// resetting the operator's whole choice for.
|
|
if out.Intensity < 5 {
|
|
out.Intensity = 12
|
|
} else if out.Intensity > 45 {
|
|
out.Intensity = 45
|
|
}
|
|
ok := map[string]bool{}
|
|
for _, c := range rowColorChannels {
|
|
ok[c] = true
|
|
}
|
|
for _, id := range rowColorOrder {
|
|
r := byID[id]
|
|
r.ID = id
|
|
if !hexColor.MatchString(strings.TrimSpace(r.Color)) {
|
|
r.Color = rowColorDefaults[id]
|
|
}
|
|
// Keep only channels we know, in the canonical order, de-duplicated.
|
|
want := map[string]bool{}
|
|
for _, c := range r.Channels {
|
|
want[strings.ToLower(strings.TrimSpace(c))] = true
|
|
}
|
|
chans := []string{}
|
|
for _, c := range rowColorChannels {
|
|
if want[c] {
|
|
chans = append(chans, c)
|
|
}
|
|
}
|
|
r.Channels = chans
|
|
out.Rules = append(out.Rules, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MatrixColors recolours the band/mode matrix — the PH/CW/DIG grid in the Stats
|
|
// panel, whose five fills and current-entry ring are the fastest read in the
|
|
// whole app and the one an operator is most likely to want in their own colours.
|
|
//
|
|
// Every colour is OPTIONAL and an empty one keeps whatever the active theme
|
|
// paints. That is why this stores OVERRIDES rather than a palette: each of the
|
|
// twelve themes ships a matrix ramp tuned to its own background, and an operator
|
|
// who only wants a different green must not thereby freeze the other four to the
|
|
// theme they happened to be using the day they picked it.
|
|
type MatrixColors struct {
|
|
// Enabled off leaves the theme's own ramp untouched, so switching it off is a
|
|
// genuine revert and not "some other set of colours".
|
|
Enabled bool `json:"enabled"`
|
|
CallConfirmed string `json:"call_confirmed"`
|
|
CallWorked string `json:"call_worked"`
|
|
EntityConfirmed string `json:"entity_confirmed"`
|
|
EntityWorked string `json:"entity_worked"`
|
|
NotWorked string `json:"not_worked"`
|
|
CurrentEntry string `json:"current_entry"`
|
|
}
|
|
|
|
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
|
|
// "use the theme's" — because these are written straight into a CSS custom
|
|
// property, and the same rule as hexColor's own comment applies: what cannot be
|
|
// trusted into a stylesheet is refused rather than passed through.
|
|
func normMatrixColors(c MatrixColors) MatrixColors {
|
|
clean := func(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if hexColor.MatchString(s) {
|
|
return strings.ToLower(s)
|
|
}
|
|
return ""
|
|
}
|
|
return MatrixColors{
|
|
Enabled: c.Enabled,
|
|
CallConfirmed: clean(c.CallConfirmed),
|
|
CallWorked: clean(c.CallWorked),
|
|
EntityConfirmed: clean(c.EntityConfirmed),
|
|
EntityWorked: clean(c.EntityWorked),
|
|
NotWorked: clean(c.NotWorked),
|
|
CurrentEntry: clean(c.CurrentEntry),
|
|
}
|
|
}
|
|
|
|
// GetMatrixColors returns the operator's matrix palette overrides. All-empty
|
|
// (the default) means "whatever the theme says".
|
|
func (a *App) GetMatrixColors() MatrixColors {
|
|
var c MatrixColors
|
|
if raw := a.settingOr(keyMatrixColors, ""); raw != "" {
|
|
_ = json.Unmarshal([]byte(raw), &c)
|
|
}
|
|
return normMatrixColors(c)
|
|
}
|
|
|
|
// SaveMatrixColors persists them.
|
|
func (a *App) SaveMatrixColors(c MatrixColors) error {
|
|
b, err := json.Marshal(normMatrixColors(c))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.setSetting(keyMatrixColors, string(b))
|
|
return nil
|
|
}
|
|
|
|
// GetRowColors returns the row-colouring configuration, defaults included so the
|
|
// panel never has to invent one.
|
|
func (a *App) GetRowColors() RowColorSettings {
|
|
var s RowColorSettings
|
|
if raw := a.settingOr(keyRowColors, ""); raw != "" {
|
|
_ = json.Unmarshal([]byte(raw), &s)
|
|
}
|
|
return normRowColors(s)
|
|
}
|
|
|
|
// SaveRowColors persists it.
|
|
func (a *App) SaveRowColors(s RowColorSettings) error {
|
|
b, err := json.Marshal(normRowColors(s))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.setSetting(keyRowColors, string(b))
|
|
return nil
|
|
}
|