1package vt
2
3import (
4 "strconv"
5 "strings"
6
7 renderer "github.com/bnema/vev-vt/core"
8)
9
10func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
11 end := -1
12 for i := 2; i < len(data); i++ {
13 if data[i] >= '@' && data[i] <= '~' {
14 end = i
15 break
16 }
17 }
18 if end == -1 {
19 return 0, true
20 }
21 params := string(data[2:end])
22 cmd := data[end]
23 s.applyCSI(params, cmd)
24 return end + 1, false
25}
26
27func (s *Screen) applyCSI(params string, cmd byte) {
28 private := strings.HasPrefix(params, "?")
29 parts := s.parseCSIInts(params)
30 switch cmd {
31 case 'c':
32 switch params {
33 case "", "0":
34 // Identify as a VT220-compatible terminal with ANSI color. Modern
35 // capability probes require a DA1 response with a conformance level;
36 // the shorter VT102 response is ignored by kitten icat.
37 s.respond([]byte("\x1b[?62;22c"))
38 case ">", ">0":
39 s.respond([]byte("\x1b[>0;0;0c"))
40 }
41 case 'n':
42 switch firstPositive(parts, 0) {
43 case 5:
44 if !private {
45 s.respond([]byte("\x1b[0n"))
46 }
47 case 996:
48 if private {
49 if report := s.colorSchemeReport(); report != nil {
50 s.respond(report)
51 }
52 }
53 case 6:
54 resp := make([]byte, 0, 16)
55 resp = append(resp, "\x1b["...)
56 if private {
57 resp = append(resp, '?')
58 }
59 resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
60 resp = append(resp, ';')
61 resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
62 resp = append(resp, 'R')
63 s.respond(resp)
64 }
65 case 'p':
66 if params == "!" {
67 s.softReset()
68 return
69 }
70 if modeText, ok := strings.CutSuffix(params, "$"); ok {
71 if private {
72 modeText = strings.TrimPrefix(modeText, "?")
73 }
74 mode, err := strconv.Atoi(modeText)
75 if err != nil {
76 return
77 }
78 state := s.modeReportState(private, mode)
79 resp := make([]byte, 0, 16)
80 resp = append(resp, "\x1b["...)
81 if private {
82 resp = append(resp, '?')
83 }
84 resp = strconv.AppendInt(resp, int64(mode), 10)
85 resp = append(resp, ';')
86 resp = strconv.AppendInt(resp, int64(state), 10)
87 resp = append(resp, "$y"...)
88 s.respond(resp)
89 }
90 case 'b':
91 // REP repeats the last printed graphic character; ncurses relies on it
92 // for long runs when terminfo advertises rep (xterm-256color does).
93 if private || s.lastPrinted == 0 {
94 return
95 }
96 for n := min(firstPositive(parts, 1), maxRepeat(s.frame.Width, s.frame.Height)); n > 0; n-- {
97 s.putPrintable(s.lastPrinted)
98 }
99 case 'm':
100 if strings.HasPrefix(params, ">") {
101 return
102 }
103 s.applySGR(params)
104 case 'q':
105 s.applyCursorStyle(params)
106 case 'J':
107 mode := 0
108 if len(parts) > 0 {
109 mode = parts[0]
110 }
111 s.clearScreenMode(mode)
112 case 'K':
113 mode := 0
114 if len(parts) > 0 {
115 mode = parts[0]
116 }
117 s.clearLineMode(mode)
118 case 'X':
119 s.eraseChars(firstPositive(parts, 1))
120 case 'S':
121 s.scrollUpBy(firstPositive(parts, 1))
122 case 'T':
123 s.scrollDownBy(firstPositive(parts, 1))
124 case 'H', 'f':
125 row, col := 1, 1
126 if len(parts) > 0 && parts[0] > 0 {
127 row = parts[0]
128 }
129 if len(parts) > 1 && parts[1] > 0 {
130 col = parts[1]
131 }
132 s.addressCursor(row, col)
133 case 'A':
134 s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
135 case 'B':
136 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
137 case 'C':
138 s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.frame.Width-1)
139 case 'D':
140 s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.frame.Width-1)
141 case 'E':
142 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
143 s.Col = 0
144 case 'F':
145 s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
146 s.Col = 0
147 case 'G':
148 col := firstPositive(parts, 1)
149 s.Col = clamp(col-1, 0, s.frame.Width-1)
150 case 'd':
151 row := firstPositive(parts, 1)
152 s.Row = s.addressedRow(row)
153 case '@':
154 s.insertChars(firstPositive(parts, 1))
155 case 'P':
156 s.deleteChars(firstPositive(parts, 1))
157 case 'L':
158 s.insertLines(firstPositive(parts, 1))
159 case 'M':
160 s.deleteLines(firstPositive(parts, 1))
161 case 'r':
162 s.setScrollRegion(parts)
163 case 't':
164 if !private {
165 s.reportWindowSize(parts)
166 }
167 case 's':
168 s.saveCursor()
169 case 'u':
170 if params == "" {
171 s.restoreCursor()
172 return
173 }
174 s.applyKittyKeyboard(params)
175 case 'h':
176 s.setMode(private, parts, true)
177 case 'l':
178 s.setMode(private, parts, false)
179 }
180}
181
182func firstPositive(parts []int, fallback int) int {
183 if len(parts) == 0 || parts[0] <= 0 {
184 return fallback
185 }
186 return parts[0]
187}
188
189func (s *Screen) modeReportState(private bool, mode int) int {
190 if private {
191 switch mode {
192 case 1:
193 return boolModeReportState(s.applicationCursorMode)
194 case 6:
195 return boolModeReportState(s.originMode)
196 case 7:
197 return boolModeReportState(s.autoWrapMode)
198 case 2026:
199 return boolModeReportState(s.syncUpdateActive)
200 case 2031:
201 return boolModeReportState(s.colorSchemeMode)
202 }
203 return 0
204 }
205 if mode == 4 {
206 return boolModeReportState(s.insertMode)
207 }
208 return 0
209}
210
211func boolModeReportState(enabled bool) int {
212 if enabled {
213 return 1
214 }
215 return 2
216}
217
218const sgrUnderlineStyleMarker = -1000
219
220func (s *Screen) applySGR(params string) {
221 if params == "" {
222 s.Style = renderer.DefaultStyle()
223 return
224 }
225 parts := s.parseSGRInts(params)
226 for i := 0; i < len(parts); i++ {
227 switch parts[i] {
228 case 0:
229 s.Style = renderer.DefaultStyle()
230 case 1:
231 s.Style.Bold = true
232 case 2:
233 s.Style.Attrs |= renderer.AttrDim
234 case 3:
235 s.Style.Italic = true
236 case 4:
237 s.Style.Attrs |= renderer.AttrUnderline
238 s.Style.UnderlineStyle = renderer.UnderlineSingle
239 if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
240 underlineStyle := sgrUnderlineStyle(parts[i+2])
241 if underlineStyle == renderer.UnderlineNone {
242 s.Style.Attrs &^= renderer.AttrUnderline
243 } else {
244 s.Style.Attrs |= renderer.AttrUnderline
245 }
246 s.Style.UnderlineStyle = underlineStyle
247 i += 2
248 }
249 case 5, 6:
250 s.Style.Attrs |= renderer.AttrBlink
251 case 7:
252 s.Style.Inverse = true
253 case 9:
254 s.Style.Attrs |= renderer.AttrStrikethrough
255 case 21:
256 s.Style.Attrs |= renderer.AttrUnderline
257 s.Style.UnderlineStyle = renderer.UnderlineDouble
258 case 22:
259 s.Style.Bold = false
260 s.Style.Attrs &^= renderer.AttrDim
261 case 23:
262 s.Style.Italic = false
263 case 24:
264 s.Style.Attrs &^= renderer.AttrUnderline
265 s.Style.UnderlineStyle = renderer.UnderlineNone
266 case 25:
267 s.Style.Attrs &^= renderer.AttrBlink
268 case 27:
269 s.Style.Inverse = false
270 case 29:
271 s.Style.Attrs &^= renderer.AttrStrikethrough
272 case 30, 31, 32, 33, 34, 35, 36, 37:
273 s.Style.Foreground = parts[i] - 30
274 s.Style.HasForegroundRGB = false
275 case 39:
276 s.Style.Foreground = -1
277 s.Style.HasForegroundRGB = false
278 case 40, 41, 42, 43, 44, 45, 46, 47:
279 s.Style.Background = parts[i] - 40
280 s.Style.HasBackgroundRGB = false
281 case 49:
282 s.Style.Background = -1
283 s.Style.HasBackgroundRGB = false
284 case 90, 91, 92, 93, 94, 95, 96, 97:
285 s.Style.Foreground = parts[i] - 90 + 8
286 s.Style.HasForegroundRGB = false
287 case 100, 101, 102, 103, 104, 105, 106, 107:
288 s.Style.Background = parts[i] - 100 + 8
289 s.Style.HasBackgroundRGB = false
290 case 38:
291 if i+2 < len(parts) && parts[i+1] == 5 {
292 s.Style.Foreground = parts[i+2]
293 s.Style.HasForegroundRGB = false
294 i += 2
295 } else if i+4 < len(parts) && parts[i+1] == 2 {
296 s.Style.Foreground = -1
297 s.Style.HasForegroundRGB = true
298 s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
299 i += 4
300 }
301 case 48:
302 if i+2 < len(parts) && parts[i+1] == 5 {
303 s.Style.Background = parts[i+2]
304 s.Style.HasBackgroundRGB = false
305 i += 2
306 } else if i+4 < len(parts) && parts[i+1] == 2 {
307 s.Style.Background = -1
308 s.Style.HasBackgroundRGB = true
309 s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
310 i += 4
311 }
312 case 58:
313 if i+2 < len(parts) && parts[i+1] == 5 {
314 s.Style.UnderlineColor = parts[i+2]
315 s.Style.HasUnderlineColor = true
316 s.Style.HasUnderlineColorRGB = false
317 i += 2
318 } else if i+4 < len(parts) && parts[i+1] == 2 {
319 s.Style.HasUnderlineColor = false
320 s.Style.HasUnderlineColorRGB = true
321 s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
322 i += 4
323 }
324 case 59:
325 s.Style.HasUnderlineColor = false
326 s.Style.HasUnderlineColorRGB = false
327 }
328 }
329}
330
331func sgrRGB(r, g, b int) renderer.RGB {
332 return renderer.RGB{
333 R: uint8(clamp(r, 0, 255)),
334 G: uint8(clamp(g, 0, 255)),
335 B: uint8(clamp(b, 0, 255)),
336 }
337}
338
339func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
340 switch param {
341 case 0:
342 return renderer.UnderlineNone
343 case 2:
344 return renderer.UnderlineDouble
345 case 3:
346 return renderer.UnderlineCurly
347 case 4:
348 return renderer.UnderlineDotted
349 case 5:
350 return renderer.UnderlineDashed
351 default:
352 return renderer.UnderlineSingle
353 }
354}
355
356func (s *Screen) insertChars(n int) {
357 if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 {
358 return
359 }
360 w := s.frame.Width
361 if n > w-s.Col {
362 n = w - s.Col
363 }
364 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
365 // by the shift; its repair falls outside the default damage rect.
366 leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
367 s.frame.CopyRow(s.Row, s.Col+n, s.Col, w-s.Col-n)
368 s.frame.FillRow(s.Row, s.Col, s.Col+n, renderer.BlankCell())
369 s.repairRow(s.Row)
370 dmgX := s.Col
371 if leftSplit {
372 dmgX = s.Col - 1
373 }
374 s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
375}
376
377func (s *Screen) deleteChars(n int) {
378 if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 {
379 return
380 }
381 w := s.frame.Width
382 if n > w-s.Col {
383 n = w - s.Col
384 }
385 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
386 // by the shift; its repair falls outside the default damage rect.
387 leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
388 s.frame.CopyRow(s.Row, s.Col, s.Col+n, w-s.Col-n)
389 s.frame.FillRow(s.Row, w-n, w, renderer.BlankCell())
390 s.repairRow(s.Row)
391 dmgX := s.Col
392 if leftSplit {
393 dmgX = s.Col - 1
394 }
395 s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
396}
397
398func (s *Screen) insertLines(n int) {
399 if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
400 return
401 }
402 s.scrollDownRegion(s.Row, s.scrollBottom, n)
403}
404
405func (s *Screen) deleteLines(n int) {
406 if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
407 return
408 }
409 s.scrollUpRegion(s.Row, s.scrollBottom, n)
410}
411
412func (s *Screen) clearScreenMode(mode int) {
413 s.clampCursor()
414 switch mode {
415 case 1:
416 for y := range min(s.Row+1, s.frame.Height) {
417 end := s.frame.Width
418 if y == s.Row {
419 end = min(s.Col+1, s.frame.Width)
420 }
421 s.clearRow(y, 0, end)
422 }
423 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: min(s.Row+1, s.frame.Height), Count: 1})
424 case 2, 3:
425 blank := s.eraseCell()
426 for y := range s.frame.Height {
427 s.frame.FillRow(y, 0, s.frame.Width, blank)
428 }
429 for y := range s.buffer.boundaries {
430 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
431 s.buffer.rowIDs[y] = s.nextRowIDValue()
432 }
433 s.graphicsClearPlacements()
434 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: s.frame.Height, Count: 1})
435 default:
436 for y := s.Row; y < s.frame.Height; y++ {
437 start := 0
438 if y == s.Row {
439 start = s.Col
440 }
441 s.clearRow(y, start, s.frame.Width)
442 }
443 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.frame.Width, Height: s.frame.Height - s.Row, Count: 1})
444 }
445}
446
447func (s *Screen) clearLineMode(mode int) {
448 s.clampCursor()
449 switch mode {
450 case 1:
451 start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.frame.Width))
452 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
453 case 2:
454 start, width := s.clearRow(s.Row, 0, s.frame.Width)
455 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
456 default:
457 start, width := s.clearRow(s.Row, s.Col, s.frame.Width)
458 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
459 }
460}
461
462func (s *Screen) eraseChars(n int) {
463 if n <= 0 {
464 return
465 }
466 s.clampCursor()
467 if remaining := s.frame.Width - s.Col; n > remaining {
468 n = remaining
469 }
470 end := s.Col + n
471 start, width := s.clearRow(s.Row, s.Col, end)
472 if width > 0 {
473 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
474 }
475}
476
477func (s *Screen) clampCursor() {
478 s.Row = clamp(s.Row, 0, s.frame.Height-1)
479 s.Col = clamp(s.Col, 0, s.frame.Width-1)
480}
481
482func (s *Screen) parseCSIInts(params string) []int {
483 s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
484 return s.csiScratch
485}
486
487func parseCSIInts(params string) []int {
488 return parseCSIIntsInto(nil, params)
489}
490
491func parseCSIIntsInto(out []int, params string) []int {
492 if params == "" {
493 return out
494 }
495 params = strings.TrimPrefix(params, "?")
496 params = strings.TrimPrefix(params, ">")
497 start := 0
498 for i := 0; i <= len(params); i++ {
499 if i == len(params) || params[i] == ';' {
500 out = append(out, parseCSIInt(params[start:i]))
501 start = i + 1
502 }
503 }
504 return out
505}
506
507func (s *Screen) parseSGRInts(params string) []int {
508 s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
509 return s.sgrScratch
510}
511
512func parseSGRInts(params string) []int {
513 return parseSGRIntsInto(nil, params)
514}
515
516func parseSGRIntsInto(out []int, params string) []int {
517 if params == "" {
518 return out
519 }
520 start := 0
521 for i := 0; i <= len(params); i++ {
522 if i == len(params) || params[i] == ';' {
523 out = appendSGRGroup(out, params[start:i])
524 start = i + 1
525 }
526 }
527 return out
528}
529
530func appendSGRGroup(out []int, group string) []int {
531 colon := false
532 for i := 0; i < len(group); i++ {
533 if group[i] == ':' {
534 colon = true
535 break
536 }
537 }
538 if !colon {
539 return append(out, parseCSIInt(group))
540 }
541
542 parts := countSGRColonFields(group)
543 code := parseCSIInt(sgrColonField(group, 0))
544 if code == 4 {
545 if parts < 2 {
546 return append(out, code)
547 }
548 return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
549 }
550 if code != 38 && code != 48 && code != 58 {
551 return append(out, code)
552 }
553 mode := 0
554 if parts > 1 {
555 mode = parseCSIInt(sgrColonField(group, 1))
556 }
557 switch mode {
558 case 5:
559 if parts < 3 {
560 return out
561 }
562 out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
563 case 2:
564 // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
565 // the colorspace slot; code:mode:R:G:B omits that slot.
566 start := 2
567 if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
568 start = 3
569 }
570 if parts < start+3 {
571 return out
572 }
573 out = append(out, code, mode)
574 for i := range 3 {
575 out = append(out, parseCSIInt(sgrColonField(group, start+i)))
576 }
577 }
578 return out
579}
580
581func countSGRColonFields(group string) int {
582 count := 1
583 for i := 0; i < len(group); i++ {
584 if group[i] == ':' {
585 count++
586 }
587 }
588 return count
589}
590
591func sgrColonField(group string, index int) string {
592 start := 0
593 field := 0
594 for i := 0; i <= len(group); i++ {
595 if i == len(group) || group[i] == ':' {
596 if field == index {
597 return group[start:i]
598 }
599 field++
600 start = i + 1
601 }
602 }
603 return ""
604}
605
606func parseCSIInt(param string) int {
607 if param == "" {
608 return 0
609 }
610 v, err := strconv.Atoi(param)
611 if err != nil {
612 return 0
613 }
614 return v
615}