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 // charsets holds the final byte designated to G0 and G1 (ESC ( F and
110 // ESC ) F); shiftOut selects G1 into GL (SO) instead of G0 (SI).
111 charsets [2]byte
112 shiftOut bool
113 // lastPrinted is the last graphic rune written, repeated by REP.
114 lastPrinted rune
115}
116
117func NewScreen(width, height int) *Screen {
118 s := &Screen{
119 Style: renderer.DefaultStyle(),
120 damage: []renderer.Damage{renderer.FullRedraw()},
121 damageGeneration: 1,
122 cursorVisible: true,
123 autoWrapMode: true,
124 geometry: Geometry{Cols: width, Rows: height},
125 }
126 s.buffer = s.newBuffer(width, height)
127 s.frame = s.buffer.frame
128 s.resetScrollRegion()
129 return s
130}
131
132// NewScreenWithHistory creates a screen that records rows evicted from its
133// primary screen into bounded immutable terminal history.
134func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
135 s := NewScreen(width, height)
136 s.history = NewHistory(config)
137 return s
138}
139
140// History returns this screen's terminal history, or nil when history was not
141// configured with NewScreenWithHistory.
142func (s *Screen) History() *History { return s.history }
143
144// Columns returns the visible grid width.
145func (s *Screen) Columns() int {
146 if s == nil || s.buffer == nil {
147 return 0
148 }
149 return s.buffer.frame.Width
150}
151
152// Rows returns the visible grid height.
153func (s *Screen) Rows() int {
154 if s == nil || s.buffer == nil {
155 return 0
156 }
157 return s.buffer.frame.Height
158}
159
160// Cell returns the semantic cell at x, y. Coordinates must be inside the
161// visible grid.
162func (s *Screen) Cell(x, y int) renderer.Cell {
163 return s.buffer.frame.At(x, y)
164}
165
166// RowCells returns an owned copy of visible row y, or nil when y is out of range.
167func (s *Screen) RowCells(y int) []renderer.Cell {
168 if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height {
169 return nil
170 }
171 return s.buffer.frame.Row(y)
172}
173
174// LineBounds returns an owned copy of the live grid's per-row logical extents,
175// indexed like Frame rows. It returns nil when the screen has no buffer.
176func (s *Screen) LineBounds() []LineBound {
177 if s == nil || s.buffer == nil {
178 return nil
179 }
180 return append([]LineBound(nil), s.buffer.boundaries...)
181}
182
183func (s *Screen) Write(data []byte) {
184 if len(s.escapeBuf) == 0 {
185 s.trimEscapeBuffers()
186 } else if isKittyEscapeContinuation(s.escapeBuf, data) {
187 // A Kitty APC sits between any earlier graphic and what follows. An
188 // empty write has not identified the buffered escape yet.
189 if len(data) > 0 {
190 s.lastPrinted = 0
191 }
192 prefix := s.escapeBuf
193 s.escapeBuf = nil
194 data = s.continueKittyEscape(prefix, data)
195 if len(data) == 0 {
196 return
197 }
198 }
199 if len(s.escapeBuf) > 0 {
200 // Reuse a bounded scratch buffer: data is consumed before Write returns
201 // and nothing retains slices of it (the partial-escape path copies).
202 // Detach it while in use: callbacks invoked below may call Write
203 // re-entrantly, and that inner Write must not overwrite bytes this
204 // Write is still reading.
205 need := len(s.escapeBuf) + len(data)
206 combined := s.combineBuf[:0]
207 s.combineBuf = nil
208 if cap(combined) < need {
209 combined = make([]byte, 0, need)
210 }
211 combined = append(combined, s.escapeBuf...)
212 combined = append(combined, data...)
213 defer func() {
214 if s.combineBuf == nil && cap(combined) <= maxRetainedEscapeBufferCap {
215 s.combineBuf = combined[:0]
216 }
217 }()
218 data = combined
219 if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
220 s.escapeBuf = nil
221 } else {
222 s.escapeBuf = s.escapeBuf[:0]
223 }
224 }
225 if s.kittyDiscard {
226 s.lastPrinted = 0
227 consumed := s.consumeKittyDiscard(data)
228 data = data[consumed:]
229 if s.kittyDiscard {
230 return
231 }
232 }
233
234 for len(data) > 0 {
235 if data[0] == 0x1b {
236 consumed, partial := s.consumeEscape(data)
237 if consumed > 0 {
238 // A completed sequence (including REP itself) ends REP eligibility.
239 s.lastPrinted = 0
240 data = data[consumed:]
241 continue
242 }
243 if partial {
244 limit := maxEscapeBufferLen
245 if isKittyEscapePrefix(data) {
246 limit = maxKittyEscapeBufferLen
247 }
248 if len(data) <= limit {
249 s.escapeBuf = append(s.escapeBuf[:0], data...)
250 } else if isKittyEscapePrefix(data) {
251 s.abortKittyPendingDisplay()
252 s.kittyDiscard = true
253 s.kittyDiscardEscaped = false
254 }
255 return
256 }
257 }
258 r, size := utf8.DecodeRune(data)
259 if r == utf8.RuneError && size == 1 {
260 data = data[1:]
261 continue
262 }
263 s.putRune(r)
264 data = data[size:]
265 }
266}
267
268func isKittyEscapePrefix(data []byte) bool {
269 if len(data) == 0 || data[0] != 0x1b {
270 return false
271 }
272 if len(data) > 1 && data[1] != '_' {
273 return false
274 }
275 return len(data) < 3 || data[2] == 'G'
276}
277
278func isKittyEscapeContinuation(prefix, data []byte) bool {
279 if !isKittyEscapePrefix(prefix) {
280 return false
281 }
282 if len(data) == 0 {
283 return true
284 }
285 if len(prefix) == 1 {
286 return data[0] == '_'
287 }
288 if len(prefix) == 2 {
289 return data[0] == 'G'
290 }
291 return true
292}
293
294// trimEscapeBuffers drops oversized idle escape scratch buffers.
295func (s *Screen) trimEscapeBuffers() {
296 if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
297 s.escapeBuf = nil
298 }
299 if cap(s.combineBuf) > maxRetainedEscapeBufferCap {
300 s.combineBuf = nil
301 }
302}
303
304// recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no
305// other buffer has been installed meanwhile. Callers must be done with buf.
306func (s *Screen) recycleEscapeBuf(buf []byte) {
307 if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap {
308 s.escapeBuf = buf[:0]
309 }
310}
311
312// continueKittyEscape completes or extends the pending Kitty APC in prefix,
313// which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The
314// protocol adapter copies whatever it retains from a dispatched APC, so prefix
315// is recycled afterwards instead of being reallocated for every APC.
316func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
317 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
318 if len(prefix)+1 <= maxKittyEscapeBufferLen {
319 apc := append(prefix, data[:1]...)
320 s.dispatchKittyGraphics(apc)
321 s.recycleEscapeBuf(apc)
322 } else {
323 s.abortKittyPendingDisplay()
324 s.recycleEscapeBuf(prefix)
325 }
326 return data[1:]
327 }
328 for i := 0; i < len(data); i++ {
329 if data[i] == 0x9c {
330 end := i + 1
331 s.finishKittyEscape(prefix, data[:end])
332 return data[end:]
333 }
334 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
335 end := i + 2
336 s.finishKittyEscape(prefix, data[:end])
337 return data[end:]
338 }
339 }
340 remaining := maxKittyEscapeBufferLen - len(prefix)
341 if len(data) <= remaining {
342 s.escapeBuf = append(prefix, data...)
343 return nil
344 }
345 s.abortKittyPendingDisplay()
346 s.kittyDiscard = true
347 escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
348 if remaining > 0 {
349 escaped = data[remaining-1] == 0x1b
350 }
351 s.kittyDiscardEscaped = escaped
352 s.recycleEscapeBuf(prefix)
353 tail := data[remaining:]
354 return tail[s.consumeKittyDiscard(tail):]
355}
356
357// finishKittyEscape dispatches prefix+terminated, or aborts when the framed
358// APC would exceed the Kitty escape bound, then recycles prefix.
359func (s *Screen) finishKittyEscape(prefix, terminated []byte) {
360 if len(prefix)+len(terminated) > maxKittyEscapeBufferLen {
361 s.abortKittyPendingDisplay()
362 s.recycleEscapeBuf(prefix)
363 return
364 }
365 apc := append(prefix, terminated...)
366 s.dispatchKittyGraphics(apc)
367 s.recycleEscapeBuf(apc)
368}
369
370func (s *Screen) consumeKittyDiscard(data []byte) int {
371 for i, b := range data {
372 if s.kittyDiscardEscaped {
373 s.kittyDiscardEscaped = false
374 if b == '\\' {
375 s.kittyDiscard = false
376 return i + 1
377 }
378 }
379 if b == 0x9c {
380 s.kittyDiscard = false
381 return i + 1
382 }
383 if b == 0x1b {
384 if i+1 < len(data) && data[i+1] == '\\' {
385 s.kittyDiscard = false
386 return i + 2
387 }
388 s.kittyDiscardEscaped = true
389 }
390 }
391 return len(data)
392}
393
394func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
395 if len(data) < 2 {
396 return 0, true
397 }
398 switch data[1] {
399 case ']':
400 return s.consumeOSC(data)
401 case 'P':
402 return consumeSTString(data)
403 case '_':
404 if len(data) >= 3 && data[2] == 'G' {
405 return s.consumeKittyGraphics(data)
406 }
407 return consumeSTString(data)
408 case '^', 'X':
409 return consumeSTString(data)
410 case '[':
411 return s.consumeCSI(data)
412 case '=':
413 return 2, false
414 case '>':
415 return 2, false
416 case '7':
417 s.saveCursor()
418 return 2, false
419 case '8':
420 s.restoreCursor()
421 return 2, false
422 case 'D':
423 s.buffer.hard(s.Row)
424 s.index()
425 return 2, false
426 case 'E':
427 s.nextLine()
428 return 2, false
429 case 'M':
430 s.reverseIndex()
431 return 2, false
432 case 'c':
433 s.reset()
434 return 2, false
435 case 'H':
436 return 2, false
437 case '(', ')', '*', '+', '-', '.', '/':
438 if len(data) < 3 {
439 return 0, true
440 }
441 switch data[1] {
442 case '(':
443 s.charsets[0] = data[2]
444 case ')':
445 s.charsets[1] = data[2]
446 }
447 return 3, false
448 default:
449 if data[1] >= 0x30 && data[1] <= 0x7e {
450 return 2, false
451 }
452 return 0, false
453 }
454}
455
456func consumeSTString(data []byte) (consumed int, partial bool) {
457 for i := 2; i < len(data); i++ {
458 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
459 return i + 2, false
460 }
461 }
462 return 0, true
463}
464
465// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
466// complete bytes to the protocol adapter. The ordinary parser never sees the
467// APC body as text, and the adapter is allocated only on the first complete
468// graphics command.
469func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
470 for i := 3; i < len(data); i++ {
471 switch data[i] {
472 case 0x9c: // C1 string terminator.
473 end := i + 1
474 if end > maxKittyEscapeBufferLen {
475 s.abortKittyPendingDisplay()
476 return end, false
477 }
478 s.dispatchKittyGraphics(data[:end])
479 return end, false
480 case 0x1b:
481 if i+1 < len(data) && data[i+1] == '\\' {
482 end := i + 2
483 if end > maxKittyEscapeBufferLen {
484 s.abortKittyPendingDisplay()
485 return end, false
486 }
487 s.dispatchKittyGraphics(data[:end])
488 return end, false
489 }
490 }
491 }
492 return 0, true
493}
494
495func (s *Screen) dispatchKittyGraphics(apc []byte) {
496 // The session copies any payload it retains, so the APC bytes may be
497 // borrowed for the duration of this call only.
498 command, err := kittygraphics.ParseAPCBorrowed(apc)
499 if err != nil {
500 s.abortKittyPendingDisplay()
501 return
502 }
503 if s.graphics == nil {
504 s.graphics = newScreenGraphicsState()
505 }
506 controls := command.Controls
507 pending := s.kittyPendingDisplay
508 action := controls.Action
509 if !controls.HasAction {
510 action = kittygraphics.ActionTransmit
511 if pending != nil {
512 action = pending.Action
513 }
514 }
515 display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
516 movementControls := controls
517 if pending != nil {
518 movementControls = *pending
519 }
520 if display {
521 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
522 cellWidth, cellHeight, _ := s.kittyCellPixels()
523 s.graphics.kitty.SetCellPixels(cellWidth, cellHeight)
524 }
525 if display && controls.HasMore && controls.More == 1 {
526 copy := controls
527 s.kittyPendingDisplay = ©
528 }
529 result, processErr := s.graphics.kitty.Process(command)
530 if processErr != nil {
531 s.abortKittyPendingDisplay()
532 }
533 for _, response := range result.Responses {
534 s.respond(response)
535 }
536 if len(result.Mutations) != 0 {
537 if display {
538 mutation := result.Mutations[len(result.Mutations)-1]
539 s.applyKittyCursorMovement(movementControls, mutation)
540 s.kittyPendingDisplay = nil
541 }
542 // Graphics are rendered independently from Frame. A graphics mutation
543 // still needs to wake consumers which only redraw on screen damage.
544 s.fullRedraw()
545 }
546}
547
548func (s *Screen) kittyCursorX() uint64 {
549 if cellWidth, _, ok := s.kittyCellPixels(); ok {
550 return uint64(s.kittyCursorColumn() * cellWidth)
551 }
552 return 0
553}
554
555func (s *Screen) kittyCursorColumn() int {
556 if s.frame.Width <= 0 {
557 return 0
558 }
559 return clamp(s.Col, 0, s.frame.Width-1)
560}
561
562func (s *Screen) kittyCursorY() uint64 {
563 _, cellHeight, ok := s.kittyCellPixels()
564 if ok {
565 return uint64(s.Row * cellHeight)
566 }
567 return 0
568}
569
570func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
571 if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
572 return 0, 0, false
573 }
574 width = s.geometry.PixelWidth / s.geometry.Cols
575 height = s.geometry.PixelHeight / s.geometry.Rows
576 if width <= 0 || height <= 0 {
577 return 0, 0, false
578 }
579 return width, height, true
580}
581
582func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
583 if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 {
584 return
585 }
586 columns, rows := s.kittyPlacementGridSize(c, mutation)
587 targetColumn := s.kittyCursorColumn() + columns
588 wraps := targetColumn >= s.frame.Width
589 rowMoves := max(rows-1, 0)
590 if wraps {
591 rowMoves++
592 }
593 s.advanceKittyCursorRows(rowMoves)
594 if wraps {
595 s.Col = 0
596 } else {
597 s.Col = targetColumn
598 }
599}
600
601func (s *Screen) advanceKittyCursorRows(rows int) {
602 if rows <= 0 || s.frame.Height <= 0 {
603 return
604 }
605 if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
606 beforeScroll := s.scrollBottom - s.Row
607 if rows <= beforeScroll {
608 s.Row += rows
609 return
610 }
611 s.Row = s.scrollBottom
612 s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
613 return
614 }
615 s.Row = min(s.Row+rows, s.frame.Height-1)
616}
617
618func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
619 // c=0 and r=0 mean unset, as in Kitty.
620 hasColumns := c.HasColumns && c.Columns != 0
621 hasRows := c.HasRows && c.Rows != 0
622 columns, rows = 1, 1
623 if hasColumns {
624 columns = max(kittyCursorAdvance(c.Columns), 1)
625 }
626 if hasRows {
627 rows = max(kittyCursorAdvance(c.Rows), 1)
628 }
629 if hasColumns && hasRows || s.graphics == nil || s.graphics.scene == nil {
630 return columns, rows
631 }
632 placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
633 if !ok {
634 return columns, rows
635 }
636 // Cell counts derived from a single c/r are already stored on the placement.
637 if cells := placement.Cells(); !cells.Empty() {
638 return max(kittyCursorAdvance(uint64(cells.Width)), 1), max(kittyCursorAdvance(uint64(cells.Height)), 1)
639 }
640 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
641 if !pixelsKnown {
642 return columns, rows
643 }
644 destination := placement.Destination()
645 if !hasColumns {
646 offset := destination.X % int64(cellWidth)
647 columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
648 }
649 if !hasRows {
650 offset := destination.Y % int64(cellHeight)
651 rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
652 }
653 return columns, rows
654}
655
656func kittyPixelCellSpan(offset, size int64, cellSize int) int {
657 if offset < 0 || size <= 0 || cellSize <= 0 {
658 return 1
659 }
660 span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
661 if span <= 0 {
662 return 1
663 }
664 if span > int64(^uint(0)>>1) {
665 return int(^uint(0) >> 1)
666 }
667 return int(span)
668}
669
670func kittyCursorAdvance(value uint64) int {
671 if value > uint64(^uint(0)>>1) {
672 return int(^uint(0) >> 1)
673 }
674 return int(value)
675}