package theme import ( renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" ) const ( accentMinChroma = 0.025 accentMinBackgroundDistance = 0.08 accentClusterDistance = 0.04 ) // Accent is the single terminal-owned color selected for chrome. IndexedOnly // means the slot may decorate foregrounds and borders, but cannot fill RGB // backgrounds because terminal defaults or truecolor support are unavailable. type Accent struct { RGB renderer.RGB Slot uint8 Known bool IndexedOnly bool } // The resolver only ever considers the chromatic ANSI slots in automatic // mode. This immutable mapping keeps resolution bounded and independent of // terminal response order without mutable package-level state. const accentCandidateCount = 12 func accentCandidateSlot(index int) uint8 { if index < 6 { return uint8(index + 1) } return uint8(index + 3) } type accentGroup struct { members uint16 size uint8 pairs uint8 chromaSum float64 rep uint8 } // ResolveAccent deterministically selects a terminal-owned accent. It uses // only fixed arrays and bitsets so this pure hot-reload operation makes no // per-call heap allocations. func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent { if !t.UsePalette { return Accent{} } if policy.Mode == domain.ThemeAccentSlot { return resolveExplicitAccent(t, policy.Slot) } return resolveAutoAccent(t) } func resolveExplicitAccent(t Theme, slot uint8) Accent { if slot >= uint8(len(t.Palette)) { return Accent{} } if t.PaletteKnown&(uint16(1)<= 0 { runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs } if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) { slot := accentCandidateSlot(int(winner.rep)) return resolvedAccent(t, t.Palette[slot], slot) } return fallbackAutoAccent(t, background) } func fallbackAutoAccent(t Theme, background oklab) Accent { for _, slot := range [...]uint8{4, 12} { if t.PaletteKnown&(uint16(1)< b.size } if a.pairs != b.pairs { return a.pairs > b.pairs } averageA := a.chromaSum / float64(a.size) averageB := b.chromaSum / float64(b.size) if averageA != averageB { return averageA > averageB } aBlue := accentGroupHasSlot(a, 4) || accentGroupHasSlot(a, 12) bBlue := accentGroupHasSlot(b, 4) || accentGroupHasSlot(b, 12) if aBlue != bBlue { return aBlue } return accentCandidateSlot(int(a.rep)) < accentCandidateSlot(int(b.rep)) } func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent { return Accent{ RGB: color, Slot: slot, Known: true, IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG, } } func accentEligible(candidate, background oklab) bool { return okLabChroma(candidate) >= accentMinChroma && okLabDistance(candidate, background) >= accentMinBackgroundDistance } func accentConnected(a, b oklab) bool { return okLabDistance(a, b) <= accentClusterDistance }