Merge pull request #53 from bnema/fix/vt-mode-correctness
fix(vt): correct VT modes and wide-cell repair
5 files · +336 -42
M README.md +4 -0
| @@ -134,6 +134,10 @@ When the attaching client reports truecolor support, vev advertises newly spawne | |||
| 134 | 134 | ||
| 135 | 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 | 141 | ## Keys | |
| 138 | 142 | ||
| 139 | 143 | 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 | ## Keys | 141 | ## 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. | |
| 2 | 8 | 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 vt | 8 | package vt | ||
M pkg/vt/query_test.go +5 -0
| @@ -21,6 +21,11 @@ func TestScreenQueryResponses(t *testing.T) { | |||
| 21 | 21 | {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"}, | |
| 22 | 22 | {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"}, | |
| 23 | 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 | 29 | {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"}, | |
| 25 | 30 | {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"}, | |
| 26 | 31 | {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 ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | 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 | |
| 16 | 18 | } | |
| 17 | 19 | ||
| 18 | 20 | type screenState struct { | |
| @@ -23,6 +25,8 @@ type screenState struct { | |||
| 23 | 25 | scrollTop int | |
| 24 | 26 | scrollBottom int | |
| 25 | 27 | savedCursor cursorState | |
| 28 | + | originMode bool | |
| 29 | + | insertMode bool | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 28 | 32 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | |
| @@ -55,8 +59,8 @@ type Screen struct { | |||
| 55 | 59 | // and blanks rows. The callback receives a stable copy of each evicted row. | |
| 56 | 60 | OnLineEvicted func([]renderer.Cell) | |
| 57 | 61 | // 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. | |
| 60 | 64 | OnResponse func([]byte) | |
| 61 | 65 | // OnBell is called synchronously from Write for each lone BEL (0x07) | |
| 62 | 66 | // outside escape sequences. BELs that terminate an OSC never fire it. | |
| @@ -98,6 +102,8 @@ type Screen struct { | |||
| 98 | 102 | mouseMode int | |
| 99 | 103 | mouseSGR bool | |
| 100 | 104 | bracketedPaste bool | |
| 105 | + | originMode bool | |
| 106 | + | insertMode bool | |
| 101 | 107 | colorSchemeMode bool | |
| 102 | 108 | colorSchemeLight bool | |
| 103 | 109 | 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 | 352 | // Determine the range of cells actually modified, extending over any wide | |
| 329 | 353 | // pair the write lands on so no orphaned half is left behind. | |
| 330 | 354 | lo, hi := s.Col, s.Col+w-1 | |
| @@ -341,6 +365,10 @@ func (s *Screen) putPrintable(r rune) { | |||
| 341 | 365 | if w == 2 { | |
| 342 | 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 | 372 | s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1}) | |
| 345 | 373 | s.Col += w | |
| 346 | 374 | } | |
| @@ -402,26 +430,30 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 402 | 430 | // continuation. Used after erase/insert/delete operations that may split a pair | |
| 403 | 431 | // at a boundary or by shifting cells. | |
| 404 | 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 | 438 | return | |
| 407 | 439 | } | |
| 408 | - | w := s.Frame.Width | |
| 440 | + | w := frame.Width | |
| 409 | 441 | for x := range w { | |
| 410 | - | c := s.Frame.At(x, y) | |
| 442 | + | c := frame.At(x, y) | |
| 411 | 443 | if c.Continuation { | |
| 412 | 444 | if x == 0 { | |
| 413 | - | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 445 | + | frame.Set(x, y, renderer.BlankCell()) | |
| 414 | 446 | continue | |
| 415 | 447 | } | |
| 416 | - | left := s.Frame.At(x-1, y) | |
| 448 | + | left := frame.At(x-1, y) | |
| 417 | 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 | 452 | continue | |
| 421 | 453 | } | |
| 422 | 454 | 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()) | |
| 425 | 457 | } | |
| 426 | 458 | } | |
| 427 | 459 | } | |
| @@ -813,33 +845,27 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 813 | 845 | if private { | |
| 814 | 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 | 849 | resp = append(resp, ';') | |
| 818 | 850 | resp = strconv.AppendInt(resp, int64(s.Col+1), 10) | |
| 819 | 851 | resp = append(resp, 'R') | |
| 820 | 852 | s.respond(resp) | |
| 821 | 853 | } | |
| 822 | 854 | 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) | |
| 825 | 860 | if err != nil { | |
| 826 | 861 | return | |
| 827 | 862 | } | |
| 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) | |
| 841 | 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 | 869 | resp = strconv.AppendInt(resp, int64(mode), 10) | |
| 844 | 870 | resp = append(resp, ';') | |
| 845 | 871 | resp = strconv.AppendInt(resp, int64(state), 10) | |
| @@ -877,28 +903,27 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 877 | 903 | if len(parts) > 1 && parts[1] > 0 { | |
| 878 | 904 | col = parts[1] | |
| 879 | 905 | } | |
| 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) | |
| 882 | 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 | 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 | 911 | case 'C': | |
| 887 | 912 | s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1) | |
| 888 | 913 | case 'D': | |
| 889 | 914 | s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1) | |
| 890 | 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 | 917 | s.Col = 0 | |
| 893 | 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 | 920 | s.Col = 0 | |
| 896 | 921 | case 'G': | |
| 897 | 922 | col := firstPositive(parts, 1) | |
| 898 | 923 | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |
| 899 | 924 | case 'd': | |
| 900 | 925 | row := firstPositive(parts, 1) | |
| 901 | - | s.Row = clamp(row-1, 0, s.Frame.Height-1) | |
| 926 | + | s.Row = s.addressedRow(row) | |
| 902 | 927 | case '@': | |
| 903 | 928 | s.insertChars(firstPositive(parts, 1)) | |
| 904 | 929 | case 'P': | |
| @@ -929,6 +954,68 @@ func firstPositive(parts []int, fallback int) int { | |||
| 929 | 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 | 1019 | func (s *Screen) applyCursorStyle(params string) { | |
| 933 | 1020 | if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") { | |
| 934 | 1021 | return | |
| @@ -1024,7 +1111,7 @@ func sgrRGB(r, g, b int) renderer.RGB { | |||
| 1024 | 1111 | } | |
| 1025 | 1112 | ||
| 1026 | 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 | 1117 | func (s *Screen) restoreCursor() { | |
| @@ -1034,6 +1121,8 @@ func (s *Screen) restoreCursor() { | |||
| 1034 | 1121 | s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1) | |
| 1035 | 1122 | s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1) | |
| 1036 | 1123 | s.Style = s.savedCursor.style | |
| 1124 | + | s.originMode = s.savedCursor.originMode | |
| 1125 | + | s.insertMode = s.savedCursor.insertMode | |
| 1037 | 1126 | } | |
| 1038 | 1127 | ||
| 1039 | 1128 | func (s *Screen) reset() { | |
| @@ -1043,6 +1132,8 @@ func (s *Screen) reset() { | |||
| 1043 | 1132 | s.escapeBuf = s.escapeBuf[:0] | |
| 1044 | 1133 | s.savedCursor = cursorState{} | |
| 1045 | 1134 | s.alternate = nil | |
| 1135 | + | s.originMode = false | |
| 1136 | + | s.insertMode = false | |
| 1046 | 1137 | s.cursorVisible = true | |
| 1047 | 1138 | s.cursorStyle = 0 | |
| 1048 | 1139 | s.cursorStyleSet = false | |
| @@ -1074,7 +1165,7 @@ func (s *Screen) setScrollRegion(parts []int) { | |||
| 1074 | 1165 | s.resetScrollRegion() | |
| 1075 | 1166 | } | |
| 1076 | 1167 | } | |
| 1077 | - | s.Row, s.Col = 0, 0 | |
| 1168 | + | s.homeCursor() | |
| 1078 | 1169 | } | |
| 1079 | 1170 | ||
| 1080 | 1171 | func (s *Screen) resetScrollRegion() { | |
| @@ -1088,10 +1179,23 @@ func (s *Screen) resetScrollRegion() { | |||
| 1088 | 1179 | ||
| 1089 | 1180 | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |
| 1090 | 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 | 1192 | return | |
| 1092 | 1193 | } | |
| 1093 | 1194 | for _, mode := range parts { | |
| 1094 | 1195 | switch mode { | |
| 1196 | + | case 6: | |
| 1197 | + | s.originMode = enabled | |
| 1198 | + | s.homeCursor() | |
| 1095 | 1199 | case 47, 1047, 1049: | |
| 1096 | 1200 | if enabled { | |
| 1097 | 1201 | s.enterAlternateScreen() | |
| @@ -1132,6 +1236,8 @@ func (s *Screen) enterAlternateScreen() { | |||
| 1132 | 1236 | scrollTop: s.scrollTop, | |
| 1133 | 1237 | scrollBottom: s.scrollBottom, | |
| 1134 | 1238 | savedCursor: s.savedCursor, | |
| 1239 | + | originMode: s.originMode, | |
| 1240 | + | insertMode: s.insertMode, | |
| 1135 | 1241 | } | |
| 1136 | 1242 | } | |
| 1137 | 1243 | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| @@ -1154,6 +1260,8 @@ func (s *Screen) exitAlternateScreen() { | |||
| 1154 | 1260 | s.scrollTop = state.scrollTop | |
| 1155 | 1261 | s.scrollBottom = state.scrollBottom | |
| 1156 | 1262 | s.savedCursor = state.savedCursor | |
| 1263 | + | s.originMode = state.originMode | |
| 1264 | + | s.insertMode = state.insertMode | |
| 1157 | 1265 | s.alternate = nil | |
| 1158 | 1266 | s.fullRedraw() | |
| 1159 | 1267 | } | |
| @@ -1172,6 +1280,7 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren | |||
| 1172 | 1280 | break | |
| 1173 | 1281 | } | |
| 1174 | 1282 | copy(next.Row(dy), old.Row(sy)) | |
| 1283 | + | repairFrameRow(next, dy) | |
| 1175 | 1284 | } | |
| 1176 | 1285 | return next, shift | |
| 1177 | 1286 | } | |
| @@ -1397,7 +1506,7 @@ func appendSGRGroup(out []int, group string) []int { | |||
| 1397 | 1506 | return out | |
| 1398 | 1507 | } | |
| 1399 | 1508 | out = append(out, code, mode) | |
| 1400 | - | for i := 0; i < 3; i++ { | |
| 1509 | + | for i := range 3 { | |
| 1401 | 1510 | out = append(out, parseCSIInt(sgrColonField(group, start+i))) | |
| 1402 | 1511 | } | |
| 1403 | 1512 | } | |
| @@ -9,10 +9,12 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | type cursorState struct { | 11 | type cursorState struct { | ||
| 12 | - | row int | 12 | + | row int |
| 13 | - | col int | 13 | + | col int |
| 14 | - | style renderer.Style | 14 | + | style renderer.Style |
| 15 | - | saved bool | 15 | + | 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 int | 25 | scrollTop int | ||
| 24 | scrollBottom int | 26 | scrollBottom int | ||
| 25 | savedCursor cursorState | 27 | savedCursor cursorState | ||
| 28 | + | originMode bool | |||
| 29 | + | insertMode bool | |||
| 26 | } | 30 | } | ||
| 27 | 31 | ||||
| 28 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | 32 | // 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 the | 61 | // 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 int | 102 | mouseMode int | ||
| 99 | mouseSGR bool | 103 | mouseSGR bool | ||
| 100 | bracketedPaste bool | 104 | bracketedPaste bool | ||
| 105 | + | originMode bool | |||
| 106 | + | insertMode bool | |||
| 101 | colorSchemeMode bool | 107 | colorSchemeMode bool | ||
| 102 | colorSchemeLight bool | 108 | colorSchemeLight bool | ||
| 103 | colorSchemeSet bool | 109 | 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 wide | 352 | // 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-1 | 354 | 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 += w | 373 | 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 pair | 430 | // 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 | return | 438 | return | ||
| 407 | } | 439 | } | ||
| 408 | - | w := s.Frame.Width | 440 | + | 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 | continue | 446 | 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 | continue | 452 | 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 | return | 861 | return | ||
| 827 | } | 862 | } | ||
| 828 | - | state := 0 | 863 | + | 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 = 0 | 917 | 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 = 0 | 920 | 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 | return | 1021 | 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.style | 1123 | 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 = nil | 1134 | s.alternate = nil | ||
| 1135 | + | s.originMode = false | |||
| 1136 | + | s.insertMode = false | |||
| 1046 | s.cursorVisible = true | 1137 | s.cursorVisible = true | ||
| 1047 | s.cursorStyle = 0 | 1138 | s.cursorStyle = 0 | ||
| 1048 | s.cursorStyleSet = false | 1139 | 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, 0 | 1168 | + | 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 | return | 1192 | 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.scrollTop | 1260 | s.scrollTop = state.scrollTop | ||
| 1155 | s.scrollBottom = state.scrollBottom | 1261 | s.scrollBottom = state.scrollBottom | ||
| 1156 | s.savedCursor = state.savedCursor | 1262 | s.savedCursor = state.savedCursor | ||
| 1263 | + | s.originMode = state.originMode | |||
| 1264 | + | s.insertMode = state.insertMode | |||
| 1157 | s.alternate = nil | 1265 | 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 | break | 1280 | 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, shift | 1285 | return next, shift | ||
| 1177 | } | 1286 | } | ||
| @@ -1397,7 +1506,7 @@ func appendSGRGroup(out []int, group string) []int { | |||||
| 1397 | return out | 1506 | 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 @@ | |||
| 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 { | |
| @@ -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 | 1223 | name: "ESC D (index), ESC E (next line), ESC M (reverse index)", | |
| 1182 | 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 | 1377 | name: "private alternate screen mode restores normal screen on exit", | |
| 1283 | 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 | 1764 | name: "cursor and saved cursor clamp", | |
| 1641 | 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 | 2378 | name: "wide char on a width-1 screen degrades to a single cell", | |
| 2209 | 2379 | 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 { | ||
| @@ -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) { | ||