detached 86b7f27

refactor(vt): split write and escape handling

commit
86b7f270f2c7623aaff7f730626cbf64fb88f91e
author
brice · · 2026-07-14 20:20 UTC
parent
cbbe70e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +1051 -1021
M pkg/vt/screen.go +0 -1021
@@ -1,22 +1,11 @@
11 package vt
22
33 import (
4- "strconv"
5- "strings"
64 "unicode/utf8"
75
86 "github.com/bnema/vev/pkg/renderer"
97 )
108
11-type cursorState struct {
12- row int
13- col int
14- style renderer.Style
15- originMode bool
16- insertMode bool
17- saved bool
18-}
19-
209 type screenState struct {
2110 frame renderer.Frame
2211 row int
@@ -46,14 +35,6 @@ const (
4635 ColorSchemeReportLight = "\x1b[?997;2n"
4736 )
4837
49-const (
50- progressStateClear = iota
51- progressStateNormal
52- progressStateError
53- progressStateIndeterminate
54- progressStatePaused
55-)
56-
5738 type Screen struct {
5839 Frame renderer.Frame
5940 Row int
@@ -300,226 +281,6 @@ func (s *Screen) Write(data []byte) {
300281 }
301282 }
302283
303-func (s *Screen) putRune(r rune) {
304- switch r {
305- case '\a':
306- if s.OnBell != nil {
307- s.OnBell()
308- }
309- return
310- case 0x00, 0x0e, 0x0f, 0x7f:
311- return
312- case '\r':
313- s.Col = 0
314- case '\n', '\v', '\f':
315- if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
316- s.Col = s.Frame.Width - 1
317- }
318- s.index()
319- case '\b':
320- if s.Col > 0 {
321- s.Col--
322- }
323- case '\t':
324- spaces := 8 - (s.Col % 8)
325- for range spaces {
326- s.putPrintable(' ')
327- }
328- default:
329- // Drop C1 control characters (U+0080-U+009F).
330- if r >= 0x80 && r <= 0x9F {
331- return
332- }
333- if r >= 0x20 {
334- s.putPrintable(r)
335- }
336- }
337-}
338-
339-func (s *Screen) putPrintable(r rune) {
340- w := renderer.RuneWidth(r)
341- // Skip combining marks and zero-width characters.
342- if w == 0 {
343- return
344- }
345- if s.Frame.Width == 0 || s.Frame.Height == 0 {
346- return
347- }
348- // Deferred wrap: cursor sits past the last column.
349- if s.Col >= s.Frame.Width {
350- s.Col = 0
351- s.index()
352- }
353- if s.Row >= s.Frame.Height {
354- s.Row = s.Frame.Height - 1
355- }
356-
357- // A wide rune must never straddle the right edge. If it does not fit on the
358- // current line, clear the abandoned last cell and wrap to the next line.
359- if w == 2 && s.Col+1 >= s.Frame.Width {
360- if s.Frame.Width < 2 {
361- // The screen is too narrow to ever hold a wide rune; degrade to a
362- // single narrow cell so the cursor stays aligned and we never write
363- // a continuation cell out of bounds.
364- w = 1
365- } else {
366- // The abandoned cell may itself be the continuation of a pair whose
367- // left half sits one column back; report damage over the full span.
368- cx := s.Col
369- if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
370- cx = s.Col - 1
371- }
372- s.clearWidePairAt(s.Col, s.Row)
373- s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
374- s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
375- s.Col = 0
376- s.index()
377- if s.Row >= s.Frame.Height {
378- s.Row = s.Frame.Height - 1
379- }
380- }
381- }
382-
383- insertDamageX := s.Col
384- insertDamageWidth := 0
385- if s.insertMode {
386- row := s.Frame.Row(s.Row)
387- leftSplit := s.Col > 0 && row[s.Col].Continuation
388- for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
389- row[x] = row[x-w]
390- }
391- for x := s.Col; x < s.Col+w; x++ {
392- row[x] = renderer.BlankCell()
393- }
394- s.repairRow(s.Row)
395- if leftSplit {
396- insertDamageX = s.Col - 1
397- }
398- insertDamageWidth = s.Frame.Width - insertDamageX
399- }
400-
401- // Determine the range of cells actually modified, extending over any wide
402- // pair the write lands on so no orphaned half is left behind.
403- lo, hi := s.Col, s.Col+w-1
404- if s.Frame.At(s.Col, s.Row).Continuation {
405- lo = s.Col - 1
406- }
407- if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
408- hi = right
409- }
410- for x := lo; x <= hi; x++ {
411- s.Frame.Set(x, s.Row, renderer.BlankCell())
412- }
413- s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
414- if w == 2 {
415- s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
416- }
417- if insertDamageWidth > 0 {
418- lo = min(lo, insertDamageX)
419- hi = max(hi, insertDamageX+insertDamageWidth-1)
420- }
421- s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
422- s.Col += w
423-}
424-
425-// clearWidePairAt blanks both halves of a wide-character pair when the cell at
426-// (x,y) is either the left (wide) half or the right (continuation) half. It is
427-// O(1) and assumes the pair invariant (a continuation is preceded by its wide
428-// left half) holds — which the writer maintains.
429-func (s *Screen) clearWidePairAt(x, y int) {
430- if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
431- return
432- }
433- if s.Frame.At(x, y).Continuation {
434- if x-1 >= 0 {
435- s.Frame.Set(x-1, y, renderer.BlankCell())
436- }
437- s.Frame.Set(x, y, renderer.BlankCell())
438- return
439- }
440- if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
441- s.Frame.Set(x, y, renderer.BlankCell())
442- s.Frame.Set(x+1, y, renderer.BlankCell())
443- }
444-}
445-
446-func (s *Screen) eraseCell() renderer.Cell {
447- style := renderer.DefaultStyle()
448- if s.Style.HasBackgroundRGB {
449- style.HasBackgroundRGB = true
450- style.BackgroundRGB = s.Style.BackgroundRGB
451- } else {
452- style.Background = s.Style.Background
453- }
454- return renderer.Cell{Rune: ' ', Style: style}
455-}
456-
457-// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
458-// half of a wide pair that straddles a boundary so no orphan half is left. It
459-// returns the actual modified span [start, start+width).
460-func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
461- if y < 0 || y >= s.Frame.Height {
462- return x0, 0
463- }
464- if x0 < 0 {
465- x0 = 0
466- }
467- if x1 > s.Frame.Width {
468- x1 = s.Frame.Width
469- }
470- if x0 >= x1 {
471- return x0, 0
472- }
473- // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
474- if x0 > 0 && s.Frame.At(x0, y).Continuation {
475- x0--
476- }
477- // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
478- // inside the range and will be blanked, so swallow the continuation too.
479- if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
480- x1++
481- }
482- blank := s.eraseCell()
483- for x := x0; x < x1; x++ {
484- s.Frame.Set(x, y, blank)
485- }
486- return x0, x1 - x0
487-}
488-
489-// repairRow blanks any orphaned wide-character halves on row y: a continuation
490-// cell not preceded by its wide left half, or a wide rune not followed by its
491-// continuation. Used after erase/insert/delete operations that may split a pair
492-// at a boundary or by shifting cells.
493-func (s *Screen) repairRow(y int) {
494- repairFrameRow(s.Frame, y)
495-}
496-
497-func repairFrameRow(frame renderer.Frame, y int) {
498- if y < 0 || y >= frame.Height {
499- return
500- }
501- w := frame.Width
502- for x := range w {
503- c := frame.At(x, y)
504- if c.Continuation {
505- if x == 0 {
506- frame.Set(x, y, renderer.BlankCell())
507- continue
508- }
509- left := frame.At(x-1, y)
510- if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
511- frame.Set(x, y, renderer.BlankCell())
512- }
513- continue
514- }
515- if renderer.RuneWidth(c.Rune) == 2 {
516- if x+1 >= w || !frame.At(x+1, y).Continuation {
517- frame.Set(x, y, renderer.BlankCell())
518- }
519- }
520- }
521-}
522-
523284 func (s *Screen) index() {
524285 if s.Frame.Height == 0 {
525286 return
@@ -737,164 +498,6 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
737498 }
738499 }
739500
740-func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
741- end := -1
742- for i := 2; i < len(data); i++ {
743- if data[i] >= '@' && data[i] <= '~' {
744- end = i
745- break
746- }
747- }
748- if end == -1 {
749- return 0, true
750- }
751- params := string(data[2:end])
752- cmd := data[end]
753- s.applyCSI(params, cmd)
754- return end + 1, false
755-}
756-
757-func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
758- for i := 2; i < len(data); i++ {
759- switch data[i] {
760- case 0x07:
761- s.applyOSC(data[2:i], []byte{0x07})
762- s.handleOSC(data[2:i])
763- return i + 1, false
764- case 0x1b:
765- if i+1 < len(data) && data[i+1] == '\\' {
766- s.applyOSC(data[2:i], []byte{0x1b, '\\'})
767- s.handleOSC(data[2:i])
768- return i + 2, false
769- }
770- }
771- }
772- return 0, true
773-}
774-
775-func (s *Screen) applyOSC(payload, terminator []byte) {
776- if !s.defaultColorsKnown {
777- return
778- }
779-
780- var color renderer.RGB
781- switch string(payload) {
782- case "10;?":
783- color = s.defaultFG
784- case "11;?":
785- color = s.defaultBG
786- default:
787- return
788- }
789-
790- resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
791- resp = append(resp, "\x1b]"...)
792- resp = append(resp, payload[:2]...)
793- resp = append(resp, ";rgb:"...)
794- resp = appendOSCColorComponent(resp, color.R)
795- resp = append(resp, '/')
796- resp = appendOSCColorComponent(resp, color.G)
797- resp = append(resp, '/')
798- resp = appendOSCColorComponent(resp, color.B)
799- resp = append(resp, terminator...)
800- s.respond(resp)
801-}
802-
803-func appendOSCColorComponent(dst []byte, c uint8) []byte {
804- const hex = "0123456789abcdef"
805- v := uint16(c)<<8 | uint16(c)
806- return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
807-}
808-
809-// handleOSC inspects a complete OSC payload (between "ESC ]" and its
810-// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
811-// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
812-// is discarded.
813-func (s *Screen) handleOSC(payload []byte) {
814- if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
815- s.handleOSC52(string(payload[len("52;"):]))
816- return
817- }
818- p := string(payload)
819- if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
820- s.terminalTitle = title
821- return
822- }
823- if p == "9;4" || strings.HasPrefix(p, "9;4;") {
824- s.handleProgress(p[len("9;4"):])
825- return
826- }
827- if s.OnNotify == nil {
828- return
829- }
830- switch {
831- case strings.HasPrefix(p, "9;"):
832- s.OnNotify("", p[len("9;"):])
833- case strings.HasPrefix(p, "777;"):
834- parts := strings.SplitN(p[len("777;"):], ";", 3)
835- if parts[0] != "notify" {
836- return
837- }
838- var title, body string
839- if len(parts) > 1 {
840- title = parts[1]
841- }
842- if len(parts) > 2 {
843- body = parts[2]
844- }
845- s.OnNotify(title, body)
846- }
847-}
848-
849-func (s *Screen) handleProgress(rest string) {
850- rest = strings.TrimPrefix(rest, ";")
851- if rest == "" {
852- return
853- }
854- token, _, _ := strings.Cut(rest, ";")
855- state, err := strconv.Atoi(token)
856- if err != nil {
857- return
858- }
859- switch state {
860- case progressStateNormal, progressStateIndeterminate, progressStatePaused:
861- s.progressState = state
862- case progressStateClear:
863- previous := s.progressState
864- s.progressState = state
865- if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
866- if s.OnProgress != nil {
867- s.OnProgress(false)
868- }
869- }
870- case progressStateError:
871- previous := s.progressState
872- s.progressState = state
873- if previous != progressStateError {
874- if s.OnProgress != nil {
875- s.OnProgress(true)
876- }
877- }
878- }
879-}
880-
881-// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
882-// clipboard payload (selection may be empty; split on the first ";"). A
883-// clipboard query (data == "?") is always ignored — vev never answers
884-// clipboard queries. A payload with no second ";" is malformed and ignored.
885-func (s *Screen) handleOSC52(rest string) {
886- _, data, ok := strings.Cut(rest, ";")
887- if !ok {
888- return
889- }
890- if data == "?" {
891- return
892- }
893- if s.OnClipboard != nil {
894- s.OnClipboard(data)
895- }
896-}
897-
898501 func consumeSTString(data []byte) (consumed int, partial bool) {
899502 for i := 2; i < len(data); i++ {
900503 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -904,379 +507,6 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
904507 return 0, true
905508 }
906509
907-func (s *Screen) applyCSI(params string, cmd byte) {
908- private := strings.HasPrefix(params, "?")
909- parts := s.parseCSIInts(params)
910- switch cmd {
911- case 'c':
912- switch params {
913- case "", "0":
914- s.respond([]byte("\x1b[?6c"))
915- case ">", ">0":
916- s.respond([]byte("\x1b[>0;0;0c"))
917- }
918- case 'n':
919- switch firstPositive(parts, 0) {
920- case 5:
921- if !private {
922- s.respond([]byte("\x1b[0n"))
923- }
924- case 996:
925- if private {
926- if report := s.colorSchemeReport(); report != nil {
927- s.respond(report)
928- }
929- }
930- case 6:
931- resp := make([]byte, 0, 16)
932- resp = append(resp, "\x1b["...)
933- if private {
934- resp = append(resp, '?')
935- }
936- resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
937- resp = append(resp, ';')
938- resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
939- resp = append(resp, 'R')
940- s.respond(resp)
941- }
942- case 'p':
943- if modeText, ok := strings.CutSuffix(params, "$"); ok {
944- if private {
945- modeText = strings.TrimPrefix(modeText, "?")
946- }
947- mode, err := strconv.Atoi(modeText)
948- if err != nil {
949- return
950- }
951- state := s.modeReportState(private, mode)
952- resp := make([]byte, 0, 16)
953- resp = append(resp, "\x1b["...)
954- if private {
955- resp = append(resp, '?')
956- }
957- resp = strconv.AppendInt(resp, int64(mode), 10)
958- resp = append(resp, ';')
959- resp = strconv.AppendInt(resp, int64(state), 10)
960- resp = append(resp, "$y"...)
961- s.respond(resp)
962- }
963- case 'm':
964- if strings.HasPrefix(params, ">") {
965- return
966- }
967- s.applySGR(params)
968- case 'q':
969- s.applyCursorStyle(params)
970- case 'J':
971- mode := 0
972- if len(parts) > 0 {
973- mode = parts[0]
974- }
975- s.clearScreenMode(mode)
976- case 'K':
977- mode := 0
978- if len(parts) > 0 {
979- mode = parts[0]
980- }
981- s.clearLineMode(mode)
982- case 'S':
983- s.scrollUpBy(firstPositive(parts, 1))
984- case 'T':
985- s.scrollDownBy(firstPositive(parts, 1))
986- case 'H', 'f':
987- row, col := 1, 1
988- if len(parts) > 0 && parts[0] > 0 {
989- row = parts[0]
990- }
991- if len(parts) > 1 && parts[1] > 0 {
992- col = parts[1]
993- }
994- s.addressCursor(row, col)
995- case 'A':
996- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
997- case 'B':
998- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
999- case 'C':
1000- s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
1001- case 'D':
1002- s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
1003- case 'E':
1004- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
1005- s.Col = 0
1006- case 'F':
1007- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
1008- s.Col = 0
1009- case 'G':
1010- col := firstPositive(parts, 1)
1011- s.Col = clamp(col-1, 0, s.Frame.Width-1)
1012- case 'd':
1013- row := firstPositive(parts, 1)
1014- s.Row = s.addressedRow(row)
1015- case '@':
1016- s.insertChars(firstPositive(parts, 1))
1017- case 'P':
1018- s.deleteChars(firstPositive(parts, 1))
1019- case 'L':
1020- s.insertLines(firstPositive(parts, 1))
1021- case 'M':
1022- s.deleteLines(firstPositive(parts, 1))
1023- case 'r':
1024- s.setScrollRegion(parts)
1025- case 's':
1026- s.saveCursor()
1027- case 'u':
1028- if params == "" {
1029- s.restoreCursor()
1030- }
1031- case 'h':
1032- s.setMode(private, parts, true)
1033- case 'l':
1034- s.setMode(private, parts, false)
1035- }
1036-}
1037-
1038-func firstPositive(parts []int, fallback int) int {
1039- if len(parts) == 0 || parts[0] <= 0 {
1040- return fallback
1041- }
1042- return parts[0]
1043-}
1044-
1045-func (s *Screen) cursorReportRow() int {
1046- if s.originMode {
1047- return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
1048- }
1049- return s.Row + 1
1050-}
1051-
1052-func (s *Screen) modeReportState(private bool, mode int) int {
1053- if private {
1054- switch mode {
1055- case 6:
1056- return boolModeReportState(s.originMode)
1057- case 2026:
1058- return boolModeReportState(s.syncUpdateActive)
1059- case 2031:
1060- return boolModeReportState(s.colorSchemeMode)
1061- }
1062- return 0
1063- }
1064- if mode == 4 {
1065- return boolModeReportState(s.insertMode)
1066- }
1067- return 0
1068-}
1069-
1070-func boolModeReportState(enabled bool) int {
1071- if enabled {
1072- return 1
1073- }
1074- return 2
1075-}
1076-
1077-func (s *Screen) cursorMinRow() int {
1078- if s.originMode {
1079- return clamp(s.scrollTop, 0, s.Frame.Height-1)
1080- }
1081- return 0
1082-}
1083-
1084-func (s *Screen) cursorMaxRow() int {
1085- if s.originMode {
1086- return clamp(s.scrollBottom, 0, s.Frame.Height-1)
1087- }
1088- return max(s.Frame.Height-1, 0)
1089-}
1090-
1091-func (s *Screen) addressedRow(row int) int {
1092- if s.originMode {
1093- return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
1094- }
1095- return clamp(row-1, 0, s.Frame.Height-1)
1096-}
1097-
1098-func (s *Screen) addressCursor(row, col int) {
1099- s.Row = s.addressedRow(row)
1100- s.Col = clamp(col-1, 0, s.Frame.Width-1)
1101-}
1102-
1103-func (s *Screen) homeCursor() {
1104- s.addressCursor(1, 1)
1105-}
1106-
1107-func (s *Screen) applyCursorStyle(params string) {
1108- if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
1109- return
1110- }
1111- styleParam := strings.TrimSuffix(params, " ")
1112- style := 0
1113- if styleParam != "" {
1114- v, err := strconv.Atoi(styleParam)
1115- if err != nil {
1116- return
1117- }
1118- style = v
1119- }
1120- if style < 0 || style > 6 {
1121- return
1122- }
1123- s.cursorStyle = style
1124- s.cursorStyleSet = true
1125-}
1126-
1127-const sgrUnderlineStyleMarker = -1000
1128-
1129-func (s *Screen) applySGR(params string) {
1130- if params == "" {
1131- s.Style = renderer.DefaultStyle()
1132- return
1133- }
1134- parts := s.parseSGRInts(params)
1135- for i := 0; i < len(parts); i++ {
1136- switch parts[i] {
1137- case 0:
1138- s.Style = renderer.DefaultStyle()
1139- case 1:
1140- s.Style.Bold = true
1141- case 2:
1142- s.Style.Attrs |= renderer.AttrDim
1143- case 3:
1144- s.Style.Italic = true
1145- case 4:
1146- s.Style.Attrs |= renderer.AttrUnderline
1147- s.Style.UnderlineStyle = renderer.UnderlineSingle
1148- if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
1149- underlineStyle := sgrUnderlineStyle(parts[i+2])
1150- if underlineStyle == renderer.UnderlineNone {
1151- s.Style.Attrs &^= renderer.AttrUnderline
1152- } else {
1153- s.Style.Attrs |= renderer.AttrUnderline
1154- }
1155- s.Style.UnderlineStyle = underlineStyle
1156- i += 2
1157- }
1158- case 5, 6:
1159- s.Style.Attrs |= renderer.AttrBlink
1160- case 7:
1161- s.Style.Inverse = true
1162- case 9:
1163- s.Style.Attrs |= renderer.AttrStrikethrough
1164- case 21:
1165- s.Style.Attrs |= renderer.AttrUnderline
1166- s.Style.UnderlineStyle = renderer.UnderlineDouble
1167- case 22:
1168- s.Style.Bold = false
1169- s.Style.Attrs &^= renderer.AttrDim
1170- case 23:
1171- s.Style.Italic = false
1172- case 24:
1173- s.Style.Attrs &^= renderer.AttrUnderline
1174- s.Style.UnderlineStyle = renderer.UnderlineNone
1175- case 25:
1176- s.Style.Attrs &^= renderer.AttrBlink
1177- case 27:
1178- s.Style.Inverse = false
1179- case 29:
1180- s.Style.Attrs &^= renderer.AttrStrikethrough
1181- case 30, 31, 32, 33, 34, 35, 36, 37:
1182- s.Style.Foreground = parts[i] - 30
1183- s.Style.HasForegroundRGB = false
1184- case 39:
1185- s.Style.Foreground = -1
1186- s.Style.HasForegroundRGB = false
1187- case 40, 41, 42, 43, 44, 45, 46, 47:
1188- s.Style.Background = parts[i] - 40
1189- s.Style.HasBackgroundRGB = false
1190- case 49:
1191- s.Style.Background = -1
1192- s.Style.HasBackgroundRGB = false
1193- case 90, 91, 92, 93, 94, 95, 96, 97:
1194- s.Style.Foreground = parts[i] - 90 + 8
1195- s.Style.HasForegroundRGB = false
1196- case 100, 101, 102, 103, 104, 105, 106, 107:
1197- s.Style.Background = parts[i] - 100 + 8
1198- s.Style.HasBackgroundRGB = false
1199- case 38:
1200- if i+2 < len(parts) && parts[i+1] == 5 {
1201- s.Style.Foreground = parts[i+2]
1202- s.Style.HasForegroundRGB = false
1203- i += 2
1204- } else if i+4 < len(parts) && parts[i+1] == 2 {
1205- s.Style.Foreground = -1
1206- s.Style.HasForegroundRGB = true
1207- s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1208- i += 4
1209- }
1210- case 48:
1211- if i+2 < len(parts) && parts[i+1] == 5 {
1212- s.Style.Background = parts[i+2]
1213- s.Style.HasBackgroundRGB = false
1214- i += 2
1215- } else if i+4 < len(parts) && parts[i+1] == 2 {
1216- s.Style.Background = -1
1217- s.Style.HasBackgroundRGB = true
1218- s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1219- i += 4
1220- }
1221- case 58:
1222- if i+2 < len(parts) && parts[i+1] == 5 {
1223- s.Style.UnderlineColor = parts[i+2]
1224- s.Style.HasUnderlineColor = true
1225- s.Style.HasUnderlineColorRGB = false
1226- i += 2
1227- } else if i+4 < len(parts) && parts[i+1] == 2 {
1228- s.Style.HasUnderlineColor = false
1229- s.Style.HasUnderlineColorRGB = true
1230- s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1231- i += 4
1232- }
1233- case 59:
1234- s.Style.HasUnderlineColor = false
1235- s.Style.HasUnderlineColorRGB = false
1236- }
1237- }
1238-}
1239-
1240-func sgrRGB(r, g, b int) renderer.RGB {
1241- return renderer.RGB{
1242- R: uint8(clamp(r, 0, 255)),
1243- G: uint8(clamp(g, 0, 255)),
1244- B: uint8(clamp(b, 0, 255)),
1245- }
1246-}
1247-
1248-func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
1249- switch param {
1250- case 0:
1251- return renderer.UnderlineNone
1252- case 2:
1253- return renderer.UnderlineDouble
1254- case 3:
1255- return renderer.UnderlineCurly
1256- case 4:
1257- return renderer.UnderlineDotted
1258- case 5:
1259- return renderer.UnderlineDashed
1260- default:
1261- return renderer.UnderlineSingle
1262- }
1263-}
1264-
1265-func (s *Screen) saveCursor() {
1266- s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
1267-}
1268-
1269-func (s *Screen) restoreCursor() {
1270- if !s.savedCursor.saved {
1271- return
1272- }
1273- s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
1274- s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
1275- s.Style = s.savedCursor.style
1276- s.originMode = s.savedCursor.originMode
1277- s.insertMode = s.savedCursor.insertMode
1278-}
1279-
1280510 func (s *Screen) reset() {
1281511 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
1282512 s.Row, s.Col = 0, 0
@@ -1457,257 +687,6 @@ func cloneFrame(frame renderer.Frame) renderer.Frame {
1457687 return out
1458688 }
1459689
1460-func (s *Screen) insertChars(n int) {
1461- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
1462- return
1463- }
1464- w := s.Frame.Width
1465- if n > w-s.Col {
1466- n = w - s.Col
1467- }
1468- row := s.Frame.Row(s.Row)
1469- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
1470- // by the shift; its repair falls outside the default damage rect.
1471- leftSplit := s.Col > 0 && row[s.Col].Continuation
1472- for x := w - 1; x >= s.Col+n; x-- {
1473- row[x] = row[x-n]
1474- }
1475- for x := s.Col; x < s.Col+n; x++ {
1476- row[x] = renderer.BlankCell()
1477- }
1478- s.repairRow(s.Row)
1479- dmgX := s.Col
1480- if leftSplit {
1481- dmgX = s.Col - 1
1482- }
1483- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
1484-}
1485-
1486-func (s *Screen) deleteChars(n int) {
1487- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
1488- return
1489- }
1490- w := s.Frame.Width
1491- if n > w-s.Col {
1492- n = w - s.Col
1493- }
1494- row := s.Frame.Row(s.Row)
1495- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
1496- // by the shift; its repair falls outside the default damage rect.
1497- leftSplit := s.Col > 0 && row[s.Col].Continuation
1498- for x := s.Col; x < w-n; x++ {
1499- row[x] = row[x+n]
1500- }
1501- for x := w - n; x < w; x++ {
1502- row[x] = renderer.BlankCell()
1503- }
1504- s.repairRow(s.Row)
1505- dmgX := s.Col
1506- if leftSplit {
1507- dmgX = s.Col - 1
1508- }
1509- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
1510-}
1511-
1512-func (s *Screen) insertLines(n int) {
1513- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
1514- return
1515- }
1516- s.scrollDownRegion(s.Row, s.scrollBottom, n)
1517-}
1518-
1519-func (s *Screen) deleteLines(n int) {
1520- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
1521- return
1522- }
1523- s.scrollUpRegion(s.Row, s.scrollBottom, n)
1524-}
1525-
1526-func (s *Screen) clearScreenMode(mode int) {
1527- s.clampCursor()
1528- switch mode {
1529- case 1:
1530- for y := range min(s.Row+1, s.Frame.Height) {
1531- end := s.Frame.Width
1532- if y == s.Row {
1533- end = min(s.Col+1, s.Frame.Width)
1534- }
1535- s.clearRow(y, 0, end)
1536- }
1537- 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})
1538- case 2, 3:
1539- blank := s.eraseCell()
1540- for i := range s.Frame.Cells {
1541- s.Frame.Cells[i] = blank
1542- }
1543- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
1544- default:
1545- for y := s.Row; y < s.Frame.Height; y++ {
1546- start := 0
1547- if y == s.Row {
1548- start = s.Col
1549- }
1550- s.clearRow(y, start, s.Frame.Width)
1551- }
1552- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
1553- }
1554-}
1555-
1556-func (s *Screen) clearLineMode(mode int) {
1557- s.clampCursor()
1558- switch mode {
1559- case 1:
1560- start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
1561- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1562- case 2:
1563- start, width := s.clearRow(s.Row, 0, s.Frame.Width)
1564- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1565- default:
1566- start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
1567- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1568- }
1569-}
1570-
1571-func (s *Screen) clampCursor() {
1572- s.Row = clamp(s.Row, 0, s.Frame.Height-1)
1573- s.Col = clamp(s.Col, 0, s.Frame.Width-1)
1574-}
1575-
1576-func (s *Screen) parseCSIInts(params string) []int {
1577- s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
1578- return s.csiScratch
1579-}
1580-
1581-func parseCSIInts(params string) []int {
1582- return parseCSIIntsInto(nil, params)
1583-}
1584-
1585-func parseCSIIntsInto(out []int, params string) []int {
1586- if params == "" {
1587- return out
1588- }
1589- params = strings.TrimPrefix(params, "?")
1590- params = strings.TrimPrefix(params, ">")
1591- start := 0
1592- for i := 0; i <= len(params); i++ {
1593- if i == len(params) || params[i] == ';' {
1594- out = append(out, parseCSIInt(params[start:i]))
1595- start = i + 1
1596- }
1597- }
1598- return out
1599-}
1600-
1601-func (s *Screen) parseSGRInts(params string) []int {
1602- s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
1603- return s.sgrScratch
1604-}
1605-
1606-func parseSGRInts(params string) []int {
1607- return parseSGRIntsInto(nil, params)
1608-}
1609-
1610-func parseSGRIntsInto(out []int, params string) []int {
1611- if params == "" {
1612- return out
1613- }
1614- start := 0
1615- for i := 0; i <= len(params); i++ {
1616- if i == len(params) || params[i] == ';' {
1617- out = appendSGRGroup(out, params[start:i])
1618- start = i + 1
1619- }
1620- }
1621- return out
1622-}
1623-
1624-func appendSGRGroup(out []int, group string) []int {
1625- colon := false
1626- for i := 0; i < len(group); i++ {
1627- if group[i] == ':' {
1628- colon = true
1629- break
1630- }
1631- }
1632- if !colon {
1633- return append(out, parseCSIInt(group))
1634- }
1635-
1636- parts := countSGRColonFields(group)
1637- code := parseCSIInt(sgrColonField(group, 0))
1638- if code == 4 {
1639- if parts < 2 {
1640- return append(out, code)
1641- }
1642- return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
1643- }
1644- if code != 38 && code != 48 && code != 58 {
1645- return append(out, code)
1646- }
1647- mode := 0
1648- if parts > 1 {
1649- mode = parseCSIInt(sgrColonField(group, 1))
1650- }
1651- switch mode {
1652- case 5:
1653- if parts < 3 {
1654- return out
1655- }
1656- out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
1657- case 2:
1658- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1659- // the colorspace slot; code:mode:R:G:B omits that slot.
1660- start := 2
1661- if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
1662- start = 3
1663- }
1664- if parts < start+3 {
1665- return out
1666- }
1667- out = append(out, code, mode)
1668- for i := range 3 {
1669- out = append(out, parseCSIInt(sgrColonField(group, start+i)))
1670- }
1671- }
1672- return out
1673-}
1674-
1675-func countSGRColonFields(group string) int {
1676- count := 1
1677- for i := 0; i < len(group); i++ {
1678- if group[i] == ':' {
1679- count++
1680- }
1681- }
1682- return count
1683-}
1684-
1685-func sgrColonField(group string, index int) string {
1686- start := 0
1687- field := 0
1688- for i := 0; i <= len(group); i++ {
1689- if i == len(group) || group[i] == ':' {
1690- if field == index {
1691- return group[start:i]
1692- }
1693- field++
1694- start = i + 1
1695- }
1696- }
1697- return ""
1698-}
1699-
1700-func parseCSIInt(param string) int {
1701- if param == "" {
1702- return 0
1703- }
1704- v, err := strconv.Atoi(param)
1705- if err != nil {
1706- return 0
1707- }
1708- return v
1709-}
1710-
1711690 func clamp(v, lo, hi int) int {
1712691 if hi < lo {
1713692 return lo
@@ -1,22 +1,11 @@
1 package vt1 package vt
2 2
3 import (3 import (
4- "strconv"
5- "strings"
6 "unicode/utf8"4 "unicode/utf8"
7 5
8 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
9 )7 )
10 8
11-type cursorState struct {
12- row int
13- col int
14- style renderer.Style
15- originMode bool
16- insertMode bool
17- saved bool
18-}
19-
20 type screenState struct {9 type screenState struct {
21 frame renderer.Frame10 frame renderer.Frame
22 row int11 row int
@@ -46,14 +35,6 @@ const (
46 ColorSchemeReportLight = "\x1b[?997;2n"35 ColorSchemeReportLight = "\x1b[?997;2n"
47 )36 )
48 37
49-const (
50- progressStateClear = iota
51- progressStateNormal
52- progressStateError
53- progressStateIndeterminate
54- progressStatePaused
55-)
56-
57 type Screen struct {38 type Screen struct {
58 Frame renderer.Frame39 Frame renderer.Frame
59 Row int40 Row int
@@ -300,226 +281,6 @@ func (s *Screen) Write(data []byte) {
300 }281 }
301 }282 }
302 283
303-func (s *Screen) putRune(r rune) {
304- switch r {
305- case '\a':
306- if s.OnBell != nil {
307- s.OnBell()
308- }
309- return
310- case 0x00, 0x0e, 0x0f, 0x7f:
311- return
312- case '\r':
313- s.Col = 0
314- case '\n', '\v', '\f':
315- if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
316- s.Col = s.Frame.Width - 1
317- }
318- s.index()
319- case '\b':
320- if s.Col > 0 {
321- s.Col--
322- }
323- case '\t':
324- spaces := 8 - (s.Col % 8)
325- for range spaces {
326- s.putPrintable(' ')
327- }
328- default:
329- // Drop C1 control characters (U+0080-U+009F).
330- if r >= 0x80 && r <= 0x9F {
331- return
332- }
333- if r >= 0x20 {
334- s.putPrintable(r)
335- }
336- }
337-}
338-
339-func (s *Screen) putPrintable(r rune) {
340- w := renderer.RuneWidth(r)
341- // Skip combining marks and zero-width characters.
342- if w == 0 {
343- return
344- }
345- if s.Frame.Width == 0 || s.Frame.Height == 0 {
346- return
347- }
348- // Deferred wrap: cursor sits past the last column.
349- if s.Col >= s.Frame.Width {
350- s.Col = 0
351- s.index()
352- }
353- if s.Row >= s.Frame.Height {
354- s.Row = s.Frame.Height - 1
355- }
356-
357- // A wide rune must never straddle the right edge. If it does not fit on the
358- // current line, clear the abandoned last cell and wrap to the next line.
359- if w == 2 && s.Col+1 >= s.Frame.Width {
360- if s.Frame.Width < 2 {
361- // The screen is too narrow to ever hold a wide rune; degrade to a
362- // single narrow cell so the cursor stays aligned and we never write
363- // a continuation cell out of bounds.
364- w = 1
365- } else {
366- // The abandoned cell may itself be the continuation of a pair whose
367- // left half sits one column back; report damage over the full span.
368- cx := s.Col
369- if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
370- cx = s.Col - 1
371- }
372- s.clearWidePairAt(s.Col, s.Row)
373- s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
374- s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
375- s.Col = 0
376- s.index()
377- if s.Row >= s.Frame.Height {
378- s.Row = s.Frame.Height - 1
379- }
380- }
381- }
382-
383- insertDamageX := s.Col
384- insertDamageWidth := 0
385- if s.insertMode {
386- row := s.Frame.Row(s.Row)
387- leftSplit := s.Col > 0 && row[s.Col].Continuation
388- for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
389- row[x] = row[x-w]
390- }
391- for x := s.Col; x < s.Col+w; x++ {
392- row[x] = renderer.BlankCell()
393- }
394- s.repairRow(s.Row)
395- if leftSplit {
396- insertDamageX = s.Col - 1
397- }
398- insertDamageWidth = s.Frame.Width - insertDamageX
399- }
400-
401- // Determine the range of cells actually modified, extending over any wide
402- // pair the write lands on so no orphaned half is left behind.
403- lo, hi := s.Col, s.Col+w-1
404- if s.Frame.At(s.Col, s.Row).Continuation {
405- lo = s.Col - 1
406- }
407- if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
408- hi = right
409- }
410- for x := lo; x <= hi; x++ {
411- s.Frame.Set(x, s.Row, renderer.BlankCell())
412- }
413- s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
414- if w == 2 {
415- s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
416- }
417- if insertDamageWidth > 0 {
418- lo = min(lo, insertDamageX)
419- hi = max(hi, insertDamageX+insertDamageWidth-1)
420- }
421- s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
422- s.Col += w
423-}
424-
425-// clearWidePairAt blanks both halves of a wide-character pair when the cell at
426-// (x,y) is either the left (wide) half or the right (continuation) half. It is
427-// O(1) and assumes the pair invariant (a continuation is preceded by its wide
428-// left half) holds — which the writer maintains.
429-func (s *Screen) clearWidePairAt(x, y int) {
430- if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
431- return
432- }
433- if s.Frame.At(x, y).Continuation {
434- if x-1 >= 0 {
435- s.Frame.Set(x-1, y, renderer.BlankCell())
436- }
437- s.Frame.Set(x, y, renderer.BlankCell())
438- return
439- }
440- if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
441- s.Frame.Set(x, y, renderer.BlankCell())
442- s.Frame.Set(x+1, y, renderer.BlankCell())
443- }
444-}
445-
446-func (s *Screen) eraseCell() renderer.Cell {
447- style := renderer.DefaultStyle()
448- if s.Style.HasBackgroundRGB {
449- style.HasBackgroundRGB = true
450- style.BackgroundRGB = s.Style.BackgroundRGB
451- } else {
452- style.Background = s.Style.Background
453- }
454- return renderer.Cell{Rune: ' ', Style: style}
455-}
456-
457-// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
458-// half of a wide pair that straddles a boundary so no orphan half is left. It
459-// returns the actual modified span [start, start+width).
460-func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
461- if y < 0 || y >= s.Frame.Height {
462- return x0, 0
463- }
464- if x0 < 0 {
465- x0 = 0
466- }
467- if x1 > s.Frame.Width {
468- x1 = s.Frame.Width
469- }
470- if x0 >= x1 {
471- return x0, 0
472- }
473- // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
474- if x0 > 0 && s.Frame.At(x0, y).Continuation {
475- x0--
476- }
477- // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
478- // inside the range and will be blanked, so swallow the continuation too.
479- if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
480- x1++
481- }
482- blank := s.eraseCell()
483- for x := x0; x < x1; x++ {
484- s.Frame.Set(x, y, blank)
485- }
486- return x0, x1 - x0
487-}
488-
489-// repairRow blanks any orphaned wide-character halves on row y: a continuation
490-// cell not preceded by its wide left half, or a wide rune not followed by its
491-// continuation. Used after erase/insert/delete operations that may split a pair
492-// at a boundary or by shifting cells.
493-func (s *Screen) repairRow(y int) {
494- repairFrameRow(s.Frame, y)
495-}
496-
497-func repairFrameRow(frame renderer.Frame, y int) {
498- if y < 0 || y >= frame.Height {
499- return
500- }
501- w := frame.Width
502- for x := range w {
503- c := frame.At(x, y)
504- if c.Continuation {
505- if x == 0 {
506- frame.Set(x, y, renderer.BlankCell())
507- continue
508- }
509- left := frame.At(x-1, y)
510- if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
511- frame.Set(x, y, renderer.BlankCell())
512- }
513- continue
514- }
515- if renderer.RuneWidth(c.Rune) == 2 {
516- if x+1 >= w || !frame.At(x+1, y).Continuation {
517- frame.Set(x, y, renderer.BlankCell())
518- }
519- }
520- }
521-}
522-
523 func (s *Screen) index() {284 func (s *Screen) index() {
524 if s.Frame.Height == 0 {285 if s.Frame.Height == 0 {
525 return286 return
@@ -737,164 +498,6 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
737 }498 }
738 }499 }
739 500
740-func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
741- end := -1
742- for i := 2; i < len(data); i++ {
743- if data[i] >= '@' && data[i] <= '~' {
744- end = i
745- break
746- }
747- }
748- if end == -1 {
749- return 0, true
750- }
751- params := string(data[2:end])
752- cmd := data[end]
753- s.applyCSI(params, cmd)
754- return end + 1, false
755-}
756-
757-func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
758- for i := 2; i < len(data); i++ {
759- switch data[i] {
760- case 0x07:
761- s.applyOSC(data[2:i], []byte{0x07})
762- s.handleOSC(data[2:i])
763- return i + 1, false
764- case 0x1b:
765- if i+1 < len(data) && data[i+1] == '\\' {
766- s.applyOSC(data[2:i], []byte{0x1b, '\\'})
767- s.handleOSC(data[2:i])
768- return i + 2, false
769- }
770- }
771- }
772- return 0, true
773-}
774-
775-func (s *Screen) applyOSC(payload, terminator []byte) {
776- if !s.defaultColorsKnown {
777- return
778- }
779-
780- var color renderer.RGB
781- switch string(payload) {
782- case "10;?":
783- color = s.defaultFG
784- case "11;?":
785- color = s.defaultBG
786- default:
787- return
788- }
789-
790- resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
791- resp = append(resp, "\x1b]"...)
792- resp = append(resp, payload[:2]...)
793- resp = append(resp, ";rgb:"...)
794- resp = appendOSCColorComponent(resp, color.R)
795- resp = append(resp, '/')
796- resp = appendOSCColorComponent(resp, color.G)
797- resp = append(resp, '/')
798- resp = appendOSCColorComponent(resp, color.B)
799- resp = append(resp, terminator...)
800- s.respond(resp)
801-}
802-
803-func appendOSCColorComponent(dst []byte, c uint8) []byte {
804- const hex = "0123456789abcdef"
805- v := uint16(c)<<8 | uint16(c)
806- return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
807-}
808-
809-// handleOSC inspects a complete OSC payload (between "ESC ]" and its
810-// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
811-// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
812-// is discarded.
813-func (s *Screen) handleOSC(payload []byte) {
814- if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
815- s.handleOSC52(string(payload[len("52;"):]))
816- return
817- }
818- p := string(payload)
819- if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
820- s.terminalTitle = title
821- return
822- }
823- if p == "9;4" || strings.HasPrefix(p, "9;4;") {
824- s.handleProgress(p[len("9;4"):])
825- return
826- }
827- if s.OnNotify == nil {
828- return
829- }
830- switch {
831- case strings.HasPrefix(p, "9;"):
832- s.OnNotify("", p[len("9;"):])
833- case strings.HasPrefix(p, "777;"):
834- parts := strings.SplitN(p[len("777;"):], ";", 3)
835- if parts[0] != "notify" {
836- return
837- }
838- var title, body string
839- if len(parts) > 1 {
840- title = parts[1]
841- }
842- if len(parts) > 2 {
843- body = parts[2]
844- }
845- s.OnNotify(title, body)
846- }
847-}
848-
849-func (s *Screen) handleProgress(rest string) {
850- rest = strings.TrimPrefix(rest, ";")
851- if rest == "" {
852- return
853- }
854- token, _, _ := strings.Cut(rest, ";")
855- state, err := strconv.Atoi(token)
856- if err != nil {
857- return
858- }
859- switch state {
860- case progressStateNormal, progressStateIndeterminate, progressStatePaused:
861- s.progressState = state
862- case progressStateClear:
863- previous := s.progressState
864- s.progressState = state
865- if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
866- if s.OnProgress != nil {
867- s.OnProgress(false)
868- }
869- }
870- case progressStateError:
871- previous := s.progressState
872- s.progressState = state
873- if previous != progressStateError {
874- if s.OnProgress != nil {
875- s.OnProgress(true)
876- }
877- }
878- }
879-}
880-
881-// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
882-// clipboard payload (selection may be empty; split on the first ";"). A
883-// clipboard query (data == "?") is always ignored — vev never answers
884-// clipboard queries. A payload with no second ";" is malformed and ignored.
885-func (s *Screen) handleOSC52(rest string) {
886- _, data, ok := strings.Cut(rest, ";")
887- if !ok {
888- return
889- }
890- if data == "?" {
891- return
892- }
893- if s.OnClipboard != nil {
894- s.OnClipboard(data)
895- }
896-}
897-
898 func consumeSTString(data []byte) (consumed int, partial bool) {501 func consumeSTString(data []byte) (consumed int, partial bool) {
899 for i := 2; i < len(data); i++ {502 for i := 2; i < len(data); i++ {
900 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {503 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -904,379 +507,6 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
904 return 0, true507 return 0, true
905 }508 }
906 509
907-func (s *Screen) applyCSI(params string, cmd byte) {
908- private := strings.HasPrefix(params, "?")
909- parts := s.parseCSIInts(params)
910- switch cmd {
911- case 'c':
912- switch params {
913- case "", "0":
914- s.respond([]byte("\x1b[?6c"))
915- case ">", ">0":
916- s.respond([]byte("\x1b[>0;0;0c"))
917- }
918- case 'n':
919- switch firstPositive(parts, 0) {
920- case 5:
921- if !private {
922- s.respond([]byte("\x1b[0n"))
923- }
924- case 996:
925- if private {
926- if report := s.colorSchemeReport(); report != nil {
927- s.respond(report)
928- }
929- }
930- case 6:
931- resp := make([]byte, 0, 16)
932- resp = append(resp, "\x1b["...)
933- if private {
934- resp = append(resp, '?')
935- }
936- resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
937- resp = append(resp, ';')
938- resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
939- resp = append(resp, 'R')
940- s.respond(resp)
941- }
942- case 'p':
943- if modeText, ok := strings.CutSuffix(params, "$"); ok {
944- if private {
945- modeText = strings.TrimPrefix(modeText, "?")
946- }
947- mode, err := strconv.Atoi(modeText)
948- if err != nil {
949- return
950- }
951- state := s.modeReportState(private, mode)
952- resp := make([]byte, 0, 16)
953- resp = append(resp, "\x1b["...)
954- if private {
955- resp = append(resp, '?')
956- }
957- resp = strconv.AppendInt(resp, int64(mode), 10)
958- resp = append(resp, ';')
959- resp = strconv.AppendInt(resp, int64(state), 10)
960- resp = append(resp, "$y"...)
961- s.respond(resp)
962- }
963- case 'm':
964- if strings.HasPrefix(params, ">") {
965- return
966- }
967- s.applySGR(params)
968- case 'q':
969- s.applyCursorStyle(params)
970- case 'J':
971- mode := 0
972- if len(parts) > 0 {
973- mode = parts[0]
974- }
975- s.clearScreenMode(mode)
976- case 'K':
977- mode := 0
978- if len(parts) > 0 {
979- mode = parts[0]
980- }
981- s.clearLineMode(mode)
982- case 'S':
983- s.scrollUpBy(firstPositive(parts, 1))
984- case 'T':
985- s.scrollDownBy(firstPositive(parts, 1))
986- case 'H', 'f':
987- row, col := 1, 1
988- if len(parts) > 0 && parts[0] > 0 {
989- row = parts[0]
990- }
991- if len(parts) > 1 && parts[1] > 0 {
992- col = parts[1]
993- }
994- s.addressCursor(row, col)
995- case 'A':
996- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
997- case 'B':
998- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
999- case 'C':
1000- s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
1001- case 'D':
1002- s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
1003- case 'E':
1004- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
1005- s.Col = 0
1006- case 'F':
1007- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
1008- s.Col = 0
1009- case 'G':
1010- col := firstPositive(parts, 1)
1011- s.Col = clamp(col-1, 0, s.Frame.Width-1)
1012- case 'd':
1013- row := firstPositive(parts, 1)
1014- s.Row = s.addressedRow(row)
1015- case '@':
1016- s.insertChars(firstPositive(parts, 1))
1017- case 'P':
1018- s.deleteChars(firstPositive(parts, 1))
1019- case 'L':
1020- s.insertLines(firstPositive(parts, 1))
1021- case 'M':
1022- s.deleteLines(firstPositive(parts, 1))
1023- case 'r':
1024- s.setScrollRegion(parts)
1025- case 's':
1026- s.saveCursor()
1027- case 'u':
1028- if params == "" {
1029- s.restoreCursor()
1030- }
1031- case 'h':
1032- s.setMode(private, parts, true)
1033- case 'l':
1034- s.setMode(private, parts, false)
1035- }
1036-}
1037-
1038-func firstPositive(parts []int, fallback int) int {
1039- if len(parts) == 0 || parts[0] <= 0 {
1040- return fallback
1041- }
1042- return parts[0]
1043-}
1044-
1045-func (s *Screen) cursorReportRow() int {
1046- if s.originMode {
1047- return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
1048- }
1049- return s.Row + 1
1050-}
1051-
1052-func (s *Screen) modeReportState(private bool, mode int) int {
1053- if private {
1054- switch mode {
1055- case 6:
1056- return boolModeReportState(s.originMode)
1057- case 2026:
1058- return boolModeReportState(s.syncUpdateActive)
1059- case 2031:
1060- return boolModeReportState(s.colorSchemeMode)
1061- }
1062- return 0
1063- }
1064- if mode == 4 {
1065- return boolModeReportState(s.insertMode)
1066- }
1067- return 0
1068-}
1069-
1070-func boolModeReportState(enabled bool) int {
1071- if enabled {
1072- return 1
1073- }
1074- return 2
1075-}
1076-
1077-func (s *Screen) cursorMinRow() int {
1078- if s.originMode {
1079- return clamp(s.scrollTop, 0, s.Frame.Height-1)
1080- }
1081- return 0
1082-}
1083-
1084-func (s *Screen) cursorMaxRow() int {
1085- if s.originMode {
1086- return clamp(s.scrollBottom, 0, s.Frame.Height-1)
1087- }
1088- return max(s.Frame.Height-1, 0)
1089-}
1090-
1091-func (s *Screen) addressedRow(row int) int {
1092- if s.originMode {
1093- return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
1094- }
1095- return clamp(row-1, 0, s.Frame.Height-1)
1096-}
1097-
1098-func (s *Screen) addressCursor(row, col int) {
1099- s.Row = s.addressedRow(row)
1100- s.Col = clamp(col-1, 0, s.Frame.Width-1)
1101-}
1102-
1103-func (s *Screen) homeCursor() {
1104- s.addressCursor(1, 1)
1105-}
1106-
1107-func (s *Screen) applyCursorStyle(params string) {
1108- if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
1109- return
1110- }
1111- styleParam := strings.TrimSuffix(params, " ")
1112- style := 0
1113- if styleParam != "" {
1114- v, err := strconv.Atoi(styleParam)
1115- if err != nil {
1116- return
1117- }
1118- style = v
1119- }
1120- if style < 0 || style > 6 {
1121- return
1122- }
1123- s.cursorStyle = style
1124- s.cursorStyleSet = true
1125-}
1126-
1127-const sgrUnderlineStyleMarker = -1000
1128-
1129-func (s *Screen) applySGR(params string) {
1130- if params == "" {
1131- s.Style = renderer.DefaultStyle()
1132- return
1133- }
1134- parts := s.parseSGRInts(params)
1135- for i := 0; i < len(parts); i++ {
1136- switch parts[i] {
1137- case 0:
1138- s.Style = renderer.DefaultStyle()
1139- case 1:
1140- s.Style.Bold = true
1141- case 2:
1142- s.Style.Attrs |= renderer.AttrDim
1143- case 3:
1144- s.Style.Italic = true
1145- case 4:
1146- s.Style.Attrs |= renderer.AttrUnderline
1147- s.Style.UnderlineStyle = renderer.UnderlineSingle
1148- if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
1149- underlineStyle := sgrUnderlineStyle(parts[i+2])
1150- if underlineStyle == renderer.UnderlineNone {
1151- s.Style.Attrs &^= renderer.AttrUnderline
1152- } else {
1153- s.Style.Attrs |= renderer.AttrUnderline
1154- }
1155- s.Style.UnderlineStyle = underlineStyle
1156- i += 2
1157- }
1158- case 5, 6:
1159- s.Style.Attrs |= renderer.AttrBlink
1160- case 7:
1161- s.Style.Inverse = true
1162- case 9:
1163- s.Style.Attrs |= renderer.AttrStrikethrough
1164- case 21:
1165- s.Style.Attrs |= renderer.AttrUnderline
1166- s.Style.UnderlineStyle = renderer.UnderlineDouble
1167- case 22:
1168- s.Style.Bold = false
1169- s.Style.Attrs &^= renderer.AttrDim
1170- case 23:
1171- s.Style.Italic = false
1172- case 24:
1173- s.Style.Attrs &^= renderer.AttrUnderline
1174- s.Style.UnderlineStyle = renderer.UnderlineNone
1175- case 25:
1176- s.Style.Attrs &^= renderer.AttrBlink
1177- case 27:
1178- s.Style.Inverse = false
1179- case 29:
1180- s.Style.Attrs &^= renderer.AttrStrikethrough
1181- case 30, 31, 32, 33, 34, 35, 36, 37:
1182- s.Style.Foreground = parts[i] - 30
1183- s.Style.HasForegroundRGB = false
1184- case 39:
1185- s.Style.Foreground = -1
1186- s.Style.HasForegroundRGB = false
1187- case 40, 41, 42, 43, 44, 45, 46, 47:
1188- s.Style.Background = parts[i] - 40
1189- s.Style.HasBackgroundRGB = false
1190- case 49:
1191- s.Style.Background = -1
1192- s.Style.HasBackgroundRGB = false
1193- case 90, 91, 92, 93, 94, 95, 96, 97:
1194- s.Style.Foreground = parts[i] - 90 + 8
1195- s.Style.HasForegroundRGB = false
1196- case 100, 101, 102, 103, 104, 105, 106, 107:
1197- s.Style.Background = parts[i] - 100 + 8
1198- s.Style.HasBackgroundRGB = false
1199- case 38:
1200- if i+2 < len(parts) && parts[i+1] == 5 {
1201- s.Style.Foreground = parts[i+2]
1202- s.Style.HasForegroundRGB = false
1203- i += 2
1204- } else if i+4 < len(parts) && parts[i+1] == 2 {
1205- s.Style.Foreground = -1
1206- s.Style.HasForegroundRGB = true
1207- s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1208- i += 4
1209- }
1210- case 48:
1211- if i+2 < len(parts) && parts[i+1] == 5 {
1212- s.Style.Background = parts[i+2]
1213- s.Style.HasBackgroundRGB = false
1214- i += 2
1215- } else if i+4 < len(parts) && parts[i+1] == 2 {
1216- s.Style.Background = -1
1217- s.Style.HasBackgroundRGB = true
1218- s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1219- i += 4
1220- }
1221- case 58:
1222- if i+2 < len(parts) && parts[i+1] == 5 {
1223- s.Style.UnderlineColor = parts[i+2]
1224- s.Style.HasUnderlineColor = true
1225- s.Style.HasUnderlineColorRGB = false
1226- i += 2
1227- } else if i+4 < len(parts) && parts[i+1] == 2 {
1228- s.Style.HasUnderlineColor = false
1229- s.Style.HasUnderlineColorRGB = true
1230- s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1231- i += 4
1232- }
1233- case 59:
1234- s.Style.HasUnderlineColor = false
1235- s.Style.HasUnderlineColorRGB = false
1236- }
1237- }
1238-}
1239-
1240-func sgrRGB(r, g, b int) renderer.RGB {
1241- return renderer.RGB{
1242- R: uint8(clamp(r, 0, 255)),
1243- G: uint8(clamp(g, 0, 255)),
1244- B: uint8(clamp(b, 0, 255)),
1245- }
1246-}
1247-
1248-func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
1249- switch param {
1250- case 0:
1251- return renderer.UnderlineNone
1252- case 2:
1253- return renderer.UnderlineDouble
1254- case 3:
1255- return renderer.UnderlineCurly
1256- case 4:
1257- return renderer.UnderlineDotted
1258- case 5:
1259- return renderer.UnderlineDashed
1260- default:
1261- return renderer.UnderlineSingle
1262- }
1263-}
1264-
1265-func (s *Screen) saveCursor() {
1266- s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
1267-}
1268-
1269-func (s *Screen) restoreCursor() {
1270- if !s.savedCursor.saved {
1271- return
1272- }
1273- s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
1274- s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
1275- s.Style = s.savedCursor.style
1276- s.originMode = s.savedCursor.originMode
1277- s.insertMode = s.savedCursor.insertMode
1278-}
1279-
1280 func (s *Screen) reset() {510 func (s *Screen) reset() {
1281 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)511 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
1282 s.Row, s.Col = 0, 0512 s.Row, s.Col = 0, 0
@@ -1457,257 +687,6 @@ func cloneFrame(frame renderer.Frame) renderer.Frame {
1457 return out687 return out
1458 }688 }
1459 689
1460-func (s *Screen) insertChars(n int) {
1461- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
1462- return
1463- }
1464- w := s.Frame.Width
1465- if n > w-s.Col {
1466- n = w - s.Col
1467- }
1468- row := s.Frame.Row(s.Row)
1469- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
1470- // by the shift; its repair falls outside the default damage rect.
1471- leftSplit := s.Col > 0 && row[s.Col].Continuation
1472- for x := w - 1; x >= s.Col+n; x-- {
1473- row[x] = row[x-n]
1474- }
1475- for x := s.Col; x < s.Col+n; x++ {
1476- row[x] = renderer.BlankCell()
1477- }
1478- s.repairRow(s.Row)
1479- dmgX := s.Col
1480- if leftSplit {
1481- dmgX = s.Col - 1
1482- }
1483- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
1484-}
1485-
1486-func (s *Screen) deleteChars(n int) {
1487- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
1488- return
1489- }
1490- w := s.Frame.Width
1491- if n > w-s.Col {
1492- n = w - s.Col
1493- }
1494- row := s.Frame.Row(s.Row)
1495- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
1496- // by the shift; its repair falls outside the default damage rect.
1497- leftSplit := s.Col > 0 && row[s.Col].Continuation
1498- for x := s.Col; x < w-n; x++ {
1499- row[x] = row[x+n]
1500- }
1501- for x := w - n; x < w; x++ {
1502- row[x] = renderer.BlankCell()
1503- }
1504- s.repairRow(s.Row)
1505- dmgX := s.Col
1506- if leftSplit {
1507- dmgX = s.Col - 1
1508- }
1509- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
1510-}
1511-
1512-func (s *Screen) insertLines(n int) {
1513- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
1514- return
1515- }
1516- s.scrollDownRegion(s.Row, s.scrollBottom, n)
1517-}
1518-
1519-func (s *Screen) deleteLines(n int) {
1520- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
1521- return
1522- }
1523- s.scrollUpRegion(s.Row, s.scrollBottom, n)
1524-}
1525-
1526-func (s *Screen) clearScreenMode(mode int) {
1527- s.clampCursor()
1528- switch mode {
1529- case 1:
1530- for y := range min(s.Row+1, s.Frame.Height) {
1531- end := s.Frame.Width
1532- if y == s.Row {
1533- end = min(s.Col+1, s.Frame.Width)
1534- }
1535- s.clearRow(y, 0, end)
1536- }
1537- 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})
1538- case 2, 3:
1539- blank := s.eraseCell()
1540- for i := range s.Frame.Cells {
1541- s.Frame.Cells[i] = blank
1542- }
1543- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
1544- default:
1545- for y := s.Row; y < s.Frame.Height; y++ {
1546- start := 0
1547- if y == s.Row {
1548- start = s.Col
1549- }
1550- s.clearRow(y, start, s.Frame.Width)
1551- }
1552- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
1553- }
1554-}
1555-
1556-func (s *Screen) clearLineMode(mode int) {
1557- s.clampCursor()
1558- switch mode {
1559- case 1:
1560- start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
1561- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1562- case 2:
1563- start, width := s.clearRow(s.Row, 0, s.Frame.Width)
1564- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1565- default:
1566- start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
1567- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1568- }
1569-}
1570-
1571-func (s *Screen) clampCursor() {
1572- s.Row = clamp(s.Row, 0, s.Frame.Height-1)
1573- s.Col = clamp(s.Col, 0, s.Frame.Width-1)
1574-}
1575-
1576-func (s *Screen) parseCSIInts(params string) []int {
1577- s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
1578- return s.csiScratch
1579-}
1580-
1581-func parseCSIInts(params string) []int {
1582- return parseCSIIntsInto(nil, params)
1583-}
1584-
1585-func parseCSIIntsInto(out []int, params string) []int {
1586- if params == "" {
1587- return out
1588- }
1589- params = strings.TrimPrefix(params, "?")
1590- params = strings.TrimPrefix(params, ">")
1591- start := 0
1592- for i := 0; i <= len(params); i++ {
1593- if i == len(params) || params[i] == ';' {
1594- out = append(out, parseCSIInt(params[start:i]))
1595- start = i + 1
1596- }
1597- }
1598- return out
1599-}
1600-
1601-func (s *Screen) parseSGRInts(params string) []int {
1602- s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
1603- return s.sgrScratch
1604-}
1605-
1606-func parseSGRInts(params string) []int {
1607- return parseSGRIntsInto(nil, params)
1608-}
1609-
1610-func parseSGRIntsInto(out []int, params string) []int {
1611- if params == "" {
1612- return out
1613- }
1614- start := 0
1615- for i := 0; i <= len(params); i++ {
1616- if i == len(params) || params[i] == ';' {
1617- out = appendSGRGroup(out, params[start:i])
1618- start = i + 1
1619- }
1620- }
1621- return out
1622-}
1623-
1624-func appendSGRGroup(out []int, group string) []int {
1625- colon := false
1626- for i := 0; i < len(group); i++ {
1627- if group[i] == ':' {
1628- colon = true
1629- break
1630- }
1631- }
1632- if !colon {
1633- return append(out, parseCSIInt(group))
1634- }
1635-
1636- parts := countSGRColonFields(group)
1637- code := parseCSIInt(sgrColonField(group, 0))
1638- if code == 4 {
1639- if parts < 2 {
1640- return append(out, code)
1641- }
1642- return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
1643- }
1644- if code != 38 && code != 48 && code != 58 {
1645- return append(out, code)
1646- }
1647- mode := 0
1648- if parts > 1 {
1649- mode = parseCSIInt(sgrColonField(group, 1))
1650- }
1651- switch mode {
1652- case 5:
1653- if parts < 3 {
1654- return out
1655- }
1656- out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
1657- case 2:
1658- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1659- // the colorspace slot; code:mode:R:G:B omits that slot.
1660- start := 2
1661- if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
1662- start = 3
1663- }
1664- if parts < start+3 {
1665- return out
1666- }
1667- out = append(out, code, mode)
1668- for i := range 3 {
1669- out = append(out, parseCSIInt(sgrColonField(group, start+i)))
1670- }
1671- }
1672- return out
1673-}
1674-
1675-func countSGRColonFields(group string) int {
1676- count := 1
1677- for i := 0; i < len(group); i++ {
1678- if group[i] == ':' {
1679- count++
1680- }
1681- }
1682- return count
1683-}
1684-
1685-func sgrColonField(group string, index int) string {
1686- start := 0
1687- field := 0
1688- for i := 0; i <= len(group); i++ {
1689- if i == len(group) || group[i] == ':' {
1690- if field == index {
1691- return group[start:i]
1692- }
1693- field++
1694- start = i + 1
1695- }
1696- }
1697- return ""
1698-}
1699-
1700-func parseCSIInt(param string) int {
1701- if param == "" {
1702- return 0
1703- }
1704- v, err := strconv.Atoi(param)
1705- if err != nil {
1706- return 0
1707- }
1708- return v
1709-}
1710-
1711 func clamp(v, lo, hi int) int {690 func clamp(v, lo, hi int) int {
1712 if hi < lo {691 if hi < lo {
1713 return lo692 return lo
A pkg/vt/screen_csi.go +577 -0
@@ -0,0 +1,577 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func (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+
27+func (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+ s.respond([]byte("\x1b[?6c"))
35+ case ">", ">0":
36+ s.respond([]byte("\x1b[>0;0;0c"))
37+ }
38+ case 'n':
39+ switch firstPositive(parts, 0) {
40+ case 5:
41+ if !private {
42+ s.respond([]byte("\x1b[0n"))
43+ }
44+ case 996:
45+ if private {
46+ if report := s.colorSchemeReport(); report != nil {
47+ s.respond(report)
48+ }
49+ }
50+ case 6:
51+ resp := make([]byte, 0, 16)
52+ resp = append(resp, "\x1b["...)
53+ if private {
54+ resp = append(resp, '?')
55+ }
56+ resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
57+ resp = append(resp, ';')
58+ resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
59+ resp = append(resp, 'R')
60+ s.respond(resp)
61+ }
62+ case 'p':
63+ if modeText, ok := strings.CutSuffix(params, "$"); ok {
64+ if private {
65+ modeText = strings.TrimPrefix(modeText, "?")
66+ }
67+ mode, err := strconv.Atoi(modeText)
68+ if err != nil {
69+ return
70+ }
71+ state := s.modeReportState(private, mode)
72+ resp := make([]byte, 0, 16)
73+ resp = append(resp, "\x1b["...)
74+ if private {
75+ resp = append(resp, '?')
76+ }
77+ resp = strconv.AppendInt(resp, int64(mode), 10)
78+ resp = append(resp, ';')
79+ resp = strconv.AppendInt(resp, int64(state), 10)
80+ resp = append(resp, "$y"...)
81+ s.respond(resp)
82+ }
83+ case 'm':
84+ if strings.HasPrefix(params, ">") {
85+ return
86+ }
87+ s.applySGR(params)
88+ case 'q':
89+ s.applyCursorStyle(params)
90+ case 'J':
91+ mode := 0
92+ if len(parts) > 0 {
93+ mode = parts[0]
94+ }
95+ s.clearScreenMode(mode)
96+ case 'K':
97+ mode := 0
98+ if len(parts) > 0 {
99+ mode = parts[0]
100+ }
101+ s.clearLineMode(mode)
102+ case 'S':
103+ s.scrollUpBy(firstPositive(parts, 1))
104+ case 'T':
105+ s.scrollDownBy(firstPositive(parts, 1))
106+ case 'H', 'f':
107+ row, col := 1, 1
108+ if len(parts) > 0 && parts[0] > 0 {
109+ row = parts[0]
110+ }
111+ if len(parts) > 1 && parts[1] > 0 {
112+ col = parts[1]
113+ }
114+ s.addressCursor(row, col)
115+ case 'A':
116+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
117+ case 'B':
118+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
119+ case 'C':
120+ s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
121+ case 'D':
122+ s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
123+ case 'E':
124+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
125+ s.Col = 0
126+ case 'F':
127+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
128+ s.Col = 0
129+ case 'G':
130+ col := firstPositive(parts, 1)
131+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
132+ case 'd':
133+ row := firstPositive(parts, 1)
134+ s.Row = s.addressedRow(row)
135+ case '@':
136+ s.insertChars(firstPositive(parts, 1))
137+ case 'P':
138+ s.deleteChars(firstPositive(parts, 1))
139+ case 'L':
140+ s.insertLines(firstPositive(parts, 1))
141+ case 'M':
142+ s.deleteLines(firstPositive(parts, 1))
143+ case 'r':
144+ s.setScrollRegion(parts)
145+ case 's':
146+ s.saveCursor()
147+ case 'u':
148+ if params == "" {
149+ s.restoreCursor()
150+ }
151+ case 'h':
152+ s.setMode(private, parts, true)
153+ case 'l':
154+ s.setMode(private, parts, false)
155+ }
156+}
157+
158+func firstPositive(parts []int, fallback int) int {
159+ if len(parts) == 0 || parts[0] <= 0 {
160+ return fallback
161+ }
162+ return parts[0]
163+}
164+
165+func (s *Screen) modeReportState(private bool, mode int) int {
166+ if private {
167+ switch mode {
168+ case 6:
169+ return boolModeReportState(s.originMode)
170+ case 2026:
171+ return boolModeReportState(s.syncUpdateActive)
172+ case 2031:
173+ return boolModeReportState(s.colorSchemeMode)
174+ }
175+ return 0
176+ }
177+ if mode == 4 {
178+ return boolModeReportState(s.insertMode)
179+ }
180+ return 0
181+}
182+
183+func boolModeReportState(enabled bool) int {
184+ if enabled {
185+ return 1
186+ }
187+ return 2
188+}
189+
190+const sgrUnderlineStyleMarker = -1000
191+
192+func (s *Screen) applySGR(params string) {
193+ if params == "" {
194+ s.Style = renderer.DefaultStyle()
195+ return
196+ }
197+ parts := s.parseSGRInts(params)
198+ for i := 0; i < len(parts); i++ {
199+ switch parts[i] {
200+ case 0:
201+ s.Style = renderer.DefaultStyle()
202+ case 1:
203+ s.Style.Bold = true
204+ case 2:
205+ s.Style.Attrs |= renderer.AttrDim
206+ case 3:
207+ s.Style.Italic = true
208+ case 4:
209+ s.Style.Attrs |= renderer.AttrUnderline
210+ s.Style.UnderlineStyle = renderer.UnderlineSingle
211+ if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
212+ underlineStyle := sgrUnderlineStyle(parts[i+2])
213+ if underlineStyle == renderer.UnderlineNone {
214+ s.Style.Attrs &^= renderer.AttrUnderline
215+ } else {
216+ s.Style.Attrs |= renderer.AttrUnderline
217+ }
218+ s.Style.UnderlineStyle = underlineStyle
219+ i += 2
220+ }
221+ case 5, 6:
222+ s.Style.Attrs |= renderer.AttrBlink
223+ case 7:
224+ s.Style.Inverse = true
225+ case 9:
226+ s.Style.Attrs |= renderer.AttrStrikethrough
227+ case 21:
228+ s.Style.Attrs |= renderer.AttrUnderline
229+ s.Style.UnderlineStyle = renderer.UnderlineDouble
230+ case 22:
231+ s.Style.Bold = false
232+ s.Style.Attrs &^= renderer.AttrDim
233+ case 23:
234+ s.Style.Italic = false
235+ case 24:
236+ s.Style.Attrs &^= renderer.AttrUnderline
237+ s.Style.UnderlineStyle = renderer.UnderlineNone
238+ case 25:
239+ s.Style.Attrs &^= renderer.AttrBlink
240+ case 27:
241+ s.Style.Inverse = false
242+ case 29:
243+ s.Style.Attrs &^= renderer.AttrStrikethrough
244+ case 30, 31, 32, 33, 34, 35, 36, 37:
245+ s.Style.Foreground = parts[i] - 30
246+ s.Style.HasForegroundRGB = false
247+ case 39:
248+ s.Style.Foreground = -1
249+ s.Style.HasForegroundRGB = false
250+ case 40, 41, 42, 43, 44, 45, 46, 47:
251+ s.Style.Background = parts[i] - 40
252+ s.Style.HasBackgroundRGB = false
253+ case 49:
254+ s.Style.Background = -1
255+ s.Style.HasBackgroundRGB = false
256+ case 90, 91, 92, 93, 94, 95, 96, 97:
257+ s.Style.Foreground = parts[i] - 90 + 8
258+ s.Style.HasForegroundRGB = false
259+ case 100, 101, 102, 103, 104, 105, 106, 107:
260+ s.Style.Background = parts[i] - 100 + 8
261+ s.Style.HasBackgroundRGB = false
262+ case 38:
263+ if i+2 < len(parts) && parts[i+1] == 5 {
264+ s.Style.Foreground = parts[i+2]
265+ s.Style.HasForegroundRGB = false
266+ i += 2
267+ } else if i+4 < len(parts) && parts[i+1] == 2 {
268+ s.Style.Foreground = -1
269+ s.Style.HasForegroundRGB = true
270+ s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
271+ i += 4
272+ }
273+ case 48:
274+ if i+2 < len(parts) && parts[i+1] == 5 {
275+ s.Style.Background = parts[i+2]
276+ s.Style.HasBackgroundRGB = false
277+ i += 2
278+ } else if i+4 < len(parts) && parts[i+1] == 2 {
279+ s.Style.Background = -1
280+ s.Style.HasBackgroundRGB = true
281+ s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
282+ i += 4
283+ }
284+ case 58:
285+ if i+2 < len(parts) && parts[i+1] == 5 {
286+ s.Style.UnderlineColor = parts[i+2]
287+ s.Style.HasUnderlineColor = true
288+ s.Style.HasUnderlineColorRGB = false
289+ i += 2
290+ } else if i+4 < len(parts) && parts[i+1] == 2 {
291+ s.Style.HasUnderlineColor = false
292+ s.Style.HasUnderlineColorRGB = true
293+ s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
294+ i += 4
295+ }
296+ case 59:
297+ s.Style.HasUnderlineColor = false
298+ s.Style.HasUnderlineColorRGB = false
299+ }
300+ }
301+}
302+
303+func sgrRGB(r, g, b int) renderer.RGB {
304+ return renderer.RGB{
305+ R: uint8(clamp(r, 0, 255)),
306+ G: uint8(clamp(g, 0, 255)),
307+ B: uint8(clamp(b, 0, 255)),
308+ }
309+}
310+
311+func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
312+ switch param {
313+ case 0:
314+ return renderer.UnderlineNone
315+ case 2:
316+ return renderer.UnderlineDouble
317+ case 3:
318+ return renderer.UnderlineCurly
319+ case 4:
320+ return renderer.UnderlineDotted
321+ case 5:
322+ return renderer.UnderlineDashed
323+ default:
324+ return renderer.UnderlineSingle
325+ }
326+}
327+
328+func (s *Screen) insertChars(n int) {
329+ if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
330+ return
331+ }
332+ w := s.Frame.Width
333+ if n > w-s.Col {
334+ n = w - s.Col
335+ }
336+ row := s.Frame.Row(s.Row)
337+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
338+ // by the shift; its repair falls outside the default damage rect.
339+ leftSplit := s.Col > 0 && row[s.Col].Continuation
340+ for x := w - 1; x >= s.Col+n; x-- {
341+ row[x] = row[x-n]
342+ }
343+ for x := s.Col; x < s.Col+n; x++ {
344+ row[x] = renderer.BlankCell()
345+ }
346+ s.repairRow(s.Row)
347+ dmgX := s.Col
348+ if leftSplit {
349+ dmgX = s.Col - 1
350+ }
351+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
352+}
353+
354+func (s *Screen) deleteChars(n int) {
355+ if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
356+ return
357+ }
358+ w := s.Frame.Width
359+ if n > w-s.Col {
360+ n = w - s.Col
361+ }
362+ row := s.Frame.Row(s.Row)
363+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
364+ // by the shift; its repair falls outside the default damage rect.
365+ leftSplit := s.Col > 0 && row[s.Col].Continuation
366+ for x := s.Col; x < w-n; x++ {
367+ row[x] = row[x+n]
368+ }
369+ for x := w - n; x < w; x++ {
370+ row[x] = renderer.BlankCell()
371+ }
372+ s.repairRow(s.Row)
373+ dmgX := s.Col
374+ if leftSplit {
375+ dmgX = s.Col - 1
376+ }
377+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
378+}
379+
380+func (s *Screen) insertLines(n int) {
381+ if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
382+ return
383+ }
384+ s.scrollDownRegion(s.Row, s.scrollBottom, n)
385+}
386+
387+func (s *Screen) deleteLines(n int) {
388+ if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
389+ return
390+ }
391+ s.scrollUpRegion(s.Row, s.scrollBottom, n)
392+}
393+
394+func (s *Screen) clearScreenMode(mode int) {
395+ s.clampCursor()
396+ switch mode {
397+ case 1:
398+ for y := range min(s.Row+1, s.Frame.Height) {
399+ end := s.Frame.Width
400+ if y == s.Row {
401+ end = min(s.Col+1, s.Frame.Width)
402+ }
403+ s.clearRow(y, 0, end)
404+ }
405+ 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})
406+ case 2, 3:
407+ blank := s.eraseCell()
408+ for i := range s.Frame.Cells {
409+ s.Frame.Cells[i] = blank
410+ }
411+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
412+ default:
413+ for y := s.Row; y < s.Frame.Height; y++ {
414+ start := 0
415+ if y == s.Row {
416+ start = s.Col
417+ }
418+ s.clearRow(y, start, s.Frame.Width)
419+ }
420+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
421+ }
422+}
423+
424+func (s *Screen) clearLineMode(mode int) {
425+ s.clampCursor()
426+ switch mode {
427+ case 1:
428+ start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
429+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
430+ case 2:
431+ start, width := s.clearRow(s.Row, 0, s.Frame.Width)
432+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
433+ default:
434+ start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
435+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
436+ }
437+}
438+
439+func (s *Screen) clampCursor() {
440+ s.Row = clamp(s.Row, 0, s.Frame.Height-1)
441+ s.Col = clamp(s.Col, 0, s.Frame.Width-1)
442+}
443+
444+func (s *Screen) parseCSIInts(params string) []int {
445+ s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
446+ return s.csiScratch
447+}
448+
449+func parseCSIInts(params string) []int {
450+ return parseCSIIntsInto(nil, params)
451+}
452+
453+func parseCSIIntsInto(out []int, params string) []int {
454+ if params == "" {
455+ return out
456+ }
457+ params = strings.TrimPrefix(params, "?")
458+ params = strings.TrimPrefix(params, ">")
459+ start := 0
460+ for i := 0; i <= len(params); i++ {
461+ if i == len(params) || params[i] == ';' {
462+ out = append(out, parseCSIInt(params[start:i]))
463+ start = i + 1
464+ }
465+ }
466+ return out
467+}
468+
469+func (s *Screen) parseSGRInts(params string) []int {
470+ s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
471+ return s.sgrScratch
472+}
473+
474+func parseSGRInts(params string) []int {
475+ return parseSGRIntsInto(nil, params)
476+}
477+
478+func parseSGRIntsInto(out []int, params string) []int {
479+ if params == "" {
480+ return out
481+ }
482+ start := 0
483+ for i := 0; i <= len(params); i++ {
484+ if i == len(params) || params[i] == ';' {
485+ out = appendSGRGroup(out, params[start:i])
486+ start = i + 1
487+ }
488+ }
489+ return out
490+}
491+
492+func appendSGRGroup(out []int, group string) []int {
493+ colon := false
494+ for i := 0; i < len(group); i++ {
495+ if group[i] == ':' {
496+ colon = true
497+ break
498+ }
499+ }
500+ if !colon {
501+ return append(out, parseCSIInt(group))
502+ }
503+
504+ parts := countSGRColonFields(group)
505+ code := parseCSIInt(sgrColonField(group, 0))
506+ if code == 4 {
507+ if parts < 2 {
508+ return append(out, code)
509+ }
510+ return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
511+ }
512+ if code != 38 && code != 48 && code != 58 {
513+ return append(out, code)
514+ }
515+ mode := 0
516+ if parts > 1 {
517+ mode = parseCSIInt(sgrColonField(group, 1))
518+ }
519+ switch mode {
520+ case 5:
521+ if parts < 3 {
522+ return out
523+ }
524+ out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
525+ case 2:
526+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
527+ // the colorspace slot; code:mode:R:G:B omits that slot.
528+ start := 2
529+ if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
530+ start = 3
531+ }
532+ if parts < start+3 {
533+ return out
534+ }
535+ out = append(out, code, mode)
536+ for i := range 3 {
537+ out = append(out, parseCSIInt(sgrColonField(group, start+i)))
538+ }
539+ }
540+ return out
541+}
542+
543+func countSGRColonFields(group string) int {
544+ count := 1
545+ for i := 0; i < len(group); i++ {
546+ if group[i] == ':' {
547+ count++
548+ }
549+ }
550+ return count
551+}
552+
553+func sgrColonField(group string, index int) string {
554+ start := 0
555+ field := 0
556+ for i := 0; i <= len(group); i++ {
557+ if i == len(group) || group[i] == ':' {
558+ if field == index {
559+ return group[start:i]
560+ }
561+ field++
562+ start = i + 1
563+ }
564+ }
565+ return ""
566+}
567+
568+func parseCSIInt(param string) int {
569+ if param == "" {
570+ return 0
571+ }
572+ v, err := strconv.Atoi(param)
573+ if err != nil {
574+ return 0
575+ }
576+ return v
577+}
@@ -0,0 +1,577 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func (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+
27+func (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+ s.respond([]byte("\x1b[?6c"))
35+ case ">", ">0":
36+ s.respond([]byte("\x1b[>0;0;0c"))
37+ }
38+ case 'n':
39+ switch firstPositive(parts, 0) {
40+ case 5:
41+ if !private {
42+ s.respond([]byte("\x1b[0n"))
43+ }
44+ case 996:
45+ if private {
46+ if report := s.colorSchemeReport(); report != nil {
47+ s.respond(report)
48+ }
49+ }
50+ case 6:
51+ resp := make([]byte, 0, 16)
52+ resp = append(resp, "\x1b["...)
53+ if private {
54+ resp = append(resp, '?')
55+ }
56+ resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
57+ resp = append(resp, ';')
58+ resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
59+ resp = append(resp, 'R')
60+ s.respond(resp)
61+ }
62+ case 'p':
63+ if modeText, ok := strings.CutSuffix(params, "$"); ok {
64+ if private {
65+ modeText = strings.TrimPrefix(modeText, "?")
66+ }
67+ mode, err := strconv.Atoi(modeText)
68+ if err != nil {
69+ return
70+ }
71+ state := s.modeReportState(private, mode)
72+ resp := make([]byte, 0, 16)
73+ resp = append(resp, "\x1b["...)
74+ if private {
75+ resp = append(resp, '?')
76+ }
77+ resp = strconv.AppendInt(resp, int64(mode), 10)
78+ resp = append(resp, ';')
79+ resp = strconv.AppendInt(resp, int64(state), 10)
80+ resp = append(resp, "$y"...)
81+ s.respond(resp)
82+ }
83+ case 'm':
84+ if strings.HasPrefix(params, ">") {
85+ return
86+ }
87+ s.applySGR(params)
88+ case 'q':
89+ s.applyCursorStyle(params)
90+ case 'J':
91+ mode := 0
92+ if len(parts) > 0 {
93+ mode = parts[0]
94+ }
95+ s.clearScreenMode(mode)
96+ case 'K':
97+ mode := 0
98+ if len(parts) > 0 {
99+ mode = parts[0]
100+ }
101+ s.clearLineMode(mode)
102+ case 'S':
103+ s.scrollUpBy(firstPositive(parts, 1))
104+ case 'T':
105+ s.scrollDownBy(firstPositive(parts, 1))
106+ case 'H', 'f':
107+ row, col := 1, 1
108+ if len(parts) > 0 && parts[0] > 0 {
109+ row = parts[0]
110+ }
111+ if len(parts) > 1 && parts[1] > 0 {
112+ col = parts[1]
113+ }
114+ s.addressCursor(row, col)
115+ case 'A':
116+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
117+ case 'B':
118+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
119+ case 'C':
120+ s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
121+ case 'D':
122+ s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
123+ case 'E':
124+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
125+ s.Col = 0
126+ case 'F':
127+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
128+ s.Col = 0
129+ case 'G':
130+ col := firstPositive(parts, 1)
131+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
132+ case 'd':
133+ row := firstPositive(parts, 1)
134+ s.Row = s.addressedRow(row)
135+ case '@':
136+ s.insertChars(firstPositive(parts, 1))
137+ case 'P':
138+ s.deleteChars(firstPositive(parts, 1))
139+ case 'L':
140+ s.insertLines(firstPositive(parts, 1))
141+ case 'M':
142+ s.deleteLines(firstPositive(parts, 1))
143+ case 'r':
144+ s.setScrollRegion(parts)
145+ case 's':
146+ s.saveCursor()
147+ case 'u':
148+ if params == "" {
149+ s.restoreCursor()
150+ }
151+ case 'h':
152+ s.setMode(private, parts, true)
153+ case 'l':
154+ s.setMode(private, parts, false)
155+ }
156+}
157+
158+func firstPositive(parts []int, fallback int) int {
159+ if len(parts) == 0 || parts[0] <= 0 {
160+ return fallback
161+ }
162+ return parts[0]
163+}
164+
165+func (s *Screen) modeReportState(private bool, mode int) int {
166+ if private {
167+ switch mode {
168+ case 6:
169+ return boolModeReportState(s.originMode)
170+ case 2026:
171+ return boolModeReportState(s.syncUpdateActive)
172+ case 2031:
173+ return boolModeReportState(s.colorSchemeMode)
174+ }
175+ return 0
176+ }
177+ if mode == 4 {
178+ return boolModeReportState(s.insertMode)
179+ }
180+ return 0
181+}
182+
183+func boolModeReportState(enabled bool) int {
184+ if enabled {
185+ return 1
186+ }
187+ return 2
188+}
189+
190+const sgrUnderlineStyleMarker = -1000
191+
192+func (s *Screen) applySGR(params string) {
193+ if params == "" {
194+ s.Style = renderer.DefaultStyle()
195+ return
196+ }
197+ parts := s.parseSGRInts(params)
198+ for i := 0; i < len(parts); i++ {
199+ switch parts[i] {
200+ case 0:
201+ s.Style = renderer.DefaultStyle()
202+ case 1:
203+ s.Style.Bold = true
204+ case 2:
205+ s.Style.Attrs |= renderer.AttrDim
206+ case 3:
207+ s.Style.Italic = true
208+ case 4:
209+ s.Style.Attrs |= renderer.AttrUnderline
210+ s.Style.UnderlineStyle = renderer.UnderlineSingle
211+ if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
212+ underlineStyle := sgrUnderlineStyle(parts[i+2])
213+ if underlineStyle == renderer.UnderlineNone {
214+ s.Style.Attrs &^= renderer.AttrUnderline
215+ } else {
216+ s.Style.Attrs |= renderer.AttrUnderline
217+ }
218+ s.Style.UnderlineStyle = underlineStyle
219+ i += 2
220+ }
221+ case 5, 6:
222+ s.Style.Attrs |= renderer.AttrBlink
223+ case 7:
224+ s.Style.Inverse = true
225+ case 9:
226+ s.Style.Attrs |= renderer.AttrStrikethrough
227+ case 21:
228+ s.Style.Attrs |= renderer.AttrUnderline
229+ s.Style.UnderlineStyle = renderer.UnderlineDouble
230+ case 22:
231+ s.Style.Bold = false
232+ s.Style.Attrs &^= renderer.AttrDim
233+ case 23:
234+ s.Style.Italic = false
235+ case 24:
236+ s.Style.Attrs &^= renderer.AttrUnderline
237+ s.Style.UnderlineStyle = renderer.UnderlineNone
238+ case 25:
239+ s.Style.Attrs &^= renderer.AttrBlink
240+ case 27:
241+ s.Style.Inverse = false
242+ case 29:
243+ s.Style.Attrs &^= renderer.AttrStrikethrough
244+ case 30, 31, 32, 33, 34, 35, 36, 37:
245+ s.Style.Foreground = parts[i] - 30
246+ s.Style.HasForegroundRGB = false
247+ case 39:
248+ s.Style.Foreground = -1
249+ s.Style.HasForegroundRGB = false
250+ case 40, 41, 42, 43, 44, 45, 46, 47:
251+ s.Style.Background = parts[i] - 40
252+ s.Style.HasBackgroundRGB = false
253+ case 49:
254+ s.Style.Background = -1
255+ s.Style.HasBackgroundRGB = false
256+ case 90, 91, 92, 93, 94, 95, 96, 97:
257+ s.Style.Foreground = parts[i] - 90 + 8
258+ s.Style.HasForegroundRGB = false
259+ case 100, 101, 102, 103, 104, 105, 106, 107:
260+ s.Style.Background = parts[i] - 100 + 8
261+ s.Style.HasBackgroundRGB = false
262+ case 38:
263+ if i+2 < len(parts) && parts[i+1] == 5 {
264+ s.Style.Foreground = parts[i+2]
265+ s.Style.HasForegroundRGB = false
266+ i += 2
267+ } else if i+4 < len(parts) && parts[i+1] == 2 {
268+ s.Style.Foreground = -1
269+ s.Style.HasForegroundRGB = true
270+ s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
271+ i += 4
272+ }
273+ case 48:
274+ if i+2 < len(parts) && parts[i+1] == 5 {
275+ s.Style.Background = parts[i+2]
276+ s.Style.HasBackgroundRGB = false
277+ i += 2
278+ } else if i+4 < len(parts) && parts[i+1] == 2 {
279+ s.Style.Background = -1
280+ s.Style.HasBackgroundRGB = true
281+ s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
282+ i += 4
283+ }
284+ case 58:
285+ if i+2 < len(parts) && parts[i+1] == 5 {
286+ s.Style.UnderlineColor = parts[i+2]
287+ s.Style.HasUnderlineColor = true
288+ s.Style.HasUnderlineColorRGB = false
289+ i += 2
290+ } else if i+4 < len(parts) && parts[i+1] == 2 {
291+ s.Style.HasUnderlineColor = false
292+ s.Style.HasUnderlineColorRGB = true
293+ s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
294+ i += 4
295+ }
296+ case 59:
297+ s.Style.HasUnderlineColor = false
298+ s.Style.HasUnderlineColorRGB = false
299+ }
300+ }
301+}
302+
303+func sgrRGB(r, g, b int) renderer.RGB {
304+ return renderer.RGB{
305+ R: uint8(clamp(r, 0, 255)),
306+ G: uint8(clamp(g, 0, 255)),
307+ B: uint8(clamp(b, 0, 255)),
308+ }
309+}
310+
311+func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
312+ switch param {
313+ case 0:
314+ return renderer.UnderlineNone
315+ case 2:
316+ return renderer.UnderlineDouble
317+ case 3:
318+ return renderer.UnderlineCurly
319+ case 4:
320+ return renderer.UnderlineDotted
321+ case 5:
322+ return renderer.UnderlineDashed
323+ default:
324+ return renderer.UnderlineSingle
325+ }
326+}
327+
328+func (s *Screen) insertChars(n int) {
329+ if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
330+ return
331+ }
332+ w := s.Frame.Width
333+ if n > w-s.Col {
334+ n = w - s.Col
335+ }
336+ row := s.Frame.Row(s.Row)
337+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
338+ // by the shift; its repair falls outside the default damage rect.
339+ leftSplit := s.Col > 0 && row[s.Col].Continuation
340+ for x := w - 1; x >= s.Col+n; x-- {
341+ row[x] = row[x-n]
342+ }
343+ for x := s.Col; x < s.Col+n; x++ {
344+ row[x] = renderer.BlankCell()
345+ }
346+ s.repairRow(s.Row)
347+ dmgX := s.Col
348+ if leftSplit {
349+ dmgX = s.Col - 1
350+ }
351+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
352+}
353+
354+func (s *Screen) deleteChars(n int) {
355+ if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
356+ return
357+ }
358+ w := s.Frame.Width
359+ if n > w-s.Col {
360+ n = w - s.Col
361+ }
362+ row := s.Frame.Row(s.Row)
363+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
364+ // by the shift; its repair falls outside the default damage rect.
365+ leftSplit := s.Col > 0 && row[s.Col].Continuation
366+ for x := s.Col; x < w-n; x++ {
367+ row[x] = row[x+n]
368+ }
369+ for x := w - n; x < w; x++ {
370+ row[x] = renderer.BlankCell()
371+ }
372+ s.repairRow(s.Row)
373+ dmgX := s.Col
374+ if leftSplit {
375+ dmgX = s.Col - 1
376+ }
377+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
378+}
379+
380+func (s *Screen) insertLines(n int) {
381+ if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
382+ return
383+ }
384+ s.scrollDownRegion(s.Row, s.scrollBottom, n)
385+}
386+
387+func (s *Screen) deleteLines(n int) {
388+ if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
389+ return
390+ }
391+ s.scrollUpRegion(s.Row, s.scrollBottom, n)
392+}
393+
394+func (s *Screen) clearScreenMode(mode int) {
395+ s.clampCursor()
396+ switch mode {
397+ case 1:
398+ for y := range min(s.Row+1, s.Frame.Height) {
399+ end := s.Frame.Width
400+ if y == s.Row {
401+ end = min(s.Col+1, s.Frame.Width)
402+ }
403+ s.clearRow(y, 0, end)
404+ }
405+ 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})
406+ case 2, 3:
407+ blank := s.eraseCell()
408+ for i := range s.Frame.Cells {
409+ s.Frame.Cells[i] = blank
410+ }
411+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
412+ default:
413+ for y := s.Row; y < s.Frame.Height; y++ {
414+ start := 0
415+ if y == s.Row {
416+ start = s.Col
417+ }
418+ s.clearRow(y, start, s.Frame.Width)
419+ }
420+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
421+ }
422+}
423+
424+func (s *Screen) clearLineMode(mode int) {
425+ s.clampCursor()
426+ switch mode {
427+ case 1:
428+ start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
429+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
430+ case 2:
431+ start, width := s.clearRow(s.Row, 0, s.Frame.Width)
432+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
433+ default:
434+ start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
435+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
436+ }
437+}
438+
439+func (s *Screen) clampCursor() {
440+ s.Row = clamp(s.Row, 0, s.Frame.Height-1)
441+ s.Col = clamp(s.Col, 0, s.Frame.Width-1)
442+}
443+
444+func (s *Screen) parseCSIInts(params string) []int {
445+ s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
446+ return s.csiScratch
447+}
448+
449+func parseCSIInts(params string) []int {
450+ return parseCSIIntsInto(nil, params)
451+}
452+
453+func parseCSIIntsInto(out []int, params string) []int {
454+ if params == "" {
455+ return out
456+ }
457+ params = strings.TrimPrefix(params, "?")
458+ params = strings.TrimPrefix(params, ">")
459+ start := 0
460+ for i := 0; i <= len(params); i++ {
461+ if i == len(params) || params[i] == ';' {
462+ out = append(out, parseCSIInt(params[start:i]))
463+ start = i + 1
464+ }
465+ }
466+ return out
467+}
468+
469+func (s *Screen) parseSGRInts(params string) []int {
470+ s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
471+ return s.sgrScratch
472+}
473+
474+func parseSGRInts(params string) []int {
475+ return parseSGRIntsInto(nil, params)
476+}
477+
478+func parseSGRIntsInto(out []int, params string) []int {
479+ if params == "" {
480+ return out
481+ }
482+ start := 0
483+ for i := 0; i <= len(params); i++ {
484+ if i == len(params) || params[i] == ';' {
485+ out = appendSGRGroup(out, params[start:i])
486+ start = i + 1
487+ }
488+ }
489+ return out
490+}
491+
492+func appendSGRGroup(out []int, group string) []int {
493+ colon := false
494+ for i := 0; i < len(group); i++ {
495+ if group[i] == ':' {
496+ colon = true
497+ break
498+ }
499+ }
500+ if !colon {
501+ return append(out, parseCSIInt(group))
502+ }
503+
504+ parts := countSGRColonFields(group)
505+ code := parseCSIInt(sgrColonField(group, 0))
506+ if code == 4 {
507+ if parts < 2 {
508+ return append(out, code)
509+ }
510+ return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
511+ }
512+ if code != 38 && code != 48 && code != 58 {
513+ return append(out, code)
514+ }
515+ mode := 0
516+ if parts > 1 {
517+ mode = parseCSIInt(sgrColonField(group, 1))
518+ }
519+ switch mode {
520+ case 5:
521+ if parts < 3 {
522+ return out
523+ }
524+ out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
525+ case 2:
526+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
527+ // the colorspace slot; code:mode:R:G:B omits that slot.
528+ start := 2
529+ if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
530+ start = 3
531+ }
532+ if parts < start+3 {
533+ return out
534+ }
535+ out = append(out, code, mode)
536+ for i := range 3 {
537+ out = append(out, parseCSIInt(sgrColonField(group, start+i)))
538+ }
539+ }
540+ return out
541+}
542+
543+func countSGRColonFields(group string) int {
544+ count := 1
545+ for i := 0; i < len(group); i++ {
546+ if group[i] == ':' {
547+ count++
548+ }
549+ }
550+ return count
551+}
552+
553+func sgrColonField(group string, index int) string {
554+ start := 0
555+ field := 0
556+ for i := 0; i <= len(group); i++ {
557+ if i == len(group) || group[i] == ':' {
558+ if field == index {
559+ return group[start:i]
560+ }
561+ field++
562+ start = i + 1
563+ }
564+ }
565+ return ""
566+}
567+
568+func parseCSIInt(param string) int {
569+ if param == "" {
570+ return 0
571+ }
572+ v, err := strconv.Atoi(param)
573+ if err != nil {
574+ return 0
575+ }
576+ return v
577+}
A pkg/vt/screen_cursor.go +89 -0
@@ -0,0 +1,89 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+type cursorState struct {
11+ row int
12+ col int
13+ style renderer.Style
14+ originMode bool
15+ insertMode bool
16+ saved bool
17+}
18+
19+func (s *Screen) cursorReportRow() int {
20+ if s.originMode {
21+ return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
22+ }
23+ return s.Row + 1
24+}
25+
26+func (s *Screen) cursorMinRow() int {
27+ if s.originMode {
28+ return clamp(s.scrollTop, 0, s.Frame.Height-1)
29+ }
30+ return 0
31+}
32+
33+func (s *Screen) cursorMaxRow() int {
34+ if s.originMode {
35+ return clamp(s.scrollBottom, 0, s.Frame.Height-1)
36+ }
37+ return max(s.Frame.Height-1, 0)
38+}
39+
40+func (s *Screen) addressedRow(row int) int {
41+ if s.originMode {
42+ return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
43+ }
44+ return clamp(row-1, 0, s.Frame.Height-1)
45+}
46+
47+func (s *Screen) addressCursor(row, col int) {
48+ s.Row = s.addressedRow(row)
49+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
50+}
51+
52+func (s *Screen) homeCursor() {
53+ s.addressCursor(1, 1)
54+}
55+
56+func (s *Screen) applyCursorStyle(params string) {
57+ if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
58+ return
59+ }
60+ styleParam := strings.TrimSuffix(params, " ")
61+ style := 0
62+ if styleParam != "" {
63+ v, err := strconv.Atoi(styleParam)
64+ if err != nil {
65+ return
66+ }
67+ style = v
68+ }
69+ if style < 0 || style > 6 {
70+ return
71+ }
72+ s.cursorStyle = style
73+ s.cursorStyleSet = true
74+}
75+
76+func (s *Screen) saveCursor() {
77+ s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
78+}
79+
80+func (s *Screen) restoreCursor() {
81+ if !s.savedCursor.saved {
82+ return
83+ }
84+ s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
85+ s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
86+ s.Style = s.savedCursor.style
87+ s.originMode = s.savedCursor.originMode
88+ s.insertMode = s.savedCursor.insertMode
89+}
@@ -0,0 +1,89 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+type cursorState struct {
11+ row int
12+ col int
13+ style renderer.Style
14+ originMode bool
15+ insertMode bool
16+ saved bool
17+}
18+
19+func (s *Screen) cursorReportRow() int {
20+ if s.originMode {
21+ return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
22+ }
23+ return s.Row + 1
24+}
25+
26+func (s *Screen) cursorMinRow() int {
27+ if s.originMode {
28+ return clamp(s.scrollTop, 0, s.Frame.Height-1)
29+ }
30+ return 0
31+}
32+
33+func (s *Screen) cursorMaxRow() int {
34+ if s.originMode {
35+ return clamp(s.scrollBottom, 0, s.Frame.Height-1)
36+ }
37+ return max(s.Frame.Height-1, 0)
38+}
39+
40+func (s *Screen) addressedRow(row int) int {
41+ if s.originMode {
42+ return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
43+ }
44+ return clamp(row-1, 0, s.Frame.Height-1)
45+}
46+
47+func (s *Screen) addressCursor(row, col int) {
48+ s.Row = s.addressedRow(row)
49+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
50+}
51+
52+func (s *Screen) homeCursor() {
53+ s.addressCursor(1, 1)
54+}
55+
56+func (s *Screen) applyCursorStyle(params string) {
57+ if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
58+ return
59+ }
60+ styleParam := strings.TrimSuffix(params, " ")
61+ style := 0
62+ if styleParam != "" {
63+ v, err := strconv.Atoi(styleParam)
64+ if err != nil {
65+ return
66+ }
67+ style = v
68+ }
69+ if style < 0 || style > 6 {
70+ return
71+ }
72+ s.cursorStyle = style
73+ s.cursorStyleSet = true
74+}
75+
76+func (s *Screen) saveCursor() {
77+ s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
78+}
79+
80+func (s *Screen) restoreCursor() {
81+ if !s.savedCursor.saved {
82+ return
83+ }
84+ s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
85+ s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
86+ s.Style = s.savedCursor.style
87+ s.originMode = s.savedCursor.originMode
88+ s.insertMode = s.savedCursor.insertMode
89+}
A pkg/vt/screen_osc.go +159 -0
@@ -0,0 +1,159 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+const (
11+ progressStateClear = iota
12+ progressStateNormal
13+ progressStateError
14+ progressStateIndeterminate
15+ progressStatePaused
16+)
17+
18+func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
19+ for i := 2; i < len(data); i++ {
20+ switch data[i] {
21+ case 0x07:
22+ s.applyOSC(data[2:i], []byte{0x07})
23+ s.handleOSC(data[2:i])
24+ return i + 1, false
25+ case 0x1b:
26+ if i+1 < len(data) && data[i+1] == '\\' {
27+ s.applyOSC(data[2:i], []byte{0x1b, '\\'})
28+ s.handleOSC(data[2:i])
29+ return i + 2, false
30+ }
31+ }
32+ }
33+ return 0, true
34+}
35+
36+func (s *Screen) applyOSC(payload, terminator []byte) {
37+ if !s.defaultColorsKnown {
38+ return
39+ }
40+
41+ var color renderer.RGB
42+ switch string(payload) {
43+ case "10;?":
44+ color = s.defaultFG
45+ case "11;?":
46+ color = s.defaultBG
47+ default:
48+ return
49+ }
50+
51+ resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
52+ resp = append(resp, "\x1b]"...)
53+ resp = append(resp, payload[:2]...)
54+ resp = append(resp, ";rgb:"...)
55+ resp = appendOSCColorComponent(resp, color.R)
56+ resp = append(resp, '/')
57+ resp = appendOSCColorComponent(resp, color.G)
58+ resp = append(resp, '/')
59+ resp = appendOSCColorComponent(resp, color.B)
60+ resp = append(resp, terminator...)
61+ s.respond(resp)
62+}
63+
64+func appendOSCColorComponent(dst []byte, c uint8) []byte {
65+ const hex = "0123456789abcdef"
66+ v := uint16(c)<<8 | uint16(c)
67+ return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
68+}
69+
70+// handleOSC inspects a complete OSC payload (between "ESC ]" and its
71+// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
72+// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
73+// is discarded.
74+
75+func (s *Screen) handleOSC(payload []byte) {
76+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
77+ s.handleOSC52(string(payload[len("52;"):]))
78+ return
79+ }
80+ p := string(payload)
81+ if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
82+ s.terminalTitle = title
83+ return
84+ }
85+ if p == "9;4" || strings.HasPrefix(p, "9;4;") {
86+ s.handleProgress(p[len("9;4"):])
87+ return
88+ }
89+ if s.OnNotify == nil {
90+ return
91+ }
92+ switch {
93+ case strings.HasPrefix(p, "9;"):
94+ s.OnNotify("", p[len("9;"):])
95+ case strings.HasPrefix(p, "777;"):
96+ parts := strings.SplitN(p[len("777;"):], ";", 3)
97+ if parts[0] != "notify" {
98+ return
99+ }
100+ var title, body string
101+ if len(parts) > 1 {
102+ title = parts[1]
103+ }
104+ if len(parts) > 2 {
105+ body = parts[2]
106+ }
107+ s.OnNotify(title, body)
108+ }
109+}
110+
111+func (s *Screen) handleProgress(rest string) {
112+ rest = strings.TrimPrefix(rest, ";")
113+ if rest == "" {
114+ return
115+ }
116+ token, _, _ := strings.Cut(rest, ";")
117+ state, err := strconv.Atoi(token)
118+ if err != nil {
119+ return
120+ }
121+ switch state {
122+ case progressStateNormal, progressStateIndeterminate, progressStatePaused:
123+ s.progressState = state
124+ case progressStateClear:
125+ previous := s.progressState
126+ s.progressState = state
127+ if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
128+ if s.OnProgress != nil {
129+ s.OnProgress(false)
130+ }
131+ }
132+ case progressStateError:
133+ previous := s.progressState
134+ s.progressState = state
135+ if previous != progressStateError {
136+ if s.OnProgress != nil {
137+ s.OnProgress(true)
138+ }
139+ }
140+ }
141+}
142+
143+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
144+// clipboard payload (selection may be empty; split on the first ";"). A
145+// clipboard query (data == "?") is always ignored — vev never answers
146+// clipboard queries. A payload with no second ";" is malformed and ignored.
147+
148+func (s *Screen) handleOSC52(rest string) {
149+ _, data, ok := strings.Cut(rest, ";")
150+ if !ok {
151+ return
152+ }
153+ if data == "?" {
154+ return
155+ }
156+ if s.OnClipboard != nil {
157+ s.OnClipboard(data)
158+ }
159+}
@@ -0,0 +1,159 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+const (
11+ progressStateClear = iota
12+ progressStateNormal
13+ progressStateError
14+ progressStateIndeterminate
15+ progressStatePaused
16+)
17+
18+func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
19+ for i := 2; i < len(data); i++ {
20+ switch data[i] {
21+ case 0x07:
22+ s.applyOSC(data[2:i], []byte{0x07})
23+ s.handleOSC(data[2:i])
24+ return i + 1, false
25+ case 0x1b:
26+ if i+1 < len(data) && data[i+1] == '\\' {
27+ s.applyOSC(data[2:i], []byte{0x1b, '\\'})
28+ s.handleOSC(data[2:i])
29+ return i + 2, false
30+ }
31+ }
32+ }
33+ return 0, true
34+}
35+
36+func (s *Screen) applyOSC(payload, terminator []byte) {
37+ if !s.defaultColorsKnown {
38+ return
39+ }
40+
41+ var color renderer.RGB
42+ switch string(payload) {
43+ case "10;?":
44+ color = s.defaultFG
45+ case "11;?":
46+ color = s.defaultBG
47+ default:
48+ return
49+ }
50+
51+ resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
52+ resp = append(resp, "\x1b]"...)
53+ resp = append(resp, payload[:2]...)
54+ resp = append(resp, ";rgb:"...)
55+ resp = appendOSCColorComponent(resp, color.R)
56+ resp = append(resp, '/')
57+ resp = appendOSCColorComponent(resp, color.G)
58+ resp = append(resp, '/')
59+ resp = appendOSCColorComponent(resp, color.B)
60+ resp = append(resp, terminator...)
61+ s.respond(resp)
62+}
63+
64+func appendOSCColorComponent(dst []byte, c uint8) []byte {
65+ const hex = "0123456789abcdef"
66+ v := uint16(c)<<8 | uint16(c)
67+ return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
68+}
69+
70+// handleOSC inspects a complete OSC payload (between "ESC ]" and its
71+// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
72+// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
73+// is discarded.
74+
75+func (s *Screen) handleOSC(payload []byte) {
76+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
77+ s.handleOSC52(string(payload[len("52;"):]))
78+ return
79+ }
80+ p := string(payload)
81+ if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
82+ s.terminalTitle = title
83+ return
84+ }
85+ if p == "9;4" || strings.HasPrefix(p, "9;4;") {
86+ s.handleProgress(p[len("9;4"):])
87+ return
88+ }
89+ if s.OnNotify == nil {
90+ return
91+ }
92+ switch {
93+ case strings.HasPrefix(p, "9;"):
94+ s.OnNotify("", p[len("9;"):])
95+ case strings.HasPrefix(p, "777;"):
96+ parts := strings.SplitN(p[len("777;"):], ";", 3)
97+ if parts[0] != "notify" {
98+ return
99+ }
100+ var title, body string
101+ if len(parts) > 1 {
102+ title = parts[1]
103+ }
104+ if len(parts) > 2 {
105+ body = parts[2]
106+ }
107+ s.OnNotify(title, body)
108+ }
109+}
110+
111+func (s *Screen) handleProgress(rest string) {
112+ rest = strings.TrimPrefix(rest, ";")
113+ if rest == "" {
114+ return
115+ }
116+ token, _, _ := strings.Cut(rest, ";")
117+ state, err := strconv.Atoi(token)
118+ if err != nil {
119+ return
120+ }
121+ switch state {
122+ case progressStateNormal, progressStateIndeterminate, progressStatePaused:
123+ s.progressState = state
124+ case progressStateClear:
125+ previous := s.progressState
126+ s.progressState = state
127+ if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
128+ if s.OnProgress != nil {
129+ s.OnProgress(false)
130+ }
131+ }
132+ case progressStateError:
133+ previous := s.progressState
134+ s.progressState = state
135+ if previous != progressStateError {
136+ if s.OnProgress != nil {
137+ s.OnProgress(true)
138+ }
139+ }
140+ }
141+}
142+
143+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
144+// clipboard payload (selection may be empty; split on the first ";"). A
145+// clipboard query (data == "?") is always ignored — vev never answers
146+// clipboard queries. A payload with no second ";" is malformed and ignored.
147+
148+func (s *Screen) handleOSC52(rest string) {
149+ _, data, ok := strings.Cut(rest, ";")
150+ if !ok {
151+ return
152+ }
153+ if data == "?" {
154+ return
155+ }
156+ if s.OnClipboard != nil {
157+ s.OnClipboard(data)
158+ }
159+}
A pkg/vt/screen_write.go +226 -0
@@ -0,0 +1,226 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) putRune(r rune) {
6+ switch r {
7+ case '\a':
8+ if s.OnBell != nil {
9+ s.OnBell()
10+ }
11+ return
12+ case 0x00, 0x0e, 0x0f, 0x7f:
13+ return
14+ case '\r':
15+ s.Col = 0
16+ case '\n', '\v', '\f':
17+ if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
18+ s.Col = s.Frame.Width - 1
19+ }
20+ s.index()
21+ case '\b':
22+ if s.Col > 0 {
23+ s.Col--
24+ }
25+ case '\t':
26+ spaces := 8 - (s.Col % 8)
27+ for range spaces {
28+ s.putPrintable(' ')
29+ }
30+ default:
31+ // Drop C1 control characters (U+0080-U+009F).
32+ if r >= 0x80 && r <= 0x9F {
33+ return
34+ }
35+ if r >= 0x20 {
36+ s.putPrintable(r)
37+ }
38+ }
39+}
40+
41+func (s *Screen) putPrintable(r rune) {
42+ w := renderer.RuneWidth(r)
43+ // Skip combining marks and zero-width characters.
44+ if w == 0 {
45+ return
46+ }
47+ if s.Frame.Width == 0 || s.Frame.Height == 0 {
48+ return
49+ }
50+ // Deferred wrap: cursor sits past the last column.
51+ if s.Col >= s.Frame.Width {
52+ s.Col = 0
53+ s.index()
54+ }
55+ if s.Row >= s.Frame.Height {
56+ s.Row = s.Frame.Height - 1
57+ }
58+
59+ // A wide rune must never straddle the right edge. If it does not fit on the
60+ // current line, clear the abandoned last cell and wrap to the next line.
61+ if w == 2 && s.Col+1 >= s.Frame.Width {
62+ if s.Frame.Width < 2 {
63+ // The screen is too narrow to ever hold a wide rune; degrade to a
64+ // single narrow cell so the cursor stays aligned and we never write
65+ // a continuation cell out of bounds.
66+ w = 1
67+ } else {
68+ // The abandoned cell may itself be the continuation of a pair whose
69+ // left half sits one column back; report damage over the full span.
70+ cx := s.Col
71+ if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
72+ cx = s.Col - 1
73+ }
74+ s.clearWidePairAt(s.Col, s.Row)
75+ s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
76+ s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
77+ s.Col = 0
78+ s.index()
79+ if s.Row >= s.Frame.Height {
80+ s.Row = s.Frame.Height - 1
81+ }
82+ }
83+ }
84+
85+ insertDamageX := s.Col
86+ insertDamageWidth := 0
87+ if s.insertMode {
88+ row := s.Frame.Row(s.Row)
89+ leftSplit := s.Col > 0 && row[s.Col].Continuation
90+ for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
91+ row[x] = row[x-w]
92+ }
93+ for x := s.Col; x < s.Col+w; x++ {
94+ row[x] = renderer.BlankCell()
95+ }
96+ s.repairRow(s.Row)
97+ if leftSplit {
98+ insertDamageX = s.Col - 1
99+ }
100+ insertDamageWidth = s.Frame.Width - insertDamageX
101+ }
102+
103+ // Determine the range of cells actually modified, extending over any wide
104+ // pair the write lands on so no orphaned half is left behind.
105+ lo, hi := s.Col, s.Col+w-1
106+ if s.Frame.At(s.Col, s.Row).Continuation {
107+ lo = s.Col - 1
108+ }
109+ if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
110+ hi = right
111+ }
112+ for x := lo; x <= hi; x++ {
113+ s.Frame.Set(x, s.Row, renderer.BlankCell())
114+ }
115+ s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
116+ if w == 2 {
117+ s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
118+ }
119+ if insertDamageWidth > 0 {
120+ lo = min(lo, insertDamageX)
121+ hi = max(hi, insertDamageX+insertDamageWidth-1)
122+ }
123+ s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
124+ s.Col += w
125+}
126+
127+// clearWidePairAt blanks both halves of a wide-character pair when the cell at
128+// (x,y) is either the left (wide) half or the right (continuation) half. It is
129+// O(1) and assumes the pair invariant (a continuation is preceded by its wide
130+// left half) holds — which the writer maintains.
131+
132+func (s *Screen) clearWidePairAt(x, y int) {
133+ if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
134+ return
135+ }
136+ if s.Frame.At(x, y).Continuation {
137+ if x-1 >= 0 {
138+ s.Frame.Set(x-1, y, renderer.BlankCell())
139+ }
140+ s.Frame.Set(x, y, renderer.BlankCell())
141+ return
142+ }
143+ if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
144+ s.Frame.Set(x, y, renderer.BlankCell())
145+ s.Frame.Set(x+1, y, renderer.BlankCell())
146+ }
147+}
148+
149+func (s *Screen) eraseCell() renderer.Cell {
150+ style := renderer.DefaultStyle()
151+ if s.Style.HasBackgroundRGB {
152+ style.HasBackgroundRGB = true
153+ style.BackgroundRGB = s.Style.BackgroundRGB
154+ } else {
155+ style.Background = s.Style.Background
156+ }
157+ return renderer.Cell{Rune: ' ', Style: style}
158+}
159+
160+// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
161+// half of a wide pair that straddles a boundary so no orphan half is left. It
162+// returns the actual modified span [start, start+width).
163+
164+func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
165+ if y < 0 || y >= s.Frame.Height {
166+ return x0, 0
167+ }
168+ if x0 < 0 {
169+ x0 = 0
170+ }
171+ if x1 > s.Frame.Width {
172+ x1 = s.Frame.Width
173+ }
174+ if x0 >= x1 {
175+ return x0, 0
176+ }
177+ // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
178+ if x0 > 0 && s.Frame.At(x0, y).Continuation {
179+ x0--
180+ }
181+ // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
182+ // inside the range and will be blanked, so swallow the continuation too.
183+ if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
184+ x1++
185+ }
186+ blank := s.eraseCell()
187+ for x := x0; x < x1; x++ {
188+ s.Frame.Set(x, y, blank)
189+ }
190+ return x0, x1 - x0
191+}
192+
193+// repairRow blanks any orphaned wide-character halves on row y: a continuation
194+// cell not preceded by its wide left half, or a wide rune not followed by its
195+// continuation. Used after erase/insert/delete operations that may split a pair
196+// at a boundary or by shifting cells.
197+
198+func (s *Screen) repairRow(y int) {
199+ repairFrameRow(s.Frame, y)
200+}
201+
202+func repairFrameRow(frame renderer.Frame, y int) {
203+ if y < 0 || y >= frame.Height {
204+ return
205+ }
206+ w := frame.Width
207+ for x := range w {
208+ c := frame.At(x, y)
209+ if c.Continuation {
210+ if x == 0 {
211+ frame.Set(x, y, renderer.BlankCell())
212+ continue
213+ }
214+ left := frame.At(x-1, y)
215+ if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
216+ frame.Set(x, y, renderer.BlankCell())
217+ }
218+ continue
219+ }
220+ if renderer.RuneWidth(c.Rune) == 2 {
221+ if x+1 >= w || !frame.At(x+1, y).Continuation {
222+ frame.Set(x, y, renderer.BlankCell())
223+ }
224+ }
225+ }
226+}
@@ -0,0 +1,226 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) putRune(r rune) {
6+ switch r {
7+ case '\a':
8+ if s.OnBell != nil {
9+ s.OnBell()
10+ }
11+ return
12+ case 0x00, 0x0e, 0x0f, 0x7f:
13+ return
14+ case '\r':
15+ s.Col = 0
16+ case '\n', '\v', '\f':
17+ if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
18+ s.Col = s.Frame.Width - 1
19+ }
20+ s.index()
21+ case '\b':
22+ if s.Col > 0 {
23+ s.Col--
24+ }
25+ case '\t':
26+ spaces := 8 - (s.Col % 8)
27+ for range spaces {
28+ s.putPrintable(' ')
29+ }
30+ default:
31+ // Drop C1 control characters (U+0080-U+009F).
32+ if r >= 0x80 && r <= 0x9F {
33+ return
34+ }
35+ if r >= 0x20 {
36+ s.putPrintable(r)
37+ }
38+ }
39+}
40+
41+func (s *Screen) putPrintable(r rune) {
42+ w := renderer.RuneWidth(r)
43+ // Skip combining marks and zero-width characters.
44+ if w == 0 {
45+ return
46+ }
47+ if s.Frame.Width == 0 || s.Frame.Height == 0 {
48+ return
49+ }
50+ // Deferred wrap: cursor sits past the last column.
51+ if s.Col >= s.Frame.Width {
52+ s.Col = 0
53+ s.index()
54+ }
55+ if s.Row >= s.Frame.Height {
56+ s.Row = s.Frame.Height - 1
57+ }
58+
59+ // A wide rune must never straddle the right edge. If it does not fit on the
60+ // current line, clear the abandoned last cell and wrap to the next line.
61+ if w == 2 && s.Col+1 >= s.Frame.Width {
62+ if s.Frame.Width < 2 {
63+ // The screen is too narrow to ever hold a wide rune; degrade to a
64+ // single narrow cell so the cursor stays aligned and we never write
65+ // a continuation cell out of bounds.
66+ w = 1
67+ } else {
68+ // The abandoned cell may itself be the continuation of a pair whose
69+ // left half sits one column back; report damage over the full span.
70+ cx := s.Col
71+ if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
72+ cx = s.Col - 1
73+ }
74+ s.clearWidePairAt(s.Col, s.Row)
75+ s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
76+ s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
77+ s.Col = 0
78+ s.index()
79+ if s.Row >= s.Frame.Height {
80+ s.Row = s.Frame.Height - 1
81+ }
82+ }
83+ }
84+
85+ insertDamageX := s.Col
86+ insertDamageWidth := 0
87+ if s.insertMode {
88+ row := s.Frame.Row(s.Row)
89+ leftSplit := s.Col > 0 && row[s.Col].Continuation
90+ for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
91+ row[x] = row[x-w]
92+ }
93+ for x := s.Col; x < s.Col+w; x++ {
94+ row[x] = renderer.BlankCell()
95+ }
96+ s.repairRow(s.Row)
97+ if leftSplit {
98+ insertDamageX = s.Col - 1
99+ }
100+ insertDamageWidth = s.Frame.Width - insertDamageX
101+ }
102+
103+ // Determine the range of cells actually modified, extending over any wide
104+ // pair the write lands on so no orphaned half is left behind.
105+ lo, hi := s.Col, s.Col+w-1
106+ if s.Frame.At(s.Col, s.Row).Continuation {
107+ lo = s.Col - 1
108+ }
109+ if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
110+ hi = right
111+ }
112+ for x := lo; x <= hi; x++ {
113+ s.Frame.Set(x, s.Row, renderer.BlankCell())
114+ }
115+ s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
116+ if w == 2 {
117+ s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
118+ }
119+ if insertDamageWidth > 0 {
120+ lo = min(lo, insertDamageX)
121+ hi = max(hi, insertDamageX+insertDamageWidth-1)
122+ }
123+ s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
124+ s.Col += w
125+}
126+
127+// clearWidePairAt blanks both halves of a wide-character pair when the cell at
128+// (x,y) is either the left (wide) half or the right (continuation) half. It is
129+// O(1) and assumes the pair invariant (a continuation is preceded by its wide
130+// left half) holds — which the writer maintains.
131+
132+func (s *Screen) clearWidePairAt(x, y int) {
133+ if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
134+ return
135+ }
136+ if s.Frame.At(x, y).Continuation {
137+ if x-1 >= 0 {
138+ s.Frame.Set(x-1, y, renderer.BlankCell())
139+ }
140+ s.Frame.Set(x, y, renderer.BlankCell())
141+ return
142+ }
143+ if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
144+ s.Frame.Set(x, y, renderer.BlankCell())
145+ s.Frame.Set(x+1, y, renderer.BlankCell())
146+ }
147+}
148+
149+func (s *Screen) eraseCell() renderer.Cell {
150+ style := renderer.DefaultStyle()
151+ if s.Style.HasBackgroundRGB {
152+ style.HasBackgroundRGB = true
153+ style.BackgroundRGB = s.Style.BackgroundRGB
154+ } else {
155+ style.Background = s.Style.Background
156+ }
157+ return renderer.Cell{Rune: ' ', Style: style}
158+}
159+
160+// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
161+// half of a wide pair that straddles a boundary so no orphan half is left. It
162+// returns the actual modified span [start, start+width).
163+
164+func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
165+ if y < 0 || y >= s.Frame.Height {
166+ return x0, 0
167+ }
168+ if x0 < 0 {
169+ x0 = 0
170+ }
171+ if x1 > s.Frame.Width {
172+ x1 = s.Frame.Width
173+ }
174+ if x0 >= x1 {
175+ return x0, 0
176+ }
177+ // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
178+ if x0 > 0 && s.Frame.At(x0, y).Continuation {
179+ x0--
180+ }
181+ // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
182+ // inside the range and will be blanked, so swallow the continuation too.
183+ if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
184+ x1++
185+ }
186+ blank := s.eraseCell()
187+ for x := x0; x < x1; x++ {
188+ s.Frame.Set(x, y, blank)
189+ }
190+ return x0, x1 - x0
191+}
192+
193+// repairRow blanks any orphaned wide-character halves on row y: a continuation
194+// cell not preceded by its wide left half, or a wide rune not followed by its
195+// continuation. Used after erase/insert/delete operations that may split a pair
196+// at a boundary or by shifting cells.
197+
198+func (s *Screen) repairRow(y int) {
199+ repairFrameRow(s.Frame, y)
200+}
201+
202+func repairFrameRow(frame renderer.Frame, y int) {
203+ if y < 0 || y >= frame.Height {
204+ return
205+ }
206+ w := frame.Width
207+ for x := range w {
208+ c := frame.At(x, y)
209+ if c.Continuation {
210+ if x == 0 {
211+ frame.Set(x, y, renderer.BlankCell())
212+ continue
213+ }
214+ left := frame.At(x-1, y)
215+ if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
216+ frame.Set(x, y, renderer.BlankCell())
217+ }
218+ continue
219+ }
220+ if renderer.RuneWidth(c.Rune) == 2 {
221+ if x+1 >= w || !frame.At(x+1, y).Continuation {
222+ frame.Set(x, y, renderer.BlankCell())
223+ }
224+ }
225+ }
226+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close