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// Columns returns the visible grid width.
128func (s *Screen) Columns() int {
129 if s == nil || s.buffer == nil {
130 return 0
131 }
132 return s.buffer.frame.Width
133}
134
135// Rows returns the visible grid height.
136func (s *Screen) Rows() int {
137 if s == nil || s.buffer == nil {
138 return 0
139 }
140 return s.buffer.frame.Height
141}
142
143// Cell returns the semantic cell at x, y. Coordinates must be inside the
144// visible grid.
145func (s *Screen) Cell(x, y int) renderer.Cell {
146 return s.buffer.frame.At(x, y)
147}
148
149// RowCells returns an owned copy of visible row y, or nil when y is out of range.
150func (s *Screen) RowCells(y int) []renderer.Cell {
151 if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height {
152 return nil
153 }
154 return s.buffer.frame.Row(y)
155}
156
157// LineBounds returns an owned copy of the live grid's per-row logical extents,
158// indexed like Frame rows. It returns nil when the screen has no buffer.
159func (s *Screen) LineBounds() []LineBound {
160 if s == nil || s.buffer == nil {
161 return nil
162 }
163 return append([]LineBound(nil), s.buffer.boundaries...)
164}
165
166func (s *Screen) Write(data []byte) {
167 if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
168 prefix := s.escapeBuf
169 s.escapeBuf = nil
170 data = s.continueKittyEscape(prefix, data)
171 if len(data) == 0 {
172 return
173 }
174 }
175 if len(s.escapeBuf) > 0 {
176 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
177 combined = append(combined, s.escapeBuf...)
178 combined = append(combined, data...)
179 data = combined
180 s.escapeBuf = nil
181 }
182 if s.kittyDiscard {
183 consumed := s.consumeKittyDiscard(data)
184 data = data[consumed:]
185 if s.kittyDiscard {
186 return
187 }
188 }
189
190 for len(data) > 0 {
191 if data[0] == 0x1b {
192 consumed, partial := s.consumeEscape(data)
193 if consumed > 0 {
194 data = data[consumed:]
195 continue
196 }
197 if partial {
198 limit := maxEscapeBufferLen
199 if isKittyEscapePrefix(data) {
200 limit = maxKittyEscapeBufferLen
201 }
202 if len(data) <= limit {
203 s.escapeBuf = append(s.escapeBuf[:0], data...)
204 } else if isKittyEscapePrefix(data) {
205 s.abortKittyPendingDisplay()
206 s.kittyDiscard = true
207 s.kittyDiscardEscaped = false
208 }
209 return
210 }
211 }
212 r, size := utf8.DecodeRune(data)
213 if r == utf8.RuneError && size == 1 {
214 data = data[1:]
215 continue
216 }
217 s.putRune(r)
218 data = data[size:]
219 }
220}
221
222func isKittyEscapePrefix(data []byte) bool {
223 if len(data) == 0 || data[0] != 0x1b {
224 return false
225 }
226 if len(data) > 1 && data[1] != '_' {
227 return false
228 }
229 return len(data) < 3 || data[2] == 'G'
230}
231
232func isKittyEscapeContinuation(prefix, data []byte) bool {
233 if !isKittyEscapePrefix(prefix) {
234 return false
235 }
236 if len(data) == 0 {
237 return true
238 }
239 if len(prefix) == 1 {
240 return data[0] == '_'
241 }
242 if len(prefix) == 2 {
243 return data[0] == 'G'
244 }
245 return true
246}
247
248func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
249 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
250 if len(prefix)+1 <= maxKittyEscapeBufferLen {
251 apc := append(prefix, data[:1]...)
252 s.dispatchKittyGraphics(apc)
253 } else {
254 s.abortKittyPendingDisplay()
255 }
256 return data[1:]
257 }
258 for i := 0; i < len(data); i++ {
259 if data[i] == 0x9c {
260 end := i + 1
261 if len(prefix)+end <= maxKittyEscapeBufferLen {
262 apc := append(prefix, data[:end]...)
263 s.dispatchKittyGraphics(apc)
264 } else {
265 s.abortKittyPendingDisplay()
266 }
267 return data[end:]
268 }
269 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
270 end := i + 2
271 if len(prefix)+end <= maxKittyEscapeBufferLen {
272 apc := append(prefix, data[:end]...)
273 s.dispatchKittyGraphics(apc)
274 } else {
275 s.abortKittyPendingDisplay()
276 }
277 return data[end:]
278 }
279 }
280 remaining := maxKittyEscapeBufferLen - len(prefix)
281 if len(data) <= remaining {
282 returnData := append(prefix, data...)
283 s.escapeBuf = returnData
284 return nil
285 }
286 s.abortKittyPendingDisplay()
287 s.kittyDiscard = true
288 escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
289 if remaining > 0 {
290 escaped = data[remaining-1] == 0x1b
291 }
292 s.kittyDiscardEscaped = escaped
293 tail := data[remaining:]
294 return tail[s.consumeKittyDiscard(tail):]
295}
296
297func (s *Screen) consumeKittyDiscard(data []byte) int {
298 for i, b := range data {
299 if s.kittyDiscardEscaped {
300 s.kittyDiscardEscaped = false
301 if b == '\\' {
302 s.kittyDiscard = false
303 return i + 1
304 }
305 }
306 if b == 0x9c {
307 s.kittyDiscard = false
308 return i + 1
309 }
310 if b == 0x1b {
311 if i+1 < len(data) && data[i+1] == '\\' {
312 s.kittyDiscard = false
313 return i + 2
314 }
315 s.kittyDiscardEscaped = true
316 }
317 }
318 return len(data)
319}
320
321func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
322 if len(data) < 2 {
323 return 0, true
324 }
325 switch data[1] {
326 case ']':
327 return s.consumeOSC(data)
328 case 'P':
329 return consumeSTString(data)
330 case '_':
331 if len(data) >= 3 && data[2] == 'G' {
332 return s.consumeKittyGraphics(data)
333 }
334 return consumeSTString(data)
335 case '^', 'X':
336 return consumeSTString(data)
337 case '[':
338 return s.consumeCSI(data)
339 case '=':
340 return 2, false
341 case '>':
342 return 2, false
343 case '7':
344 s.saveCursor()
345 return 2, false
346 case '8':
347 s.restoreCursor()
348 return 2, false
349 case 'D':
350 s.buffer.hard(s.Row)
351 s.index()
352 return 2, false
353 case 'E':
354 s.nextLine()
355 return 2, false
356 case 'M':
357 s.reverseIndex()
358 return 2, false
359 case 'c':
360 s.reset()
361 return 2, false
362 case 'H':
363 return 2, false
364 case '(', ')', '*', '+', '-', '.', '/':
365 if len(data) < 3 {
366 return 0, true
367 }
368 return 3, false
369 default:
370 if data[1] >= 0x30 && data[1] <= 0x7e {
371 return 2, false
372 }
373 return 0, false
374 }
375}
376
377func consumeSTString(data []byte) (consumed int, partial bool) {
378 for i := 2; i < len(data); i++ {
379 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
380 return i + 2, false
381 }
382 }
383 return 0, true
384}
385
386// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
387// complete bytes to the protocol adapter. The ordinary parser never sees the
388// APC body as text, and the adapter is allocated only on the first complete
389// graphics command.
390func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
391 for i := 3; i < len(data); i++ {
392 switch data[i] {
393 case 0x9c: // C1 string terminator.
394 end := i + 1
395 if end > maxKittyEscapeBufferLen {
396 s.abortKittyPendingDisplay()
397 return end, false
398 }
399 s.dispatchKittyGraphics(data[:end])
400 return end, false
401 case 0x1b:
402 if i+1 < len(data) && data[i+1] == '\\' {
403 end := i + 2
404 if end > maxKittyEscapeBufferLen {
405 s.abortKittyPendingDisplay()
406 return end, false
407 }
408 s.dispatchKittyGraphics(data[:end])
409 return end, false
410 }
411 }
412 }
413 return 0, true
414}
415
416func (s *Screen) dispatchKittyGraphics(apc []byte) {
417 command, err := kittygraphics.ParseAPC(apc)
418 if err != nil {
419 s.abortKittyPendingDisplay()
420 return
421 }
422 if s.graphics == nil {
423 s.graphics = newScreenGraphicsState()
424 }
425 controls := command.Controls
426 pending := s.kittyPendingDisplay
427 action := controls.Action
428 if !controls.HasAction {
429 action = kittygraphics.ActionTransmit
430 if pending != nil {
431 action = pending.Action
432 }
433 }
434 display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
435 movementControls := controls
436 if pending != nil {
437 movementControls = *pending
438 }
439 if display {
440 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
441 }
442 if display && controls.HasMore && controls.More == 1 {
443 copy := controls
444 s.kittyPendingDisplay = ©
445 }
446 result, processErr := s.graphics.kitty.Process(command)
447 if processErr != nil {
448 s.abortKittyPendingDisplay()
449 }
450 for _, response := range result.Responses {
451 s.respond(response)
452 }
453 if len(result.Mutations) != 0 {
454 if display {
455 mutation := result.Mutations[len(result.Mutations)-1]
456 s.applyKittyCursorMovement(movementControls, mutation)
457 s.kittyPendingDisplay = nil
458 }
459 // Graphics are rendered independently from Frame. A graphics mutation
460 // still needs to wake consumers which only redraw on screen damage.
461 s.fullRedraw()
462 }
463}
464
465func (s *Screen) kittyCursorX() uint64 {
466 if cellWidth, _, ok := s.kittyCellPixels(); ok {
467 return uint64(s.kittyCursorColumn() * cellWidth)
468 }
469 return 0
470}
471
472func (s *Screen) kittyCursorColumn() int {
473 if s.frame.Width <= 0 {
474 return 0
475 }
476 return clamp(s.Col, 0, s.frame.Width-1)
477}
478
479func (s *Screen) kittyCursorY() uint64 {
480 _, cellHeight, ok := s.kittyCellPixels()
481 if ok {
482 return uint64(s.Row * cellHeight)
483 }
484 return 0
485}
486
487func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
488 if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
489 return 0, 0, false
490 }
491 width = s.geometry.PixelWidth / s.geometry.Cols
492 height = s.geometry.PixelHeight / s.geometry.Rows
493 if width <= 0 || height <= 0 {
494 return 0, 0, false
495 }
496 return width, height, true
497}
498
499func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
500 if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 {
501 return
502 }
503 columns, rows := s.kittyPlacementGridSize(c, mutation)
504 targetColumn := s.kittyCursorColumn() + columns
505 wraps := targetColumn >= s.frame.Width
506 rowMoves := max(rows-1, 0)
507 if wraps {
508 rowMoves++
509 }
510 s.advanceKittyCursorRows(rowMoves)
511 if wraps {
512 s.Col = 0
513 } else {
514 s.Col = targetColumn
515 }
516}
517
518func (s *Screen) advanceKittyCursorRows(rows int) {
519 if rows <= 0 || s.frame.Height <= 0 {
520 return
521 }
522 if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
523 beforeScroll := s.scrollBottom - s.Row
524 if rows <= beforeScroll {
525 s.Row += rows
526 return
527 }
528 s.Row = s.scrollBottom
529 s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
530 return
531 }
532 s.Row = min(s.Row+rows, s.frame.Height-1)
533}
534
535func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
536 columns, rows = 1, 1
537 if c.HasColumns {
538 columns = max(kittyCursorAdvance(c.Columns), 1)
539 }
540 if c.HasRows {
541 rows = max(kittyCursorAdvance(c.Rows), 1)
542 }
543 if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {
544 return columns, rows
545 }
546 placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
547 if !ok {
548 return columns, rows
549 }
550 cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
551 if !pixelsKnown {
552 return columns, rows
553 }
554 destination := placement.Destination()
555 if !c.HasColumns {
556 offset := destination.X % int64(cellWidth)
557 columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
558 }
559 if !c.HasRows {
560 offset := destination.Y % int64(cellHeight)
561 rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
562 }
563 return columns, rows
564}
565
566func kittyPixelCellSpan(offset, size int64, cellSize int) int {
567 if offset < 0 || size <= 0 || cellSize <= 0 {
568 return 1
569 }
570 span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
571 if span <= 0 {
572 return 1
573 }
574 if span > int64(^uint(0)>>1) {
575 return int(^uint(0) >> 1)
576 }
577 return int(span)
578}
579
580func kittyCursorAdvance(value uint64) int {
581 if value > uint64(^uint(0)>>1) {
582 return int(^uint(0) >> 1)
583 }
584 return int(value)
585}