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 the terminal's semantic foreground or background is unknown. 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 distanceSum float64 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 { runnerUp = groups[runner] } if winner.size >= 2 && (winner.size > runnerUp.size || winner.size == runnerUp.size && (winner.distanceSum < runnerUp.distanceSum || winner.distanceSum == runnerUp.distanceSum && winner.pairs > runnerUp.pairs)) { 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)< i { group.distanceSum += distance } } if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) { first = false representative = uint8(i) representativeDistance = totalDistance } } group.rep = representative for slot := uint8(1); slot <= 6; slot++ { if accentGroupHasSlot(*group, slot) && accentGroupHasSlot(*group, slot+8) { group.pairs++ } } } func accentGroupHasSlot(group accentGroup, slot uint8) bool { for i := range accentCandidateCount { if accentCandidateSlot(i) == slot { return group.members&(uint16(1)< b.size } if a.distanceSum != b.distanceSum { return a.distanceSum < b.distanceSum } 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.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 }