fix(screen): track cursor and autowrap modes (#30)
* fix(screen): track cursor and autowrap modes * fix(snapshot): preserve ModeSnapshot source compatibility
- commit
- 3c9eb0e0d8f1f43ae01bfadf7c8740f1629e0cf7
- author
- bnema · · 2026-09-06 11:06 UTC
- committer
- GitHub · · 2026-09-06 11:06 UTC
- parent
- 8e4df5c
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
9 files · +294 -73
M doc.go +3 -1
| @@ -4,7 +4,9 @@ | |||
| 4 | 4 | // alternate-screen state, cursor/reporting modes, and common xterm/VT control | |
| 5 | 5 | // sequences. Wide runes that fit are stored as a head cell plus a continuation | |
| 6 | 6 | // cell, and resize or edit operations repair row-boundary splits instead of | |
| 7 | - | // leaving orphaned halves. | |
| 7 | + | // leaving orphaned halves. Application cursor mode is tracked for hosts that | |
| 8 | + | // encode arrow keys; application keypad mode and keypad-key encoding are not | |
| 9 | + | // supported. | |
| 8 | 10 | // | |
| 9 | 11 | // Stateful values are single-owner and are not internally synchronized. The | |
| 10 | 12 | // owner must serialize Write, Resize, Snapshot, and History mutations. Write | |
| @@ -4,7 +4,9 @@ | |||||
| 4 | // alternate-screen state, cursor/reporting modes, and common xterm/VT control | 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 | 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 | 6 | // cell, and resize or edit operations repair row-boundary splits instead of | ||
| 7 | - | // leaving orphaned halves. | 7 | + | // leaving orphaned halves. Application cursor mode is tracked for hosts that |
| 8 | + | // encode arrow keys; application keypad mode and keypad-key encoding are not | |||
| 9 | + | // supported. | |||
| 8 | // | 10 | // | ||
| 9 | // Stateful values are single-owner and are not internally synchronized. The | 11 | // Stateful values are single-owner and are not internally synchronized. The | ||
| 10 | // owner must serialize Write, Resize, Snapshot, and History mutations. Write | 12 | // owner must serialize Write, Resize, Snapshot, and History mutations. Write | ||
M screen.go +21 -18
| @@ -78,24 +78,26 @@ type Screen struct { | |||
| 78 | 78 | csiScratch []int | |
| 79 | 79 | sgrScratch []int | |
| 80 | 80 | ||
| 81 | - | scrollTop int | |
| 82 | - | scrollBottom int | |
| 83 | - | savedCursor cursorState | |
| 84 | - | alternate *screenState | |
| 85 | - | graphics *screenGraphicsState | |
| 86 | - | syncUpdateActive bool | |
| 87 | - | progressState int | |
| 88 | - | cursorVisible bool | |
| 89 | - | cursorStyle int | |
| 90 | - | cursorStyleSet bool | |
| 91 | - | mouseMode int | |
| 92 | - | mouseSGR bool | |
| 93 | - | bracketedPaste bool | |
| 94 | - | originMode bool | |
| 95 | - | insertMode bool | |
| 96 | - | colorSchemeMode bool | |
| 97 | - | colorSchemeLight bool | |
| 98 | - | colorSchemeSet bool | |
| 81 | + | scrollTop int | |
| 82 | + | scrollBottom int | |
| 83 | + | savedCursor cursorState | |
| 84 | + | alternate *screenState | |
| 85 | + | graphics *screenGraphicsState | |
| 86 | + | syncUpdateActive bool | |
| 87 | + | progressState int | |
| 88 | + | cursorVisible bool | |
| 89 | + | cursorStyle int | |
| 90 | + | cursorStyleSet bool | |
| 91 | + | mouseMode int | |
| 92 | + | mouseSGR bool | |
| 93 | + | bracketedPaste bool | |
| 94 | + | originMode bool | |
| 95 | + | insertMode bool | |
| 96 | + | autoWrapMode bool | |
| 97 | + | applicationCursorMode bool | |
| 98 | + | colorSchemeMode bool | |
| 99 | + | colorSchemeLight bool | |
| 100 | + | colorSchemeSet bool | |
| 99 | 101 | } | |
| 100 | 102 | ||
| 101 | 103 | func NewScreen(width, height int) *Screen { | |
| @@ -104,6 +106,7 @@ func NewScreen(width, height int) *Screen { | |||
| 104 | 106 | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 105 | 107 | damageGeneration: 1, | |
| 106 | 108 | cursorVisible: true, | |
| 109 | + | autoWrapMode: true, | |
| 107 | 110 | geometry: Geometry{Cols: width, Rows: height}, | |
| 108 | 111 | } | |
| 109 | 112 | s.buffer = s.newBuffer(width, height) | |
| @@ -78,24 +78,26 @@ type Screen struct { | |||||
| 78 | csiScratch []int | 78 | csiScratch []int | ||
| 79 | sgrScratch []int | 79 | sgrScratch []int | ||
| 80 | 80 | ||||
| 81 | - | scrollTop int | 81 | + | scrollTop int |
| 82 | - | scrollBottom int | 82 | + | scrollBottom int |
| 83 | - | savedCursor cursorState | 83 | + | savedCursor cursorState |
| 84 | - | alternate *screenState | 84 | + | alternate *screenState |
| 85 | - | graphics *screenGraphicsState | 85 | + | graphics *screenGraphicsState |
| 86 | - | syncUpdateActive bool | 86 | + | syncUpdateActive bool |
| 87 | - | progressState int | 87 | + | progressState int |
| 88 | - | cursorVisible bool | 88 | + | cursorVisible bool |
| 89 | - | cursorStyle int | 89 | + | cursorStyle int |
| 90 | - | cursorStyleSet bool | 90 | + | cursorStyleSet bool |
| 91 | - | mouseMode int | 91 | + | mouseMode int |
| 92 | - | mouseSGR bool | 92 | + | mouseSGR bool |
| 93 | - | bracketedPaste bool | 93 | + | bracketedPaste bool |
| 94 | - | originMode bool | 94 | + | originMode bool |
| 95 | - | insertMode bool | 95 | + | insertMode bool |
| 96 | - | colorSchemeMode bool | 96 | + | autoWrapMode bool |
| 97 | - | colorSchemeLight bool | 97 | + | applicationCursorMode bool |
| 98 | - | colorSchemeSet bool | 98 | + | colorSchemeMode bool |
| 99 | + | colorSchemeLight bool | |||
| 100 | + | colorSchemeSet bool | |||
| 99 | } | 101 | } | ||
| 100 | 102 | ||||
| 101 | func NewScreen(width, height int) *Screen { | 103 | func NewScreen(width, height int) *Screen { | ||
| @@ -104,6 +106,7 @@ func NewScreen(width, height int) *Screen { | |||||
| 104 | damage: []renderer.Damage{renderer.FullRedraw()}, | 106 | damage: []renderer.Damage{renderer.FullRedraw()}, | ||
| 105 | damageGeneration: 1, | 107 | damageGeneration: 1, | ||
| 106 | cursorVisible: true, | 108 | cursorVisible: true, | ||
| 109 | + | autoWrapMode: true, | |||
| 107 | geometry: Geometry{Cols: width, Rows: height}, | 110 | geometry: Geometry{Cols: width, Rows: height}, | ||
| 108 | } | 111 | } | ||
| 109 | s.buffer = s.newBuffer(width, height) | 112 | s.buffer = s.newBuffer(width, height) | ||
A screen_autowrap_test.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestScreenAutoWrapModeRightMargin(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | width int | |
| 14 | + | height int | |
| 15 | + | input string | |
| 16 | + | wantRows []string | |
| 17 | + | wantRow int | |
| 18 | + | wantCol int | |
| 19 | + | wantHistory int | |
| 20 | + | }{ | |
| 21 | + | { | |
| 22 | + | name: "successive writes overwrite right margin", | |
| 23 | + | width: 4, height: 2, input: "\x1b[?7labcde", | |
| 24 | + | wantRows: []string{"abce", " "}, wantRow: 0, wantCol: 3, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "bottom row does not scroll", | |
| 28 | + | width: 3, height: 2, input: "top\r\n\x1b[?7labcd", | |
| 29 | + | wantRows: []string{"top", "abd"}, wantRow: 1, wantCol: 2, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "width one overwrites", | |
| 33 | + | width: 1, height: 2, input: "\x1b[?7lab", | |
| 34 | + | wantRows: []string{"b", " "}, wantRow: 0, wantCol: 0, | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "vev raw-mode initialization disables wrapping", | |
| 38 | + | width: 4, height: 2, input: "\x1b[?7lstatus", | |
| 39 | + | wantRows: []string{"stas", " "}, wantRow: 0, wantCol: 3, | |
| 40 | + | }, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | for _, test := range tests { | |
| 44 | + | t.Run(test.name, func(t *testing.T) { | |
| 45 | + | s := NewScreenWithHistory(test.width, test.height, HistoryConfig{MaxRows: 10}) | |
| 46 | + | s.Write([]byte(test.input)) | |
| 47 | + | for row, want := range test.wantRows { | |
| 48 | + | if got := screenRowText(s, row); got != want { | |
| 49 | + | t.Fatalf("row %d = %q, want %q", row, got, want) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if s.Row != test.wantRow || s.Col != test.wantCol { | |
| 53 | + | t.Fatalf("cursor = (%d,%d), want (%d,%d)", s.Row, s.Col, test.wantRow, test.wantCol) | |
| 54 | + | } | |
| 55 | + | if got := s.History().Len(); got != test.wantHistory { | |
| 56 | + | t.Fatalf("history length = %d, want %d", got, test.wantHistory) | |
| 57 | + | } | |
| 58 | + | if s.buffer.boundaries[0].Soft { | |
| 59 | + | t.Fatal("disabled autowrap created a soft wrap") | |
| 60 | + | } | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestScreenAutoWrapWideRuneAtRightMargin(t *testing.T) { | |
| 66 | + | s := NewScreen(4, 2) | |
| 67 | + | s.Write([]byte("abc\x1b[?7l界")) | |
| 68 | + | ||
| 69 | + | if got := s.Cell(3, 0); got.Rune != '\uFFFD' || got.Continuation { | |
| 70 | + | t.Fatalf("right-margin cell = %#v, want narrow replacement", got) | |
| 71 | + | } | |
| 72 | + | for x := 0; x < s.Columns(); x++ { | |
| 73 | + | if s.Cell(x, 0).Continuation && (x == 0 || renderer.RuneWidth(s.Cell(x-1, 0).Rune) != 2) { | |
| 74 | + | t.Fatalf("orphaned continuation at column %d", x) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if s.Row != 0 || s.Col != 3 { | |
| 78 | + | t.Fatalf("cursor = (%d,%d), want (0,3)", s.Row, s.Col) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestScreenAutoWrapModeTransitionsAndResize(t *testing.T) { | |
| 83 | + | s := NewScreen(4, 2) | |
| 84 | + | s.Write([]byte("abcd")) // Leave a deferred wrap pending. | |
| 85 | + | s.Write([]byte("\x1b[?7le")) | |
| 86 | + | if got := screenRowText(s, 0); got != "abce" { | |
| 87 | + | t.Fatalf("row after disabling pending wrap = %q, want %q", got, "abce") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | s.Resize(3, 2) | |
| 91 | + | if s.Row != 0 || s.Col != 2 { | |
| 92 | + | t.Fatalf("cursor after resize = (%d,%d), want (0,2)", s.Row, s.Col) | |
| 93 | + | } | |
| 94 | + | s.Write([]byte("f\x1b[?7hgh")) | |
| 95 | + | if s.Row != 1 || s.Col != 1 { | |
| 96 | + | t.Fatalf("cursor after re-enabling wrap = (%d,%d), want (1,1)", s.Row, s.Col) | |
| 97 | + | } | |
| 98 | + | if got := screenRowText(s, 1); got != "h " { | |
| 99 | + | t.Fatalf("wrapped row = %q, want %q", got, "h ") | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestScreenAutoWrapSavedAndReset(t *testing.T) { | |
| 104 | + | s := NewScreen(4, 2) | |
| 105 | + | s.Write([]byte("\x1b[?7l\x1b7\x1b[?7h\x1b8")) | |
| 106 | + | if s.AutoWrapMode() { | |
| 107 | + | t.Fatal("ESC 8 did not restore disabled autowrap mode") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | s.Write([]byte("\x1bc")) | |
| 111 | + | if !s.AutoWrapMode() { | |
| 112 | + | t.Fatal("RIS did not restore enabled autowrap default") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestScreenApplicationCursorMode(t *testing.T) { | |
| 117 | + | s := NewScreen(4, 2) | |
| 118 | + | if s.ApplicationCursorMode() { | |
| 119 | + | t.Fatal("application cursor mode enabled by default") | |
| 120 | + | } | |
| 121 | + | s.Write([]byte("\x1b[?")) | |
| 122 | + | s.Write([]byte("1h")) | |
| 123 | + | if !s.ApplicationCursorMode() || !s.Snapshot().ApplicationCursorMode() { | |
| 124 | + | t.Fatal("split DECCKM enable was not tracked in screen and snapshot") | |
| 125 | + | } | |
| 126 | + | s.Write([]byte("\x1b[?1l")) | |
| 127 | + | if s.ApplicationCursorMode() { | |
| 128 | + | t.Fatal("DECCKM disable was not tracked") | |
| 129 | + | } | |
| 130 | + | s.Write([]byte("\x1b[?1h\x1bc")) | |
| 131 | + | if s.ApplicationCursorMode() { | |
| 132 | + | t.Fatal("RIS did not reset DECCKM") | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestScreenCursorAndAutoWrapModeQueries(t *testing.T) { | |
| 137 | + | s := NewScreen(4, 2) | |
| 138 | + | var got bytes.Buffer | |
| 139 | + | s.OnResponse = func(response []byte) { got.Write(response) } | |
| 140 | + | s.Write([]byte("\x1b[?1$p\x1b[?7$p\x1b[?1h\x1b[?7l\x1b[?1$p\x1b[?7$p")) | |
| 141 | + | want := "\x1b[?1;2$y\x1b[?7;1$y\x1b[?1;1$y\x1b[?7;2$y" | |
| 142 | + | if got.String() != want { | |
| 143 | + | t.Fatalf("mode reports = %q, want %q", got.String(), want) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func screenRowText(s *Screen, row int) string { | |
| 148 | + | runes := make([]rune, s.Columns()) | |
| 149 | + | for col := range s.Columns() { | |
| 150 | + | r := s.Cell(col, row).Rune | |
| 151 | + | if r == 0 { | |
| 152 | + | r = ' ' | |
| 153 | + | } | |
| 154 | + | runes[col] = r | |
| 155 | + | } | |
| 156 | + | return string(runes) | |
| 157 | + | } | |
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestScreenAutoWrapModeRightMargin(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | width int | |||
| 14 | + | height int | |||
| 15 | + | input string | |||
| 16 | + | wantRows []string | |||
| 17 | + | wantRow int | |||
| 18 | + | wantCol int | |||
| 19 | + | wantHistory int | |||
| 20 | + | }{ | |||
| 21 | + | { | |||
| 22 | + | name: "successive writes overwrite right margin", | |||
| 23 | + | width: 4, height: 2, input: "\x1b[?7labcde", | |||
| 24 | + | wantRows: []string{"abce", " "}, wantRow: 0, wantCol: 3, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "bottom row does not scroll", | |||
| 28 | + | width: 3, height: 2, input: "top\r\n\x1b[?7labcd", | |||
| 29 | + | wantRows: []string{"top", "abd"}, wantRow: 1, wantCol: 2, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "width one overwrites", | |||
| 33 | + | width: 1, height: 2, input: "\x1b[?7lab", | |||
| 34 | + | wantRows: []string{"b", " "}, wantRow: 0, wantCol: 0, | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "vev raw-mode initialization disables wrapping", | |||
| 38 | + | width: 4, height: 2, input: "\x1b[?7lstatus", | |||
| 39 | + | wantRows: []string{"stas", " "}, wantRow: 0, wantCol: 3, | |||
| 40 | + | }, | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | for _, test := range tests { | |||
| 44 | + | t.Run(test.name, func(t *testing.T) { | |||
| 45 | + | s := NewScreenWithHistory(test.width, test.height, HistoryConfig{MaxRows: 10}) | |||
| 46 | + | s.Write([]byte(test.input)) | |||
| 47 | + | for row, want := range test.wantRows { | |||
| 48 | + | if got := screenRowText(s, row); got != want { | |||
| 49 | + | t.Fatalf("row %d = %q, want %q", row, got, want) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | if s.Row != test.wantRow || s.Col != test.wantCol { | |||
| 53 | + | t.Fatalf("cursor = (%d,%d), want (%d,%d)", s.Row, s.Col, test.wantRow, test.wantCol) | |||
| 54 | + | } | |||
| 55 | + | if got := s.History().Len(); got != test.wantHistory { | |||
| 56 | + | t.Fatalf("history length = %d, want %d", got, test.wantHistory) | |||
| 57 | + | } | |||
| 58 | + | if s.buffer.boundaries[0].Soft { | |||
| 59 | + | t.Fatal("disabled autowrap created a soft wrap") | |||
| 60 | + | } | |||
| 61 | + | }) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestScreenAutoWrapWideRuneAtRightMargin(t *testing.T) { | |||
| 66 | + | s := NewScreen(4, 2) | |||
| 67 | + | s.Write([]byte("abc\x1b[?7l界")) | |||
| 68 | + | ||||
| 69 | + | if got := s.Cell(3, 0); got.Rune != '\uFFFD' || got.Continuation { | |||
| 70 | + | t.Fatalf("right-margin cell = %#v, want narrow replacement", got) | |||
| 71 | + | } | |||
| 72 | + | for x := 0; x < s.Columns(); x++ { | |||
| 73 | + | if s.Cell(x, 0).Continuation && (x == 0 || renderer.RuneWidth(s.Cell(x-1, 0).Rune) != 2) { | |||
| 74 | + | t.Fatalf("orphaned continuation at column %d", x) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | if s.Row != 0 || s.Col != 3 { | |||
| 78 | + | t.Fatalf("cursor = (%d,%d), want (0,3)", s.Row, s.Col) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestScreenAutoWrapModeTransitionsAndResize(t *testing.T) { | |||
| 83 | + | s := NewScreen(4, 2) | |||
| 84 | + | s.Write([]byte("abcd")) // Leave a deferred wrap pending. | |||
| 85 | + | s.Write([]byte("\x1b[?7le")) | |||
| 86 | + | if got := screenRowText(s, 0); got != "abce" { | |||
| 87 | + | t.Fatalf("row after disabling pending wrap = %q, want %q", got, "abce") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | s.Resize(3, 2) | |||
| 91 | + | if s.Row != 0 || s.Col != 2 { | |||
| 92 | + | t.Fatalf("cursor after resize = (%d,%d), want (0,2)", s.Row, s.Col) | |||
| 93 | + | } | |||
| 94 | + | s.Write([]byte("f\x1b[?7hgh")) | |||
| 95 | + | if s.Row != 1 || s.Col != 1 { | |||
| 96 | + | t.Fatalf("cursor after re-enabling wrap = (%d,%d), want (1,1)", s.Row, s.Col) | |||
| 97 | + | } | |||
| 98 | + | if got := screenRowText(s, 1); got != "h " { | |||
| 99 | + | t.Fatalf("wrapped row = %q, want %q", got, "h ") | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestScreenAutoWrapSavedAndReset(t *testing.T) { | |||
| 104 | + | s := NewScreen(4, 2) | |||
| 105 | + | s.Write([]byte("\x1b[?7l\x1b7\x1b[?7h\x1b8")) | |||
| 106 | + | if s.AutoWrapMode() { | |||
| 107 | + | t.Fatal("ESC 8 did not restore disabled autowrap mode") | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | s.Write([]byte("\x1bc")) | |||
| 111 | + | if !s.AutoWrapMode() { | |||
| 112 | + | t.Fatal("RIS did not restore enabled autowrap default") | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestScreenApplicationCursorMode(t *testing.T) { | |||
| 117 | + | s := NewScreen(4, 2) | |||
| 118 | + | if s.ApplicationCursorMode() { | |||
| 119 | + | t.Fatal("application cursor mode enabled by default") | |||
| 120 | + | } | |||
| 121 | + | s.Write([]byte("\x1b[?")) | |||
| 122 | + | s.Write([]byte("1h")) | |||
| 123 | + | if !s.ApplicationCursorMode() || !s.Snapshot().ApplicationCursorMode() { | |||
| 124 | + | t.Fatal("split DECCKM enable was not tracked in screen and snapshot") | |||
| 125 | + | } | |||
| 126 | + | s.Write([]byte("\x1b[?1l")) | |||
| 127 | + | if s.ApplicationCursorMode() { | |||
| 128 | + | t.Fatal("DECCKM disable was not tracked") | |||
| 129 | + | } | |||
| 130 | + | s.Write([]byte("\x1b[?1h\x1bc")) | |||
| 131 | + | if s.ApplicationCursorMode() { | |||
| 132 | + | t.Fatal("RIS did not reset DECCKM") | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestScreenCursorAndAutoWrapModeQueries(t *testing.T) { | |||
| 137 | + | s := NewScreen(4, 2) | |||
| 138 | + | var got bytes.Buffer | |||
| 139 | + | s.OnResponse = func(response []byte) { got.Write(response) } | |||
| 140 | + | s.Write([]byte("\x1b[?1$p\x1b[?7$p\x1b[?1h\x1b[?7l\x1b[?1$p\x1b[?7$p")) | |||
| 141 | + | want := "\x1b[?1;2$y\x1b[?7;1$y\x1b[?1;1$y\x1b[?7;2$y" | |||
| 142 | + | if got.String() != want { | |||
| 143 | + | t.Fatalf("mode reports = %q, want %q", got.String(), want) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func screenRowText(s *Screen, row int) string { | |||
| 148 | + | runes := make([]rune, s.Columns()) | |||
| 149 | + | for col := range s.Columns() { | |||
| 150 | + | r := s.Cell(col, row).Rune | |||
| 151 | + | if r == 0 { | |||
| 152 | + | r = ' ' | |||
| 153 | + | } | |||
| 154 | + | runes[col] = r | |||
| 155 | + | } | |||
| 156 | + | return string(runes) | |||
| 157 | + | } | |||
M screen_csi.go +4 -0
| @@ -174,8 +174,12 @@ func firstPositive(parts []int, fallback int) int { | |||
| 174 | 174 | func (s *Screen) modeReportState(private bool, mode int) int { | |
| 175 | 175 | if private { | |
| 176 | 176 | switch mode { | |
| 177 | + | case 1: | |
| 178 | + | return boolModeReportState(s.applicationCursorMode) | |
| 177 | 179 | case 6: | |
| 178 | 180 | return boolModeReportState(s.originMode) | |
| 181 | + | case 7: | |
| 182 | + | return boolModeReportState(s.autoWrapMode) | |
| 179 | 183 | case 2026: | |
| 180 | 184 | return boolModeReportState(s.syncUpdateActive) | |
| 181 | 185 | case 2031: | |
| @@ -174,8 +174,12 @@ func firstPositive(parts []int, fallback int) int { | |||||
| 174 | func (s *Screen) modeReportState(private bool, mode int) int { | 174 | func (s *Screen) modeReportState(private bool, mode int) int { | ||
| 175 | if private { | 175 | if private { | ||
| 176 | switch mode { | 176 | switch mode { | ||
| 177 | + | case 1: | |||
| 178 | + | return boolModeReportState(s.applicationCursorMode) | |||
| 177 | case 6: | 179 | case 6: | ||
| 178 | return boolModeReportState(s.originMode) | 180 | return boolModeReportState(s.originMode) | ||
| 181 | + | case 7: | |||
| 182 | + | return boolModeReportState(s.autoWrapMode) | |||
| 179 | case 2026: | 183 | case 2026: | ||
| 180 | return boolModeReportState(s.syncUpdateActive) | 184 | return boolModeReportState(s.syncUpdateActive) | ||
| 181 | case 2031: | 185 | case 2031: | ||
M screen_cursor.go +13 -7
| @@ -8,12 +8,13 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | type cursorState struct { | |
| 11 | - | row int | |
| 12 | - | col int | |
| 13 | - | style renderer.Style | |
| 14 | - | originMode bool | |
| 15 | - | insertMode bool | |
| 16 | - | saved bool | |
| 11 | + | row int | |
| 12 | + | col int | |
| 13 | + | style renderer.Style | |
| 14 | + | originMode bool | |
| 15 | + | insertMode bool | |
| 16 | + | autoWrapMode bool | |
| 17 | + | saved bool | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | func (s *Screen) cursorReportRow() int { | |
| @@ -74,7 +75,11 @@ func (s *Screen) applyCursorStyle(params string) { | |||
| 74 | 75 | } | |
| 75 | 76 | ||
| 76 | 77 | func (s *Screen) saveCursor() { | |
| 77 | - | s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true} | |
| 78 | + | s.savedCursor = cursorState{ | |
| 79 | + | row: s.Row, col: s.Col, style: s.Style, | |
| 80 | + | originMode: s.originMode, insertMode: s.insertMode, | |
| 81 | + | autoWrapMode: s.autoWrapMode, saved: true, | |
| 82 | + | } | |
| 78 | 83 | } | |
| 79 | 84 | ||
| 80 | 85 | func (s *Screen) restoreCursor() { | |
| @@ -86,4 +91,5 @@ func (s *Screen) restoreCursor() { | |||
| 86 | 91 | s.Style = s.savedCursor.style | |
| 87 | 92 | s.originMode = s.savedCursor.originMode | |
| 88 | 93 | s.insertMode = s.savedCursor.insertMode | |
| 94 | + | s.autoWrapMode = s.savedCursor.autoWrapMode | |
| 89 | 95 | } | |
| @@ -8,12 +8,13 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | type cursorState struct { | 10 | type cursorState struct { | ||
| 11 | - | row int | 11 | + | row int |
| 12 | - | col int | 12 | + | col int |
| 13 | - | style renderer.Style | 13 | + | style renderer.Style |
| 14 | - | originMode bool | 14 | + | originMode bool |
| 15 | - | insertMode bool | 15 | + | insertMode bool |
| 16 | - | saved bool | 16 | + | autoWrapMode bool |
| 17 | + | saved bool | |||
| 17 | } | 18 | } | ||
| 18 | 19 | ||||
| 19 | func (s *Screen) cursorReportRow() int { | 20 | func (s *Screen) cursorReportRow() int { | ||
| @@ -74,7 +75,11 @@ func (s *Screen) applyCursorStyle(params string) { | |||||
| 74 | } | 75 | } | ||
| 75 | 76 | ||||
| 76 | func (s *Screen) saveCursor() { | 77 | func (s *Screen) saveCursor() { | ||
| 77 | - | s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true} | 78 | + | s.savedCursor = cursorState{ |
| 79 | + | row: s.Row, col: s.Col, style: s.Style, | |||
| 80 | + | originMode: s.originMode, insertMode: s.insertMode, | |||
| 81 | + | autoWrapMode: s.autoWrapMode, saved: true, | |||
| 82 | + | } | |||
| 78 | } | 83 | } | ||
| 79 | 84 | ||||
| 80 | func (s *Screen) restoreCursor() { | 85 | func (s *Screen) restoreCursor() { | ||
| @@ -86,4 +91,5 @@ func (s *Screen) restoreCursor() { | |||||
| 86 | s.Style = s.savedCursor.style | 91 | s.Style = s.savedCursor.style | ||
| 87 | s.originMode = s.savedCursor.originMode | 92 | s.originMode = s.savedCursor.originMode | ||
| 88 | s.insertMode = s.savedCursor.insertMode | 93 | s.insertMode = s.savedCursor.insertMode | ||
| 94 | + | s.autoWrapMode = s.savedCursor.autoWrapMode | |||
| 89 | } | 95 | } | ||
M screen_modes.go +16 -1
| @@ -27,6 +27,12 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||
| 27 | 27 | // by the child process. | |
| 28 | 28 | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | |
| 29 | 29 | ||
| 30 | + | // AutoWrapMode reports whether DEC private mode 7 (DECAWM) is enabled. | |
| 31 | + | func (s *Screen) AutoWrapMode() bool { return s.autoWrapMode } | |
| 32 | + | ||
| 33 | + | // ApplicationCursorMode reports whether DEC private mode 1 (DECCKM) is enabled. | |
| 34 | + | func (s *Screen) ApplicationCursorMode() bool { return s.applicationCursorMode } | |
| 35 | + | ||
| 30 | 36 | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |
| 31 | 37 | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |
| 32 | 38 | ||
| @@ -104,6 +110,8 @@ func (s *Screen) reset() { | |||
| 104 | 110 | s.graphics = nil | |
| 105 | 111 | s.originMode = false | |
| 106 | 112 | s.insertMode = false | |
| 113 | + | s.autoWrapMode = true | |
| 114 | + | s.applicationCursorMode = false | |
| 107 | 115 | s.cursorVisible = true | |
| 108 | 116 | s.cursorStyle = 0 | |
| 109 | 117 | s.cursorStyleSet = false | |
| @@ -163,9 +171,16 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 163 | 171 | } | |
| 164 | 172 | for _, mode := range parts { | |
| 165 | 173 | switch mode { | |
| 174 | + | case 1: | |
| 175 | + | s.applicationCursorMode = enabled | |
| 166 | 176 | case 6: | |
| 167 | 177 | s.originMode = enabled | |
| 168 | 178 | s.homeCursor() | |
| 179 | + | case 7: | |
| 180 | + | s.autoWrapMode = enabled | |
| 181 | + | if !enabled && s.frame.Width > 0 && s.Col >= s.frame.Width { | |
| 182 | + | s.Col = s.frame.Width - 1 | |
| 183 | + | } | |
| 169 | 184 | case 47, 1047, 1049: | |
| 170 | 185 | if enabled { | |
| 171 | 186 | s.enterAlternateScreen() | |
| @@ -188,7 +203,7 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 188 | 203 | s.mouseSGR = enabled | |
| 189 | 204 | case 2004: | |
| 190 | 205 | s.bracketedPaste = enabled | |
| 191 | - | case 1, 1004, 1005: | |
| 206 | + | case 1004, 1005: | |
| 192 | 207 | // Trackable terminal modes that do not directly affect the current | |
| 193 | 208 | // cell model yet. Consuming them prevents mode bytes from leaking. | |
| 194 | 209 | continue | |
| @@ -27,6 +27,12 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||||
| 27 | // by the child process. | 27 | // by the child process. | ||
| 28 | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | 28 | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | ||
| 29 | 29 | ||||
| 30 | + | // AutoWrapMode reports whether DEC private mode 7 (DECAWM) is enabled. | |||
| 31 | + | func (s *Screen) AutoWrapMode() bool { return s.autoWrapMode } | |||
| 32 | + | ||||
| 33 | + | // ApplicationCursorMode reports whether DEC private mode 1 (DECCKM) is enabled. | |||
| 34 | + | func (s *Screen) ApplicationCursorMode() bool { return s.applicationCursorMode } | |||
| 35 | + | ||||
| 30 | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | 36 | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | ||
| 31 | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | 37 | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | ||
| 32 | 38 | ||||
| @@ -104,6 +110,8 @@ func (s *Screen) reset() { | |||||
| 104 | s.graphics = nil | 110 | s.graphics = nil | ||
| 105 | s.originMode = false | 111 | s.originMode = false | ||
| 106 | s.insertMode = false | 112 | s.insertMode = false | ||
| 113 | + | s.autoWrapMode = true | |||
| 114 | + | s.applicationCursorMode = false | |||
| 107 | s.cursorVisible = true | 115 | s.cursorVisible = true | ||
| 108 | s.cursorStyle = 0 | 116 | s.cursorStyle = 0 | ||
| 109 | s.cursorStyleSet = false | 117 | s.cursorStyleSet = false | ||
| @@ -163,9 +171,16 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||||
| 163 | } | 171 | } | ||
| 164 | for _, mode := range parts { | 172 | for _, mode := range parts { | ||
| 165 | switch mode { | 173 | switch mode { | ||
| 174 | + | case 1: | |||
| 175 | + | s.applicationCursorMode = enabled | |||
| 166 | case 6: | 176 | case 6: | ||
| 167 | s.originMode = enabled | 177 | s.originMode = enabled | ||
| 168 | s.homeCursor() | 178 | s.homeCursor() | ||
| 179 | + | case 7: | |||
| 180 | + | s.autoWrapMode = enabled | |||
| 181 | + | if !enabled && s.frame.Width > 0 && s.Col >= s.frame.Width { | |||
| 182 | + | s.Col = s.frame.Width - 1 | |||
| 183 | + | } | |||
| 169 | case 47, 1047, 1049: | 184 | case 47, 1047, 1049: | ||
| 170 | if enabled { | 185 | if enabled { | ||
| 171 | s.enterAlternateScreen() | 186 | s.enterAlternateScreen() | ||
| @@ -188,7 +203,7 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||||
| 188 | s.mouseSGR = enabled | 203 | s.mouseSGR = enabled | ||
| 189 | case 2004: | 204 | case 2004: | ||
| 190 | s.bracketedPaste = enabled | 205 | s.bracketedPaste = enabled | ||
| 191 | - | case 1, 1004, 1005: | 206 | + | case 1004, 1005: |
| 192 | // Trackable terminal modes that do not directly affect the current | 207 | // Trackable terminal modes that do not directly affect the current | ||
| 193 | // cell model yet. Consuming them prevents mode bytes from leaking. | 208 | // cell model yet. Consuming them prevents mode bytes from leaking. | ||
| 194 | continue | 209 | continue | ||
M screen_snapshot.go +26 -13
| @@ -27,14 +27,16 @@ type ModeSnapshot struct { | |||
| 27 | 27 | // ScreenSnapshot is an owned immutable capture of the active terminal | |
| 28 | 28 | // viewport. It implements core.CellSource, and Row returns caller-owned storage. | |
| 29 | 29 | type ScreenSnapshot struct { | |
| 30 | - | frame renderer.Frame | |
| 31 | - | bounds []LineBound | |
| 32 | - | rowIDs []RowID | |
| 33 | - | cursor CursorSnapshot | |
| 34 | - | modes ModeSnapshot | |
| 35 | - | title string | |
| 36 | - | graphics *graphics.Snapshot | |
| 37 | - | nextRowID RowID | |
| 30 | + | frame renderer.Frame | |
| 31 | + | bounds []LineBound | |
| 32 | + | rowIDs []RowID | |
| 33 | + | cursor CursorSnapshot | |
| 34 | + | modes ModeSnapshot | |
| 35 | + | autoWrap bool | |
| 36 | + | applicationCursor bool | |
| 37 | + | title string | |
| 38 | + | graphics *graphics.Snapshot | |
| 39 | + | nextRowID RowID | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | 42 | // Snapshot captures the active visible viewport without mutating Screen, | |
| @@ -51,10 +53,12 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 51 | 53 | nextRowID++ | |
| 52 | 54 | } | |
| 53 | 55 | return ScreenSnapshot{ | |
| 54 | - | frame: s.frame.Clone(), | |
| 55 | - | bounds: s.LineBounds(), | |
| 56 | - | rowIDs: s.RowIDs(), | |
| 57 | - | nextRowID: nextRowID, | |
| 56 | + | frame: s.frame.Clone(), | |
| 57 | + | bounds: s.LineBounds(), | |
| 58 | + | rowIDs: s.RowIDs(), | |
| 59 | + | nextRowID: nextRowID, | |
| 60 | + | autoWrap: s.AutoWrapMode(), | |
| 61 | + | applicationCursor: s.ApplicationCursorMode(), | |
| 58 | 62 | cursor: CursorSnapshot{ | |
| 59 | 63 | Row: s.CursorRow(), | |
| 60 | 64 | Col: s.CursorCol(), | |
| @@ -117,7 +121,16 @@ func (s ScreenSnapshot) NextRowID() RowID { | |||
| 117 | 121 | ||
| 118 | 122 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | |
| 119 | 123 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | |
| 120 | - | func (s ScreenSnapshot) Title() string { return s.title } | |
| 124 | + | ||
| 125 | + | // AutoWrapMode reports whether DEC private mode 7 (DECAWM) was enabled in the | |
| 126 | + | // captured screen. | |
| 127 | + | func (s ScreenSnapshot) AutoWrapMode() bool { return s.autoWrap } | |
| 128 | + | ||
| 129 | + | // ApplicationCursorMode reports whether DEC private mode 1 (DECCKM) was enabled | |
| 130 | + | // in the captured screen. | |
| 131 | + | func (s ScreenSnapshot) ApplicationCursorMode() bool { return s.applicationCursor } | |
| 132 | + | ||
| 133 | + | func (s ScreenSnapshot) Title() string { return s.title } | |
| 121 | 134 | ||
| 122 | 135 | // Graphics returns the immutable graphics scene snapshot for the active | |
| 123 | 136 | // screen buffer, or nil when that buffer has not used Kitty graphics. | |
| @@ -27,14 +27,16 @@ type ModeSnapshot struct { | |||||
| 27 | // ScreenSnapshot is an owned immutable capture of the active terminal | 27 | // ScreenSnapshot is an owned immutable capture of the active terminal | ||
| 28 | // viewport. It implements core.CellSource, and Row returns caller-owned storage. | 28 | // viewport. It implements core.CellSource, and Row returns caller-owned storage. | ||
| 29 | type ScreenSnapshot struct { | 29 | type ScreenSnapshot struct { | ||
| 30 | - | frame renderer.Frame | 30 | + | frame renderer.Frame |
| 31 | - | bounds []LineBound | 31 | + | bounds []LineBound |
| 32 | - | rowIDs []RowID | 32 | + | rowIDs []RowID |
| 33 | - | cursor CursorSnapshot | 33 | + | cursor CursorSnapshot |
| 34 | - | modes ModeSnapshot | 34 | + | modes ModeSnapshot |
| 35 | - | title string | 35 | + | autoWrap bool |
| 36 | - | graphics *graphics.Snapshot | 36 | + | applicationCursor bool |
| 37 | - | nextRowID RowID | 37 | + | title string |
| 38 | + | graphics *graphics.Snapshot | |||
| 39 | + | nextRowID RowID | |||
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 40 | // Snapshot captures the active visible viewport without mutating Screen, | 42 | // Snapshot captures the active visible viewport without mutating Screen, | ||
| @@ -51,10 +53,12 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||||
| 51 | nextRowID++ | 53 | nextRowID++ | ||
| 52 | } | 54 | } | ||
| 53 | return ScreenSnapshot{ | 55 | return ScreenSnapshot{ | ||
| 54 | - | frame: s.frame.Clone(), | 56 | + | frame: s.frame.Clone(), |
| 55 | - | bounds: s.LineBounds(), | 57 | + | bounds: s.LineBounds(), |
| 56 | - | rowIDs: s.RowIDs(), | 58 | + | rowIDs: s.RowIDs(), |
| 57 | - | nextRowID: nextRowID, | 59 | + | nextRowID: nextRowID, |
| 60 | + | autoWrap: s.AutoWrapMode(), | |||
| 61 | + | applicationCursor: s.ApplicationCursorMode(), | |||
| 58 | cursor: CursorSnapshot{ | 62 | cursor: CursorSnapshot{ | ||
| 59 | Row: s.CursorRow(), | 63 | Row: s.CursorRow(), | ||
| 60 | Col: s.CursorCol(), | 64 | Col: s.CursorCol(), | ||
| @@ -117,7 +121,16 @@ func (s ScreenSnapshot) NextRowID() RowID { | |||||
| 117 | 121 | ||||
| 118 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | 122 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | ||
| 119 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | 123 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | ||
| 120 | - | func (s ScreenSnapshot) Title() string { return s.title } | 124 | + | |
| 125 | + | // AutoWrapMode reports whether DEC private mode 7 (DECAWM) was enabled in the | |||
| 126 | + | // captured screen. | |||
| 127 | + | func (s ScreenSnapshot) AutoWrapMode() bool { return s.autoWrap } | |||
| 128 | + | ||||
| 129 | + | // ApplicationCursorMode reports whether DEC private mode 1 (DECCKM) was enabled | |||
| 130 | + | // in the captured screen. | |||
| 131 | + | func (s ScreenSnapshot) ApplicationCursorMode() bool { return s.applicationCursor } | |||
| 132 | + | ||||
| 133 | + | func (s ScreenSnapshot) Title() string { return s.title } | |||
| 121 | 134 | ||||
| 122 | // Graphics returns the immutable graphics scene snapshot for the active | 135 | // Graphics returns the immutable graphics scene snapshot for the active | ||
| 123 | // screen buffer, or nil when that buffer has not used Kitty graphics. | 136 | // screen buffer, or nil when that buffer has not used Kitty graphics. | ||
M screen_snapshot_test.go +39 -26
| @@ -9,14 +9,16 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |
| 11 | 11 | tests := []struct { | |
| 12 | - | name string | |
| 13 | - | screen func() *Screen | |
| 14 | - | wantColumns int | |
| 15 | - | wantRows int | |
| 16 | - | wantTitle string | |
| 17 | - | wantCursor CursorSnapshot | |
| 18 | - | wantModes ModeSnapshot | |
| 19 | - | checkRows func(t *testing.T, snapshot ScreenSnapshot) | |
| 12 | + | name string | |
| 13 | + | screen func() *Screen | |
| 14 | + | wantColumns int | |
| 15 | + | wantRows int | |
| 16 | + | wantTitle string | |
| 17 | + | wantCursor CursorSnapshot | |
| 18 | + | wantModes ModeSnapshot | |
| 19 | + | wantAutoWrap bool | |
| 20 | + | wantApplicationCursor bool | |
| 21 | + | checkRows func(t *testing.T, snapshot ScreenSnapshot) | |
| 20 | 22 | }{ | |
| 21 | 23 | { | |
| 22 | 24 | name: "nil receiver is the zero snapshot", | |
| @@ -32,13 +34,15 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 32 | 34 | name: "normal viewport captures cursor modes and title", | |
| 33 | 35 | screen: func() *Screen { | |
| 34 | 36 | screen := NewScreen(6, 3) | |
| 35 | - | screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h")) | |
| 37 | + | screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?1h\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h")) | |
| 36 | 38 | return screen | |
| 37 | 39 | }, | |
| 38 | - | wantColumns: 6, | |
| 39 | - | wantRows: 3, | |
| 40 | - | wantTitle: "editor", | |
| 41 | - | wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true}, | |
| 40 | + | wantColumns: 6, | |
| 41 | + | wantRows: 3, | |
| 42 | + | wantTitle: "editor", | |
| 43 | + | wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true}, | |
| 44 | + | wantAutoWrap: true, | |
| 45 | + | wantApplicationCursor: true, | |
| 42 | 46 | wantModes: ModeSnapshot{ | |
| 43 | 47 | BracketedPaste: true, | |
| 44 | 48 | SynchronizedUpdate: true, | |
| @@ -59,9 +63,10 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 59 | 63 | screen.Resize(0, 0) | |
| 60 | 64 | return screen | |
| 61 | 65 | }, | |
| 62 | - | wantTitle: "collapsed", | |
| 63 | - | wantCursor: CursorSnapshot{Visible: true}, | |
| 64 | - | wantModes: ModeSnapshot{BracketedPaste: true}, | |
| 66 | + | wantTitle: "collapsed", | |
| 67 | + | wantCursor: CursorSnapshot{Visible: true}, | |
| 68 | + | wantAutoWrap: true, | |
| 69 | + | wantModes: ModeSnapshot{BracketedPaste: true}, | |
| 65 | 70 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 66 | 71 | require.Nil(t, snapshot.Row(0)) | |
| 67 | 72 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| @@ -75,9 +80,10 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 75 | 80 | screen.Resize(0, 2) | |
| 76 | 81 | return screen | |
| 77 | 82 | }, | |
| 78 | - | wantRows: 2, | |
| 79 | - | wantTitle: "zero-columns", | |
| 80 | - | wantCursor: CursorSnapshot{Visible: true}, | |
| 83 | + | wantRows: 2, | |
| 84 | + | wantTitle: "zero-columns", | |
| 85 | + | wantCursor: CursorSnapshot{Visible: true}, | |
| 86 | + | wantAutoWrap: true, | |
| 81 | 87 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 82 | 88 | require.NotNil(t, snapshot.Row(0)) | |
| 83 | 89 | require.Empty(t, snapshot.Row(0)) | |
| @@ -94,9 +100,10 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 94 | 100 | screen.Resize(2, 0) | |
| 95 | 101 | return screen | |
| 96 | 102 | }, | |
| 97 | - | wantColumns: 2, | |
| 98 | - | wantTitle: "zero-rows", | |
| 99 | - | wantCursor: CursorSnapshot{Visible: true}, | |
| 103 | + | wantColumns: 2, | |
| 104 | + | wantTitle: "zero-rows", | |
| 105 | + | wantCursor: CursorSnapshot{Visible: true}, | |
| 106 | + | wantAutoWrap: true, | |
| 100 | 107 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 101 | 108 | require.Nil(t, snapshot.Row(0)) | |
| 102 | 109 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| @@ -112,6 +119,8 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 112 | 119 | require.Equal(t, test.wantTitle, snapshot.Title()) | |
| 113 | 120 | require.Equal(t, test.wantCursor, snapshot.Cursor()) | |
| 114 | 121 | require.Equal(t, test.wantModes, snapshot.Modes()) | |
| 122 | + | require.Equal(t, test.wantAutoWrap, snapshot.AutoWrapMode()) | |
| 123 | + | require.Equal(t, test.wantApplicationCursor, snapshot.ApplicationCursorMode()) | |
| 115 | 124 | test.checkRows(t, snapshot) | |
| 116 | 125 | }) | |
| 117 | 126 | } | |
| @@ -169,9 +178,10 @@ func TestScreenSnapshotTitle(t *testing.T) { | |||
| 169 | 178 | ||
| 170 | 179 | func TestScreenSnapshotFidelity(t *testing.T) { | |
| 171 | 180 | tests := []struct { | |
| 172 | - | name string | |
| 173 | - | make func() (*Screen, []renderer.Cell, []LineBound) | |
| 174 | - | wantModes ModeSnapshot | |
| 181 | + | name string | |
| 182 | + | make func() (*Screen, []renderer.Cell, []LineBound) | |
| 183 | + | wantModes ModeSnapshot | |
| 184 | + | wantAutoWrap bool | |
| 175 | 185 | }{ | |
| 176 | 186 | { | |
| 177 | 187 | name: "styles wide continuation and bounds are exact", | |
| @@ -195,6 +205,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 195 | 205 | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | |
| 196 | 206 | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | |
| 197 | 207 | }, | |
| 208 | + | wantAutoWrap: true, | |
| 198 | 209 | }, | |
| 199 | 210 | { | |
| 200 | 211 | name: "active alternate screen is captured", | |
| @@ -204,7 +215,8 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 204 | 215 | cells := append([]renderer.Cell(nil), screen.frame.Row(0)...) | |
| 205 | 216 | return screen, cells, screen.LineBounds() | |
| 206 | 217 | }, | |
| 207 | - | wantModes: ModeSnapshot{AlternateScreen: true}, | |
| 218 | + | wantAutoWrap: true, | |
| 219 | + | wantModes: ModeSnapshot{AlternateScreen: true}, | |
| 208 | 220 | }, | |
| 209 | 221 | } | |
| 210 | 222 | ||
| @@ -216,6 +228,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 216 | 228 | require.Equal(t, screen.frame.Height, snapshot.Rows()) | |
| 217 | 229 | require.Equal(t, wantCells, snapshot.Row(0)) | |
| 218 | 230 | require.Equal(t, test.wantModes, snapshot.Modes()) | |
| 231 | + | require.Equal(t, test.wantAutoWrap, snapshot.AutoWrapMode()) | |
| 219 | 232 | for row, want := range wantBounds { | |
| 220 | 233 | require.Equal(t, want, snapshot.Bound(row)) | |
| 221 | 234 | } | |
| @@ -9,14 +9,16 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | 10 | func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | ||
| 11 | tests := []struct { | 11 | tests := []struct { | ||
| 12 | - | name string | 12 | + | name string |
| 13 | - | screen func() *Screen | 13 | + | screen func() *Screen |
| 14 | - | wantColumns int | 14 | + | wantColumns int |
| 15 | - | wantRows int | 15 | + | wantRows int |
| 16 | - | wantTitle string | 16 | + | wantTitle string |
| 17 | - | wantCursor CursorSnapshot | 17 | + | wantCursor CursorSnapshot |
| 18 | - | wantModes ModeSnapshot | 18 | + | wantModes ModeSnapshot |
| 19 | - | checkRows func(t *testing.T, snapshot ScreenSnapshot) | 19 | + | wantAutoWrap bool |
| 20 | + | wantApplicationCursor bool | |||
| 21 | + | checkRows func(t *testing.T, snapshot ScreenSnapshot) | |||
| 20 | }{ | 22 | }{ | ||
| 21 | { | 23 | { | ||
| 22 | name: "nil receiver is the zero snapshot", | 24 | name: "nil receiver is the zero snapshot", | ||
| @@ -32,13 +34,15 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 32 | name: "normal viewport captures cursor modes and title", | 34 | name: "normal viewport captures cursor modes and title", | ||
| 33 | screen: func() *Screen { | 35 | screen: func() *Screen { | ||
| 34 | screen := NewScreen(6, 3) | 36 | screen := NewScreen(6, 3) | ||
| 35 | - | screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h")) | 37 | + | screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?1h\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h")) |
| 36 | return screen | 38 | return screen | ||
| 37 | }, | 39 | }, | ||
| 38 | - | wantColumns: 6, | 40 | + | wantColumns: 6, |
| 39 | - | wantRows: 3, | 41 | + | wantRows: 3, |
| 40 | - | wantTitle: "editor", | 42 | + | wantTitle: "editor", |
| 41 | - | wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true}, | 43 | + | wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true}, |
| 44 | + | wantAutoWrap: true, | |||
| 45 | + | wantApplicationCursor: true, | |||
| 42 | wantModes: ModeSnapshot{ | 46 | wantModes: ModeSnapshot{ | ||
| 43 | BracketedPaste: true, | 47 | BracketedPaste: true, | ||
| 44 | SynchronizedUpdate: true, | 48 | SynchronizedUpdate: true, | ||
| @@ -59,9 +63,10 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 59 | screen.Resize(0, 0) | 63 | screen.Resize(0, 0) | ||
| 60 | return screen | 64 | return screen | ||
| 61 | }, | 65 | }, | ||
| 62 | - | wantTitle: "collapsed", | 66 | + | wantTitle: "collapsed", |
| 63 | - | wantCursor: CursorSnapshot{Visible: true}, | 67 | + | wantCursor: CursorSnapshot{Visible: true}, |
| 64 | - | wantModes: ModeSnapshot{BracketedPaste: true}, | 68 | + | wantAutoWrap: true, |
| 69 | + | wantModes: ModeSnapshot{BracketedPaste: true}, | |||
| 65 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | 70 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | ||
| 66 | require.Nil(t, snapshot.Row(0)) | 71 | require.Nil(t, snapshot.Row(0)) | ||
| 67 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | 72 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | ||
| @@ -75,9 +80,10 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 75 | screen.Resize(0, 2) | 80 | screen.Resize(0, 2) | ||
| 76 | return screen | 81 | return screen | ||
| 77 | }, | 82 | }, | ||
| 78 | - | wantRows: 2, | 83 | + | wantRows: 2, |
| 79 | - | wantTitle: "zero-columns", | 84 | + | wantTitle: "zero-columns", |
| 80 | - | wantCursor: CursorSnapshot{Visible: true}, | 85 | + | wantCursor: CursorSnapshot{Visible: true}, |
| 86 | + | wantAutoWrap: true, | |||
| 81 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | 87 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | ||
| 82 | require.NotNil(t, snapshot.Row(0)) | 88 | require.NotNil(t, snapshot.Row(0)) | ||
| 83 | require.Empty(t, snapshot.Row(0)) | 89 | require.Empty(t, snapshot.Row(0)) | ||
| @@ -94,9 +100,10 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 94 | screen.Resize(2, 0) | 100 | screen.Resize(2, 0) | ||
| 95 | return screen | 101 | return screen | ||
| 96 | }, | 102 | }, | ||
| 97 | - | wantColumns: 2, | 103 | + | wantColumns: 2, |
| 98 | - | wantTitle: "zero-rows", | 104 | + | wantTitle: "zero-rows", |
| 99 | - | wantCursor: CursorSnapshot{Visible: true}, | 105 | + | wantCursor: CursorSnapshot{Visible: true}, |
| 106 | + | wantAutoWrap: true, | |||
| 100 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | 107 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | ||
| 101 | require.Nil(t, snapshot.Row(0)) | 108 | require.Nil(t, snapshot.Row(0)) | ||
| 102 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | 109 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | ||
| @@ -112,6 +119,8 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 112 | require.Equal(t, test.wantTitle, snapshot.Title()) | 119 | require.Equal(t, test.wantTitle, snapshot.Title()) | ||
| 113 | require.Equal(t, test.wantCursor, snapshot.Cursor()) | 120 | require.Equal(t, test.wantCursor, snapshot.Cursor()) | ||
| 114 | require.Equal(t, test.wantModes, snapshot.Modes()) | 121 | require.Equal(t, test.wantModes, snapshot.Modes()) | ||
| 122 | + | require.Equal(t, test.wantAutoWrap, snapshot.AutoWrapMode()) | |||
| 123 | + | require.Equal(t, test.wantApplicationCursor, snapshot.ApplicationCursorMode()) | |||
| 115 | test.checkRows(t, snapshot) | 124 | test.checkRows(t, snapshot) | ||
| 116 | }) | 125 | }) | ||
| 117 | } | 126 | } | ||
| @@ -169,9 +178,10 @@ func TestScreenSnapshotTitle(t *testing.T) { | |||||
| 169 | 178 | ||||
| 170 | func TestScreenSnapshotFidelity(t *testing.T) { | 179 | func TestScreenSnapshotFidelity(t *testing.T) { | ||
| 171 | tests := []struct { | 180 | tests := []struct { | ||
| 172 | - | name string | 181 | + | name string |
| 173 | - | make func() (*Screen, []renderer.Cell, []LineBound) | 182 | + | make func() (*Screen, []renderer.Cell, []LineBound) |
| 174 | - | wantModes ModeSnapshot | 183 | + | wantModes ModeSnapshot |
| 184 | + | wantAutoWrap bool | |||
| 175 | }{ | 185 | }{ | ||
| 176 | { | 186 | { | ||
| 177 | name: "styles wide continuation and bounds are exact", | 187 | name: "styles wide continuation and bounds are exact", | ||
| @@ -195,6 +205,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||||
| 195 | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | 205 | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | ||
| 196 | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | 206 | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | ||
| 197 | }, | 207 | }, | ||
| 208 | + | wantAutoWrap: true, | |||
| 198 | }, | 209 | }, | ||
| 199 | { | 210 | { | ||
| 200 | name: "active alternate screen is captured", | 211 | name: "active alternate screen is captured", | ||
| @@ -204,7 +215,8 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||||
| 204 | cells := append([]renderer.Cell(nil), screen.frame.Row(0)...) | 215 | cells := append([]renderer.Cell(nil), screen.frame.Row(0)...) | ||
| 205 | return screen, cells, screen.LineBounds() | 216 | return screen, cells, screen.LineBounds() | ||
| 206 | }, | 217 | }, | ||
| 207 | - | wantModes: ModeSnapshot{AlternateScreen: true}, | 218 | + | wantAutoWrap: true, |
| 219 | + | wantModes: ModeSnapshot{AlternateScreen: true}, | |||
| 208 | }, | 220 | }, | ||
| 209 | } | 221 | } | ||
| 210 | 222 | ||||
| @@ -216,6 +228,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||||
| 216 | require.Equal(t, screen.frame.Height, snapshot.Rows()) | 228 | require.Equal(t, screen.frame.Height, snapshot.Rows()) | ||
| 217 | require.Equal(t, wantCells, snapshot.Row(0)) | 229 | require.Equal(t, wantCells, snapshot.Row(0)) | ||
| 218 | require.Equal(t, test.wantModes, snapshot.Modes()) | 230 | require.Equal(t, test.wantModes, snapshot.Modes()) | ||
| 231 | + | require.Equal(t, test.wantAutoWrap, snapshot.AutoWrapMode()) | |||
| 219 | for row, want := range wantBounds { | 232 | for row, want := range wantBounds { | ||
| 220 | require.Equal(t, want, snapshot.Bound(row)) | 233 | require.Equal(t, want, snapshot.Bound(row)) | ||
| 221 | } | 234 | } | ||
M screen_write.go +15 -7
| @@ -61,11 +61,16 @@ func (s *Screen) putPrintable(r rune) { | |||
| 61 | 61 | if s.frame.Width == 0 || s.frame.Height == 0 { | |
| 62 | 62 | return | |
| 63 | 63 | } | |
| 64 | - | // Deferred wrap: cursor sits past the last column. | |
| 64 | + | // Deferred wrap: cursor sits past the last column. Disabling DECAWM | |
| 65 | + | // cancels a pending wrap and leaves subsequent output at the right margin. | |
| 65 | 66 | if s.Col >= s.frame.Width { | |
| 66 | - | s.buffer.soft(s.Row) | |
| 67 | - | s.Col = 0 | |
| 68 | - | s.relinkWrap(s.index()) | |
| 67 | + | if s.autoWrapMode { | |
| 68 | + | s.buffer.soft(s.Row) | |
| 69 | + | s.Col = 0 | |
| 70 | + | s.relinkWrap(s.index()) | |
| 71 | + | } else { | |
| 72 | + | s.Col = s.frame.Width - 1 | |
| 73 | + | } | |
| 69 | 74 | } | |
| 70 | 75 | if s.Row >= s.frame.Height { | |
| 71 | 76 | s.Row = s.frame.Height - 1 | |
| @@ -74,9 +79,9 @@ func (s *Screen) putPrintable(r rune) { | |||
| 74 | 79 | // A wide rune must never straddle the right edge. If it does not fit on the | |
| 75 | 80 | // current line, clear the abandoned last cell and wrap to the next line. | |
| 76 | 81 | if w == 2 && s.Col+1 >= s.frame.Width { | |
| 77 | - | if s.frame.Width < 2 { | |
| 78 | - | // The screen is too narrow to ever hold a wide rune; store a narrow | |
| 79 | - | // replacement so the cell's renderer width matches its layout. | |
| 82 | + | if s.frame.Width < 2 || !s.autoWrapMode { | |
| 83 | + | // A wide rune cannot be represented at the right margin without an | |
| 84 | + | // orphaned continuation cell. Use the narrow replacement glyph. | |
| 80 | 85 | r = '\uFFFD' | |
| 81 | 86 | w = renderer.RuneWidth(r) | |
| 82 | 87 | } else { | |
| @@ -136,6 +141,9 @@ func (s *Screen) putPrintable(r rune) { | |||
| 136 | 141 | } | |
| 137 | 142 | s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1}) | |
| 138 | 143 | s.Col += w | |
| 144 | + | if !s.autoWrapMode && s.Col >= s.frame.Width { | |
| 145 | + | s.Col = s.frame.Width - 1 | |
| 146 | + | } | |
| 139 | 147 | } | |
| 140 | 148 | ||
| 141 | 149 | // clearWidePairAt blanks both halves of a wide-character pair when the cell at | |
| @@ -61,11 +61,16 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 61 | if s.frame.Width == 0 || s.frame.Height == 0 { | 61 | if s.frame.Width == 0 || s.frame.Height == 0 { | ||
| 62 | return | 62 | return | ||
| 63 | } | 63 | } | ||
| 64 | - | // Deferred wrap: cursor sits past the last column. | 64 | + | // Deferred wrap: cursor sits past the last column. Disabling DECAWM |
| 65 | + | // cancels a pending wrap and leaves subsequent output at the right margin. | |||
| 65 | if s.Col >= s.frame.Width { | 66 | if s.Col >= s.frame.Width { | ||
| 66 | - | s.buffer.soft(s.Row) | 67 | + | if s.autoWrapMode { |
| 67 | - | s.Col = 0 | 68 | + | s.buffer.soft(s.Row) |
| 68 | - | s.relinkWrap(s.index()) | 69 | + | s.Col = 0 |
| 70 | + | s.relinkWrap(s.index()) | |||
| 71 | + | } else { | |||
| 72 | + | s.Col = s.frame.Width - 1 | |||
| 73 | + | } | |||
| 69 | } | 74 | } | ||
| 70 | if s.Row >= s.frame.Height { | 75 | if s.Row >= s.frame.Height { | ||
| 71 | s.Row = s.frame.Height - 1 | 76 | s.Row = s.frame.Height - 1 | ||
| @@ -74,9 +79,9 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 74 | // A wide rune must never straddle the right edge. If it does not fit on the | 79 | // A wide rune must never straddle the right edge. If it does not fit on the | ||
| 75 | // current line, clear the abandoned last cell and wrap to the next line. | 80 | // current line, clear the abandoned last cell and wrap to the next line. | ||
| 76 | if w == 2 && s.Col+1 >= s.frame.Width { | 81 | if w == 2 && s.Col+1 >= s.frame.Width { | ||
| 77 | - | if s.frame.Width < 2 { | 82 | + | if s.frame.Width < 2 || !s.autoWrapMode { |
| 78 | - | // The screen is too narrow to ever hold a wide rune; store a narrow | 83 | + | // A wide rune cannot be represented at the right margin without an |
| 79 | - | // replacement so the cell's renderer width matches its layout. | 84 | + | // orphaned continuation cell. Use the narrow replacement glyph. |
| 80 | r = '\uFFFD' | 85 | r = '\uFFFD' | ||
| 81 | w = renderer.RuneWidth(r) | 86 | w = renderer.RuneWidth(r) | ||
| 82 | } else { | 87 | } else { | ||
| @@ -136,6 +141,9 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 136 | } | 141 | } | ||
| 137 | s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1}) | 142 | s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1}) | ||
| 138 | s.Col += w | 143 | s.Col += w | ||
| 144 | + | if !s.autoWrapMode && s.Col >= s.frame.Width { | |||
| 145 | + | s.Col = s.frame.Width - 1 | |||
| 146 | + | } | |||
| 139 | } | 147 | } | ||
| 140 | 148 | ||||
| 141 | // clearWidePairAt blanks both halves of a wide-character pair when the cell at | 149 | // clearWidePairAt blanks both halves of a wide-character pair when the cell at | ||