// package termcolor carries the terminal color query flow: parsing a real
// terminal's osc 4/10/11/12 replies, the attach payload section that carries
// them to the owner, and the agreement rule the owner applies before
// answering a query from an app inside the session.
package termcolor
import (
"encoding/binary"
"fmt"
"maps"
"slices"
"strconv"
"strings"
)
// color keys follow the xterm index space: 0-255 is the palette, 256 and up
// are the special slots.
const (
Foreground = 256
Background = 257
Cursor = 258
)
type Color struct{ R, G, B uint16 }
// ProbeQuery asks the terminal for its foreground, background, cursor, the
// first 16 palette entries, and a da1 sentinel to bound the wait.
var ProbeQuery = buildProbeQuery()
func buildProbeQuery() string {
q := "\x1b]10;?\x1b\\\x1b]11;?\x1b\\\x1b]12;?\x1b\\"
pairs := make([]string, 0, 32)
for i := range 16 {
pairs = append(pairs, strconv.Itoa(i), "?")
}
return q + "\x1b]4;" + strings.Join(pairs, ";") + "\x1b\\\x1b[c"
}
// ParseReplies extracts color reports from a terminal's replies to ProbeQuery.
// malformed or unrelated escape sequences are ignored.
func ParseReplies(b []byte) map[int]Color {
colors := map[int]Color{}
for i := 0; i+1 < len(b); {
if b[i] != 0x1b || b[i+1] != ']' {
i++
continue
}
body, next, ok := oscSpan(b, i)
if !ok {
if next >= len(b) {
break // incomplete tail
}
i = next
continue
}
if k, c, ok := parseReply(body); ok {
colors[k] = c
}
i = next
}
return colors
}
// oscSpan reads one osc string starting at b[i] (esc ]) and returns its body
// plus the index just past it. BEL and ST both terminate.
func oscSpan(b []byte, i int) (body string, next int, ok bool) {
for j := i + 2; j < len(b); j++ {
switch {
case b[j] == '\a':
return string(b[i+2 : j]), j + 1, true
case b[j] == 0x1b && j+1 < len(b) && b[j+1] == '\\':
return string(b[i+2 : j]), j + 2, true
case b[j] == 0x1b:
return "", j, false
}
}
return "", len(b), false
}
func parseReply(body string) (int, Color, bool) {
parts := strings.Split(body, ";")
switch parts[0] {
case "4":
if len(parts) != 3 {
return 0, Color{}, false
}
i, err := strconv.Atoi(parts[1])
if err != nil || i < 0 || i >= Foreground {
return 0, Color{}, false
}
c, ok := parseColor(parts[2])
return i, c, ok
case "10", "11", "12":
if len(parts) != 2 {
return 0, Color{}, false
}
n, _ := strconv.Atoi(parts[0]) // one of the case labels
c, ok := parseColor(parts[1])
return Foreground + n - 10, c, ok
}
return 0, Color{}, false
}
func parseColor(s string) (Color, bool) {
spec, ok := strings.CutPrefix(s, "rgb:")
if !ok {
return Color{}, false
}
parts := strings.Split(spec, "/")
if len(parts) != 3 {
return Color{}, false
}
var ch [3]uint16
for i, p := range parts {
if len(p) < 1 || len(p) > 4 {
return Color{}, false
}
v, err := strconv.ParseUint(p, 16, 32)
if err != nil {
return Color{}, false
}
max := uint64(1)<<(4*len(p)) - 1
ch[i] = uint16(v * 0xffff / max)
}
return Color{ch[0], ch[1], ch[2]}, true
}
// DA1End returns the index just past a device attributes reply, the probe's
// sentinel, or -1 when none has arrived yet.
func DA1End(b []byte) int {
for i := 0; i+1 < len(b); i++ {
if b[i] != 0x1b || b[i+1] != '[' {
continue
}
j := i + 2
for j < len(b) && b[j] >= 0x20 && b[j] <= 0x3f {
j++
}
if j < len(b) && b[j] == 'c' {
return j + 1
}
}
return -1
}
const colorsVersion = 1
// EncodeColors packs a color map as the attach payload section that follows
// the four size bytes. an empty map encodes to nothing, which leaves the
// payload in the legacy four byte shape.
func EncodeColors(colors map[int]Color) []byte {
if len(colors) == 0 {
return nil
}
keys := slices.Sorted(maps.Keys(colors))
b := []byte{colorsVersion, byte(len(keys))}
for _, k := range keys {
c := colors[k]
for _, v := range []uint16{uint16(k), c.R, c.G, c.B} {
b = binary.BigEndian.AppendUint16(b, v)
}
}
return b
}
// DecodeColors reads the colors section of an attach payload. ok is false for
// legacy payloads that carry no section.
func DecodeColors(b []byte) (map[int]Color, bool) {
if len(b) < 2 || b[0] != colorsVersion {
return nil, false
}
n := int(b[1])
if len(b) != 2+8*n {
return nil, false
}
colors := make(map[int]Color, n)
for i := range n {
o := 2 + 8*i
colors[int(binary.BigEndian.Uint16(b[o:]))] = Color{
R: binary.BigEndian.Uint16(b[o+2:]),
G: binary.BigEndian.Uint16(b[o+4:]),
B: binary.BigEndian.Uint16(b[o+6:]),
}
}
return colors, true
}
// Agreed answers a color query only when every viewer reported the color and
// all reported the same value. zero viewers, a missing color, or a mismatch
// all leave the query unanswered.
func Agreed(index int, sets []map[int]Color) (Color, bool) {
var first Color
for i, set := range sets {
c, ok := set[index]
if !ok {
return Color{}, false
}
if i > 0 && c != first {
return Color{}, false
}
first = c
}
return first, len(sets) > 0
}
// Reply formats the osc response an owner writes for a color query.
func Reply(index int, c Color) string {
if index >= Foreground {
return fmt.Sprintf("\x1b]%d;rgb:%04x/%04x/%04x\x1b\\", 10+index-Foreground, c.R, c.G, c.B)
}
return fmt.Sprintf("\x1b]4;%d;rgb:%04x/%04x/%04x\x1b\\", index, c.R, c.G, c.B)
}