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 mutation := result.Mutations[len(result.Mutations)-1]
426 s.applyKittyCursorMovement(movementControls, mutation)
427 s.kittyPendingDisplay = nil
428 }
429 // Graphics are rendered independently from Frame. A graphics mutation
430 // still needs to wake consumers which only redraw on screen damage.
431 s.fullRedraw()
432 }
433}
434
435func (s *Screen) kittyCursorX() uint64 {
436 if cellWidth, _, ok := s.kittyCellPixels(); ok {
437 return uint64(s.kittyCursorColumn() * cellWidth)
438 }
439 return 0
440}
441
442func (s *Screen) kittyCursorColumn() int {
443 if s.Frame.Width <= 0 {
444 return 0
445 }
446 return clamp(s.Col, 0, s.Frame.Width-1)
447}
448
449func (s *Screen) kittyCursorY() uint64 {
450 _, cellHeight, ok := s.kittyCellPixels()
451 if ok {
452 return uint64(s.Row * cellHeight)
453 }
454 return 0
455}
456
457func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
458 if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
459 return 0, 0, false
460 }
461 width = s.geometry.PixelWidth / s.geometry.Cols
462 height = s.geometry.PixelHeight / s.geometry.Rows
463 if width <= 0 || height <= 0 {
464 return 0, 0, false
465 }
466 return width, height, true
467}
468
469func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
470 if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 {
471 return
472 }
473 columns, rows := s.kittyPlacementGridSize(c, mutation)
474 targetColumn := s.kittyCursorColumn() + columns
475 wraps := targetColumn >= s.Frame.Width
476 rowMoves := max(rows-1, 0)
477 if wraps {
478 rowMoves++
479 }
480 s.advanceKittyCursorRows(rowMoves)
481 if wraps {
482 s.Col = 0
483 } else {
484 s.Col = targetColumn
485 }
486}
487
488func (s *Screen) advanceKittyCursorRows(rows int) {
489 if rows <= 0 || s.Frame.Height <= 0 {
490 return
491 }
492 if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
493 beforeScroll := s.scrollBottom - s.Row
494 if rows <= beforeScroll {
495 s.Row += rows
496 return
497 }
498 s.Row = s.scrollBottom
499 s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
500 return
501 }
502 s.Row = min(s.Row+rows, s.Frame.Height-1)
503}
504
505func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
506 columns, rows = 1, 1
507 if c.HasColumns {
508 columns = max(kittyCursorAdvance(c.Columns), 1)
509 }
510 if c.HasRows {
511 rows = max(kittyCursorAdvance(c.Rows), 1)
512 }
513 if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {
514 return columns, rows
515 }
516 placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
517 if !ok {
518 return columns, rows
519 }
520 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
521 if !pixelsKnown {
522 return columns, rows
523 }
524 destination := placement.Destination()
525 if !c.HasColumns {
526 offset := destination.X % int64(cellWidth)
527 columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
528 }
529 if !c.HasRows {
530 offset := destination.Y % int64(cellHeight)
531 rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
532 }
533 return columns, rows
534}
535
536func kittyPixelCellSpan(offset, size int64, cellSize int) int {
537 if offset < 0 || size <= 0 || cellSize <= 0 {
538 return 1
539 }
540 span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
541 if span <= 0 {
542 return 1
543 }
544 if span > int64(^uint(0)>>1) {
545 return int(^uint(0) >> 1)
546 }
547 return int(span)
548}
549
550func kittyCursorAdvance(value uint64) int {
551 if value > uint64(^uint(0)>>1) {
552 return int(^uint(0) >> 1)
553 }
554 return int(value)
555}