1package vt
2
3import (
4 "unicode/utf8"
5
6 renderer "github.com/bnema/vev-vt/core"
7)
8
9// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
10// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
11// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
12// so keep this value in sync if that payload cap changes.
13const maxEscapeBufferLen = 128 * 1024
14
15const (
16 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
17 ColorSchemeReportDark = "\x1b[?997;1n"
18 // ColorSchemeReportLight is the DEC 2031 light-scheme report.
19 ColorSchemeReportLight = "\x1b[?997;2n"
20)
21
22type Screen struct {
23 Frame renderer.Frame
24 Row int
25 Col int
26 Style renderer.Style
27 // OnLineEvicted is called just before a full-width upward scroll recycles
28 // and blanks rows. The callback receives a stable copy of each evicted row.
29 OnLineEvicted func([]renderer.Cell)
30 // OnResponse is called synchronously from Write with reply bytes that the
31 // emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
32 // query reports). The host wires it to the PTY input. Nil disables responses.
33 OnResponse func([]byte)
34 // OnBell is called synchronously from Write for each lone BEL (0x07)
35 // outside escape sequences. BELs that terminate an OSC never fire it.
36 // Nil disables bell reporting.
37 OnBell func()
38 // OnNotify is called synchronously from Write for explicit terminal
39 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
40 // Other non-clipboard OSC payloads remain discarded. Nil disables it.
41 OnNotify func(title, body string)
42 // OnProgress is called synchronously from Write for OSC 9;4 progress
43 // transitions that request attention: active progress cleared or first entry
44 // into error state. Nil disables progress reporting.
45 OnProgress func(errored bool)
46 // OnClipboard is called synchronously from Write for a complete OSC 52
47 // clipboard set request from the child. The OSC 52 selection field is
48 // accepted but ignored; the callback receives only the raw base64 payload.
49 // Clipboard queries (data == "?") and malformed payloads are ignored and
50 // never invoke it. Nil disables it.
51 OnClipboard func(b64 string)
52
53 history *History
54 buffer *buffer
55
56 nextRowID RowID
57
58 defaultFG renderer.RGB
59 defaultBG renderer.RGB
60 defaultColorsKnown bool
61 terminalTitle string
62
63 damage []renderer.Damage
64 damageGeneration uint64
65 damageSaturated bool
66 damageFullRedrawSticky bool
67 escapeBuf []byte
68 csiScratch []int
69 sgrScratch []int
70
71 scrollTop int
72 scrollBottom int
73 savedCursor cursorState
74 alternate *screenState
75 syncUpdateActive bool
76 progressState int
77 cursorVisible bool
78 cursorStyle int
79 cursorStyleSet bool
80 mouseMode int
81 mouseSGR bool
82 bracketedPaste bool
83 originMode bool
84 insertMode bool
85 colorSchemeMode bool
86 colorSchemeLight bool
87 colorSchemeSet bool
88}
89
90func NewScreen(width, height int) *Screen {
91 s := &Screen{
92 Style: renderer.DefaultStyle(),
93 damage: []renderer.Damage{renderer.FullRedraw()},
94 damageGeneration: 1,
95 cursorVisible: true,
96 }
97 s.buffer = s.newBuffer(width, height)
98 s.Frame = s.buffer.frame
99 s.resetScrollRegion()
100 return s
101}
102
103// NewScreenWithHistory creates a screen that records rows evicted from its
104// primary screen into bounded immutable terminal history.
105func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
106 s := NewScreen(width, height)
107 s.history = NewHistory(config)
108 return s
109}
110
111// History returns this screen's terminal history, or nil when history was not
112// configured with NewScreenWithHistory.
113func (s *Screen) History() *History { return s.history }
114
115// LineBounds returns an owned copy of the live grid's per-row logical extents,
116// indexed like Frame rows. It returns nil when the screen has no buffer.
117func (s *Screen) LineBounds() []LineBound {
118 if s == nil || s.buffer == nil {
119 return nil
120 }
121 return append([]LineBound(nil), s.buffer.boundaries...)
122}
123
124func (s *Screen) Write(data []byte) {
125 if len(s.escapeBuf) > 0 {
126 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
127 combined = append(combined, s.escapeBuf...)
128 combined = append(combined, data...)
129 data = combined
130 s.escapeBuf = s.escapeBuf[:0]
131 }
132
133 for len(data) > 0 {
134 if data[0] == 0x1b {
135 consumed, partial := s.consumeEscape(data)
136 if consumed > 0 {
137 data = data[consumed:]
138 continue
139 }
140 if partial {
141 if len(data) <= maxEscapeBufferLen {
142 s.escapeBuf = append(s.escapeBuf[:0], data...)
143 }
144 return
145 }
146 }
147 r, size := utf8.DecodeRune(data)
148 if r == utf8.RuneError && size == 1 {
149 data = data[1:]
150 continue
151 }
152 s.putRune(r)
153 data = data[size:]
154 }
155}
156
157func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
158 if len(data) < 2 {
159 return 0, true
160 }
161 switch data[1] {
162 case ']':
163 return s.consumeOSC(data)
164 case 'P':
165 return consumeSTString(data)
166 case '_', '^', 'X':
167 return consumeSTString(data)
168 case '[':
169 return s.consumeCSI(data)
170 case '=':
171 return 2, false
172 case '>':
173 return 2, false
174 case '7':
175 s.saveCursor()
176 return 2, false
177 case '8':
178 s.restoreCursor()
179 return 2, false
180 case 'D':
181 s.buffer.hard(s.Row)
182 s.index()
183 return 2, false
184 case 'E':
185 s.nextLine()
186 return 2, false
187 case 'M':
188 s.reverseIndex()
189 return 2, false
190 case 'c':
191 s.reset()
192 return 2, false
193 case 'H':
194 return 2, false
195 case '(', ')', '*', '+', '-', '.', '/':
196 if len(data) < 3 {
197 return 0, true
198 }
199 return 3, false
200 default:
201 if data[1] >= 0x30 && data[1] <= 0x7e {
202 return 2, false
203 }
204 return 0, false
205 }
206}
207
208func consumeSTString(data []byte) (consumed int, partial bool) {
209 for i := 2; i < len(data); i++ {
210 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
211 return i + 2, false
212 }
213 }
214 return 0, true
215}