detached a95d4db

Merge pull request #53 from bnema/fix/vt-mode-correctness

fix(vt): correct VT modes and wide-cell repair
commit
a95d4db479f3c53cbfd3c85b8892ba0a5b44b367
author
bnema · · 2026-07-09 15:50 UTC
committer
GitHub · · 2026-07-09 15:50 UTC
merge of
a386121 + 1b372d7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +336 -42
M README.md +4 -0
@@ -134,6 +134,10 @@ When the attaching client reports truecolor support, vev advertises newly spawne
134134
135135 Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present. Panes restored from a saved snapshot start conservatively before any client attaches; after attach or resume, newly spawned panes use that client's reported capability.
136136
137+## Terminal compatibility
138+
139+vev keeps a server-side VT screen for each pane. It tracks scroll regions with DECOM origin mode, ANSI insert mode, cursor-position and mode-query reports, alternate-screen state, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard set requests, and double-width cells. Resize and edit operations repair double-width cells so CJK or emoji glyph halves are not left behind.
140+
137141 ## Keys
138142
139143 All bindings use Alt directly; there is no prefix key.
@@ -134,6 +134,10 @@ When the attaching client reports truecolor support, vev advertises newly spawne
134 134
135 Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present. Panes restored from a saved snapshot start conservatively before any client attaches; after attach or resume, newly spawned panes use that client's reported capability.135 Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present. Panes restored from a saved snapshot start conservatively before any client attaches; after attach or resume, newly spawned panes use that client's reported capability.
136 136
137+## Terminal compatibility
138+
139+vev keeps a server-side VT screen for each pane. It tracks scroll regions with DECOM origin mode, ANSI insert mode, cursor-position and mode-query reports, alternate-screen state, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard set requests, and double-width cells. Resize and edit operations repair double-width cells so CJK or emoji glyph halves are not left behind.
140+
137 ## Keys141 ## Keys
138 142
139 All bindings use Alt directly; there is no prefix key.143 All bindings use Alt directly; there is no prefix key.
M pkg/vt/doc.go +7 -1
@@ -1,2 +1,8 @@
1-// Package vt implements a terminal emulator core.
1+// Package vt implements the server-side terminal emulator core.
2+//
3+// It tracks the visible cell grid, damage rectangles, scroll regions,
4+// alternate-screen state, cursor/reporting modes, and common xterm/VT control
5+// sequences. Wide runes that fit are stored as a head cell plus a continuation
6+// cell, and resize or edit operations repair row-boundary splits instead of
7+// leaving orphaned halves.
28 package vt
@@ -1,2 +1,8 @@
1-// Package vt implements a terminal emulator core.1+// Package vt implements the server-side terminal emulator core.
2+//
3+// It tracks the visible cell grid, damage rectangles, scroll regions,
4+// alternate-screen state, cursor/reporting modes, and common xterm/VT control
5+// sequences. Wide runes that fit are stored as a head cell plus a continuation
6+// cell, and resize or edit operations repair row-boundary splits instead of
7+// leaving orphaned halves.
2 package vt8 package vt
M pkg/vt/query_test.go +5 -0
@@ -21,6 +21,11 @@ func TestScreenQueryResponses(t *testing.T) {
2121 {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"},
2222 {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"},
2323 {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},
24+ {name: "CPR in DECOM reports origin-relative row", input: "\x1b[2;4r\x1b[?6h\x1b[6n", want: "\x1b[1;1R"},
25+ {name: "DECRQM DECOM reset", input: "\x1b[?6$p", want: "\x1b[?6;2$y"},
26+ {name: "DECRQM DECOM set", input: "\x1b[?6h\x1b[?6$p", want: "\x1b[?6;1$y"},
27+ {name: "DECRQM IRM reset", input: "\x1b[4$p", want: "\x1b[4;2$y"},
28+ {name: "DECRQM IRM set", input: "\x1b[4h\x1b[4$p", want: "\x1b[4;1$y"},
2429 {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},
2530 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
2631 {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},
@@ -21,6 +21,11 @@ func TestScreenQueryResponses(t *testing.T) {
21 {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"},21 {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"},
22 {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"},22 {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"},
23 {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},23 {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},
24+ {name: "CPR in DECOM reports origin-relative row", input: "\x1b[2;4r\x1b[?6h\x1b[6n", want: "\x1b[1;1R"},
25+ {name: "DECRQM DECOM reset", input: "\x1b[?6$p", want: "\x1b[?6;2$y"},
26+ {name: "DECRQM DECOM set", input: "\x1b[?6h\x1b[?6$p", want: "\x1b[?6;1$y"},
27+ {name: "DECRQM IRM reset", input: "\x1b[4$p", want: "\x1b[4;2$y"},
28+ {name: "DECRQM IRM set", input: "\x1b[4h\x1b[4$p", want: "\x1b[4;1$y"},
24 {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},29 {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},
25 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},30 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
26 {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},31 {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},
M pkg/vt/screen.go +150 -41
@@ -9,10 +9,12 @@ import (
99 )
1010
1111 type cursorState struct {
12- row int
13- col int
14- style renderer.Style
15- saved bool
12+ row int
13+ col int
14+ style renderer.Style
15+ originMode bool
16+ insertMode bool
17+ saved bool
1618 }
1719
1820 type screenState struct {
@@ -23,6 +25,8 @@ type screenState struct {
2325 scrollTop int
2426 scrollBottom int
2527 savedCursor cursorState
28+ originMode bool
29+ insertMode bool
2630 }
2731
2832 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -55,8 +59,8 @@ type Screen struct {
5559 // and blanks rows. The callback receives a stable copy of each evicted row.
5660 OnLineEvicted func([]renderer.Cell)
5761 // OnResponse is called synchronously from Write with reply bytes that the
58- // emulator must send back to the child process (DA, DSR, DECRQM reports).
59- // The host wires it to the PTY input. Nil disables responses.
62+ // emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
63+ // query reports). The host wires it to the PTY input. Nil disables responses.
6064 OnResponse func([]byte)
6165 // OnBell is called synchronously from Write for each lone BEL (0x07)
6266 // outside escape sequences. BELs that terminate an OSC never fire it.
@@ -98,6 +102,8 @@ type Screen struct {
98102 mouseMode int
99103 mouseSGR bool
100104 bracketedPaste bool
105+ originMode bool
106+ insertMode bool
101107 colorSchemeMode bool
102108 colorSchemeLight bool
103109 colorSchemeSet bool
@@ -325,6 +331,24 @@ func (s *Screen) putPrintable(r rune) {
325331 }
326332 }
327333
334+ insertDamageX := s.Col
335+ insertDamageWidth := 0
336+ if s.insertMode {
337+ row := s.Frame.Row(s.Row)
338+ leftSplit := s.Col > 0 && row[s.Col].Continuation
339+ for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
340+ row[x] = row[x-w]
341+ }
342+ for x := s.Col; x < s.Col+w; x++ {
343+ row[x] = renderer.BlankCell()
344+ }
345+ s.repairRow(s.Row)
346+ if leftSplit {
347+ insertDamageX = s.Col - 1
348+ }
349+ insertDamageWidth = s.Frame.Width - insertDamageX
350+ }
351+
328352 // Determine the range of cells actually modified, extending over any wide
329353 // pair the write lands on so no orphaned half is left behind.
330354 lo, hi := s.Col, s.Col+w-1
@@ -341,6 +365,10 @@ func (s *Screen) putPrintable(r rune) {
341365 if w == 2 {
342366 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
343367 }
368+ if insertDamageWidth > 0 {
369+ lo = min(lo, insertDamageX)
370+ hi = max(hi, insertDamageX+insertDamageWidth-1)
371+ }
344372 s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
345373 s.Col += w
346374 }
@@ -402,26 +430,30 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
402430 // continuation. Used after erase/insert/delete operations that may split a pair
403431 // at a boundary or by shifting cells.
404432 func (s *Screen) repairRow(y int) {
405- if y < 0 || y >= s.Frame.Height {
433+ repairFrameRow(s.Frame, y)
434+}
435+
436+func repairFrameRow(frame renderer.Frame, y int) {
437+ if y < 0 || y >= frame.Height {
406438 return
407439 }
408- w := s.Frame.Width
440+ w := frame.Width
409441 for x := range w {
410- c := s.Frame.At(x, y)
442+ c := frame.At(x, y)
411443 if c.Continuation {
412444 if x == 0 {
413- s.Frame.Set(x, y, renderer.BlankCell())
445+ frame.Set(x, y, renderer.BlankCell())
414446 continue
415447 }
416- left := s.Frame.At(x-1, y)
448+ left := frame.At(x-1, y)
417449 if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
418- s.Frame.Set(x, y, renderer.BlankCell())
450+ frame.Set(x, y, renderer.BlankCell())
419451 }
420452 continue
421453 }
422454 if renderer.RuneWidth(c.Rune) == 2 {
423- if x+1 >= w || !s.Frame.At(x+1, y).Continuation {
424- s.Frame.Set(x, y, renderer.BlankCell())
455+ if x+1 >= w || !frame.At(x+1, y).Continuation {
456+ frame.Set(x, y, renderer.BlankCell())
425457 }
426458 }
427459 }
@@ -813,33 +845,27 @@ func (s *Screen) applyCSI(params string, cmd byte) {
813845 if private {
814846 resp = append(resp, '?')
815847 }
816- resp = strconv.AppendInt(resp, int64(s.Row+1), 10)
848+ resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
817849 resp = append(resp, ';')
818850 resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
819851 resp = append(resp, 'R')
820852 s.respond(resp)
821853 }
822854 case 'p':
823- if private && strings.HasSuffix(params, "$") {
824- mode, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(params, "?"), "$"))
855+ if modeText, ok := strings.CutSuffix(params, "$"); ok {
856+ if private {
857+ modeText = strings.TrimPrefix(modeText, "?")
858+ }
859+ mode, err := strconv.Atoi(modeText)
825860 if err != nil {
826861 return
827862 }
828- state := 0
829- switch mode {
830- case 2026:
831- state = 2
832- if s.syncUpdateActive {
833- state = 1
834- }
835- case 2031:
836- state = 2
837- if s.colorSchemeMode {
838- state = 1
839- }
840- }
863+ state := s.modeReportState(private, mode)
841864 resp := make([]byte, 0, 16)
842- resp = append(resp, "\x1b[?"...)
865+ resp = append(resp, "\x1b["...)
866+ if private {
867+ resp = append(resp, '?')
868+ }
843869 resp = strconv.AppendInt(resp, int64(mode), 10)
844870 resp = append(resp, ';')
845871 resp = strconv.AppendInt(resp, int64(state), 10)
@@ -877,28 +903,27 @@ func (s *Screen) applyCSI(params string, cmd byte) {
877903 if len(parts) > 1 && parts[1] > 0 {
878904 col = parts[1]
879905 }
880- s.Row = clamp(row-1, 0, s.Frame.Height-1)
881- s.Col = clamp(col-1, 0, s.Frame.Width-1)
906+ s.addressCursor(row, col)
882907 case 'A':
883- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)
908+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
884909 case 'B':
885- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)
910+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
886911 case 'C':
887912 s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
888913 case 'D':
889914 s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
890915 case 'E':
891- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)
916+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
892917 s.Col = 0
893918 case 'F':
894- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)
919+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
895920 s.Col = 0
896921 case 'G':
897922 col := firstPositive(parts, 1)
898923 s.Col = clamp(col-1, 0, s.Frame.Width-1)
899924 case 'd':
900925 row := firstPositive(parts, 1)
901- s.Row = clamp(row-1, 0, s.Frame.Height-1)
926+ s.Row = s.addressedRow(row)
902927 case '@':
903928 s.insertChars(firstPositive(parts, 1))
904929 case 'P':
@@ -929,6 +954,68 @@ func firstPositive(parts []int, fallback int) int {
929954 return parts[0]
930955 }
931956
957+func (s *Screen) cursorReportRow() int {
958+ if s.originMode {
959+ return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
960+ }
961+ return s.Row + 1
962+}
963+
964+func (s *Screen) modeReportState(private bool, mode int) int {
965+ if private {
966+ switch mode {
967+ case 6:
968+ return boolModeReportState(s.originMode)
969+ case 2026:
970+ return boolModeReportState(s.syncUpdateActive)
971+ case 2031:
972+ return boolModeReportState(s.colorSchemeMode)
973+ }
974+ return 0
975+ }
976+ if mode == 4 {
977+ return boolModeReportState(s.insertMode)
978+ }
979+ return 0
980+}
981+
982+func boolModeReportState(enabled bool) int {
983+ if enabled {
984+ return 1
985+ }
986+ return 2
987+}
988+
989+func (s *Screen) cursorMinRow() int {
990+ if s.originMode {
991+ return clamp(s.scrollTop, 0, s.Frame.Height-1)
992+ }
993+ return 0
994+}
995+
996+func (s *Screen) cursorMaxRow() int {
997+ if s.originMode {
998+ return clamp(s.scrollBottom, 0, s.Frame.Height-1)
999+ }
1000+ return max(s.Frame.Height-1, 0)
1001+}
1002+
1003+func (s *Screen) addressedRow(row int) int {
1004+ if s.originMode {
1005+ return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
1006+ }
1007+ return clamp(row-1, 0, s.Frame.Height-1)
1008+}
1009+
1010+func (s *Screen) addressCursor(row, col int) {
1011+ s.Row = s.addressedRow(row)
1012+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
1013+}
1014+
1015+func (s *Screen) homeCursor() {
1016+ s.addressCursor(1, 1)
1017+}
1018+
9321019 func (s *Screen) applyCursorStyle(params string) {
9331020 if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
9341021 return
@@ -1024,7 +1111,7 @@ func sgrRGB(r, g, b int) renderer.RGB {
10241111 }
10251112
10261113 func (s *Screen) saveCursor() {
1027- s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, saved: true}
1114+ s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
10281115 }
10291116
10301117 func (s *Screen) restoreCursor() {
@@ -1034,6 +1121,8 @@ func (s *Screen) restoreCursor() {
10341121 s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
10351122 s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
10361123 s.Style = s.savedCursor.style
1124+ s.originMode = s.savedCursor.originMode
1125+ s.insertMode = s.savedCursor.insertMode
10371126 }
10381127
10391128 func (s *Screen) reset() {
@@ -1043,6 +1132,8 @@ func (s *Screen) reset() {
10431132 s.escapeBuf = s.escapeBuf[:0]
10441133 s.savedCursor = cursorState{}
10451134 s.alternate = nil
1135+ s.originMode = false
1136+ s.insertMode = false
10461137 s.cursorVisible = true
10471138 s.cursorStyle = 0
10481139 s.cursorStyleSet = false
@@ -1074,7 +1165,7 @@ func (s *Screen) setScrollRegion(parts []int) {
10741165 s.resetScrollRegion()
10751166 }
10761167 }
1077- s.Row, s.Col = 0, 0
1168+ s.homeCursor()
10781169 }
10791170
10801171 func (s *Screen) resetScrollRegion() {
@@ -1088,10 +1179,23 @@ func (s *Screen) resetScrollRegion() {
10881179
10891180 func (s *Screen) setMode(private bool, parts []int, enabled bool) {
10901181 if !private {
1182+ for _, mode := range parts {
1183+ switch mode {
1184+ case 4:
1185+ s.insertMode = enabled
1186+ default:
1187+ // Other ANSI modes (for example LNM) are intentionally ignored
1188+ // until the screen model implements their observable behavior.
1189+ continue
1190+ }
1191+ }
10911192 return
10921193 }
10931194 for _, mode := range parts {
10941195 switch mode {
1196+ case 6:
1197+ s.originMode = enabled
1198+ s.homeCursor()
10951199 case 47, 1047, 1049:
10961200 if enabled {
10971201 s.enterAlternateScreen()
@@ -1132,6 +1236,8 @@ func (s *Screen) enterAlternateScreen() {
11321236 scrollTop: s.scrollTop,
11331237 scrollBottom: s.scrollBottom,
11341238 savedCursor: s.savedCursor,
1239+ originMode: s.originMode,
1240+ insertMode: s.insertMode,
11351241 }
11361242 }
11371243 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
@@ -1154,6 +1260,8 @@ func (s *Screen) exitAlternateScreen() {
11541260 s.scrollTop = state.scrollTop
11551261 s.scrollBottom = state.scrollBottom
11561262 s.savedCursor = state.savedCursor
1263+ s.originMode = state.originMode
1264+ s.insertMode = state.insertMode
11571265 s.alternate = nil
11581266 s.fullRedraw()
11591267 }
@@ -1172,6 +1280,7 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
11721280 break
11731281 }
11741282 copy(next.Row(dy), old.Row(sy))
1283+ repairFrameRow(next, dy)
11751284 }
11761285 return next, shift
11771286 }
@@ -1397,7 +1506,7 @@ func appendSGRGroup(out []int, group string) []int {
13971506 return out
13981507 }
13991508 out = append(out, code, mode)
1400- for i := 0; i < 3; i++ {
1509+ for i := range 3 {
14011510 out = append(out, parseCSIInt(sgrColonField(group, start+i)))
14021511 }
14031512 }
@@ -9,10 +9,12 @@ import (
9 )9 )
10 10
11 type cursorState struct {11 type cursorState struct {
12- row int12+ row int
13- col int13+ col int
14- style renderer.Style14+ style renderer.Style
15- saved bool15+ originMode bool
16+ insertMode bool
17+ saved bool
16 }18 }
17 19
18 type screenState struct {20 type screenState struct {
@@ -23,6 +25,8 @@ type screenState struct {
23 scrollTop int25 scrollTop int
24 scrollBottom int26 scrollBottom int
25 savedCursor cursorState27 savedCursor cursorState
28+ originMode bool
29+ insertMode bool
26 }30 }
27 31
28 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads32 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -55,8 +59,8 @@ type Screen struct {
55 // and blanks rows. The callback receives a stable copy of each evicted row.59 // and blanks rows. The callback receives a stable copy of each evicted row.
56 OnLineEvicted func([]renderer.Cell)60 OnLineEvicted func([]renderer.Cell)
57 // OnResponse is called synchronously from Write with reply bytes that the61 // OnResponse is called synchronously from Write with reply bytes that the
58- // emulator must send back to the child process (DA, DSR, DECRQM reports).62+ // emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
59- // The host wires it to the PTY input. Nil disables responses.63+ // query reports). The host wires it to the PTY input. Nil disables responses.
60 OnResponse func([]byte)64 OnResponse func([]byte)
61 // OnBell is called synchronously from Write for each lone BEL (0x07)65 // OnBell is called synchronously from Write for each lone BEL (0x07)
62 // outside escape sequences. BELs that terminate an OSC never fire it.66 // outside escape sequences. BELs that terminate an OSC never fire it.
@@ -98,6 +102,8 @@ type Screen struct {
98 mouseMode int102 mouseMode int
99 mouseSGR bool103 mouseSGR bool
100 bracketedPaste bool104 bracketedPaste bool
105+ originMode bool
106+ insertMode bool
101 colorSchemeMode bool107 colorSchemeMode bool
102 colorSchemeLight bool108 colorSchemeLight bool
103 colorSchemeSet bool109 colorSchemeSet bool
@@ -325,6 +331,24 @@ func (s *Screen) putPrintable(r rune) {
325 }331 }
326 }332 }
327 333
334+ insertDamageX := s.Col
335+ insertDamageWidth := 0
336+ if s.insertMode {
337+ row := s.Frame.Row(s.Row)
338+ leftSplit := s.Col > 0 && row[s.Col].Continuation
339+ for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
340+ row[x] = row[x-w]
341+ }
342+ for x := s.Col; x < s.Col+w; x++ {
343+ row[x] = renderer.BlankCell()
344+ }
345+ s.repairRow(s.Row)
346+ if leftSplit {
347+ insertDamageX = s.Col - 1
348+ }
349+ insertDamageWidth = s.Frame.Width - insertDamageX
350+ }
351+
328 // Determine the range of cells actually modified, extending over any wide352 // Determine the range of cells actually modified, extending over any wide
329 // pair the write lands on so no orphaned half is left behind.353 // pair the write lands on so no orphaned half is left behind.
330 lo, hi := s.Col, s.Col+w-1354 lo, hi := s.Col, s.Col+w-1
@@ -341,6 +365,10 @@ func (s *Screen) putPrintable(r rune) {
341 if w == 2 {365 if w == 2 {
342 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})366 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
343 }367 }
368+ if insertDamageWidth > 0 {
369+ lo = min(lo, insertDamageX)
370+ hi = max(hi, insertDamageX+insertDamageWidth-1)
371+ }
344 s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})372 s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
345 s.Col += w373 s.Col += w
346 }374 }
@@ -402,26 +430,30 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
402 // continuation. Used after erase/insert/delete operations that may split a pair430 // continuation. Used after erase/insert/delete operations that may split a pair
403 // at a boundary or by shifting cells.431 // at a boundary or by shifting cells.
404 func (s *Screen) repairRow(y int) {432 func (s *Screen) repairRow(y int) {
405- if y < 0 || y >= s.Frame.Height {433+ repairFrameRow(s.Frame, y)
434+}
435+
436+func repairFrameRow(frame renderer.Frame, y int) {
437+ if y < 0 || y >= frame.Height {
406 return438 return
407 }439 }
408- w := s.Frame.Width440+ w := frame.Width
409 for x := range w {441 for x := range w {
410- c := s.Frame.At(x, y)442+ c := frame.At(x, y)
411 if c.Continuation {443 if c.Continuation {
412 if x == 0 {444 if x == 0 {
413- s.Frame.Set(x, y, renderer.BlankCell())445+ frame.Set(x, y, renderer.BlankCell())
414 continue446 continue
415 }447 }
416- left := s.Frame.At(x-1, y)448+ left := frame.At(x-1, y)
417 if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {449 if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
418- s.Frame.Set(x, y, renderer.BlankCell())450+ frame.Set(x, y, renderer.BlankCell())
419 }451 }
420 continue452 continue
421 }453 }
422 if renderer.RuneWidth(c.Rune) == 2 {454 if renderer.RuneWidth(c.Rune) == 2 {
423- if x+1 >= w || !s.Frame.At(x+1, y).Continuation {455+ if x+1 >= w || !frame.At(x+1, y).Continuation {
424- s.Frame.Set(x, y, renderer.BlankCell())456+ frame.Set(x, y, renderer.BlankCell())
425 }457 }
426 }458 }
427 }459 }
@@ -813,33 +845,27 @@ func (s *Screen) applyCSI(params string, cmd byte) {
813 if private {845 if private {
814 resp = append(resp, '?')846 resp = append(resp, '?')
815 }847 }
816- resp = strconv.AppendInt(resp, int64(s.Row+1), 10)848+ resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
817 resp = append(resp, ';')849 resp = append(resp, ';')
818 resp = strconv.AppendInt(resp, int64(s.Col+1), 10)850 resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
819 resp = append(resp, 'R')851 resp = append(resp, 'R')
820 s.respond(resp)852 s.respond(resp)
821 }853 }
822 case 'p':854 case 'p':
823- if private && strings.HasSuffix(params, "$") {855+ if modeText, ok := strings.CutSuffix(params, "$"); ok {
824- mode, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(params, "?"), "$"))856+ if private {
857+ modeText = strings.TrimPrefix(modeText, "?")
858+ }
859+ mode, err := strconv.Atoi(modeText)
825 if err != nil {860 if err != nil {
826 return861 return
827 }862 }
828- state := 0863+ state := s.modeReportState(private, mode)
829- switch mode {
830- case 2026:
831- state = 2
832- if s.syncUpdateActive {
833- state = 1
834- }
835- case 2031:
836- state = 2
837- if s.colorSchemeMode {
838- state = 1
839- }
840- }
841 resp := make([]byte, 0, 16)864 resp := make([]byte, 0, 16)
842- resp = append(resp, "\x1b[?"...)865+ resp = append(resp, "\x1b["...)
866+ if private {
867+ resp = append(resp, '?')
868+ }
843 resp = strconv.AppendInt(resp, int64(mode), 10)869 resp = strconv.AppendInt(resp, int64(mode), 10)
844 resp = append(resp, ';')870 resp = append(resp, ';')
845 resp = strconv.AppendInt(resp, int64(state), 10)871 resp = strconv.AppendInt(resp, int64(state), 10)
@@ -877,28 +903,27 @@ func (s *Screen) applyCSI(params string, cmd byte) {
877 if len(parts) > 1 && parts[1] > 0 {903 if len(parts) > 1 && parts[1] > 0 {
878 col = parts[1]904 col = parts[1]
879 }905 }
880- s.Row = clamp(row-1, 0, s.Frame.Height-1)906+ s.addressCursor(row, col)
881- s.Col = clamp(col-1, 0, s.Frame.Width-1)
882 case 'A':907 case 'A':
883- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)908+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
884 case 'B':909 case 'B':
885- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)910+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
886 case 'C':911 case 'C':
887 s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)912 s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
888 case 'D':913 case 'D':
889 s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)914 s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
890 case 'E':915 case 'E':
891- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)916+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
892 s.Col = 0917 s.Col = 0
893 case 'F':918 case 'F':
894- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)919+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
895 s.Col = 0920 s.Col = 0
896 case 'G':921 case 'G':
897 col := firstPositive(parts, 1)922 col := firstPositive(parts, 1)
898 s.Col = clamp(col-1, 0, s.Frame.Width-1)923 s.Col = clamp(col-1, 0, s.Frame.Width-1)
899 case 'd':924 case 'd':
900 row := firstPositive(parts, 1)925 row := firstPositive(parts, 1)
901- s.Row = clamp(row-1, 0, s.Frame.Height-1)926+ s.Row = s.addressedRow(row)
902 case '@':927 case '@':
903 s.insertChars(firstPositive(parts, 1))928 s.insertChars(firstPositive(parts, 1))
904 case 'P':929 case 'P':
@@ -929,6 +954,68 @@ func firstPositive(parts []int, fallback int) int {
929 return parts[0]954 return parts[0]
930 }955 }
931 956
957+func (s *Screen) cursorReportRow() int {
958+ if s.originMode {
959+ return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
960+ }
961+ return s.Row + 1
962+}
963+
964+func (s *Screen) modeReportState(private bool, mode int) int {
965+ if private {
966+ switch mode {
967+ case 6:
968+ return boolModeReportState(s.originMode)
969+ case 2026:
970+ return boolModeReportState(s.syncUpdateActive)
971+ case 2031:
972+ return boolModeReportState(s.colorSchemeMode)
973+ }
974+ return 0
975+ }
976+ if mode == 4 {
977+ return boolModeReportState(s.insertMode)
978+ }
979+ return 0
980+}
981+
982+func boolModeReportState(enabled bool) int {
983+ if enabled {
984+ return 1
985+ }
986+ return 2
987+}
988+
989+func (s *Screen) cursorMinRow() int {
990+ if s.originMode {
991+ return clamp(s.scrollTop, 0, s.Frame.Height-1)
992+ }
993+ return 0
994+}
995+
996+func (s *Screen) cursorMaxRow() int {
997+ if s.originMode {
998+ return clamp(s.scrollBottom, 0, s.Frame.Height-1)
999+ }
1000+ return max(s.Frame.Height-1, 0)
1001+}
1002+
1003+func (s *Screen) addressedRow(row int) int {
1004+ if s.originMode {
1005+ return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
1006+ }
1007+ return clamp(row-1, 0, s.Frame.Height-1)
1008+}
1009+
1010+func (s *Screen) addressCursor(row, col int) {
1011+ s.Row = s.addressedRow(row)
1012+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
1013+}
1014+
1015+func (s *Screen) homeCursor() {
1016+ s.addressCursor(1, 1)
1017+}
1018+
932 func (s *Screen) applyCursorStyle(params string) {1019 func (s *Screen) applyCursorStyle(params string) {
933 if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {1020 if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
934 return1021 return
@@ -1024,7 +1111,7 @@ func sgrRGB(r, g, b int) renderer.RGB {
1024 }1111 }
1025 1112
1026 func (s *Screen) saveCursor() {1113 func (s *Screen) saveCursor() {
1027- s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, saved: true}1114+ s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
1028 }1115 }
1029 1116
1030 func (s *Screen) restoreCursor() {1117 func (s *Screen) restoreCursor() {
@@ -1034,6 +1121,8 @@ func (s *Screen) restoreCursor() {
1034 s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)1121 s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
1035 s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)1122 s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
1036 s.Style = s.savedCursor.style1123 s.Style = s.savedCursor.style
1124+ s.originMode = s.savedCursor.originMode
1125+ s.insertMode = s.savedCursor.insertMode
1037 }1126 }
1038 1127
1039 func (s *Screen) reset() {1128 func (s *Screen) reset() {
@@ -1043,6 +1132,8 @@ func (s *Screen) reset() {
1043 s.escapeBuf = s.escapeBuf[:0]1132 s.escapeBuf = s.escapeBuf[:0]
1044 s.savedCursor = cursorState{}1133 s.savedCursor = cursorState{}
1045 s.alternate = nil1134 s.alternate = nil
1135+ s.originMode = false
1136+ s.insertMode = false
1046 s.cursorVisible = true1137 s.cursorVisible = true
1047 s.cursorStyle = 01138 s.cursorStyle = 0
1048 s.cursorStyleSet = false1139 s.cursorStyleSet = false
@@ -1074,7 +1165,7 @@ func (s *Screen) setScrollRegion(parts []int) {
1074 s.resetScrollRegion()1165 s.resetScrollRegion()
1075 }1166 }
1076 }1167 }
1077- s.Row, s.Col = 0, 01168+ s.homeCursor()
1078 }1169 }
1079 1170
1080 func (s *Screen) resetScrollRegion() {1171 func (s *Screen) resetScrollRegion() {
@@ -1088,10 +1179,23 @@ func (s *Screen) resetScrollRegion() {
1088 1179
1089 func (s *Screen) setMode(private bool, parts []int, enabled bool) {1180 func (s *Screen) setMode(private bool, parts []int, enabled bool) {
1090 if !private {1181 if !private {
1182+ for _, mode := range parts {
1183+ switch mode {
1184+ case 4:
1185+ s.insertMode = enabled
1186+ default:
1187+ // Other ANSI modes (for example LNM) are intentionally ignored
1188+ // until the screen model implements their observable behavior.
1189+ continue
1190+ }
1191+ }
1091 return1192 return
1092 }1193 }
1093 for _, mode := range parts {1194 for _, mode := range parts {
1094 switch mode {1195 switch mode {
1196+ case 6:
1197+ s.originMode = enabled
1198+ s.homeCursor()
1095 case 47, 1047, 1049:1199 case 47, 1047, 1049:
1096 if enabled {1200 if enabled {
1097 s.enterAlternateScreen()1201 s.enterAlternateScreen()
@@ -1132,6 +1236,8 @@ func (s *Screen) enterAlternateScreen() {
1132 scrollTop: s.scrollTop,1236 scrollTop: s.scrollTop,
1133 scrollBottom: s.scrollBottom,1237 scrollBottom: s.scrollBottom,
1134 savedCursor: s.savedCursor,1238 savedCursor: s.savedCursor,
1239+ originMode: s.originMode,
1240+ insertMode: s.insertMode,
1135 }1241 }
1136 }1242 }
1137 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)1243 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
@@ -1154,6 +1260,8 @@ func (s *Screen) exitAlternateScreen() {
1154 s.scrollTop = state.scrollTop1260 s.scrollTop = state.scrollTop
1155 s.scrollBottom = state.scrollBottom1261 s.scrollBottom = state.scrollBottom
1156 s.savedCursor = state.savedCursor1262 s.savedCursor = state.savedCursor
1263+ s.originMode = state.originMode
1264+ s.insertMode = state.insertMode
1157 s.alternate = nil1265 s.alternate = nil
1158 s.fullRedraw()1266 s.fullRedraw()
1159 }1267 }
@@ -1172,6 +1280,7 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
1172 break1280 break
1173 }1281 }
1174 copy(next.Row(dy), old.Row(sy))1282 copy(next.Row(dy), old.Row(sy))
1283+ repairFrameRow(next, dy)
1175 }1284 }
1176 return next, shift1285 return next, shift
1177 }1286 }
@@ -1397,7 +1506,7 @@ func appendSGRGroup(out []int, group string) []int {
1397 return out1506 return out
1398 }1507 }
1399 out = append(out, code, mode)1508 out = append(out, code, mode)
1400- for i := 0; i < 3; i++ {1509+ for i := range 3 {
1401 out = append(out, parseCSIInt(sgrColonField(group, start+i)))1510 out = append(out, parseCSIInt(sgrColonField(group, start+i)))
1402 }1511 }
1403 }1512 }
M pkg/vt/screen_test.go +170 -0
@@ -1,6 +1,7 @@
11 package vt
22
33 import (
4+ "math/rand"
45 "strings"
56 "testing"
67
@@ -66,6 +67,24 @@ func assertBlank(t *testing.T, s *Screen, x, y int) {
6667 }
6768 }
6869
70+func assertNoOrphanWideCells(t *testing.T, s *Screen) {
71+ t.Helper()
72+ for y := range s.Frame.Height {
73+ for x := range s.Frame.Width {
74+ c := cellAt(s, x, y)
75+ if c.Continuation {
76+ if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
77+ t.Fatalf("orphan continuation at (%d,%d)", x, y)
78+ }
79+ continue
80+ }
81+ if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
82+ t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
83+ }
84+ }
85+ }
86+}
87+
6988 func lineText(s *Screen, y int) string {
7089 out := make([]rune, s.Frame.Width)
7190 for x := range s.Frame.Width {
@@ -1177,6 +1196,29 @@ func TestESCSequences(t *testing.T) {
11771196 }
11781197 },
11791198 },
1199+ {
1200+ name: "ESC 7 / ESC 8 restore origin and insert modes",
1201+ run: func(t *testing.T) {
1202+ s := NewScreen(6, 5)
1203+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7"))
1204+ s.Write([]byte("\x1b[?6l\x1b[4l\x1b8"))
1205+
1206+ if !s.originMode {
1207+ t.Fatal("origin mode was not restored")
1208+ }
1209+ if !s.insertMode {
1210+ t.Fatal("insert mode was not restored")
1211+ }
1212+
1213+ s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY"))
1214+ if s.Row != 1 || s.Col != 4 {
1215+ t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col)
1216+ }
1217+ if got := lineText(s, 1); got != "abXYcd" {
1218+ t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd")
1219+ }
1220+ },
1221+ },
11801222 {
11811223 name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
11821224 run: func(t *testing.T) {
@@ -1278,6 +1320,59 @@ func TestCSIEditingSequences(t *testing.T) {
12781320 }
12791321 },
12801322 },
1323+ {
1324+ name: "DECOM homes and addresses relative to scroll region",
1325+ run: func(t *testing.T) {
1326+ s := NewScreen(6, 5)
1327+ s.Write([]byte("\x1b[2;4r\x1b[?6h"))
1328+ if s.Row != 1 || s.Col != 0 {
1329+ t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
1330+ }
1331+
1332+ s.Write([]byte("\x1b[2;3H"))
1333+ if s.Row != 2 || s.Col != 2 {
1334+ t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
1335+ }
1336+ s.Write([]byte("\x1b[99;1H"))
1337+ if s.Row != 3 || s.Col != 0 {
1338+ t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
1339+ }
1340+ },
1341+ },
1342+ {
1343+ name: "DECOM already set homes DECSTBM to scroll region",
1344+ run: func(t *testing.T) {
1345+ s := NewScreen(6, 5)
1346+ s.Write([]byte("\x1b[?6h\x1b[2;4r"))
1347+ if s.Row != 1 || s.Col != 0 {
1348+ t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col)
1349+ }
1350+
1351+ s.Write([]byte("\x1b[2;3H"))
1352+ if s.Row != 2 || s.Col != 2 {
1353+ t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col)
1354+ }
1355+ s.Write([]byte("\x1b[99;1H"))
1356+ if s.Row != 3 || s.Col != 0 {
1357+ t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col)
1358+ }
1359+ },
1360+ },
1361+ {
1362+ name: "DECOM reset homes and restores full-frame addressing",
1363+ run: func(t *testing.T) {
1364+ s := NewScreen(6, 5)
1365+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
1366+ if s.Row != 0 || s.Col != 0 {
1367+ t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
1368+ }
1369+
1370+ s.Write([]byte("\x1b[5;6H"))
1371+ if s.Row != 4 || s.Col != 5 {
1372+ t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
1373+ }
1374+ },
1375+ },
12811376 {
12821377 name: "private alternate screen mode restores normal screen on exit",
12831378 run: func(t *testing.T) {
@@ -1636,6 +1731,35 @@ func TestResize(t *testing.T) {
16361731 }
16371732 },
16381733 },
1734+ {
1735+ name: "width shrink repairs truncated wide head",
1736+ run: func(t *testing.T) {
1737+ s := NewScreen(4, 1)
1738+ s.Write([]byte("AB界"))
1739+
1740+ s.Resize(3, 1)
1741+
1742+ assertCell(t, s, 0, 0, 'A')
1743+ assertCell(t, s, 1, 0, 'B')
1744+ assertBlank(t, s, 2, 0)
1745+ assertNoOrphanWideCells(t, s)
1746+ },
1747+ },
1748+ {
1749+ name: "random wide resize repairs copied rows",
1750+ run: func(t *testing.T) {
1751+ rng := rand.New(rand.NewSource(1))
1752+ for range 200 {
1753+ s := NewScreen(8, 4)
1754+ s.Write([]byte("a界b好c語d"))
1755+ s.Row = rng.Intn(s.Frame.Height)
1756+
1757+ s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
1758+
1759+ assertNoOrphanWideCells(t, s)
1760+ }
1761+ },
1762+ },
16391763 {
16401764 name: "cursor and saved cursor clamp",
16411765 run: func(t *testing.T) {
@@ -2204,6 +2328,52 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
22042328 }
22052329 },
22062330 },
2331+ {
2332+ name: "IRM inserts narrow printable cells and reset restores overwrite",
2333+ run: func(t *testing.T) {
2334+ s := NewScreen(8, 1)
2335+ s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
2336+
2337+ if got := lineText(s, 0); got != "abXcd " {
2338+ t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
2339+ }
2340+ s.Write([]byte("\x1b[4l\x1b[1;3HY"))
2341+ if got := lineText(s, 0); got != "abYcd " {
2342+ t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
2343+ }
2344+ },
2345+ },
2346+ {
2347+ name: "IRM inserts wide printable cells and repairs evicted wide head",
2348+ run: func(t *testing.T) {
2349+ s := NewScreen(6, 1)
2350+ s.Write([]byte("ab界cd"))
2351+ s.Write([]byte("\x1b[1;3H\x1b[4h好"))
2352+
2353+ assertCell(t, s, 0, 0, 'a')
2354+ assertCell(t, s, 1, 0, 'b')
2355+ assertCell(t, s, 2, 0, '好')
2356+ assertContinuation(t, s, 3, 0)
2357+ assertCell(t, s, 4, 0, '界')
2358+ assertContinuation(t, s, 5, 0)
2359+ assertNoOrphanWideCells(t, s)
2360+ },
2361+ },
2362+ {
2363+ name: "IRM wide insert at right edge repairs orphan",
2364+ run: func(t *testing.T) {
2365+ s := NewScreen(5, 1)
2366+ s.Write([]byte("abc界"))
2367+ s.Write([]byte("\x1b[1;2H\x1b[4h好"))
2368+
2369+ assertCell(t, s, 0, 0, 'a')
2370+ assertCell(t, s, 1, 0, '好')
2371+ assertContinuation(t, s, 2, 0)
2372+ assertCell(t, s, 3, 0, 'b')
2373+ assertCell(t, s, 4, 0, 'c')
2374+ assertNoOrphanWideCells(t, s)
2375+ },
2376+ },
22072377 {
22082378 name: "wide char on a width-1 screen degrades to a single cell",
22092379 run: func(t *testing.T) {
@@ -1,6 +1,7 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "math/rand"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
@@ -66,6 +67,24 @@ func assertBlank(t *testing.T, s *Screen, x, y int) {
66 }67 }
67 }68 }
68 69
70+func assertNoOrphanWideCells(t *testing.T, s *Screen) {
71+ t.Helper()
72+ for y := range s.Frame.Height {
73+ for x := range s.Frame.Width {
74+ c := cellAt(s, x, y)
75+ if c.Continuation {
76+ if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
77+ t.Fatalf("orphan continuation at (%d,%d)", x, y)
78+ }
79+ continue
80+ }
81+ if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
82+ t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
83+ }
84+ }
85+ }
86+}
87+
69 func lineText(s *Screen, y int) string {88 func lineText(s *Screen, y int) string {
70 out := make([]rune, s.Frame.Width)89 out := make([]rune, s.Frame.Width)
71 for x := range s.Frame.Width {90 for x := range s.Frame.Width {
@@ -1177,6 +1196,29 @@ func TestESCSequences(t *testing.T) {
1177 }1196 }
1178 },1197 },
1179 },1198 },
1199+ {
1200+ name: "ESC 7 / ESC 8 restore origin and insert modes",
1201+ run: func(t *testing.T) {
1202+ s := NewScreen(6, 5)
1203+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7"))
1204+ s.Write([]byte("\x1b[?6l\x1b[4l\x1b8"))
1205+
1206+ if !s.originMode {
1207+ t.Fatal("origin mode was not restored")
1208+ }
1209+ if !s.insertMode {
1210+ t.Fatal("insert mode was not restored")
1211+ }
1212+
1213+ s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY"))
1214+ if s.Row != 1 || s.Col != 4 {
1215+ t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col)
1216+ }
1217+ if got := lineText(s, 1); got != "abXYcd" {
1218+ t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd")
1219+ }
1220+ },
1221+ },
1180 {1222 {
1181 name: "ESC D (index), ESC E (next line), ESC M (reverse index)",1223 name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
1182 run: func(t *testing.T) {1224 run: func(t *testing.T) {
@@ -1278,6 +1320,59 @@ func TestCSIEditingSequences(t *testing.T) {
1278 }1320 }
1279 },1321 },
1280 },1322 },
1323+ {
1324+ name: "DECOM homes and addresses relative to scroll region",
1325+ run: func(t *testing.T) {
1326+ s := NewScreen(6, 5)
1327+ s.Write([]byte("\x1b[2;4r\x1b[?6h"))
1328+ if s.Row != 1 || s.Col != 0 {
1329+ t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
1330+ }
1331+
1332+ s.Write([]byte("\x1b[2;3H"))
1333+ if s.Row != 2 || s.Col != 2 {
1334+ t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
1335+ }
1336+ s.Write([]byte("\x1b[99;1H"))
1337+ if s.Row != 3 || s.Col != 0 {
1338+ t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
1339+ }
1340+ },
1341+ },
1342+ {
1343+ name: "DECOM already set homes DECSTBM to scroll region",
1344+ run: func(t *testing.T) {
1345+ s := NewScreen(6, 5)
1346+ s.Write([]byte("\x1b[?6h\x1b[2;4r"))
1347+ if s.Row != 1 || s.Col != 0 {
1348+ t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col)
1349+ }
1350+
1351+ s.Write([]byte("\x1b[2;3H"))
1352+ if s.Row != 2 || s.Col != 2 {
1353+ t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col)
1354+ }
1355+ s.Write([]byte("\x1b[99;1H"))
1356+ if s.Row != 3 || s.Col != 0 {
1357+ t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col)
1358+ }
1359+ },
1360+ },
1361+ {
1362+ name: "DECOM reset homes and restores full-frame addressing",
1363+ run: func(t *testing.T) {
1364+ s := NewScreen(6, 5)
1365+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
1366+ if s.Row != 0 || s.Col != 0 {
1367+ t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
1368+ }
1369+
1370+ s.Write([]byte("\x1b[5;6H"))
1371+ if s.Row != 4 || s.Col != 5 {
1372+ t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
1373+ }
1374+ },
1375+ },
1281 {1376 {
1282 name: "private alternate screen mode restores normal screen on exit",1377 name: "private alternate screen mode restores normal screen on exit",
1283 run: func(t *testing.T) {1378 run: func(t *testing.T) {
@@ -1636,6 +1731,35 @@ func TestResize(t *testing.T) {
1636 }1731 }
1637 },1732 },
1638 },1733 },
1734+ {
1735+ name: "width shrink repairs truncated wide head",
1736+ run: func(t *testing.T) {
1737+ s := NewScreen(4, 1)
1738+ s.Write([]byte("AB界"))
1739+
1740+ s.Resize(3, 1)
1741+
1742+ assertCell(t, s, 0, 0, 'A')
1743+ assertCell(t, s, 1, 0, 'B')
1744+ assertBlank(t, s, 2, 0)
1745+ assertNoOrphanWideCells(t, s)
1746+ },
1747+ },
1748+ {
1749+ name: "random wide resize repairs copied rows",
1750+ run: func(t *testing.T) {
1751+ rng := rand.New(rand.NewSource(1))
1752+ for range 200 {
1753+ s := NewScreen(8, 4)
1754+ s.Write([]byte("a界b好c語d"))
1755+ s.Row = rng.Intn(s.Frame.Height)
1756+
1757+ s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
1758+
1759+ assertNoOrphanWideCells(t, s)
1760+ }
1761+ },
1762+ },
1639 {1763 {
1640 name: "cursor and saved cursor clamp",1764 name: "cursor and saved cursor clamp",
1641 run: func(t *testing.T) {1765 run: func(t *testing.T) {
@@ -2204,6 +2328,52 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
2204 }2328 }
2205 },2329 },
2206 },2330 },
2331+ {
2332+ name: "IRM inserts narrow printable cells and reset restores overwrite",
2333+ run: func(t *testing.T) {
2334+ s := NewScreen(8, 1)
2335+ s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
2336+
2337+ if got := lineText(s, 0); got != "abXcd " {
2338+ t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
2339+ }
2340+ s.Write([]byte("\x1b[4l\x1b[1;3HY"))
2341+ if got := lineText(s, 0); got != "abYcd " {
2342+ t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
2343+ }
2344+ },
2345+ },
2346+ {
2347+ name: "IRM inserts wide printable cells and repairs evicted wide head",
2348+ run: func(t *testing.T) {
2349+ s := NewScreen(6, 1)
2350+ s.Write([]byte("ab界cd"))
2351+ s.Write([]byte("\x1b[1;3H\x1b[4h好"))
2352+
2353+ assertCell(t, s, 0, 0, 'a')
2354+ assertCell(t, s, 1, 0, 'b')
2355+ assertCell(t, s, 2, 0, '好')
2356+ assertContinuation(t, s, 3, 0)
2357+ assertCell(t, s, 4, 0, '界')
2358+ assertContinuation(t, s, 5, 0)
2359+ assertNoOrphanWideCells(t, s)
2360+ },
2361+ },
2362+ {
2363+ name: "IRM wide insert at right edge repairs orphan",
2364+ run: func(t *testing.T) {
2365+ s := NewScreen(5, 1)
2366+ s.Write([]byte("abc界"))
2367+ s.Write([]byte("\x1b[1;2H\x1b[4h好"))
2368+
2369+ assertCell(t, s, 0, 0, 'a')
2370+ assertCell(t, s, 1, 0, '好')
2371+ assertContinuation(t, s, 2, 0)
2372+ assertCell(t, s, 3, 0, 'b')
2373+ assertCell(t, s, 4, 0, 'c')
2374+ assertNoOrphanWideCells(t, s)
2375+ },
2376+ },
2207 {2377 {
2208 name: "wide char on a width-1 screen degrades to a single cell",2378 name: "wide char on a width-1 screen degrades to a single cell",
2209 run: func(t *testing.T) {2379 run: func(t *testing.T) {

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 2

Tags 13

↑↓ moveenter openesc close