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// DetectThemeFromBackground queries the controlling tty for its current
25// foreground, background, and ANSI palette using OSC 10/11/4. The returned
26// theme keeps the detected light/dark built-in colors for the normal auto
27// theme and carries the terminal snapshot for the inherited theme.
28//
29// The query / parse runs synchronously before the TUI is initialised so the
30// returned snapshot can drive the entire session. We briefly put stdin into
31// raw mode and disable echo so OSC replies do not leak onto the user's screen.
32func DetectThemeFromBackground(timeout time.Duration) Theme {
33 profile := detectTerminalProfile(timeout)
34 base := Dark
35
36 // Honour explicit override env vars first; some users and CI environments
37 // know better than the heuristic. The terminal snapshot is still retained
38 // so selecting the inherited theme later remains useful.
39 switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) {
40 case "dark":
41 base = Dark
42 case "light":
43 base = Light
44 default:
45 if profile.HasBackground {
46 luma := 0.2126*float64(profile.Background.R) +
47 0.7152*float64(profile.Background.G) +
48 0.0722*float64(profile.Background.B)
49 if luma >= 127.5 {
50 base = Light
51 }
52 }
53 }
54
55 base.Terminal = profile
56 return base
57}
58
59type oscColorResponse struct {
60 kind int
61 slot int
62 color TerminalColor
63}
64
65func detectTerminalProfile(timeout time.Duration) TerminalProfile {
66 profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
67 if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
68 return profile
69 }
70
71 fd := int(os.Stdin.Fd())
72 st, err := term.MakeRaw(fd)
73 if err != nil {
74 return profile
75 }
76 defer term.Restore(fd, st)
77
78 queries := terminalColorQueries()
79 if _, err := os.Stdout.Write([]byte(queries)); err != nil {
80 return profile
81 }
82
83 responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount)
84 for _, response := range responses {
85 switch response.kind {
86 case 10:
87 profile.Foreground = response.color
88 profile.HasForeground = true
89 case 11:
90 profile.Background = response.color
91 profile.HasBackground = true
92 case 4:
93 if response.slot >= 0 && response.slot < len(profile.Palette) {
94 profile.Palette[response.slot] = response.color
95 profile.PaletteKnown |= uint16(1) << response.slot
96 }
97 }
98 }
99 if profile.HasBackground {
100 luma := 0.2126*float64(profile.Background.R) +
101 0.7152*float64(profile.Background.G) +
102 0.0722*float64(profile.Background.B)
103 profile.Light = luma >= 127.5
104 profile.SchemeKnown = true
105 }
106 return profile
107}
108
109const (
110 terminalColorQueryCount = 18
111 terminalColorResponseLimit = 64
112)
113
114func terminalColorQueries() string {
115 var b strings.Builder
116 b.Grow(terminalColorQueryCount * 12)
117 b.WriteString("\x1b]10;?\x07\x1b]11;?\x07")
118 for slot := 0; slot < 16; slot++ {
119 b.WriteString("\x1b]4;")
120 b.WriteString(itoa(slot))
121 b.WriteString(";?\x07")
122 }
123 return b.String()
124}
125
126// readOSCColorResponses drains terminal replies until all expected replies
127// arrive, the deadline expires, or stdin hits EOF. It only consumes complete
128// BEL/ST-terminated OSC color responses; an incomplete tail is discarded at
129// timeout so a failed query cannot poison the next interactive read.
130func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse {
131 responses := make([]oscColorResponse, 0, expected)
132 buf := make([]byte, 0, 64)
133 for len(responses) < expected && time.Now().Before(deadline) {
134 remaining := time.Until(deadline)
135 if remaining <= 0 {
136 break
137 }
138 b, ok, err := peekStdin(os.Stdin, remaining)
139 if err != nil || !ok {
140 break
141 }
142 if len(buf) >= terminalColorResponseLimit {
143 buf = buf[:0]
144 }
145 buf = append(buf, b)
146 terminated := b == '\x07' || (len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\')
147 if !terminated {
148 continue
149 }
150 if response, ok := parseOSCColorResponse(buf); ok {
151 responses = append(responses, response)
152 }
153 buf = buf[:0]
154 }
155 return responses
156}
157
158func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) {
159 s := string(raw)
160 s = strings.TrimSuffix(s, "\x07")
161 s = strings.TrimSuffix(s, "\x1b\\")
162
163 if strings.HasPrefix(s, "\x1b]10;") {
164 color, ok := parseTerminalRGB(s[len("\x1b]10;"):])
165 return oscColorResponse{kind: 10, color: color}, ok
166 }
167 if strings.HasPrefix(s, "\x1b]11;") {
168 color, ok := parseTerminalRGB(s[len("\x1b]11;"):])
169 return oscColorResponse{kind: 11, color: color}, ok
170 }
171 if strings.HasPrefix(s, "\x1b]4;") {
172 body := s[len("\x1b]4;"):]
173 parts := strings.SplitN(body, ";", 2)
174 if len(parts) != 2 || parts[0] == "" {
175 return oscColorResponse{}, false
176 }
177 slot, err := strconv.Atoi(parts[0])
178 if err != nil || slot < 0 || slot >= 16 {
179 return oscColorResponse{}, false
180 }
181 color, ok := parseTerminalRGB(parts[1])
182 return oscColorResponse{kind: 4, slot: slot, color: color}, ok
183 }
184 return oscColorResponse{}, false
185}
186
187func parseTerminalRGB(value string) (TerminalColor, bool) {
188 value = strings.TrimSpace(value)
189 if strings.HasPrefix(value, "#") {
190 if len(value) != 7 {
191 return TerminalColor{}, false
192 }
193 r, ok := parseHexByte(value[1:3])
194 if !ok {
195 return TerminalColor{}, false
196 }
197 g, ok := parseHexByte(value[3:5])
198 if !ok {
199 return TerminalColor{}, false
200 }
201 b, ok := parseHexByte(value[5:7])
202 if !ok {
203 return TerminalColor{}, false
204 }
205 return ColorRGB(r, g, b), true
206 }
207 if !strings.HasPrefix(value, "rgb:") {
208 return TerminalColor{}, false
209 }
210 parts := strings.Split(value[len("rgb:"):], "/")
211 if len(parts) != 3 {
212 return TerminalColor{}, false
213 }
214 var channels [3]int
215 for i, part := range parts {
216 if len(part) < 1 || len(part) > 4 {
217 return TerminalColor{}, false
218 }
219 v, err := strconv.ParseUint(part, 16, 16)
220 if err != nil {
221 return TerminalColor{}, false
222 }
223 max := uint64(1)
224 for range part {
225 max *= 16
226 }
227 max--
228 channels[i] = int(v * 255 / max)
229 }
230 return ColorRGB(channels[0], channels[1], channels[2]), true
231}