fix(screen): match xterm for REP eligibility, DECSTR saved cursor and alt-screen charsets
- commit
- 1589bdfe764f0504fefae7e8ff4849af0d25186c
- author
- brice · · 2026-10-15 02:37 UTC
- parent
- a3aae45
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +41 -3
M screen.go +2 -0
| @@ -229,6 +229,8 @@ func (s *Screen) Write(data []byte) { | |||
| 229 | 229 | if data[0] == 0x1b { | |
| 230 | 230 | consumed, partial := s.consumeEscape(data) | |
| 231 | 231 | if consumed > 0 { | |
| 232 | + | // A completed sequence (including REP itself) ends REP eligibility. | |
| 233 | + | s.lastPrinted = 0 | |
| 232 | 234 | data = data[consumed:] | |
| 233 | 235 | continue | |
| 234 | 236 | } | |
| @@ -229,6 +229,8 @@ func (s *Screen) Write(data []byte) { | |||||
| 229 | if data[0] == 0x1b { | 229 | if data[0] == 0x1b { | ||
| 230 | consumed, partial := s.consumeEscape(data) | 230 | consumed, partial := s.consumeEscape(data) | ||
| 231 | if consumed > 0 { | 231 | if consumed > 0 { | ||
| 232 | + | // A completed sequence (including REP itself) ends REP eligibility. | |||
| 233 | + | s.lastPrinted = 0 | |||
| 232 | data = data[consumed:] | 234 | data = data[consumed:] | ||
| 233 | continue | 235 | continue | ||
| 234 | } | 236 | } | ||
M screen_charset_test.go +23 -1
| @@ -21,7 +21,12 @@ func TestCharsetsAndRepeat(t *testing.T) { | |||
| 21 | 21 | {name: "REP repeats the last graphic char", input: "\x1b(0q\x1b[4b", want: "─────"}, | |
| 22 | 22 | {name: "REP defaults to one", input: "a\x1b[b", want: "aa"}, | |
| 23 | 23 | {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"}, | |
| 24 | - | {name: "REP after control repeats last graphic", input: "ab\r\x1b[2b", want: "bb"}, | |
| 24 | + | {name: "REP after a control is ignored", input: "ab\r\x1b[2b", want: "ab"}, | |
| 25 | + | {name: "REP after another sequence is ignored", input: "a\x1b[1m\x1b[2b", want: "a"}, | |
| 26 | + | {name: "consecutive REP repeats once", input: "a\x1b[b\x1b[b", want: "aa"}, | |
| 27 | + | {name: "DECSC and DECRC restore SO invocation", input: "\x1b)0\x0e\x1b7\x0f\x1b8q", want: "─"}, | |
| 28 | + | {name: "leaving the alternate screen restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"}, | |
| 29 | + | {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"}, | |
| 25 | 30 | {name: "partial charset escape across writes", input: "\x1b(", want: ""}, | |
| 26 | 31 | } | |
| 27 | 32 | for _, tt := range tests { | |
| @@ -44,6 +49,23 @@ func TestCharsetDesignationSplitAcrossWrites(t *testing.T) { | |||
| 44 | 49 | } | |
| 45 | 50 | } | |
| 46 | 51 | ||
| 52 | + | func TestSoftResetCursorAppearance(t *testing.T) { | |
| 53 | + | s := NewScreen(4, 1) | |
| 54 | + | s.Write([]byte("\x1b[6 q\x1b[?25l\x1b[!p")) | |
| 55 | + | if s.cursorStyleSet || s.cursorStyle != 0 || !s.cursorVisible { | |
| 56 | + | t.Fatalf("cursor style=%d set=%v visible=%v, want defaults", s.cursorStyle, s.cursorStyleSet, s.cursorVisible) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestRepeatSplitAcrossWrites(t *testing.T) { | |
| 61 | + | s := NewScreen(10, 1) | |
| 62 | + | s.Write([]byte("a\x1b[")) | |
| 63 | + | s.Write([]byte("2b")) | |
| 64 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "aaa" { | |
| 65 | + | t.Fatalf("line = %q, want %q", got, "aaa") | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 47 | 69 | func TestRepeatIsBounded(t *testing.T) { | |
| 48 | 70 | s := NewScreen(4, 2) | |
| 49 | 71 | s.Write([]byte("x\x1b[999999999b")) | |
| @@ -21,7 +21,12 @@ func TestCharsetsAndRepeat(t *testing.T) { | |||||
| 21 | {name: "REP repeats the last graphic char", input: "\x1b(0q\x1b[4b", want: "─────"}, | 21 | {name: "REP repeats the last graphic char", input: "\x1b(0q\x1b[4b", want: "─────"}, | ||
| 22 | {name: "REP defaults to one", input: "a\x1b[b", want: "aa"}, | 22 | {name: "REP defaults to one", input: "a\x1b[b", want: "aa"}, | ||
| 23 | {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"}, | 23 | {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"}, | ||
| 24 | - | {name: "REP after control repeats last graphic", input: "ab\r\x1b[2b", want: "bb"}, | 24 | + | {name: "REP after a control is ignored", input: "ab\r\x1b[2b", want: "ab"}, |
| 25 | + | {name: "REP after another sequence is ignored", input: "a\x1b[1m\x1b[2b", want: "a"}, | |||
| 26 | + | {name: "consecutive REP repeats once", input: "a\x1b[b\x1b[b", want: "aa"}, | |||
| 27 | + | {name: "DECSC and DECRC restore SO invocation", input: "\x1b)0\x0e\x1b7\x0f\x1b8q", want: "─"}, | |||
| 28 | + | {name: "leaving the alternate screen restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"}, | |||
| 29 | + | {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"}, | |||
| 25 | {name: "partial charset escape across writes", input: "\x1b(", want: ""}, | 30 | {name: "partial charset escape across writes", input: "\x1b(", want: ""}, | ||
| 26 | } | 31 | } | ||
| 27 | for _, tt := range tests { | 32 | for _, tt := range tests { | ||
| @@ -44,6 +49,23 @@ func TestCharsetDesignationSplitAcrossWrites(t *testing.T) { | |||||
| 44 | } | 49 | } | ||
| 45 | } | 50 | } | ||
| 46 | 51 | ||||
| 52 | + | func TestSoftResetCursorAppearance(t *testing.T) { | |||
| 53 | + | s := NewScreen(4, 1) | |||
| 54 | + | s.Write([]byte("\x1b[6 q\x1b[?25l\x1b[!p")) | |||
| 55 | + | if s.cursorStyleSet || s.cursorStyle != 0 || !s.cursorVisible { | |||
| 56 | + | t.Fatalf("cursor style=%d set=%v visible=%v, want defaults", s.cursorStyle, s.cursorStyleSet, s.cursorVisible) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestRepeatSplitAcrossWrites(t *testing.T) { | |||
| 61 | + | s := NewScreen(10, 1) | |||
| 62 | + | s.Write([]byte("a\x1b[")) | |||
| 63 | + | s.Write([]byte("2b")) | |||
| 64 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "aaa" { | |||
| 65 | + | t.Fatalf("line = %q, want %q", got, "aaa") | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 47 | func TestRepeatIsBounded(t *testing.T) { | 69 | func TestRepeatIsBounded(t *testing.T) { | ||
| 48 | s := NewScreen(4, 2) | 70 | s := NewScreen(4, 2) | ||
| 49 | s.Write([]byte("x\x1b[999999999b")) | 71 | s.Write([]byte("x\x1b[999999999b")) | ||
M screen_modes.go +12 -2
| @@ -18,6 +18,8 @@ type screenState struct { | |||
| 18 | 18 | originMode bool | |
| 19 | 19 | insertMode bool | |
| 20 | 20 | kittyKeyboard kittyKeyboardStack | |
| 21 | + | charsets [2]byte | |
| 22 | + | shiftOut bool | |
| 21 | 23 | } | |
| 22 | 24 | ||
| 23 | 25 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |
| @@ -129,17 +131,21 @@ func (s *Screen) reset() { | |||
| 129 | 131 | } | |
| 130 | 132 | ||
| 131 | 133 | // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins, | |
| 132 | - | // and character sets without touching screen contents or the cursor position. | |
| 134 | + | // cursor appearance, and character sets without touching screen contents or | |
| 135 | + | // the cursor position. Like xterm, the saved cursor becomes home with the | |
| 136 | + | // reset state. | |
| 133 | 137 | func (s *Screen) softReset() { | |
| 134 | 138 | s.Style = renderer.DefaultStyle() | |
| 135 | - | s.savedCursor = cursorState{} | |
| 136 | 139 | s.originMode = false | |
| 137 | 140 | s.insertMode = false | |
| 138 | 141 | s.autoWrapMode = true | |
| 139 | 142 | s.applicationCursorMode = false | |
| 140 | 143 | s.cursorVisible = true | |
| 144 | + | s.cursorStyle = 0 | |
| 145 | + | s.cursorStyleSet = false | |
| 141 | 146 | s.charsets = [2]byte{} | |
| 142 | 147 | s.shiftOut = false | |
| 148 | + | s.savedCursor = cursorState{style: s.Style, autoWrapMode: true, saved: true} | |
| 143 | 149 | s.resetScrollRegion() | |
| 144 | 150 | } | |
| 145 | 151 | ||
| @@ -246,6 +252,8 @@ func (s *Screen) enterAlternateScreen() { | |||
| 246 | 252 | originMode: s.originMode, | |
| 247 | 253 | insertMode: s.insertMode, | |
| 248 | 254 | kittyKeyboard: s.kittyKeyboard, | |
| 255 | + | charsets: s.charsets, | |
| 256 | + | shiftOut: s.shiftOut, | |
| 249 | 257 | } | |
| 250 | 258 | // The main and alternate screens keep independent keyboard stacks. | |
| 251 | 259 | s.kittyKeyboard = nil | |
| @@ -279,6 +287,8 @@ func (s *Screen) exitAlternateScreen() { | |||
| 279 | 287 | s.originMode = state.originMode | |
| 280 | 288 | s.insertMode = state.insertMode | |
| 281 | 289 | s.kittyKeyboard = state.kittyKeyboard | |
| 290 | + | s.charsets = state.charsets | |
| 291 | + | s.shiftOut = state.shiftOut | |
| 282 | 292 | s.alternate = nil | |
| 283 | 293 | s.fullRedraw() | |
| 284 | 294 | } | |
| @@ -18,6 +18,8 @@ type screenState struct { | |||||
| 18 | originMode bool | 18 | originMode bool | ||
| 19 | insertMode bool | 19 | insertMode bool | ||
| 20 | kittyKeyboard kittyKeyboardStack | 20 | kittyKeyboard kittyKeyboardStack | ||
| 21 | + | charsets [2]byte | |||
| 22 | + | shiftOut bool | |||
| 21 | } | 23 | } | ||
| 22 | 24 | ||||
| 23 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | 25 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | ||
| @@ -129,17 +131,21 @@ func (s *Screen) reset() { | |||||
| 129 | } | 131 | } | ||
| 130 | 132 | ||||
| 131 | // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins, | 133 | // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins, | ||
| 132 | - | // and character sets without touching screen contents or the cursor position. | 134 | + | // cursor appearance, and character sets without touching screen contents or |
| 135 | + | // the cursor position. Like xterm, the saved cursor becomes home with the | |||
| 136 | + | // reset state. | |||
| 133 | func (s *Screen) softReset() { | 137 | func (s *Screen) softReset() { | ||
| 134 | s.Style = renderer.DefaultStyle() | 138 | s.Style = renderer.DefaultStyle() | ||
| 135 | - | s.savedCursor = cursorState{} | |||
| 136 | s.originMode = false | 139 | s.originMode = false | ||
| 137 | s.insertMode = false | 140 | s.insertMode = false | ||
| 138 | s.autoWrapMode = true | 141 | s.autoWrapMode = true | ||
| 139 | s.applicationCursorMode = false | 142 | s.applicationCursorMode = false | ||
| 140 | s.cursorVisible = true | 143 | s.cursorVisible = true | ||
| 144 | + | s.cursorStyle = 0 | |||
| 145 | + | s.cursorStyleSet = false | |||
| 141 | s.charsets = [2]byte{} | 146 | s.charsets = [2]byte{} | ||
| 142 | s.shiftOut = false | 147 | s.shiftOut = false | ||
| 148 | + | s.savedCursor = cursorState{style: s.Style, autoWrapMode: true, saved: true} | |||
| 143 | s.resetScrollRegion() | 149 | s.resetScrollRegion() | ||
| 144 | } | 150 | } | ||
| 145 | 151 | ||||
| @@ -246,6 +252,8 @@ func (s *Screen) enterAlternateScreen() { | |||||
| 246 | originMode: s.originMode, | 252 | originMode: s.originMode, | ||
| 247 | insertMode: s.insertMode, | 253 | insertMode: s.insertMode, | ||
| 248 | kittyKeyboard: s.kittyKeyboard, | 254 | kittyKeyboard: s.kittyKeyboard, | ||
| 255 | + | charsets: s.charsets, | |||
| 256 | + | shiftOut: s.shiftOut, | |||
| 249 | } | 257 | } | ||
| 250 | // The main and alternate screens keep independent keyboard stacks. | 258 | // The main and alternate screens keep independent keyboard stacks. | ||
| 251 | s.kittyKeyboard = nil | 259 | s.kittyKeyboard = nil | ||
| @@ -279,6 +287,8 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 279 | s.originMode = state.originMode | 287 | s.originMode = state.originMode | ||
| 280 | s.insertMode = state.insertMode | 288 | s.insertMode = state.insertMode | ||
| 281 | s.kittyKeyboard = state.kittyKeyboard | 289 | s.kittyKeyboard = state.kittyKeyboard | ||
| 290 | + | s.charsets = state.charsets | |||
| 291 | + | s.shiftOut = state.shiftOut | |||
| 282 | s.alternate = nil | 292 | s.alternate = nil | ||
| 283 | s.fullRedraw() | 293 | s.fullRedraw() | ||
| 284 | } | 294 | } | ||
M screen_write.go +4 -0
| @@ -3,6 +3,10 @@ package vt | |||
| 3 | 3 | import renderer "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | 5 | func (s *Screen) putRune(r rune) { | |
| 6 | + | if r < 0x20 || (r >= 0x7f && r <= 0x9f) { | |
| 7 | + | // REP only repeats a graphic character that directly precedes it. | |
| 8 | + | s.lastPrinted = 0 | |
| 9 | + | } | |
| 6 | 10 | switch r { | |
| 7 | 11 | case '\a': | |
| 8 | 12 | if s.OnBell != nil { | |
| @@ -3,6 +3,10 @@ package vt | |||||
| 3 | import renderer "github.com/bnema/vev-vt/core" | 3 | import renderer "github.com/bnema/vev-vt/core" | ||
| 4 | 4 | ||||
| 5 | func (s *Screen) putRune(r rune) { | 5 | func (s *Screen) putRune(r rune) { | ||
| 6 | + | if r < 0x20 || (r >= 0x7f && r <= 0x9f) { | |||
| 7 | + | // REP only repeats a graphic character that directly precedes it. | |||
| 8 | + | s.lastPrinted = 0 | |||
| 9 | + | } | |||
| 6 | switch r { | 10 | switch r { | ||
| 7 | case '\a': | 11 | case '\a': | ||
| 8 | if s.OnBell != nil { | 12 | if s.OnBell != nil { | ||