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
21// maxRetainedEscapeBufferCap bounds the capacity of the escape scratch buffers
22// kept between writes so that a single huge APC does not pin its memory, while
23// ordinary fragmented uploads still reuse their buffer.
24const maxRetainedEscapeBufferCap = 1 << 20
25
26const (
27 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
28 ColorSchemeReportDark = "\x1b[?997;1n"
29 // ColorSchemeReportLight is the DEC 2031 light-scheme report.
30 ColorSchemeReportLight = "\x1b[?997;2n"
31)
32
33type Screen struct {
34 frame renderer.Frame
35 Row int
36 Col int
37 Style renderer.Style
38 // OnLineEvicted is called just before a full-width upward scroll recycles
39 // and blanks rows. The callback receives a stable copy of each evicted row.
40 OnLineEvicted func([]renderer.Cell)
41 // OnResponse is called synchronously from Write with reply bytes that the
42 // emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
43 // query reports). The host wires it to the PTY input. Nil disables responses.
44 OnResponse func([]byte)
45 // OnBell is called synchronously from Write for each lone BEL (0x07)
46 // outside escape sequences. BELs that terminate an OSC never fire it.
47 // Nil disables bell reporting.
48 OnBell func()
49 // OnNotify is called synchronously from Write for explicit terminal
50 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
51 // Other non-clipboard OSC payloads remain discarded. Nil disables it.
52 OnNotify func(title, body string)
53 // OnProgress is called synchronously from Write for OSC 9;4 progress
54 // transitions that request attention: active progress cleared or first entry
55 // into error state. Nil disables progress reporting.
56 OnProgress func(errored bool)
57 // OnClipboard is called synchronously from Write for a complete OSC 52
58 // clipboard set request from the child. The OSC 52 selection field is
59 // accepted but ignored; the callback receives only the raw base64 payload.
60 // Clipboard queries (data == "?") and malformed payloads are ignored and
61 // never invoke it. Nil disables it.
62 OnClipboard func(b64 string)
63
64 history *History
65 buffer *buffer
66
67 nextRowID RowID
68 geometry Geometry
69
70 defaultFG renderer.RGB
71 defaultBG renderer.RGB
72 defaultColorsKnown bool
73 terminalTitle string
74
75 damage []renderer.Damage
76 damageGeneration uint64
77 damageSaturated bool
78 damageFullRedrawSticky bool
79 escapeBuf []byte
80 combineBuf []byte
81 evictScratch []renderer.Cell
82 kittyDiscard bool
83 kittyDiscardEscaped bool
84 kittyPendingDisplay *kittygraphics.Controls
85 csiScratch []int
86 sgrScratch []int
87
88 scrollTop int
89 scrollBottom int
90 savedCursor cursorState
91 alternate *screenState
92 graphics *screenGraphicsState
93 syncUpdateActive bool
94 progressState int
95 cursorVisible bool
96 cursorStyle int
97 cursorStyleSet bool
98 mouseMode int
99 mouseSGR bool
100 bracketedPaste bool
101 kittyKeyboard kittyKeyboardStack
102 originMode bool
103 insertMode bool
104 autoWrapMode bool
105 applicationCursorMode bool
106 colorSchemeMode bool
107 colorSchemeLight bool
108 colorSchemeSet bool
109}
110
111func NewScreen(width, height int) *Screen {
112 s := &Screen{
113 Style: renderer.DefaultStyle(),
114 damage: []renderer.Damage{renderer.FullRedraw()},
115 damageGeneration: 1,
116 cursorVisible: true,
117 autoWrapMode: true,
118 geometry: Geometry{Cols: width, Rows: height},
119 }
120 s.buffer = s.newBuffer(width, height)
121 s.frame = s.buffer.frame
122 s.resetScrollRegion()
123 return s
124}
125
126// NewScreenWithHistory creates a screen that records rows evicted from its
127// primary screen into bounded immutable terminal history.
128func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
129 s := NewScreen(width, height)
130 s.history = NewHistory(config)
131 return s
132}
133
134// History returns this screen's terminal history, or nil when history was not
135// configured with NewScreenWithHistory.
136func (s *Screen) History() *History { return s.history }
137
138// Columns returns the visible grid width.
139func (s *Screen) Columns() int {
140 if s == nil || s.buffer == nil {
141 return 0
142 }
143 return s.buffer.frame.Width
144}
145
146// Rows returns the visible grid height.
147func (s *Screen) Rows() int {
148 if s == nil || s.buffer == nil {
149 return 0
150 }
151 return s.buffer.frame.Height
152}
153
154// Cell returns the semantic cell at x, y. Coordinates must be inside the
155// visible grid.
156func (s *Screen) Cell(x, y int) renderer.Cell {
157 return s.buffer.frame.At(x, y)
158}
159
160// RowCells returns an owned copy of visible row y, or nil when y is out of range.
161func (s *Screen) RowCells(y int) []renderer.Cell {
162 if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height {
163 return nil
164 }
165 return s.buffer.frame.Row(y)
166}
167
168// LineBounds returns an owned copy of the live grid's per-row logical extents,
169// indexed like Frame rows. It returns nil when the screen has no buffer.
170func (s *Screen) LineBounds() []LineBound {
171 if s == nil || s.buffer == nil {
172 return nil
173 }
174 return append([]LineBound(nil), s.buffer.boundaries...)
175}
176
177func (s *Screen) Write(data []byte) {
178 if len(s.escapeBuf) == 0 {
179 s.trimEscapeBuffers()
180 } else if isKittyEscapeContinuation(s.escapeBuf, data) {
181 prefix := s.escapeBuf
182 s.escapeBuf = nil
183 data = s.continueKittyEscape(prefix, data)
184 if len(data) == 0 {
185 return
186 }
187 }
188 if len(s.escapeBuf) > 0 {
189 // Reuse a bounded scratch buffer: data is consumed before Write returns
190 // and nothing retains slices of it (the partial-escape path copies).
191 // Detach it while in use: callbacks invoked below may call Write
192 // re-entrantly, and that inner Write must not overwrite bytes this
193 // Write is still reading.
194 need := len(s.escapeBuf) + len(data)
195 combined := s.combineBuf[:0]
196 s.combineBuf = nil
197 if cap(combined) < need {
198 combined = make([]byte, 0, need)
199 }
200 combined = append(combined, s.escapeBuf...)
201 combined = append(combined, data...)
202 defer func() {
203 if s.combineBuf == nil && cap(combined) <= maxRetainedEscapeBufferCap {
204 s.combineBuf = combined[:0]
205 }
206 }()
207 data = combined
208 if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
209 s.escapeBuf = nil
210 } else {
211 s.escapeBuf = s.escapeBuf[:0]
212 }
213 }
214 if s.kittyDiscard {
215 consumed := s.consumeKittyDiscard(data)
216 data = data[consumed:]
217 if s.kittyDiscard {
218 return
219 }
220 }
221
222 for len(data) > 0 {
223 if data[0] == 0x1b {
224 consumed, partial := s.consumeEscape(data)
225 if consumed > 0 {
226 data = data[consumed:]
227 continue
228 }
229 if partial {
230 limit := maxEscapeBufferLen
231 if isKittyEscapePrefix(data) {
232 limit = maxKittyEscapeBufferLen
233 }
234 if len(data) <= limit {
235 s.escapeBuf = append(s.escapeBuf[:0], data...)
236 } else if isKittyEscapePrefix(data) {
237 s.abortKittyPendingDisplay()
238 s.kittyDiscard = true
239 s.kittyDiscardEscaped = false
240 }
241 return
242 }
243 }
244 r, size := utf8.DecodeRune(data)
245 if r == utf8.RuneError && size == 1 {
246 data = data[1:]
247 continue
248 }
249 s.putRune(r)
250 data = data[size:]
251 }
252}
253
254func isKittyEscapePrefix(data []byte) bool {
255 if len(data) == 0 || data[0] != 0x1b {
256 return false
257 }
258 if len(data) > 1 && data[1] != '_' {
259 return false
260 }
261 return len(data) < 3 || data[2] == 'G'
262}
263
264func isKittyEscapeContinuation(prefix, data []byte) bool {
265 if !isKittyEscapePrefix(prefix) {
266 return false
267 }
268 if len(data) == 0 {
269 return true
270 }
271 if len(prefix) == 1 {
272 return data[0] == '_'
273 }
274 if len(prefix) == 2 {
275 return data[0] == 'G'
276 }
277 return true
278}
279
280// trimEscapeBuffers drops oversized idle escape scratch buffers.
281func (s *Screen) trimEscapeBuffers() {
282 if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
283 s.escapeBuf = nil
284 }
285 if cap(s.combineBuf) > maxRetainedEscapeBufferCap {
286 s.combineBuf = nil
287 }
288}
289
290// recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no
291// other buffer has been installed meanwhile. Callers must be done with buf.
292func (s *Screen) recycleEscapeBuf(buf []byte) {
293 if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap {
294 s.escapeBuf = buf[:0]
295 }
296}
297
298// continueKittyEscape completes or extends the pending Kitty APC in prefix,
299// which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The
300// protocol adapter copies whatever it retains from a dispatched APC, so prefix
301// is recycled afterwards instead of being reallocated for every APC.
302func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
303 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
304 if len(prefix)+1 <= maxKittyEscapeBufferLen {
305 apc := append(prefix, data[:1]...)
306 s.dispatchKittyGraphics(apc)
307 s.recycleEscapeBuf(apc)
308 } else {
309 s.abortKittyPendingDisplay()
310 s.recycleEscapeBuf(prefix)
311 }
312 return data[1:]
313 }
314 for i := 0; i < len(data); i++ {
315 if data[i] == 0x9c {
316 end := i + 1
317 s.finishKittyEscape(prefix, data[:end])
318 return data[end:]
319 }
320 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
321 end := i + 2
322 s.finishKittyEscape(prefix, data[:end])
323 return data[end:]
324 }
325 }
326 remaining := maxKittyEscapeBufferLen - len(prefix)
327 if len(data) <= remaining {
328 s.escapeBuf = append(prefix, data...)
329 return nil
330 }
331 s.abortKittyPendingDisplay()
332 s.kittyDiscard = true
333 escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
334 if remaining > 0 {
335 escaped = data[remaining-1] == 0x1b
336 }
337 s.kittyDiscardEscaped = escaped
338 s.recycleEscapeBuf(prefix)
339 tail := data[remaining:]
340 return tail[s.consumeKittyDiscard(tail):]
341}
342
343// finishKittyEscape dispatches prefix+terminated, or aborts when the framed
344// APC would exceed the Kitty escape bound, then recycles prefix.
345func (s *Screen) finishKittyEscape(prefix, terminated []byte) {
346 if len(prefix)+len(terminated) > maxKittyEscapeBufferLen {
347 s.abortKittyPendingDisplay()
348 s.recycleEscapeBuf(prefix)
349 return
350 }
351 apc := append(prefix, terminated...)
352 s.dispatchKittyGraphics(apc)
353 s.recycleEscapeBuf(apc)
354}
355
356func (s *Screen) consumeKittyDiscard(data []byte) int {
357 for i, b := range data {
358 if s.kittyDiscardEscaped {
359 s.kittyDiscardEscaped = false
360 if b == '\\' {
361 s.kittyDiscard = false
362 return i + 1
363 }
364 }
365 if b == 0x9c {
366 s.kittyDiscard = false
367 return i + 1
368 }
369 if b == 0x1b {
370 if i+1 < len(data) && data[i+1] == '\\' {
371 s.kittyDiscard = false
372 return i + 2
373 }
374 s.kittyDiscardEscaped = true
375 }
376 }
377 return len(data)
378}
379
380func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
381 if len(data) < 2 {
382 return 0, true
383 }
384 switch data[1] {
385 case ']':
386 return s.consumeOSC(data)
387 case 'P':
388 return consumeSTString(data)
389 case '_':
390 if len(data) >= 3 && data[2] == 'G' {
391 return s.consumeKittyGraphics(data)
392 }
393 return consumeSTString(data)
394 case '^', 'X':
395 return consumeSTString(data)
396 case '[':
397 return s.consumeCSI(data)
398 case '=':
399 return 2, false
400 case '>':
401 return 2, false
402 case '7':
403 s.saveCursor()
404 return 2, false
405 case '8':
406 s.restoreCursor()
407 return 2, false
408 case 'D':
409 s.buffer.hard(s.Row)
410 s.index()
411 return 2, false
412 case 'E':
413 s.nextLine()
414 return 2, false
415 case 'M':
416 s.reverseIndex()
417 return 2, false
418 case 'c':
419 s.reset()
420 return 2, false
421 case 'H':
422 return 2, false
423 case '(', ')', '*', '+', '-', '.', '/':
424 if len(data) < 3 {
425 return 0, true
426 }
427 return 3, false
428 default:
429 if data[1] >= 0x30 && data[1] <= 0x7e {
430 return 2, false
431 }
432 return 0, false
433 }
434}
435
436func consumeSTString(data []byte) (consumed int, partial bool) {
437 for i := 2; i < len(data); i++ {
438 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
439 return i + 2, false
440 }
441 }
442 return 0, true
443}
444
445// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
446// complete bytes to the protocol adapter. The ordinary parser never sees the
447// APC body as text, and the adapter is allocated only on the first complete
448// graphics command.
449func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
450 for i := 3; i < len(data); i++ {
451 switch data[i] {
452 case 0x9c: // C1 string terminator.
453 end := i + 1
454 if end > maxKittyEscapeBufferLen {
455 s.abortKittyPendingDisplay()
456 return end, false
457 }
458 s.dispatchKittyGraphics(data[:end])
459 return end, false
460 case 0x1b:
461 if i+1 < len(data) && data[i+1] == '\\' {
462 end := i + 2
463 if end > maxKittyEscapeBufferLen {
464 s.abortKittyPendingDisplay()
465 return end, false
466 }
467 s.dispatchKittyGraphics(data[:end])
468 return end, false
469 }
470 }
471 }
472 return 0, true
473}
474
475func (s *Screen) dispatchKittyGraphics(apc []byte) {
476 // The session copies any payload it retains, so the APC bytes may be
477 // borrowed for the duration of this call only.
478 command, err := kittygraphics.ParseAPCBorrowed(apc)
479 if err != nil {
480 s.abortKittyPendingDisplay()
481 return
482 }
483 if s.graphics == nil {
484 s.graphics = newScreenGraphicsState()
485 }
486 controls := command.Controls
487 pending := s.kittyPendingDisplay
488 action := controls.Action
489 if !controls.HasAction {
490 action = kittygraphics.ActionTransmit
491 if pending != nil {
492 action = pending.Action
493 }
494 }
495 display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
496 movementControls := controls
497 if pending != nil {
498 movementControls = *pending
499 }
500 if display {
501 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
502 }
503 if display && controls.HasMore && controls.More == 1 {
504 copy := controls
505 s.kittyPendingDisplay = ©
506 }
507 result, processErr := s.graphics.kitty.Process(command)
508 if processErr != nil {
509 s.abortKittyPendingDisplay()
510 }
511 for _, response := range result.Responses {
512 s.respond(response)
513 }
514 if len(result.Mutations) != 0 {
515 if display {
516 mutation := result.Mutations[len(result.Mutations)-1]
517 s.applyKittyCursorMovement(movementControls, mutation)
518 s.kittyPendingDisplay = nil
519 }
520 // Graphics are rendered independently from Frame. A graphics mutation
521 // still needs to wake consumers which only redraw on screen damage.
522 s.fullRedraw()
523 }
524}
525
526func (s *Screen) kittyCursorX() uint64 {
527 if cellWidth, _, ok := s.kittyCellPixels(); ok {
528 return uint64(s.kittyCursorColumn() * cellWidth)
529 }
530 return 0
531}
532
533func (s *Screen) kittyCursorColumn() int {
534 if s.frame.Width <= 0 {
535 return 0
536 }
537 return clamp(s.Col, 0, s.frame.Width-1)
538}
539
540func (s *Screen) kittyCursorY() uint64 {
541 _, cellHeight, ok := s.kittyCellPixels()
542 if ok {
543 return uint64(s.Row * cellHeight)
544 }
545 return 0
546}
547
548func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
549 if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
550 return 0, 0, false
551 }
552 width = s.geometry.PixelWidth / s.geometry.Cols
553 height = s.geometry.PixelHeight / s.geometry.Rows
554 if width <= 0 || height <= 0 {
555 return 0, 0, false
556 }
557 return width, height, true
558}
559
560func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
561 if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 {
562 return
563 }
564 columns, rows := s.kittyPlacementGridSize(c, mutation)
565 targetColumn := s.kittyCursorColumn() + columns
566 wraps := targetColumn >= s.frame.Width
567 rowMoves := max(rows-1, 0)
568 if wraps {
569 rowMoves++
570 }
571 s.advanceKittyCursorRows(rowMoves)
572 if wraps {
573 s.Col = 0
574 } else {
575 s.Col = targetColumn
576 }
577}
578
579func (s *Screen) advanceKittyCursorRows(rows int) {
580 if rows <= 0 || s.frame.Height <= 0 {
581 return
582 }
583 if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
584 beforeScroll := s.scrollBottom - s.Row
585 if rows <= beforeScroll {
586 s.Row += rows
587 return
588 }
589 s.Row = s.scrollBottom
590 s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
591 return
592 }
593 s.Row = min(s.Row+rows, s.frame.Height-1)
594}
595
596func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
597 columns, rows = 1, 1
598 if c.HasColumns {
599 columns = max(kittyCursorAdvance(c.Columns), 1)
600 }
601 if c.HasRows {
602 rows = max(kittyCursorAdvance(c.Rows), 1)
603 }
604 if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {
605 return columns, rows
606 }
607 placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
608 if !ok {
609 return columns, rows
610 }
611 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
612 if !pixelsKnown {
613 return columns, rows
614 }
615 destination := placement.Destination()
616 if !c.HasColumns {
617 offset := destination.X % int64(cellWidth)
618 columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
619 }
620 if !c.HasRows {
621 offset := destination.Y % int64(cellHeight)
622 rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
623 }
624 return columns, rows
625}
626
627func kittyPixelCellSpan(offset, size int64, cellSize int) int {
628 if offset < 0 || size <= 0 || cellSize <= 0 {
629 return 1
630 }
631 span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
632 if span <= 0 {
633 return 1
634 }
635 if span > int64(^uint(0)>>1) {
636 return int(^uint(0) >> 1)
637 }
638 return int(span)
639}
640
641func kittyCursorAdvance(value uint64) int {
642 if value > uint64(^uint(0)>>1) {
643 return int(^uint(0) >> 1)
644 }
645 return int(value)
646}