1package tui
2
3import (
4 "os"
5 "strconv"
6 "strings"
7 "time"
8
9 "golang.org/x/term"
10)
11
12// DetectTrueColor reports whether TERM or COLORTERM advertises direct color
13// support. This follows the same conservative signals used by ../vev.
14func DetectTrueColor(termEnv, colorTerm string) bool {
15 switch strings.ToLower(strings.TrimSpace(colorTerm)) {
16 case "truecolor", "24bit":
17 return true
18 }
19
20 termEnv = strings.ToLower(strings.TrimSpace(termEnv))
21 return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
22}
23
24// DetectColorDepth conservatively chooses the highest color mode advertised
25// by the environment. Terminals without a 256-color signal stay ANSI16.
26func DetectColorDepth(termEnv, colorTerm string) ColorDepth {
27 if DetectTrueColor(termEnv, colorTerm) {
28 return ColorDepthTrueColor
29 }
30 termEnv = strings.ToLower(strings.TrimSpace(termEnv))
31 if strings.Contains(termEnv, "256color") {
32 return ColorDepthIndexed256
33 }
34 return ColorDepthANSI16
35}
36
37// DetectThemeFromBackground queries the controlling tty for its current
38// foreground, background, and ANSI palette using OSC 10/11/4. Auto is always
39// terminal-owned; the snapshot is retained for subsequent runtime resolution.
40//
41// The query / parse runs synchronously before the TUI is initialised so the
42// returned snapshot can drive the entire session. We briefly put stdin into
43// raw mode and disable echo so OSC replies do not leak onto the user's screen.
44func DetectThemeFromBackground(timeout time.Duration) Theme {
45 return TerminalTheme(detectTerminalProfile(timeout))
46}
47
48type oscColorResponse struct {
49 kind int
50 slot int
51 color TerminalColor
52}
53
54func detectTerminalProfile(timeout time.Duration) TerminalProfile {
55 profile := TerminalProfile{Depth: DetectColorDepth(os.Getenv("TERM"), os.Getenv("COLORTERM")), TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
56 if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
57 return profile
58 }
59
60 fd := int(os.Stdin.Fd())
61 st, err := term.MakeRaw(fd)
62 if err != nil {
63 return profile
64 }
65 defer term.Restore(fd, st)
66
67 queries := terminalColorQueries()
68 if _, err := os.Stdout.Write([]byte(queries)); err != nil {
69 return profile
70 }
71
72 responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount)
73 for _, response := range responses {
74 switch response.kind {
75 case 10:
76 profile.Foreground = response.color
77 profile.HasForeground = true
78 case 11:
79 profile.Background = response.color
80 profile.HasBackground = true
81 case 4:
82 if response.slot >= 0 && response.slot < len(profile.Palette) {
83 profile.Palette[response.slot] = response.color
84 profile.PaletteKnown |= uint16(1) << response.slot
85 }
86 }
87 }
88 if profile.HasBackground {
89 // This is a branch-selection fallback, not a reported scheme. Runtime
90 // CSI 997 replies alone set SchemeKnown.
91 profile.Light = 0.2126*float64(profile.Background.R)+
92 0.7152*float64(profile.Background.G)+
93 0.0722*float64(profile.Background.B) >= 127.5
94 }
95 return profile
96}
97
98const (
99 terminalColorQueryCount = 18
100 terminalColorResponseLimit = 64
101)
102
103func terminalColorQueries() string {
104 var b strings.Builder
105 b.Grow(terminalColorQueryCount * 12)
106 b.WriteString("\x1b]10;?\x07\x1b]11;?\x07")
107 for slot := 0; slot < 16; slot++ {
108 b.WriteString("\x1b]4;")
109 b.WriteString(itoa(slot))
110 b.WriteString(";?\x07")
111 }
112 return b.String()
113}
114
115// readOSCColorResponses drains terminal replies until all expected replies
116// arrive, the deadline expires, or stdin hits EOF. It only consumes complete
117// BEL/ST-terminated OSC color responses; an incomplete tail is discarded at
118// timeout so a failed query cannot poison the next interactive read.
119func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse {
120 responses := make([]oscColorResponse, 0, expected)
121 buf := make([]byte, 0, 64)
122 for len(responses) < expected && time.Now().Before(deadline) {
123 remaining := time.Until(deadline)
124 if remaining <= 0 {
125 break
126 }
127 b, ok, err := peekStdin(os.Stdin, remaining)
128 if err != nil || !ok {
129 break
130 }
131 if len(buf) >= terminalColorResponseLimit {
132 buf = buf[:0]
133 }
134 buf = append(buf, b)
135 terminated := b == '\x07' || (len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\')
136 if !terminated {
137 continue
138 }
139 if response, ok := parseOSCColorResponse(buf); ok {
140 responses = append(responses, response)
141 }
142 buf = buf[:0]
143 }
144 return responses
145}
146
147func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) {
148 s := string(raw)
149 s = strings.TrimSuffix(s, "\x07")
150 s = strings.TrimSuffix(s, "\x1b\\")
151
152 if strings.HasPrefix(s, "\x1b]10;") {
153 color, ok := parseTerminalRGB(s[len("\x1b]10;"):])
154 return oscColorResponse{kind: 10, color: color}, ok
155 }
156 if strings.HasPrefix(s, "\x1b]11;") {
157 color, ok := parseTerminalRGB(s[len("\x1b]11;"):])
158 return oscColorResponse{kind: 11, color: color}, ok
159 }
160 if strings.HasPrefix(s, "\x1b]4;") {
161 body := s[len("\x1b]4;"):]
162 parts := strings.SplitN(body, ";", 2)
163 if len(parts) != 2 || parts[0] == "" {
164 return oscColorResponse{}, false
165 }
166 slot, err := strconv.Atoi(parts[0])
167 if err != nil || slot < 0 || slot >= 16 {
168 return oscColorResponse{}, false
169 }
170 color, ok := parseTerminalRGB(parts[1])
171 return oscColorResponse{kind: 4, slot: slot, color: color}, ok
172 }
173 return oscColorResponse{}, false
174}
175
176func parseTerminalRGB(value string) (TerminalColor, bool) {
177 value = strings.TrimSpace(value)
178 if strings.HasPrefix(value, "#") {
179 if len(value) != 7 {
180 return TerminalColor{}, false
181 }
182 r, ok := parseHexByte(value[1:3])
183 if !ok {
184 return TerminalColor{}, false
185 }
186 g, ok := parseHexByte(value[3:5])
187 if !ok {
188 return TerminalColor{}, false
189 }
190 b, ok := parseHexByte(value[5:7])
191 if !ok {
192 return TerminalColor{}, false
193 }
194 return ColorRGB(r, g, b), true
195 }
196 if !strings.HasPrefix(value, "rgb:") {
197 return TerminalColor{}, false
198 }
199 parts := strings.Split(value[len("rgb:"):], "/")
200 if len(parts) != 3 {
201 return TerminalColor{}, false
202 }
203 var channels [3]int
204 for i, part := range parts {
205 if len(part) < 1 || len(part) > 4 {
206 return TerminalColor{}, false
207 }
208 v, err := strconv.ParseUint(part, 16, 16)
209 if err != nil {
210 return TerminalColor{}, false
211 }
212 max := uint64(1)
213 for range part {
214 max *= 16
215 }
216 max--
217 channels[i] = int(v * 255 / max)
218 }
219 return ColorRGB(channels[0], channels[1], channels[2]), true
220}