fix(vt): repair resize and support origin insert modes
- commit
- b34df7b3aabac9eaee588ca71c25812777c70a94
- author
- brice · · 2026-07-09 14:19 UTC
- parent
- 71c1f46
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 23 | 23 | scrollTop int | |
| 24 | 24 | scrollBottom int | |
| 25 | 25 | savedCursor cursorState | |
| 26 | + | originMode bool | |
| 27 | + | insertMode bool | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | 30 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | |
| @@ -98,6 +100,8 @@ type Screen struct { | |||
| 98 | 100 | mouseMode int | |
| 99 | 101 | mouseSGR bool | |
| 100 | 102 | bracketedPaste bool | |
| 103 | + | originMode bool | |
| 104 | + | insertMode bool | |
| 101 | 105 | colorSchemeMode bool | |
| 102 | 106 | colorSchemeLight bool | |
| 103 | 107 | 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 | 350 | // Determine the range of cells actually modified, extending over any wide | |
| 329 | 351 | // pair the write lands on so no orphaned half is left behind. | |
| 330 | 352 | lo, hi := s.Col, s.Col+w-1 | |
| @@ -341,6 +363,10 @@ func (s *Screen) putPrintable(r rune) { | |||
| 341 | 363 | if w == 2 { | |
| 342 | 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 | 370 | s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1}) | |
| 345 | 371 | s.Col += w | |
| 346 | 372 | } | |
| @@ -402,26 +428,30 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 402 | 428 | // continuation. Used after erase/insert/delete operations that may split a pair | |
| 403 | 429 | // at a boundary or by shifting cells. | |
| 404 | 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 | 436 | return | |
| 407 | 437 | } | |
| 408 | - | w := s.Frame.Width | |
| 438 | + | w := frame.Width | |
| 409 | 439 | for x := range w { | |
| 410 | - | c := s.Frame.At(x, y) | |
| 440 | + | c := frame.At(x, y) | |
| 411 | 441 | if c.Continuation { | |
| 412 | 442 | if x == 0 { | |
| 413 | - | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 443 | + | frame.Set(x, y, renderer.BlankCell()) | |
| 414 | 444 | continue | |
| 415 | 445 | } | |
| 416 | - | left := s.Frame.At(x-1, y) | |
| 446 | + | left := frame.At(x-1, y) | |
| 417 | 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 | 450 | continue | |
| 421 | 451 | } | |
| 422 | 452 | 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()) | |
| 425 | 455 | } | |
| 426 | 456 | } | |
| 427 | 457 | } | |
| @@ -877,28 +907,27 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 877 | 907 | if len(parts) > 1 && parts[1] > 0 { | |
| 878 | 908 | col = parts[1] | |
| 879 | 909 | } | |
| 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) | |
| 882 | 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 | 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 | 915 | case 'C': | |
| 887 | 916 | s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1) | |
| 888 | 917 | case 'D': | |
| 889 | 918 | s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1) | |
| 890 | 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 | 921 | s.Col = 0 | |
| 893 | 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 | 924 | s.Col = 0 | |
| 896 | 925 | case 'G': | |
| 897 | 926 | col := firstPositive(parts, 1) | |
| 898 | 927 | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |
| 899 | 928 | case 'd': | |
| 900 | 929 | row := firstPositive(parts, 1) | |
| 901 | - | s.Row = clamp(row-1, 0, s.Frame.Height-1) | |
| 930 | + | s.Row = s.addressedRow(row) | |
| 902 | 931 | case '@': | |
| 903 | 932 | s.insertChars(firstPositive(parts, 1)) | |
| 904 | 933 | case 'P': | |
| @@ -929,6 +958,36 @@ func firstPositive(parts []int, fallback int) int { | |||
| 929 | 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 | 991 | func (s *Screen) applyCursorStyle(params string) { | |
| 933 | 992 | if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") { | |
| 934 | 993 | return | |
| @@ -1043,6 +1102,8 @@ func (s *Screen) reset() { | |||
| 1043 | 1102 | s.escapeBuf = s.escapeBuf[:0] | |
| 1044 | 1103 | s.savedCursor = cursorState{} | |
| 1045 | 1104 | s.alternate = nil | |
| 1105 | + | s.originMode = false | |
| 1106 | + | s.insertMode = false | |
| 1046 | 1107 | s.cursorVisible = true | |
| 1047 | 1108 | s.cursorStyle = 0 | |
| 1048 | 1109 | s.cursorStyleSet = false | |
| @@ -1088,10 +1149,23 @@ func (s *Screen) resetScrollRegion() { | |||
| 1088 | 1149 | ||
| 1089 | 1150 | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |
| 1090 | 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 | 1162 | return | |
| 1092 | 1163 | } | |
| 1093 | 1164 | for _, mode := range parts { | |
| 1094 | 1165 | switch mode { | |
| 1166 | + | case 6: | |
| 1167 | + | s.originMode = enabled | |
| 1168 | + | s.homeCursor() | |
| 1095 | 1169 | case 47, 1047, 1049: | |
| 1096 | 1170 | if enabled { | |
| 1097 | 1171 | s.enterAlternateScreen() | |
| @@ -1132,6 +1206,8 @@ func (s *Screen) enterAlternateScreen() { | |||
| 1132 | 1206 | scrollTop: s.scrollTop, | |
| 1133 | 1207 | scrollBottom: s.scrollBottom, | |
| 1134 | 1208 | savedCursor: s.savedCursor, | |
| 1209 | + | originMode: s.originMode, | |
| 1210 | + | insertMode: s.insertMode, | |
| 1135 | 1211 | } | |
| 1136 | 1212 | } | |
| 1137 | 1213 | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| @@ -1154,6 +1230,8 @@ func (s *Screen) exitAlternateScreen() { | |||
| 1154 | 1230 | s.scrollTop = state.scrollTop | |
| 1155 | 1231 | s.scrollBottom = state.scrollBottom | |
| 1156 | 1232 | s.savedCursor = state.savedCursor | |
| 1233 | + | s.originMode = state.originMode | |
| 1234 | + | s.insertMode = state.insertMode | |
| 1157 | 1235 | s.alternate = nil | |
| 1158 | 1236 | s.fullRedraw() | |
| 1159 | 1237 | } | |
| @@ -1172,6 +1250,7 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren | |||
| 1172 | 1250 | break | |
| 1173 | 1251 | } | |
| 1174 | 1252 | copy(next.Row(dy), old.Row(sy)) | |
| 1253 | + | repairFrameRow(next, dy) | |
| 1175 | 1254 | } | |
| 1176 | 1255 | return next, shift | |
| 1177 | 1256 | } | |
| @@ -23,6 +23,8 @@ type screenState struct { | |||||
| 23 | scrollTop int | 23 | scrollTop int | ||
| 24 | scrollBottom int | 24 | scrollBottom int | ||
| 25 | savedCursor cursorState | 25 | savedCursor cursorState | ||
| 26 | + | originMode bool | |||
| 27 | + | insertMode bool | |||
| 26 | } | 28 | } | ||
| 27 | 29 | ||||
| 28 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | 30 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | ||
| @@ -98,6 +100,8 @@ type Screen struct { | |||||
| 98 | mouseMode int | 100 | mouseMode int | ||
| 99 | mouseSGR bool | 101 | mouseSGR bool | ||
| 100 | bracketedPaste bool | 102 | bracketedPaste bool | ||
| 103 | + | originMode bool | |||
| 104 | + | insertMode bool | |||
| 101 | colorSchemeMode bool | 105 | colorSchemeMode bool | ||
| 102 | colorSchemeLight bool | 106 | colorSchemeLight bool | ||
| 103 | colorSchemeSet bool | 107 | 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 wide | 350 | // 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-1 | 352 | 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 += w | 371 | 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 pair | 428 | // 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 | return | 436 | return | ||
| 407 | } | 437 | } | ||
| 408 | - | w := s.Frame.Width | 438 | + | 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 | continue | 444 | 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 | continue | 450 | 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 = 0 | 921 | 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 = 0 | 924 | 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 | return | 993 | 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 = nil | 1104 | s.alternate = nil | ||
| 1105 | + | s.originMode = false | |||
| 1106 | + | s.insertMode = false | |||
| 1046 | s.cursorVisible = true | 1107 | s.cursorVisible = true | ||
| 1047 | s.cursorStyle = 0 | 1108 | s.cursorStyle = 0 | ||
| 1048 | s.cursorStyleSet = false | 1109 | 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 | return | 1162 | 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.scrollTop | 1230 | s.scrollTop = state.scrollTop | ||
| 1155 | s.scrollBottom = state.scrollBottom | 1231 | s.scrollBottom = state.scrollBottom | ||
| 1156 | s.savedCursor = state.savedCursor | 1232 | s.savedCursor = state.savedCursor | ||
| 1233 | + | s.originMode = state.originMode | |||
| 1234 | + | s.insertMode = state.insertMode | |||
| 1157 | s.alternate = nil | 1235 | 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 | break | 1250 | 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, shift | 1255 | return next, shift | ||
| 1177 | } | 1256 | } | ||
M pkg/vt/screen_test.go +128 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math/rand" | |
| 4 | 5 | "strings" | |
| 5 | 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 | 88 | func lineText(s *Screen, y int) string { | |
| 70 | 89 | out := make([]rune, s.Frame.Width) | |
| 71 | 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 | 1335 | name: "private alternate screen mode restores normal screen on exit", | |
| 1283 | 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 | 1722 | name: "cursor and saved cursor clamp", | |
| 1641 | 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 | 2336 | name: "wide char on a width-1 screen degrades to a single cell", | |
| 2209 | 2337 | run: func(t *testing.T) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package vt | 1 | 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) { | ||