1package vt
2
3import (
4 "unicode/utf8"
5
6 "github.com/bnema/vev/pkg/renderer"
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 defaultFG renderer.RGB
57 defaultBG renderer.RGB
58 defaultColorsKnown bool
59 terminalTitle string
60
61 damage []renderer.Damage
62 damageGeneration uint64
63 damageSaturated bool
64 damageFullRedrawSticky bool
65 escapeBuf []byte
66 csiScratch []int
67 sgrScratch []int
68
69 scrollTop int
70 scrollBottom int
71 savedCursor cursorState
72 alternate *screenState
73 syncUpdateActive bool
74 progressState int
75 cursorVisible bool
76 cursorStyle int
77 cursorStyleSet bool
78 mouseMode int
79 mouseSGR bool
80 bracketedPaste bool
81 originMode bool
82 insertMode bool
83 colorSchemeMode bool
84 colorSchemeLight bool
85 colorSchemeSet bool
86}
87
88func NewScreen(width, height int) *Screen {
89 b := newBuffer(width, height)
90 s := &Screen{
91 Frame: b.frame,
92 buffer: b,
93 Style: renderer.DefaultStyle(),
94 damage: []renderer.Damage{renderer.FullRedraw()},
95 damageGeneration: 1,
96 cursorVisible: true,
97 }
98 s.resetScrollRegion()
99 return s
100}
101
102// NewScreenWithHistory creates a screen that records rows evicted from its
103// primary screen into bounded immutable terminal history.
104func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
105 s := NewScreen(width, height)
106 s.history = NewHistory(config)
107 return s
108}
109
110// History returns this screen's terminal history, or nil when history was not
111// configured with NewScreenWithHistory.
112func (s *Screen) History() *History { return s.history }
113
114// LineBounds returns an owned copy of the live grid's per-row logical extents,
115// indexed like Frame rows. It returns nil when the screen has no buffer.
116func (s *Screen) LineBounds() []LineBound {
117 if s == nil || s.buffer == nil {
118 return nil
119 }
120 return append([]LineBound(nil), s.buffer.boundaries...)
121}
122
123func (s *Screen) Write(data []byte) {
124 if len(s.escapeBuf) > 0 {
125 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
126 combined = append(combined, s.escapeBuf...)
127 combined = append(combined, data...)
128 data = combined
129 s.escapeBuf = s.escapeBuf[:0]
130 }
131
132 for len(data) > 0 {
133 if data[0] == 0x1b {
134 consumed, partial := s.consumeEscape(data)
135 if consumed > 0 {
136 data = data[consumed:]
137 continue
138 }
139 if partial {
140 if len(data) <= maxEscapeBufferLen {
141 s.escapeBuf = append(s.escapeBuf[:0], data...)
142 }
143 return
144 }
145 }
146 r, size := utf8.DecodeRune(data)
147 if r == utf8.RuneError && size == 1 {
148 data = data[1:]
149 continue
150 }
151 s.putRune(r)
152 data = data[size:]
153 }
154}
155
156func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
157 if len(data) < 2 {
158 return 0, true
159 }
160 switch data[1] {
161 case ']':
162 return s.consumeOSC(data)
163 case 'P':
164 return consumeSTString(data)
165 case '_', '^', 'X':
166 return consumeSTString(data)
167 case '[':
168 return s.consumeCSI(data)
169 case '=':
170 return 2, false
171 case '>':
172 return 2, false
173 case '7':
174 s.saveCursor()
175 return 2, false
176 case '8':
177 s.restoreCursor()
178 return 2, false
179 case 'D':
180 s.buffer.hard(s.Row)
181 s.index()
182 return 2, false
183 case 'E':
184 s.nextLine()
185 return 2, false
186 case 'M':
187 s.reverseIndex()
188 return 2, false
189 case 'c':
190 s.reset()
191 return 2, false
192 case 'H':
193 return 2, false
194 case '(', ')', '*', '+', '-', '.', '/':
195 if len(data) < 3 {
196 return 0, true
197 }
198 return 3, false
199 default:
200 if data[1] >= 0x30 && data[1] <= 0x7e {
201 return 2, false
202 }
203 return 0, false
204 }
205}
206
207func consumeSTString(data []byte) (consumed int, partial bool) {
208 for i := 2; i < len(data); i++ {
209 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
210 return i + 2, false
211 }
212 }
213 return 0, true
214}