1package tui
2
3import (
4 "bytes"
5 "strconv"
6 "strings"
7 "sync"
8 "time"
9)
10
11// Appearance query and DEC mode 2031 control sequences.
12const (
13 SeqQueryColorScheme = "\x1b[?996n"
14 SeqQueryMode2031 = "\x1b[?2031$p"
15 SeqEnableMode2031 = "\x1b[?2031h"
16 SeqDisableMode2031 = "\x1b[?2031l"
17)
18
19// AppearanceQuery returns the complete profile query used at startup and on a
20// replacement generation. The ANSI palette is requested in one OSC 4 batch.
21func AppearanceQuery() string {
22 var b strings.Builder
23 b.WriteString(SeqQueryColorScheme)
24 b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;")
25 for slot := 0; slot < 16; slot++ {
26 if slot > 0 {
27 b.WriteByte(';')
28 }
29 b.WriteString(strconv.Itoa(slot))
30 b.WriteString(";?")
31 }
32 b.WriteByte('\a')
33 return b.String()
34}
35
36// SchemeEvent is a reported terminal color-scheme preference. Solicited is
37// true only for the reply to a query initiated by zut.
38type SchemeEvent struct {
39 Light bool
40 Solicited bool
41}
42
43// ColorEvent is one default foreground/background or ANSI-palette reply.
44type ColorEvent struct {
45 Kind int // 10 foreground, 11 background, 4 palette
46 Slot int
47 Color TerminalColor
48}
49
50// ModeReportEvent is a DECRQM response. Status follows the terminal protocol:
51// 1/3 set, 2 reset, 0/4 unsupported or permanently reset.
52type ModeReportEvent struct {
53 Mode int
54 Status int
55}
56
57// InputEvent is emitted by the appearance-aware input path. Exactly one field
58// is set. Key bytes that do not form an accepted protocol reply remain keys.
59type InputEvent struct {
60 Key *Key
61 Scheme *SchemeEvent
62 Color *ColorEvent
63 Mode *ModeReportEvent
64}
65
66// AppearanceParser controls which terminal replies may be consumed. Keeping
67// the gate here prevents OSC-looking editor input from disappearing unless zut
68// actively issued a matching query, while 997 notifications are accepted only
69// after notification support is known.
70type AppearanceParser struct {
71 mu sync.RWMutex
72 pendingColors bool
73 pendingScheme bool
74 notifications bool
75}
76
77func (p *AppearanceParser) SetPendingColors(pending bool) {
78 p.mu.Lock()
79 p.pendingColors = pending
80 p.mu.Unlock()
81}
82
83func (p *AppearanceParser) SetNotifications(enabled bool) {
84 p.mu.Lock()
85 p.notifications = enabled
86 p.mu.Unlock()
87}
88
89// SetPendingScheme permits one solicited 997 response and marks it so the
90// event loop does not mistake it for a new terminal notification.
91func (p *AppearanceParser) SetPendingScheme(pending bool) {
92 p.mu.Lock()
93 p.pendingScheme = pending
94 p.mu.Unlock()
95}
96
97func (p *AppearanceParser) acceptsColors() bool {
98 p.mu.RLock()
99 defer p.mu.RUnlock()
100 return p.pendingColors
101}
102
103func (p *AppearanceParser) acceptsNotifications() bool {
104 p.mu.RLock()
105 defer p.mu.RUnlock()
106 return p.notifications
107}
108
109func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) {
110 p.mu.Lock()
111 defer p.mu.Unlock()
112 if p.pendingScheme {
113 p.pendingScheme = false
114 return true, true
115 }
116 return false, p.notifications
117}
118
119// ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
120// Combined OSC 4 responses produce one ColorEvent for every valid pair.
121func ParseAppearanceOSC(raw []byte) []ColorEvent {
122 s := strings.TrimSuffix(string(raw), "\a")
123 s = strings.TrimSuffix(s, "\x1b\\")
124 if !strings.HasPrefix(s, "\x1b]") {
125 return nil
126 }
127 body := strings.TrimPrefix(s, "\x1b]")
128 parts := strings.Split(body, ";")
129 if len(parts) < 2 {
130 return nil
131 }
132 kind, err := strconv.Atoi(parts[0])
133 if err != nil {
134 return nil
135 }
136 switch kind {
137 case 10, 11:
138 color, ok := parseTerminalRGB(strings.Join(parts[1:], ";"))
139 if !ok {
140 return nil
141 }
142 return []ColorEvent{{Kind: kind, Slot: -1, Color: color}}
143 case 4:
144 var events []ColorEvent
145 for idx := 1; idx+1 < len(parts); idx += 2 {
146 slot, err := strconv.Atoi(parts[idx])
147 if err != nil || slot < 0 || slot >= 16 {
148 continue
149 }
150 color, ok := parseTerminalRGB(parts[idx+1])
151 if ok {
152 events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color})
153 }
154 }
155 return events
156 default:
157 return nil
158 }
159}
160
161// ParseAppearanceCSI parses the two runtime CSI response families used by
162// terminal-owned themes: current scheme (997) and mode ownership (DECRQM).
163func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) {
164 s := string(raw)
165 if !strings.HasPrefix(s, "\x1b[") {
166 return nil, nil
167 }
168 body := strings.TrimPrefix(s, "\x1b[")
169 if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") {
170 value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n"))
171 if err == nil && (value == 1 || value == 2) {
172 return &SchemeEvent{Light: value == 2}, nil
173 }
174 }
175 if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") {
176 value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y"))
177 if err == nil {
178 return nil, &ModeReportEvent{Mode: 2031, Status: value}
179 }
180 }
181 return nil, nil
182}
183
184// AppearanceSource filters accepted terminal appearance replies before they
185// reach Reader. It deliberately leaves bracketed-paste payload opaque.
186type AppearanceSource struct {
187 read func() (byte, error)
188 peek func(time.Duration) (byte, bool, error)
189 parser *AppearanceParser
190 emit func(InputEvent)
191
192 pending []byte
193 inPaste bool
194 pasteTail []byte
195}
196
197func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource {
198 return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit}
199}
200
201func (s *AppearanceSource) ReadByte() (byte, error) {
202 if len(s.pending) > 0 {
203 b := s.pending[0]
204 s.pending = s.pending[1:]
205 return b, nil
206 }
207 for {
208 b, err := s.read()
209 if err != nil || b != '\x1b' || s.inPaste || s.peek == nil {
210 s.trackPaste(b)
211 return b, err
212 }
213 lookahead := time.Millisecond
214 if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
215 lookahead = 50 * time.Millisecond
216 }
217 next, ok, err := s.peek(lookahead)
218 if err != nil || !ok || (next != ']' && next != '[') {
219 if ok {
220 s.pending = append(s.pending, next)
221 }
222 return b, err
223 }
224 raw := []byte{'\x1b', next}
225 if next == ']' {
226 sequence, err := s.readOSC(raw)
227 if err != nil {
228 return 0, err
229 }
230 if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() {
231 for _, event := range events {
232 e := event
233 s.emit(InputEvent{Color: &e})
234 }
235 continue
236 }
237 s.pending = append(s.pending, sequence[1:]...)
238 return '\x1b', nil
239 }
240 sequence, err := s.readCSI(raw)
241 if err != nil {
242 return 0, err
243 }
244 if bytes.Equal(sequence, []byte("\x1b[200~")) {
245 s.inPaste = true
246 }
247 scheme, mode := ParseAppearanceCSI(sequence)
248 if scheme != nil && s.parser != nil {
249 if solicited, accepted := s.parser.consumeScheme(); accepted {
250 scheme.Solicited = solicited
251 s.emit(InputEvent{Scheme: scheme})
252 continue
253 }
254 }
255 if mode != nil {
256 s.emit(InputEvent{Mode: mode})
257 continue
258 }
259 s.pending = append(s.pending, sequence[1:]...)
260 return '\x1b', nil
261 }
262}
263
264func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) {
265 const limit = 4096
266 for len(raw) < limit {
267 b, err := s.read()
268 if err != nil {
269 return raw, err
270 }
271 raw = append(raw, b)
272 if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') {
273 return raw, nil
274 }
275 }
276 return raw, nil
277}
278
279func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) {
280 const limit = 256
281 for len(raw) < limit {
282 b, err := s.read()
283 if err != nil {
284 return raw, err
285 }
286 raw = append(raw, b)
287 if b >= 0x40 && b <= 0x7e {
288 return raw, nil
289 }
290 }
291 return raw, nil
292}
293
294func (s *AppearanceSource) trackPaste(b byte) {
295 if !s.inPaste {
296 return
297 }
298 const end = "\x1b[201~"
299 s.pasteTail = append(s.pasteTail, b)
300 if len(s.pasteTail) > len(end) {
301 s.pasteTail = s.pasteTail[1:]
302 }
303 if string(s.pasteTail) == end {
304 s.inPaste = false
305 s.pasteTail = nil
306 }
307}