detached b34df7b

fix(vt): repair resize and support origin insert modes

commit
b34df7b3aabac9eaee588ca71c25812777c70a94
author
brice · · 2026-07-09 14:19 UTC
parent
71c1f46
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +222 -15
M pkg/vt/screen.go +94 -15
@@ -23,6 +23,8 @@ type screenState struct {
2323 scrollTop int
2424 scrollBottom int
2525 savedCursor cursorState
26+ originMode bool
27+ insertMode bool
2628 }
2729
2830 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -98,6 +100,8 @@ type Screen struct {
98100 mouseMode int
99101 mouseSGR bool
100102 bracketedPaste bool
103+ originMode bool
104+ insertMode bool
101105 colorSchemeMode bool
102106 colorSchemeLight bool
103107 colorSchemeSet bool
@@ -325,6 +329,24 @@ func (s *Screen) putPrintable(r rune) {
325329 }
326330 }
327331
332+ insertDamageX := s.Col
333+ insertDamageWidth := 0
334+ if s.insertMode {
335+ row := s.Frame.Row(s.Row)
336+ leftSplit := s.Col > 0 && row[s.Col].Continuation
337+ for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
338+ row[x] = row[x-w]
339+ }
340+ for x := s.Col; x < s.Col+w; x++ {
341+ row[x] = renderer.BlankCell()
342+ }
343+ s.repairRow(s.Row)
344+ if leftSplit {
345+ insertDamageX = s.Col - 1
346+ }
347+ insertDamageWidth = s.Frame.Width - insertDamageX
348+ }
349+
328350 // Determine the range of cells actually modified, extending over any wide
329351 // pair the write lands on so no orphaned half is left behind.
330352 lo, hi := s.Col, s.Col+w-1
@@ -341,6 +363,10 @@ func (s *Screen) putPrintable(r rune) {
341363 if w == 2 {
342364 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
343365 }
366+ if insertDamageWidth > 0 {
367+ lo = min(lo, insertDamageX)
368+ hi = max(hi, insertDamageX+insertDamageWidth-1)
369+ }
344370 s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
345371 s.Col += w
346372 }
@@ -402,26 +428,30 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
402428 // continuation. Used after erase/insert/delete operations that may split a pair
403429 // at a boundary or by shifting cells.
404430 func (s *Screen) repairRow(y int) {
405- if y < 0 || y >= s.Frame.Height {
431+ repairFrameRow(s.Frame, y)
432+}
433+
434+func repairFrameRow(frame renderer.Frame, y int) {
435+ if y < 0 || y >= frame.Height {
406436 return
407437 }
408- w := s.Frame.Width
438+ w := frame.Width
409439 for x := range w {
410- c := s.Frame.At(x, y)
440+ c := frame.At(x, y)
411441 if c.Continuation {
412442 if x == 0 {
413- s.Frame.Set(x, y, renderer.BlankCell())
443+ frame.Set(x, y, renderer.BlankCell())
414444 continue
415445 }
416- left := s.Frame.At(x-1, y)
446+ left := frame.At(x-1, y)
417447 if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
418- s.Frame.Set(x, y, renderer.BlankCell())
448+ frame.Set(x, y, renderer.BlankCell())
419449 }
420450 continue
421451 }
422452 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())
453+ if x+1 >= w || !frame.At(x+1, y).Continuation {
454+ frame.Set(x, y, renderer.BlankCell())
425455 }
426456 }
427457 }
@@ -877,28 +907,27 @@ func (s *Screen) applyCSI(params string, cmd byte) {
877907 if len(parts) > 1 && parts[1] > 0 {
878908 col = parts[1]
879909 }
880- s.Row = clamp(row-1, 0, s.Frame.Height-1)
881- s.Col = clamp(col-1, 0, s.Frame.Width-1)
910+ s.addressCursor(row, col)
882911 case 'A':
883- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)
912+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
884913 case 'B':
885- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)
914+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
886915 case 'C':
887916 s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
888917 case 'D':
889918 s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
890919 case 'E':
891- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)
920+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
892921 s.Col = 0
893922 case 'F':
894- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)
923+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
895924 s.Col = 0
896925 case 'G':
897926 col := firstPositive(parts, 1)
898927 s.Col = clamp(col-1, 0, s.Frame.Width-1)
899928 case 'd':
900929 row := firstPositive(parts, 1)
901- s.Row = clamp(row-1, 0, s.Frame.Height-1)
930+ s.Row = s.addressedRow(row)
902931 case '@':
903932 s.insertChars(firstPositive(parts, 1))
904933 case 'P':
@@ -929,6 +958,36 @@ func firstPositive(parts []int, fallback int) int {
929958 return parts[0]
930959 }
931960
961+func (s *Screen) cursorMinRow() int {
962+ if s.originMode {
963+ return clamp(s.scrollTop, 0, s.Frame.Height-1)
964+ }
965+ return 0
966+}
967+
968+func (s *Screen) cursorMaxRow() int {
969+ if s.originMode {
970+ return clamp(s.scrollBottom, 0, s.Frame.Height-1)
971+ }
972+ return max(s.Frame.Height-1, 0)
973+}
974+
975+func (s *Screen) addressedRow(row int) int {
976+ if s.originMode {
977+ return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
978+ }
979+ return clamp(row-1, 0, s.Frame.Height-1)
980+}
981+
982+func (s *Screen) addressCursor(row, col int) {
983+ s.Row = s.addressedRow(row)
984+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
985+}
986+
987+func (s *Screen) homeCursor() {
988+ s.addressCursor(1, 1)
989+}
990+
932991 func (s *Screen) applyCursorStyle(params string) {
933992 if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
934993 return
@@ -1043,6 +1102,8 @@ func (s *Screen) reset() {
10431102 s.escapeBuf = s.escapeBuf[:0]
10441103 s.savedCursor = cursorState{}
10451104 s.alternate = nil
1105+ s.originMode = false
1106+ s.insertMode = false
10461107 s.cursorVisible = true
10471108 s.cursorStyle = 0
10481109 s.cursorStyleSet = false
@@ -1088,10 +1149,23 @@ func (s *Screen) resetScrollRegion() {
10881149
10891150 func (s *Screen) setMode(private bool, parts []int, enabled bool) {
10901151 if !private {
1152+ for _, mode := range parts {
1153+ switch mode {
1154+ case 4:
1155+ s.insertMode = enabled
1156+ default:
1157+ // Other ANSI modes (for example LNM) are intentionally ignored
1158+ // until the screen model implements their observable behavior.
1159+ continue
1160+ }
1161+ }
10911162 return
10921163 }
10931164 for _, mode := range parts {
10941165 switch mode {
1166+ case 6:
1167+ s.originMode = enabled
1168+ s.homeCursor()
10951169 case 47, 1047, 1049:
10961170 if enabled {
10971171 s.enterAlternateScreen()
@@ -1132,6 +1206,8 @@ func (s *Screen) enterAlternateScreen() {
11321206 scrollTop: s.scrollTop,
11331207 scrollBottom: s.scrollBottom,
11341208 savedCursor: s.savedCursor,
1209+ originMode: s.originMode,
1210+ insertMode: s.insertMode,
11351211 }
11361212 }
11371213 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
@@ -1154,6 +1230,8 @@ func (s *Screen) exitAlternateScreen() {
11541230 s.scrollTop = state.scrollTop
11551231 s.scrollBottom = state.scrollBottom
11561232 s.savedCursor = state.savedCursor
1233+ s.originMode = state.originMode
1234+ s.insertMode = state.insertMode
11571235 s.alternate = nil
11581236 s.fullRedraw()
11591237 }
@@ -1172,6 +1250,7 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
11721250 break
11731251 }
11741252 copy(next.Row(dy), old.Row(sy))
1253+ repairFrameRow(next, dy)
11751254 }
11761255 return next, shift
11771256 }
@@ -23,6 +23,8 @@ type screenState struct {
23 scrollTop int23 scrollTop int
24 scrollBottom int24 scrollBottom int
25 savedCursor cursorState25 savedCursor cursorState
26+ originMode bool
27+ insertMode bool
26 }28 }
27 29
28 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads30 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -98,6 +100,8 @@ type Screen struct {
98 mouseMode int100 mouseMode int
99 mouseSGR bool101 mouseSGR bool
100 bracketedPaste bool102 bracketedPaste bool
103+ originMode bool
104+ insertMode bool
101 colorSchemeMode bool105 colorSchemeMode bool
102 colorSchemeLight bool106 colorSchemeLight bool
103 colorSchemeSet bool107 colorSchemeSet bool
@@ -325,6 +329,24 @@ func (s *Screen) putPrintable(r rune) {
325 }329 }
326 }330 }
327 331
332+ insertDamageX := s.Col
333+ insertDamageWidth := 0
334+ if s.insertMode {
335+ row := s.Frame.Row(s.Row)
336+ leftSplit := s.Col > 0 && row[s.Col].Continuation
337+ for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
338+ row[x] = row[x-w]
339+ }
340+ for x := s.Col; x < s.Col+w; x++ {
341+ row[x] = renderer.BlankCell()
342+ }
343+ s.repairRow(s.Row)
344+ if leftSplit {
345+ insertDamageX = s.Col - 1
346+ }
347+ insertDamageWidth = s.Frame.Width - insertDamageX
348+ }
349+
328 // Determine the range of cells actually modified, extending over any wide350 // Determine the range of cells actually modified, extending over any wide
329 // pair the write lands on so no orphaned half is left behind.351 // pair the write lands on so no orphaned half is left behind.
330 lo, hi := s.Col, s.Col+w-1352 lo, hi := s.Col, s.Col+w-1
@@ -341,6 +363,10 @@ func (s *Screen) putPrintable(r rune) {
341 if w == 2 {363 if w == 2 {
342 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})364 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
343 }365 }
366+ if insertDamageWidth > 0 {
367+ lo = min(lo, insertDamageX)
368+ hi = max(hi, insertDamageX+insertDamageWidth-1)
369+ }
344 s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})370 s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
345 s.Col += w371 s.Col += w
346 }372 }
@@ -402,26 +428,30 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
402 // continuation. Used after erase/insert/delete operations that may split a pair428 // continuation. Used after erase/insert/delete operations that may split a pair
403 // at a boundary or by shifting cells.429 // at a boundary or by shifting cells.
404 func (s *Screen) repairRow(y int) {430 func (s *Screen) repairRow(y int) {
405- if y < 0 || y >= s.Frame.Height {431+ repairFrameRow(s.Frame, y)
432+}
433+
434+func repairFrameRow(frame renderer.Frame, y int) {
435+ if y < 0 || y >= frame.Height {
406 return436 return
407 }437 }
408- w := s.Frame.Width438+ w := frame.Width
409 for x := range w {439 for x := range w {
410- c := s.Frame.At(x, y)440+ c := frame.At(x, y)
411 if c.Continuation {441 if c.Continuation {
412 if x == 0 {442 if x == 0 {
413- s.Frame.Set(x, y, renderer.BlankCell())443+ frame.Set(x, y, renderer.BlankCell())
414 continue444 continue
415 }445 }
416- left := s.Frame.At(x-1, y)446+ left := frame.At(x-1, y)
417 if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {447 if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
418- s.Frame.Set(x, y, renderer.BlankCell())448+ frame.Set(x, y, renderer.BlankCell())
419 }449 }
420 continue450 continue
421 }451 }
422 if renderer.RuneWidth(c.Rune) == 2 {452 if renderer.RuneWidth(c.Rune) == 2 {
423- if x+1 >= w || !s.Frame.At(x+1, y).Continuation {453+ if x+1 >= w || !frame.At(x+1, y).Continuation {
424- s.Frame.Set(x, y, renderer.BlankCell())454+ frame.Set(x, y, renderer.BlankCell())
425 }455 }
426 }456 }
427 }457 }
@@ -877,28 +907,27 @@ func (s *Screen) applyCSI(params string, cmd byte) {
877 if len(parts) > 1 && parts[1] > 0 {907 if len(parts) > 1 && parts[1] > 0 {
878 col = parts[1]908 col = parts[1]
879 }909 }
880- s.Row = clamp(row-1, 0, s.Frame.Height-1)910+ s.addressCursor(row, col)
881- s.Col = clamp(col-1, 0, s.Frame.Width-1)
882 case 'A':911 case 'A':
883- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)912+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
884 case 'B':913 case 'B':
885- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)914+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
886 case 'C':915 case 'C':
887 s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)916 s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
888 case 'D':917 case 'D':
889 s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)918 s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
890 case 'E':919 case 'E':
891- s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1)920+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
892 s.Col = 0921 s.Col = 0
893 case 'F':922 case 'F':
894- s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1)923+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
895 s.Col = 0924 s.Col = 0
896 case 'G':925 case 'G':
897 col := firstPositive(parts, 1)926 col := firstPositive(parts, 1)
898 s.Col = clamp(col-1, 0, s.Frame.Width-1)927 s.Col = clamp(col-1, 0, s.Frame.Width-1)
899 case 'd':928 case 'd':
900 row := firstPositive(parts, 1)929 row := firstPositive(parts, 1)
901- s.Row = clamp(row-1, 0, s.Frame.Height-1)930+ s.Row = s.addressedRow(row)
902 case '@':931 case '@':
903 s.insertChars(firstPositive(parts, 1))932 s.insertChars(firstPositive(parts, 1))
904 case 'P':933 case 'P':
@@ -929,6 +958,36 @@ func firstPositive(parts []int, fallback int) int {
929 return parts[0]958 return parts[0]
930 }959 }
931 960
961+func (s *Screen) cursorMinRow() int {
962+ if s.originMode {
963+ return clamp(s.scrollTop, 0, s.Frame.Height-1)
964+ }
965+ return 0
966+}
967+
968+func (s *Screen) cursorMaxRow() int {
969+ if s.originMode {
970+ return clamp(s.scrollBottom, 0, s.Frame.Height-1)
971+ }
972+ return max(s.Frame.Height-1, 0)
973+}
974+
975+func (s *Screen) addressedRow(row int) int {
976+ if s.originMode {
977+ return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
978+ }
979+ return clamp(row-1, 0, s.Frame.Height-1)
980+}
981+
982+func (s *Screen) addressCursor(row, col int) {
983+ s.Row = s.addressedRow(row)
984+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
985+}
986+
987+func (s *Screen) homeCursor() {
988+ s.addressCursor(1, 1)
989+}
990+
932 func (s *Screen) applyCursorStyle(params string) {991 func (s *Screen) applyCursorStyle(params string) {
933 if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {992 if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
934 return993 return
@@ -1043,6 +1102,8 @@ func (s *Screen) reset() {
1043 s.escapeBuf = s.escapeBuf[:0]1102 s.escapeBuf = s.escapeBuf[:0]
1044 s.savedCursor = cursorState{}1103 s.savedCursor = cursorState{}
1045 s.alternate = nil1104 s.alternate = nil
1105+ s.originMode = false
1106+ s.insertMode = false
1046 s.cursorVisible = true1107 s.cursorVisible = true
1047 s.cursorStyle = 01108 s.cursorStyle = 0
1048 s.cursorStyleSet = false1109 s.cursorStyleSet = false
@@ -1088,10 +1149,23 @@ func (s *Screen) resetScrollRegion() {
1088 1149
1089 func (s *Screen) setMode(private bool, parts []int, enabled bool) {1150 func (s *Screen) setMode(private bool, parts []int, enabled bool) {
1090 if !private {1151 if !private {
1152+ for _, mode := range parts {
1153+ switch mode {
1154+ case 4:
1155+ s.insertMode = enabled
1156+ default:
1157+ // Other ANSI modes (for example LNM) are intentionally ignored
1158+ // until the screen model implements their observable behavior.
1159+ continue
1160+ }
1161+ }
1091 return1162 return
1092 }1163 }
1093 for _, mode := range parts {1164 for _, mode := range parts {
1094 switch mode {1165 switch mode {
1166+ case 6:
1167+ s.originMode = enabled
1168+ s.homeCursor()
1095 case 47, 1047, 1049:1169 case 47, 1047, 1049:
1096 if enabled {1170 if enabled {
1097 s.enterAlternateScreen()1171 s.enterAlternateScreen()
@@ -1132,6 +1206,8 @@ func (s *Screen) enterAlternateScreen() {
1132 scrollTop: s.scrollTop,1206 scrollTop: s.scrollTop,
1133 scrollBottom: s.scrollBottom,1207 scrollBottom: s.scrollBottom,
1134 savedCursor: s.savedCursor,1208 savedCursor: s.savedCursor,
1209+ originMode: s.originMode,
1210+ insertMode: s.insertMode,
1135 }1211 }
1136 }1212 }
1137 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)1213 s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
@@ -1154,6 +1230,8 @@ func (s *Screen) exitAlternateScreen() {
1154 s.scrollTop = state.scrollTop1230 s.scrollTop = state.scrollTop
1155 s.scrollBottom = state.scrollBottom1231 s.scrollBottom = state.scrollBottom
1156 s.savedCursor = state.savedCursor1232 s.savedCursor = state.savedCursor
1233+ s.originMode = state.originMode
1234+ s.insertMode = state.insertMode
1157 s.alternate = nil1235 s.alternate = nil
1158 s.fullRedraw()1236 s.fullRedraw()
1159 }1237 }
@@ -1172,6 +1250,7 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
1172 break1250 break
1173 }1251 }
1174 copy(next.Row(dy), old.Row(sy))1252 copy(next.Row(dy), old.Row(sy))
1253+ repairFrameRow(next, dy)
1175 }1254 }
1176 return next, shift1255 return next, shift
1177 }1256 }
M pkg/vt/screen_test.go +128 -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 {
@@ -1278,6 +1297,40 @@ func TestCSIEditingSequences(t *testing.T) {
12781297 }
12791298 },
12801299 },
1300+ {
1301+ name: "DECOM homes and addresses relative to scroll region",
1302+ run: func(t *testing.T) {
1303+ s := NewScreen(6, 5)
1304+ s.Write([]byte("\x1b[2;4r\x1b[?6h"))
1305+ if s.Row != 1 || s.Col != 0 {
1306+ t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
1307+ }
1308+
1309+ s.Write([]byte("\x1b[2;3H"))
1310+ if s.Row != 2 || s.Col != 2 {
1311+ t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
1312+ }
1313+ s.Write([]byte("\x1b[99;1H"))
1314+ if s.Row != 3 || s.Col != 0 {
1315+ t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
1316+ }
1317+ },
1318+ },
1319+ {
1320+ name: "DECOM reset homes and restores full-frame addressing",
1321+ run: func(t *testing.T) {
1322+ s := NewScreen(6, 5)
1323+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
1324+ if s.Row != 0 || s.Col != 0 {
1325+ t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
1326+ }
1327+
1328+ s.Write([]byte("\x1b[5;6H"))
1329+ if s.Row != 4 || s.Col != 5 {
1330+ t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
1331+ }
1332+ },
1333+ },
12811334 {
12821335 name: "private alternate screen mode restores normal screen on exit",
12831336 run: func(t *testing.T) {
@@ -1636,6 +1689,35 @@ func TestResize(t *testing.T) {
16361689 }
16371690 },
16381691 },
1692+ {
1693+ name: "width shrink repairs truncated wide head",
1694+ run: func(t *testing.T) {
1695+ s := NewScreen(4, 1)
1696+ s.Write([]byte("AB界"))
1697+
1698+ s.Resize(3, 1)
1699+
1700+ assertCell(t, s, 0, 0, 'A')
1701+ assertCell(t, s, 1, 0, 'B')
1702+ assertBlank(t, s, 2, 0)
1703+ assertNoOrphanWideCells(t, s)
1704+ },
1705+ },
1706+ {
1707+ name: "random wide resize repairs copied rows",
1708+ run: func(t *testing.T) {
1709+ rng := rand.New(rand.NewSource(1))
1710+ for i := 0; i < 200; i++ {
1711+ s := NewScreen(8, 4)
1712+ s.Write([]byte("a界b好c語d"))
1713+ s.Row = rng.Intn(s.Frame.Height)
1714+
1715+ s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
1716+
1717+ assertNoOrphanWideCells(t, s)
1718+ }
1719+ },
1720+ },
16391721 {
16401722 name: "cursor and saved cursor clamp",
16411723 run: func(t *testing.T) {
@@ -2204,6 +2286,52 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
22042286 }
22052287 },
22062288 },
2289+ {
2290+ name: "IRM inserts narrow printable cells and reset restores overwrite",
2291+ run: func(t *testing.T) {
2292+ s := NewScreen(8, 1)
2293+ s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
2294+
2295+ if got := lineText(s, 0); got != "abXcd " {
2296+ t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
2297+ }
2298+ s.Write([]byte("\x1b[4l\x1b[1;3HY"))
2299+ if got := lineText(s, 0); got != "abYcd " {
2300+ t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
2301+ }
2302+ },
2303+ },
2304+ {
2305+ name: "IRM inserts wide printable cells and repairs evicted wide head",
2306+ run: func(t *testing.T) {
2307+ s := NewScreen(6, 1)
2308+ s.Write([]byte("ab界cd"))
2309+ s.Write([]byte("\x1b[1;3H\x1b[4h好"))
2310+
2311+ assertCell(t, s, 0, 0, 'a')
2312+ assertCell(t, s, 1, 0, 'b')
2313+ assertCell(t, s, 2, 0, '好')
2314+ assertContinuation(t, s, 3, 0)
2315+ assertCell(t, s, 4, 0, '界')
2316+ assertContinuation(t, s, 5, 0)
2317+ assertNoOrphanWideCells(t, s)
2318+ },
2319+ },
2320+ {
2321+ name: "IRM wide insert at right edge repairs orphan",
2322+ run: func(t *testing.T) {
2323+ s := NewScreen(5, 1)
2324+ s.Write([]byte("abc界"))
2325+ s.Write([]byte("\x1b[1;2H\x1b[4h好"))
2326+
2327+ assertCell(t, s, 0, 0, 'a')
2328+ assertCell(t, s, 1, 0, '好')
2329+ assertContinuation(t, s, 2, 0)
2330+ assertCell(t, s, 3, 0, 'b')
2331+ assertCell(t, s, 4, 0, 'c')
2332+ assertNoOrphanWideCells(t, s)
2333+ },
2334+ },
22072335 {
22082336 name: "wide char on a width-1 screen degrades to a single cell",
22092337 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 {
@@ -1278,6 +1297,40 @@ func TestCSIEditingSequences(t *testing.T) {
1278 }1297 }
1279 },1298 },
1280 },1299 },
1300+ {
1301+ name: "DECOM homes and addresses relative to scroll region",
1302+ run: func(t *testing.T) {
1303+ s := NewScreen(6, 5)
1304+ s.Write([]byte("\x1b[2;4r\x1b[?6h"))
1305+ if s.Row != 1 || s.Col != 0 {
1306+ t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
1307+ }
1308+
1309+ s.Write([]byte("\x1b[2;3H"))
1310+ if s.Row != 2 || s.Col != 2 {
1311+ t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
1312+ }
1313+ s.Write([]byte("\x1b[99;1H"))
1314+ if s.Row != 3 || s.Col != 0 {
1315+ t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
1316+ }
1317+ },
1318+ },
1319+ {
1320+ name: "DECOM reset homes and restores full-frame addressing",
1321+ run: func(t *testing.T) {
1322+ s := NewScreen(6, 5)
1323+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
1324+ if s.Row != 0 || s.Col != 0 {
1325+ t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
1326+ }
1327+
1328+ s.Write([]byte("\x1b[5;6H"))
1329+ if s.Row != 4 || s.Col != 5 {
1330+ t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
1331+ }
1332+ },
1333+ },
1281 {1334 {
1282 name: "private alternate screen mode restores normal screen on exit",1335 name: "private alternate screen mode restores normal screen on exit",
1283 run: func(t *testing.T) {1336 run: func(t *testing.T) {
@@ -1636,6 +1689,35 @@ func TestResize(t *testing.T) {
1636 }1689 }
1637 },1690 },
1638 },1691 },
1692+ {
1693+ name: "width shrink repairs truncated wide head",
1694+ run: func(t *testing.T) {
1695+ s := NewScreen(4, 1)
1696+ s.Write([]byte("AB界"))
1697+
1698+ s.Resize(3, 1)
1699+
1700+ assertCell(t, s, 0, 0, 'A')
1701+ assertCell(t, s, 1, 0, 'B')
1702+ assertBlank(t, s, 2, 0)
1703+ assertNoOrphanWideCells(t, s)
1704+ },
1705+ },
1706+ {
1707+ name: "random wide resize repairs copied rows",
1708+ run: func(t *testing.T) {
1709+ rng := rand.New(rand.NewSource(1))
1710+ for i := 0; i < 200; i++ {
1711+ s := NewScreen(8, 4)
1712+ s.Write([]byte("a界b好c語d"))
1713+ s.Row = rng.Intn(s.Frame.Height)
1714+
1715+ s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
1716+
1717+ assertNoOrphanWideCells(t, s)
1718+ }
1719+ },
1720+ },
1639 {1721 {
1640 name: "cursor and saved cursor clamp",1722 name: "cursor and saved cursor clamp",
1641 run: func(t *testing.T) {1723 run: func(t *testing.T) {
@@ -2204,6 +2286,52 @@ func TestWideAndZeroWidthRunes(t *testing.T) {
2204 }2286 }
2205 },2287 },
2206 },2288 },
2289+ {
2290+ name: "IRM inserts narrow printable cells and reset restores overwrite",
2291+ run: func(t *testing.T) {
2292+ s := NewScreen(8, 1)
2293+ s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
2294+
2295+ if got := lineText(s, 0); got != "abXcd " {
2296+ t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
2297+ }
2298+ s.Write([]byte("\x1b[4l\x1b[1;3HY"))
2299+ if got := lineText(s, 0); got != "abYcd " {
2300+ t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
2301+ }
2302+ },
2303+ },
2304+ {
2305+ name: "IRM inserts wide printable cells and repairs evicted wide head",
2306+ run: func(t *testing.T) {
2307+ s := NewScreen(6, 1)
2308+ s.Write([]byte("ab界cd"))
2309+ s.Write([]byte("\x1b[1;3H\x1b[4h好"))
2310+
2311+ assertCell(t, s, 0, 0, 'a')
2312+ assertCell(t, s, 1, 0, 'b')
2313+ assertCell(t, s, 2, 0, '好')
2314+ assertContinuation(t, s, 3, 0)
2315+ assertCell(t, s, 4, 0, '界')
2316+ assertContinuation(t, s, 5, 0)
2317+ assertNoOrphanWideCells(t, s)
2318+ },
2319+ },
2320+ {
2321+ name: "IRM wide insert at right edge repairs orphan",
2322+ run: func(t *testing.T) {
2323+ s := NewScreen(5, 1)
2324+ s.Write([]byte("abc界"))
2325+ s.Write([]byte("\x1b[1;2H\x1b[4h好"))
2326+
2327+ assertCell(t, s, 0, 0, 'a')
2328+ assertCell(t, s, 1, 0, '好')
2329+ assertContinuation(t, s, 2, 0)
2330+ assertCell(t, s, 3, 0, 'b')
2331+ assertCell(t, s, 4, 0, 'c')
2332+ assertNoOrphanWideCells(t, s)
2333+ },
2334+ },
2207 {2335 {
2208 name: "wide char on a width-1 screen degrades to a single cell",2336 name: "wide char on a width-1 screen degrades to a single cell",
2209 run: func(t *testing.T) {2337 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