1package vt
2
3import (
4 "unicode/utf8"
5
6 renderer "github.com/bnema/vev-vt/core"
7 "github.com/bnema/vev-vt/protocol/kittygraphics"
8)
9
10// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
11// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
12// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
13// so keep this value in sync if that payload cap changes.
14const maxEscapeBufferLen = 128 * 1024
15
16// Kitty graphics APCs have their own protocol limit. Keep this separate from
17// the ordinary escape-string limit so a fragmented image does not get fed
18// through the text path or truncated at the OSC clipboard bound.
19const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
20
21const (
22 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
23 ColorSchemeReportDark = "\x1b[?997;1n"
24 // ColorSchemeReportLight is the DEC 2031 light-scheme report.
25 ColorSchemeReportLight = "\x1b[?997;2n"
26)
27
28type Screen struct {
29 Frame renderer.Frame
30 Row int
31 Col int
32 Style renderer.Style
33 // OnLineEvicted is called just before a full-width upward scroll recycles
34 // and blanks rows. The callback receives a stable copy of each evicted row.
35 OnLineEvicted func([]renderer.Cell)
36 // OnResponse is called synchronously from Write with reply bytes that the
37 // emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
38 // query reports). The host wires it to the PTY input. Nil disables responses.
39 OnResponse func([]byte)
40 // OnBell is called synchronously from Write for each lone BEL (0x07)
41 // outside escape sequences. BELs that terminate an OSC never fire it.
42 // Nil disables bell reporting.
43 OnBell func()
44 // OnNotify is called synchronously from Write for explicit terminal
45 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
46 // Other non-clipboard OSC payloads remain discarded. Nil disables it.
47 OnNotify func(title, body string)
48 // OnProgress is called synchronously from Write for OSC 9;4 progress
49 // transitions that request attention: active progress cleared or first entry
50 // into error state. Nil disables progress reporting.
51 OnProgress func(errored bool)
52 // OnClipboard is called synchronously from Write for a complete OSC 52
53 // clipboard set request from the child. The OSC 52 selection field is
54 // accepted but ignored; the callback receives only the raw base64 payload.
55 // Clipboard queries (data == "?") and malformed payloads are ignored and
56 // never invoke it. Nil disables it.
57 OnClipboard func(b64 string)
58
59 history *History
60 buffer *buffer
61
62 nextRowID RowID
63 geometry Geometry
64
65 defaultFG renderer.RGB
66 defaultBG renderer.RGB
67 defaultColorsKnown bool
68 terminalTitle string
69
70 damage []renderer.Damage
71 damageGeneration uint64
72 damageSaturated bool
73 damageFullRedrawSticky bool
74 escapeBuf []byte
75 kittyDiscard bool
76 kittyDiscardEscaped bool
77 kittyPendingDisplay *kittygraphics.Controls
78 csiScratch []int
79 sgrScratch []int
80
81 scrollTop int
82 scrollBottom int
83 savedCursor cursorState
84 alternate *screenState
85 graphics *screenGraphicsState
86 syncUpdateActive bool
87 progressState int
88 cursorVisible bool
89 cursorStyle int
90 cursorStyleSet bool
91 mouseMode int
92 mouseSGR bool
93 bracketedPaste bool
94 originMode bool
95 insertMode bool
96 colorSchemeMode bool
97 colorSchemeLight bool
98 colorSchemeSet bool
99}
100
101func NewScreen(width, height int) *Screen {
102 s := &Screen{
103 Style: renderer.DefaultStyle(),
104 damage: []renderer.Damage{renderer.FullRedraw()},
105 damageGeneration: 1,
106 cursorVisible: true,
107 geometry: Geometry{Cols: width, Rows: height},
108 }
109 s.buffer = s.newBuffer(width, height)
110 s.Frame = s.buffer.frame
111 s.resetScrollRegion()
112 return s
113}
114
115// NewScreenWithHistory creates a screen that records rows evicted from its
116// primary screen into bounded immutable terminal history.
117func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
118 s := NewScreen(width, height)
119 s.history = NewHistory(config)
120 return s
121}
122
123// History returns this screen's terminal history, or nil when history was not
124// configured with NewScreenWithHistory.
125func (s *Screen) History() *History { return s.history }
126
127// LineBounds returns an owned copy of the live grid's per-row logical extents,
128// indexed like Frame rows. It returns nil when the screen has no buffer.
129func (s *Screen) LineBounds() []LineBound {
130 if s == nil || s.buffer == nil {
131 return nil
132 }
133 return append([]LineBound(nil), s.buffer.boundaries...)
134}
135
136func (s *Screen) Write(data []byte) {
137 if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
138 prefix := s.escapeBuf
139 s.escapeBuf = nil
140 data = s.continueKittyEscape(prefix, data)
141 if len(data) == 0 {
142 return
143 }
144 }
145 if len(s.escapeBuf) > 0 {
146 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
147 combined = append(combined, s.escapeBuf...)
148 combined = append(combined, data...)
149 data = combined
150 s.escapeBuf = nil
151 }
152 if s.kittyDiscard {
153 consumed := s.consumeKittyDiscard(data)
154 data = data[consumed:]
155 if s.kittyDiscard {
156 return
157 }
158 }
159
160 for len(data) > 0 {
161 if data[0] == 0x1b {
162 consumed, partial := s.consumeEscape(data)
163 if consumed > 0 {
164 data = data[consumed:]
165 continue
166 }
167 if partial {
168 limit := maxEscapeBufferLen
169 if isKittyEscapePrefix(data) {
170 limit = maxKittyEscapeBufferLen
171 }
172 if len(data) <= limit {
173 s.escapeBuf = append(s.escapeBuf[:0], data...)
174 } else if isKittyEscapePrefix(data) {
175 s.abortKittyPendingDisplay()
176 s.kittyDiscard = true
177 s.kittyDiscardEscaped = false
178 }
179 return
180 }
181 }
182 r, size := utf8.DecodeRune(data)
183 if r == utf8.RuneError && size == 1 {
184 data = data[1:]
185 continue
186 }
187 s.putRune(r)
188 data = data[size:]
189 }
190}
191
192func isKittyEscapePrefix(data []byte) bool {
193 if len(data) == 0 || data[0] != 0x1b {
194 return false
195 }
196 if len(data) > 1 && data[1] != '_' {
197 return false
198 }
199 return len(data) < 3 || data[2] == 'G'
200}
201
202func isKittyEscapeContinuation(prefix, data []byte) bool {
203 if !isKittyEscapePrefix(prefix) {
204 return false
205 }
206 if len(data) == 0 {
207 return true
208 }
209 if len(prefix) == 1 {
210 return data[0] == '_'
211 }
212 if len(prefix) == 2 {
213 return data[0] == 'G'
214 }
215 return true
216}
217
218func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
219 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
220 if len(prefix)+1 <= maxKittyEscapeBufferLen {
221 apc := append(prefix, data[:1]...)
222 s.dispatchKittyGraphics(apc)
223 } else {
224 s.abortKittyPendingDisplay()
225 }
226 return data[1:]
227 }
228 for i := 0; i < len(data); i++ {
229 if data[i] == 0x9c {
230 end := i + 1
231 if len(prefix)+end <= maxKittyEscapeBufferLen {
232 apc := append(prefix, data[:end]...)
233 s.dispatchKittyGraphics(apc)
234 } else {
235 s.abortKittyPendingDisplay()
236 }
237 return data[end:]
238 }
239 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
240 end := i + 2
241 if len(prefix)+end <= maxKittyEscapeBufferLen {
242 apc := append(prefix, data[:end]...)
243 s.dispatchKittyGraphics(apc)
244 } else {
245 s.abortKittyPendingDisplay()
246 }
247 return data[end:]
248 }
249 }
250 remaining := maxKittyEscapeBufferLen - len(prefix)
251 if len(data) <= remaining {
252 returnData := append(prefix, data...)
253 s.escapeBuf = returnData
254 return nil
255 }
256 s.abortKittyPendingDisplay()
257 s.kittyDiscard = true
258 escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
259 if remaining > 0 {
260 escaped = data[remaining-1] == 0x1b
261 }
262 s.kittyDiscardEscaped = escaped
263 tail := data[remaining:]
264 return tail[s.consumeKittyDiscard(tail):]
265}
266
267func (s *Screen) consumeKittyDiscard(data []byte) int {
268 for i, b := range data {
269 if s.kittyDiscardEscaped {
270 s.kittyDiscardEscaped = false
271 if b == '\\' {
272 s.kittyDiscard = false
273 return i + 1
274 }
275 }
276 if b == 0x9c {
277 s.kittyDiscard = false
278 return i + 1
279 }
280 if b == 0x1b {
281 if i+1 < len(data) && data[i+1] == '\\' {
282 s.kittyDiscard = false
283 return i + 2
284 }
285 s.kittyDiscardEscaped = true
286 }
287 }
288 return len(data)
289}
290
291func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
292 if len(data) < 2 {
293 return 0, true
294 }
295 switch data[1] {
296 case ']':
297 return s.consumeOSC(data)
298 case 'P':
299 return consumeSTString(data)
300 case '_':
301 if len(data) >= 3 && data[2] == 'G' {
302 return s.consumeKittyGraphics(data)
303 }
304 return consumeSTString(data)
305 case '^', 'X':
306 return consumeSTString(data)
307 case '[':
308 return s.consumeCSI(data)
309 case '=':
310 return 2, false
311 case '>':
312 return 2, false
313 case '7':
314 s.saveCursor()
315 return 2, false
316 case '8':
317 s.restoreCursor()
318 return 2, false
319 case 'D':
320 s.buffer.hard(s.Row)
321 s.index()
322 return 2, false
323 case 'E':
324 s.nextLine()
325 return 2, false
326 case 'M':
327 s.reverseIndex()
328 return 2, false
329 case 'c':
330 s.reset()
331 return 2, false
332 case 'H':
333 return 2, false
334 case '(', ')', '*', '+', '-', '.', '/':
335 if len(data) < 3 {
336 return 0, true
337 }
338 return 3, false
339 default:
340 if data[1] >= 0x30 && data[1] <= 0x7e {
341 return 2, false
342 }
343 return 0, false
344 }
345}
346
347func consumeSTString(data []byte) (consumed int, partial bool) {
348 for i := 2; i < len(data); i++ {
349 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
350 return i + 2, false
351 }
352 }
353 return 0, true
354}
355
356// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
357// complete bytes to the protocol adapter. The ordinary parser never sees the
358// APC body as text, and the adapter is allocated only on the first complete
359// graphics command.
360func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
361 for i := 3; i < len(data); i++ {
362 switch data[i] {
363 case 0x9c: // C1 string terminator.
364 end := i + 1
365 if end > maxKittyEscapeBufferLen {
366 s.abortKittyPendingDisplay()
367 return end, false
368 }
369 s.dispatchKittyGraphics(data[:end])
370 return end, false
371 case 0x1b:
372 if i+1 < len(data) && data[i+1] == '\\' {
373 end := i + 2
374 if end > maxKittyEscapeBufferLen {
375 s.abortKittyPendingDisplay()
376 return end, false
377 }
378 s.dispatchKittyGraphics(data[:end])
379 return end, false
380 }
381 }
382 }
383 return 0, true
384}
385
386func (s *Screen) dispatchKittyGraphics(apc []byte) {
387 command, err := kittygraphics.ParseAPC(apc)
388 if err != nil {
389 s.abortKittyPendingDisplay()
390 return
391 }
392 if s.graphics == nil {
393 s.graphics = newScreenGraphicsState()
394 }
395 controls := command.Controls
396 pending := s.kittyPendingDisplay
397 action := controls.Action
398 if !controls.HasAction {
399 action = kittygraphics.ActionTransmit
400 if pending != nil {
401 action = pending.Action
402 }
403 }
404 display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
405 movementControls := controls
406 if pending != nil {
407 movementControls = *pending
408 }
409 if display {
410 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
411 }
412 if display && controls.HasMore && controls.More == 1 {
413 copy := controls
414 s.kittyPendingDisplay = ©
415 }
416 result, processErr := s.graphics.kitty.Process(command)
417 if processErr != nil {
418 s.abortKittyPendingDisplay()
419 }
420 for _, response := range result.Responses {
421 s.respond(response)
422 }
423 if len(result.Mutations) != 0 {
424 if display {
425 s.applyKittyCursorMovement(movementControls)
426 s.kittyPendingDisplay = nil
427 }
428 // Graphics are rendered independently from Frame. A graphics mutation
429 // still needs to wake consumers which only redraw on screen damage.
430 s.fullRedraw()
431 }
432}
433
434func (s *Screen) kittyCursorX() uint64 {
435 if cellWidth, _, ok := s.kittyCellPixels(); ok {
436 return uint64(s.Col * cellWidth)
437 }
438 return 0
439}
440
441func (s *Screen) kittyCursorY() uint64 {
442 _, cellHeight, ok := s.kittyCellPixels()
443 if ok {
444 return uint64(s.Row * cellHeight)
445 }
446 return 0
447}
448
449func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
450 if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
451 return 0, 0, false
452 }
453 width = s.geometry.PixelWidth / s.geometry.Cols
454 height = s.geometry.PixelHeight / s.geometry.Rows
455 if width <= 0 || height <= 0 {
456 return 0, 0, false
457 }
458 return width, height, true
459}
460
461func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) {
462 if c.HasCursor && c.Cursor == 1 {
463 return
464 }
465 columns, rows := 1, 1
466 if c.HasColumns {
467 columns = kittyCursorAdvance(c.Columns)
468 }
469 if c.HasRows {
470 rows = kittyCursorAdvance(c.Rows)
471 }
472 s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1)
473 s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow())
474}
475
476func advanceKittyCursor(current, amount, minimum, maximum int) int {
477 if maximum < minimum {
478 return minimum
479 }
480 if current < minimum {
481 current = minimum
482 }
483 if current > maximum {
484 current = maximum
485 }
486 if amount > maximum-current {
487 return maximum
488 }
489 return current + amount
490}
491
492func kittyCursorAdvance(value uint64) int {
493 if value > uint64(^uint(0)>>1) {
494 return int(^uint(0) >> 1)
495 }
496 return int(value)
497}