Merge pull request 'fix(screen): support DEC special graphics, REP and DECSTR' (#3) from fix/dec-special-graphics into main
Reviewed-on: https://git.bnema.dev/bnema/vev-vt/pulls/3
7 files · +210 -5
M screen.go +20 -0
| @@ -106,6 +106,12 @@ type Screen struct { | |||
| 106 | 106 | colorSchemeMode bool | |
| 107 | 107 | colorSchemeLight bool | |
| 108 | 108 | colorSchemeSet bool | |
| 109 | + | // charsets holds the final byte designated to G0 and G1 (ESC ( F and | |
| 110 | + | // ESC ) F); shiftOut selects G1 into GL (SO) instead of G0 (SI). | |
| 111 | + | charsets [2]byte | |
| 112 | + | shiftOut bool | |
| 113 | + | // lastPrinted is the last graphic rune written, repeated by REP. | |
| 114 | + | lastPrinted rune | |
| 109 | 115 | } | |
| 110 | 116 | ||
| 111 | 117 | func NewScreen(width, height int) *Screen { | |
| @@ -178,6 +184,11 @@ func (s *Screen) Write(data []byte) { | |||
| 178 | 184 | if len(s.escapeBuf) == 0 { | |
| 179 | 185 | s.trimEscapeBuffers() | |
| 180 | 186 | } else if isKittyEscapeContinuation(s.escapeBuf, data) { | |
| 187 | + | // A Kitty APC sits between any earlier graphic and what follows. An | |
| 188 | + | // empty write has not identified the buffered escape yet. | |
| 189 | + | if len(data) > 0 { | |
| 190 | + | s.lastPrinted = 0 | |
| 191 | + | } | |
| 181 | 192 | prefix := s.escapeBuf | |
| 182 | 193 | s.escapeBuf = nil | |
| 183 | 194 | data = s.continueKittyEscape(prefix, data) | |
| @@ -212,6 +223,7 @@ func (s *Screen) Write(data []byte) { | |||
| 212 | 223 | } | |
| 213 | 224 | } | |
| 214 | 225 | if s.kittyDiscard { | |
| 226 | + | s.lastPrinted = 0 | |
| 215 | 227 | consumed := s.consumeKittyDiscard(data) | |
| 216 | 228 | data = data[consumed:] | |
| 217 | 229 | if s.kittyDiscard { | |
| @@ -223,6 +235,8 @@ func (s *Screen) Write(data []byte) { | |||
| 223 | 235 | if data[0] == 0x1b { | |
| 224 | 236 | consumed, partial := s.consumeEscape(data) | |
| 225 | 237 | if consumed > 0 { | |
| 238 | + | // A completed sequence (including REP itself) ends REP eligibility. | |
| 239 | + | s.lastPrinted = 0 | |
| 226 | 240 | data = data[consumed:] | |
| 227 | 241 | continue | |
| 228 | 242 | } | |
| @@ -424,6 +438,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||
| 424 | 438 | if len(data) < 3 { | |
| 425 | 439 | return 0, true | |
| 426 | 440 | } | |
| 441 | + | switch data[1] { | |
| 442 | + | case '(': | |
| 443 | + | s.charsets[0] = data[2] | |
| 444 | + | case ')': | |
| 445 | + | s.charsets[1] = data[2] | |
| 446 | + | } | |
| 427 | 447 | return 3, false | |
| 428 | 448 | default: | |
| 429 | 449 | if data[1] >= 0x30 && data[1] <= 0x7e { | |
| @@ -106,6 +106,12 @@ type Screen struct { | |||||
| 106 | colorSchemeMode bool | 106 | colorSchemeMode bool | ||
| 107 | colorSchemeLight bool | 107 | colorSchemeLight bool | ||
| 108 | colorSchemeSet bool | 108 | colorSchemeSet bool | ||
| 109 | + | // charsets holds the final byte designated to G0 and G1 (ESC ( F and | |||
| 110 | + | // ESC ) F); shiftOut selects G1 into GL (SO) instead of G0 (SI). | |||
| 111 | + | charsets [2]byte | |||
| 112 | + | shiftOut bool | |||
| 113 | + | // lastPrinted is the last graphic rune written, repeated by REP. | |||
| 114 | + | lastPrinted rune | |||
| 109 | } | 115 | } | ||
| 110 | 116 | ||||
| 111 | func NewScreen(width, height int) *Screen { | 117 | func NewScreen(width, height int) *Screen { | ||
| @@ -178,6 +184,11 @@ func (s *Screen) Write(data []byte) { | |||||
| 178 | if len(s.escapeBuf) == 0 { | 184 | if len(s.escapeBuf) == 0 { | ||
| 179 | s.trimEscapeBuffers() | 185 | s.trimEscapeBuffers() | ||
| 180 | } else if isKittyEscapeContinuation(s.escapeBuf, data) { | 186 | } else if isKittyEscapeContinuation(s.escapeBuf, data) { | ||
| 187 | + | // A Kitty APC sits between any earlier graphic and what follows. An | |||
| 188 | + | // empty write has not identified the buffered escape yet. | |||
| 189 | + | if len(data) > 0 { | |||
| 190 | + | s.lastPrinted = 0 | |||
| 191 | + | } | |||
| 181 | prefix := s.escapeBuf | 192 | prefix := s.escapeBuf | ||
| 182 | s.escapeBuf = nil | 193 | s.escapeBuf = nil | ||
| 183 | data = s.continueKittyEscape(prefix, data) | 194 | data = s.continueKittyEscape(prefix, data) | ||
| @@ -212,6 +223,7 @@ func (s *Screen) Write(data []byte) { | |||||
| 212 | } | 223 | } | ||
| 213 | } | 224 | } | ||
| 214 | if s.kittyDiscard { | 225 | if s.kittyDiscard { | ||
| 226 | + | s.lastPrinted = 0 | |||
| 215 | consumed := s.consumeKittyDiscard(data) | 227 | consumed := s.consumeKittyDiscard(data) | ||
| 216 | data = data[consumed:] | 228 | data = data[consumed:] | ||
| 217 | if s.kittyDiscard { | 229 | if s.kittyDiscard { | ||
| @@ -223,6 +235,8 @@ func (s *Screen) Write(data []byte) { | |||||
| 223 | if data[0] == 0x1b { | 235 | if data[0] == 0x1b { | ||
| 224 | consumed, partial := s.consumeEscape(data) | 236 | consumed, partial := s.consumeEscape(data) | ||
| 225 | if consumed > 0 { | 237 | if consumed > 0 { | ||
| 238 | + | // A completed sequence (including REP itself) ends REP eligibility. | |||
| 239 | + | s.lastPrinted = 0 | |||
| 226 | data = data[consumed:] | 240 | data = data[consumed:] | ||
| 227 | continue | 241 | continue | ||
| 228 | } | 242 | } | ||
| @@ -424,6 +438,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||||
| 424 | if len(data) < 3 { | 438 | if len(data) < 3 { | ||
| 425 | return 0, true | 439 | return 0, true | ||
| 426 | } | 440 | } | ||
| 441 | + | switch data[1] { | |||
| 442 | + | case '(': | |||
| 443 | + | s.charsets[0] = data[2] | |||
| 444 | + | case ')': | |||
| 445 | + | s.charsets[1] = data[2] | |||
| 446 | + | } | |||
| 427 | return 3, false | 447 | return 3, false | ||
| 428 | default: | 448 | default: | ||
| 429 | if data[1] >= 0x30 && data[1] <= 0x7e { | 449 | if data[1] >= 0x30 && data[1] <= 0x7e { | ||
A screen_charset.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | // decSpecialGraphics maps 0x5f..0x7e to the DEC Special Graphics set | |
| 4 | + | // (designated by ESC ( 0 or ESC ) 0), used by ncurses for line drawing. | |
| 5 | + | var decSpecialGraphics = [...]rune{ | |
| 6 | + | ' ', '◆', '▒', '␉', '␌', '␍', '␊', '°', '±', '', '␋', '┘', '┐', '┌', '└', '┼', | |
| 7 | + | '⎺', '⎻', '─', '⎼', '⎽', '├', '┤', '┴', '┬', '│', '≤', '≥', 'π', '≠', '£', '·', | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | // maxRepeat bounds REP to one full screen: repeating further only rewrites | |
| 11 | + | // cells and scrolls, so a hostile count cannot stall the parser. | |
| 12 | + | func maxRepeat(width, height int) int { | |
| 13 | + | return max(width*height, 1) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // translateCharset maps r through the character set currently invoked into GL. | |
| 17 | + | func (s *Screen) translateCharset(r rune) rune { | |
| 18 | + | g := 0 | |
| 19 | + | if s.shiftOut { | |
| 20 | + | g = 1 | |
| 21 | + | } | |
| 22 | + | if s.charsets[g] == '0' && r >= 0x5f && r <= 0x7e { | |
| 23 | + | return decSpecialGraphics[r-0x5f] | |
| 24 | + | } | |
| 25 | + | return r | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | // decSpecialGraphics maps 0x5f..0x7e to the DEC Special Graphics set | |||
| 4 | + | // (designated by ESC ( 0 or ESC ) 0), used by ncurses for line drawing. | |||
| 5 | + | var decSpecialGraphics = [...]rune{ | |||
| 6 | + | ' ', '◆', '▒', '␉', '␌', '␍', '␊', '°', '±', '', '␋', '┘', '┐', '┌', '└', '┼', | |||
| 7 | + | '⎺', '⎻', '─', '⎼', '⎽', '├', '┤', '┴', '┬', '│', '≤', '≥', 'π', '≠', '£', '·', | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | // maxRepeat bounds REP to one full screen: repeating further only rewrites | |||
| 11 | + | // cells and scrolls, so a hostile count cannot stall the parser. | |||
| 12 | + | func maxRepeat(width, height int) int { | |||
| 13 | + | return max(width*height, 1) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // translateCharset maps r through the character set currently invoked into GL. | |||
| 17 | + | func (s *Screen) translateCharset(r rune) rune { | |||
| 18 | + | g := 0 | |||
| 19 | + | if s.shiftOut { | |||
| 20 | + | g = 1 | |||
| 21 | + | } | |||
| 22 | + | if s.charsets[g] == '0' && r >= 0x5f && r <= 0x7e { | |||
| 23 | + | return decSpecialGraphics[r-0x5f] | |||
| 24 | + | } | |||
| 25 | + | return r | |||
| 26 | + | } | |||
A screen_charset_test.go +99 -0
| @@ -0,0 +1,99 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestCharsetsAndRepeat(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | input string | |
| 12 | + | want string | |
| 13 | + | }{ | |
| 14 | + | {name: "G0 DEC special graphics draws lines", input: "\x1b(0lqqk\x1b(Bq", want: "┌──┐q"}, | |
| 15 | + | {name: "G1 via SO and SI", input: "\x1b)0q\x0eqx\x0fq", want: "q─│q"}, | |
| 16 | + | {name: "SO without G1 designation stays ASCII", input: "\x0eq\x0f", want: "q"}, | |
| 17 | + | {name: "chars outside the graphics range pass through", input: "\x1b(0Aa~", want: "A▒·"}, | |
| 18 | + | {name: "DECSC and DECRC restore charsets", input: "\x1b(0\x1b7\x1b(B\x1b8q", want: "─"}, | |
| 19 | + | {name: "DECSTR resets charsets", input: "\x1b(0q\x1b[!pq", want: "─q"}, | |
| 20 | + | {name: "RIS resets charsets", input: "\x1b(0\x1bcq", want: "q"}, | |
| 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"}, | |
| 23 | + | {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"}, | |
| 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 1049 restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"}, | |
| 29 | + | {name: "leaving 47 keeps current charsets", input: "\x1b(0\x1b[?47h\x1b(B\x1b[?47lq", want: "q"}, | |
| 30 | + | {name: "leaving 1047 keeps current charsets", input: "\x1b(0\x1b[?1047h\x1b(B\x1b[?1047lq", want: "q"}, | |
| 31 | + | {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"}, | |
| 32 | + | {name: "partial charset escape across writes", input: "\x1b(", want: ""}, | |
| 33 | + | } | |
| 34 | + | for _, tt := range tests { | |
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |
| 36 | + | s := NewScreen(10, 3) | |
| 37 | + | s.Write([]byte(tt.input)) | |
| 38 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != tt.want { | |
| 39 | + | t.Fatalf("line = %q, want %q", got, tt.want) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestCharsetDesignationSplitAcrossWrites(t *testing.T) { | |
| 46 | + | s := NewScreen(10, 1) | |
| 47 | + | s.Write([]byte("\x1b(")) | |
| 48 | + | s.Write([]byte("0q")) | |
| 49 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "─" { | |
| 50 | + | t.Fatalf("line = %q, want %q", got, "─") | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestSoftResetCursorAppearance(t *testing.T) { | |
| 55 | + | s := NewScreen(4, 1) | |
| 56 | + | s.Write([]byte("\x1b[6 q\x1b[?25l\x1b[!p")) | |
| 57 | + | if s.cursorStyleSet || s.cursorStyle != 0 || !s.cursorVisible { | |
| 58 | + | t.Fatalf("cursor style=%d set=%v visible=%v, want defaults", s.cursorStyle, s.cursorStyleSet, s.cursorVisible) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestRepeatSplitAcrossWrites(t *testing.T) { | |
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | writes []string | |
| 66 | + | }{ | |
| 67 | + | {name: "split inside CSI", writes: []string{"a\x1b[", "2b"}}, | |
| 68 | + | {name: "empty write after lone ESC", writes: []string{"a\x1b", "", "[2b"}}, | |
| 69 | + | } | |
| 70 | + | for _, tt := range tests { | |
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |
| 72 | + | s := NewScreen(10, 1) | |
| 73 | + | for _, w := range tt.writes { | |
| 74 | + | s.Write([]byte(w)) | |
| 75 | + | } | |
| 76 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "aaa" { | |
| 77 | + | t.Fatalf("line = %q, want %q", got, "aaa") | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestRepeatAfterFragmentedKittyAPCIsIgnored(t *testing.T) { | |
| 84 | + | s := NewScreen(10, 1) | |
| 85 | + | s.Write([]byte("a\x1b_G")) | |
| 86 | + | s.Write([]byte("a=q,i=1;\x1b\\")) | |
| 87 | + | s.Write([]byte("\x1b[2b")) | |
| 88 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "a" { | |
| 89 | + | t.Fatalf("line = %q, want %q", got, "a") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestRepeatIsBounded(t *testing.T) { | |
| 94 | + | s := NewScreen(4, 2) | |
| 95 | + | s.Write([]byte("x\x1b[999999999b")) | |
| 96 | + | if got := lineText(s, 0); got != "xxxx" { | |
| 97 | + | t.Fatalf("line = %q, want %q", got, "xxxx") | |
| 98 | + | } | |
| 99 | + | } | |
| @@ -0,0 +1,99 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestCharsetsAndRepeat(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | input string | |||
| 12 | + | want string | |||
| 13 | + | }{ | |||
| 14 | + | {name: "G0 DEC special graphics draws lines", input: "\x1b(0lqqk\x1b(Bq", want: "┌──┐q"}, | |||
| 15 | + | {name: "G1 via SO and SI", input: "\x1b)0q\x0eqx\x0fq", want: "q─│q"}, | |||
| 16 | + | {name: "SO without G1 designation stays ASCII", input: "\x0eq\x0f", want: "q"}, | |||
| 17 | + | {name: "chars outside the graphics range pass through", input: "\x1b(0Aa~", want: "A▒·"}, | |||
| 18 | + | {name: "DECSC and DECRC restore charsets", input: "\x1b(0\x1b7\x1b(B\x1b8q", want: "─"}, | |||
| 19 | + | {name: "DECSTR resets charsets", input: "\x1b(0q\x1b[!pq", want: "─q"}, | |||
| 20 | + | {name: "RIS resets charsets", input: "\x1b(0\x1bcq", want: "q"}, | |||
| 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"}, | |||
| 23 | + | {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"}, | |||
| 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 1049 restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"}, | |||
| 29 | + | {name: "leaving 47 keeps current charsets", input: "\x1b(0\x1b[?47h\x1b(B\x1b[?47lq", want: "q"}, | |||
| 30 | + | {name: "leaving 1047 keeps current charsets", input: "\x1b(0\x1b[?1047h\x1b(B\x1b[?1047lq", want: "q"}, | |||
| 31 | + | {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"}, | |||
| 32 | + | {name: "partial charset escape across writes", input: "\x1b(", want: ""}, | |||
| 33 | + | } | |||
| 34 | + | for _, tt := range tests { | |||
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 36 | + | s := NewScreen(10, 3) | |||
| 37 | + | s.Write([]byte(tt.input)) | |||
| 38 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != tt.want { | |||
| 39 | + | t.Fatalf("line = %q, want %q", got, tt.want) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestCharsetDesignationSplitAcrossWrites(t *testing.T) { | |||
| 46 | + | s := NewScreen(10, 1) | |||
| 47 | + | s.Write([]byte("\x1b(")) | |||
| 48 | + | s.Write([]byte("0q")) | |||
| 49 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "─" { | |||
| 50 | + | t.Fatalf("line = %q, want %q", got, "─") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestSoftResetCursorAppearance(t *testing.T) { | |||
| 55 | + | s := NewScreen(4, 1) | |||
| 56 | + | s.Write([]byte("\x1b[6 q\x1b[?25l\x1b[!p")) | |||
| 57 | + | if s.cursorStyleSet || s.cursorStyle != 0 || !s.cursorVisible { | |||
| 58 | + | t.Fatalf("cursor style=%d set=%v visible=%v, want defaults", s.cursorStyle, s.cursorStyleSet, s.cursorVisible) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestRepeatSplitAcrossWrites(t *testing.T) { | |||
| 63 | + | tests := []struct { | |||
| 64 | + | name string | |||
| 65 | + | writes []string | |||
| 66 | + | }{ | |||
| 67 | + | {name: "split inside CSI", writes: []string{"a\x1b[", "2b"}}, | |||
| 68 | + | {name: "empty write after lone ESC", writes: []string{"a\x1b", "", "[2b"}}, | |||
| 69 | + | } | |||
| 70 | + | for _, tt := range tests { | |||
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 72 | + | s := NewScreen(10, 1) | |||
| 73 | + | for _, w := range tt.writes { | |||
| 74 | + | s.Write([]byte(w)) | |||
| 75 | + | } | |||
| 76 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "aaa" { | |||
| 77 | + | t.Fatalf("line = %q, want %q", got, "aaa") | |||
| 78 | + | } | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestRepeatAfterFragmentedKittyAPCIsIgnored(t *testing.T) { | |||
| 84 | + | s := NewScreen(10, 1) | |||
| 85 | + | s.Write([]byte("a\x1b_G")) | |||
| 86 | + | s.Write([]byte("a=q,i=1;\x1b\\")) | |||
| 87 | + | s.Write([]byte("\x1b[2b")) | |||
| 88 | + | if got := strings.TrimRight(lineText(s, 0), " "); got != "a" { | |||
| 89 | + | t.Fatalf("line = %q, want %q", got, "a") | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestRepeatIsBounded(t *testing.T) { | |||
| 94 | + | s := NewScreen(4, 2) | |||
| 95 | + | s.Write([]byte("x\x1b[999999999b")) | |||
| 96 | + | if got := lineText(s, 0); got != "xxxx" { | |||
| 97 | + | t.Fatalf("line = %q, want %q", got, "xxxx") | |||
| 98 | + | } | |||
| 99 | + | } | |||
M screen_csi.go +13 -0
| @@ -63,6 +63,10 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 63 | 63 | s.respond(resp) | |
| 64 | 64 | } | |
| 65 | 65 | case 'p': | |
| 66 | + | if params == "!" { | |
| 67 | + | s.softReset() | |
| 68 | + | return | |
| 69 | + | } | |
| 66 | 70 | if modeText, ok := strings.CutSuffix(params, "$"); ok { | |
| 67 | 71 | if private { | |
| 68 | 72 | modeText = strings.TrimPrefix(modeText, "?") | |
| @@ -83,6 +87,15 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 83 | 87 | resp = append(resp, "$y"...) | |
| 84 | 88 | s.respond(resp) | |
| 85 | 89 | } | |
| 90 | + | case 'b': | |
| 91 | + | // REP repeats the last printed graphic character; ncurses relies on it | |
| 92 | + | // for long runs when terminfo advertises rep (xterm-256color does). | |
| 93 | + | if private || s.lastPrinted == 0 { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | for n := min(firstPositive(parts, 1), maxRepeat(s.frame.Width, s.frame.Height)); n > 0; n-- { | |
| 97 | + | s.putPrintable(s.lastPrinted) | |
| 98 | + | } | |
| 86 | 99 | case 'm': | |
| 87 | 100 | if strings.HasPrefix(params, ">") { | |
| 88 | 101 | return | |
| @@ -63,6 +63,10 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 63 | s.respond(resp) | 63 | s.respond(resp) | ||
| 64 | } | 64 | } | ||
| 65 | case 'p': | 65 | case 'p': | ||
| 66 | + | if params == "!" { | |||
| 67 | + | s.softReset() | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 66 | if modeText, ok := strings.CutSuffix(params, "$"); ok { | 70 | if modeText, ok := strings.CutSuffix(params, "$"); ok { | ||
| 67 | if private { | 71 | if private { | ||
| 68 | modeText = strings.TrimPrefix(modeText, "?") | 72 | modeText = strings.TrimPrefix(modeText, "?") | ||
| @@ -83,6 +87,15 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 83 | resp = append(resp, "$y"...) | 87 | resp = append(resp, "$y"...) | ||
| 84 | s.respond(resp) | 88 | s.respond(resp) | ||
| 85 | } | 89 | } | ||
| 90 | + | case 'b': | |||
| 91 | + | // REP repeats the last printed graphic character; ncurses relies on it | |||
| 92 | + | // for long runs when terminfo advertises rep (xterm-256color does). | |||
| 93 | + | if private || s.lastPrinted == 0 { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | for n := min(firstPositive(parts, 1), maxRepeat(s.frame.Width, s.frame.Height)); n > 0; n-- { | |||
| 97 | + | s.putPrintable(s.lastPrinted) | |||
| 98 | + | } | |||
| 86 | case 'm': | 99 | case 'm': | ||
| 87 | if strings.HasPrefix(params, ">") { | 100 | if strings.HasPrefix(params, ">") { | ||
| 88 | return | 101 | return | ||
M screen_cursor.go +6 -1
| @@ -14,6 +14,8 @@ type cursorState struct { | |||
| 14 | 14 | originMode bool | |
| 15 | 15 | insertMode bool | |
| 16 | 16 | autoWrapMode bool | |
| 17 | + | charsets [2]byte | |
| 18 | + | shiftOut bool | |
| 17 | 19 | saved bool | |
| 18 | 20 | } | |
| 19 | 21 | ||
| @@ -78,7 +80,8 @@ func (s *Screen) saveCursor() { | |||
| 78 | 80 | s.savedCursor = cursorState{ | |
| 79 | 81 | row: s.Row, col: s.Col, style: s.Style, | |
| 80 | 82 | originMode: s.originMode, insertMode: s.insertMode, | |
| 81 | - | autoWrapMode: s.autoWrapMode, saved: true, | |
| 83 | + | autoWrapMode: s.autoWrapMode, charsets: s.charsets, | |
| 84 | + | shiftOut: s.shiftOut, saved: true, | |
| 82 | 85 | } | |
| 83 | 86 | } | |
| 84 | 87 | ||
| @@ -92,4 +95,6 @@ func (s *Screen) restoreCursor() { | |||
| 92 | 95 | s.originMode = s.savedCursor.originMode | |
| 93 | 96 | s.insertMode = s.savedCursor.insertMode | |
| 94 | 97 | s.autoWrapMode = s.savedCursor.autoWrapMode | |
| 98 | + | s.charsets = s.savedCursor.charsets | |
| 99 | + | s.shiftOut = s.savedCursor.shiftOut | |
| 95 | 100 | } | |
| @@ -14,6 +14,8 @@ type cursorState struct { | |||||
| 14 | originMode bool | 14 | originMode bool | ||
| 15 | insertMode bool | 15 | insertMode bool | ||
| 16 | autoWrapMode bool | 16 | autoWrapMode bool | ||
| 17 | + | charsets [2]byte | |||
| 18 | + | shiftOut bool | |||
| 17 | saved bool | 19 | saved bool | ||
| 18 | } | 20 | } | ||
| 19 | 21 | ||||
| @@ -78,7 +80,8 @@ func (s *Screen) saveCursor() { | |||||
| 78 | s.savedCursor = cursorState{ | 80 | s.savedCursor = cursorState{ | ||
| 79 | row: s.Row, col: s.Col, style: s.Style, | 81 | row: s.Row, col: s.Col, style: s.Style, | ||
| 80 | originMode: s.originMode, insertMode: s.insertMode, | 82 | originMode: s.originMode, insertMode: s.insertMode, | ||
| 81 | - | autoWrapMode: s.autoWrapMode, saved: true, | 83 | + | autoWrapMode: s.autoWrapMode, charsets: s.charsets, |
| 84 | + | shiftOut: s.shiftOut, saved: true, | |||
| 82 | } | 85 | } | ||
| 83 | } | 86 | } | ||
| 84 | 87 | ||||
| @@ -92,4 +95,6 @@ func (s *Screen) restoreCursor() { | |||||
| 92 | s.originMode = s.savedCursor.originMode | 95 | s.originMode = s.savedCursor.originMode | ||
| 93 | s.insertMode = s.savedCursor.insertMode | 96 | s.insertMode = s.savedCursor.insertMode | ||
| 94 | s.autoWrapMode = s.savedCursor.autoWrapMode | 97 | s.autoWrapMode = s.savedCursor.autoWrapMode | ||
| 98 | + | s.charsets = s.savedCursor.charsets | |||
| 99 | + | s.shiftOut = s.savedCursor.shiftOut | |||
| 95 | } | 100 | } | ||
M screen_modes.go +33 -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) | |
| @@ -121,10 +123,32 @@ func (s *Screen) reset() { | |||
| 121 | 123 | s.bracketedPaste = false | |
| 122 | 124 | s.kittyKeyboard = nil | |
| 123 | 125 | s.colorSchemeMode = false | |
| 126 | + | s.charsets = [2]byte{} | |
| 127 | + | s.shiftOut = false | |
| 128 | + | s.lastPrinted = 0 | |
| 124 | 129 | s.resetScrollRegion() | |
| 125 | 130 | s.fullRedraw() | |
| 126 | 131 | } | |
| 127 | 132 | ||
| 133 | + | // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins, | |
| 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. | |
| 137 | + | func (s *Screen) softReset() { | |
| 138 | + | s.Style = renderer.DefaultStyle() | |
| 139 | + | s.originMode = false | |
| 140 | + | s.insertMode = false | |
| 141 | + | s.autoWrapMode = true | |
| 142 | + | s.applicationCursorMode = false | |
| 143 | + | s.cursorVisible = true | |
| 144 | + | s.cursorStyle = 0 | |
| 145 | + | s.cursorStyleSet = false | |
| 146 | + | s.charsets = [2]byte{} | |
| 147 | + | s.shiftOut = false | |
| 148 | + | s.savedCursor = cursorState{style: s.Style, autoWrapMode: true, saved: true} | |
| 149 | + | s.resetScrollRegion() | |
| 150 | + | } | |
| 151 | + | ||
| 128 | 152 | func (s *Screen) setScrollRegion(parts []int) { | |
| 129 | 153 | if s.frame.Height == 0 { | |
| 130 | 154 | return | |
| @@ -187,7 +211,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 187 | 211 | if enabled { | |
| 188 | 212 | s.enterAlternateScreen() | |
| 189 | 213 | } else { | |
| 190 | - | s.exitAlternateScreen() | |
| 214 | + | // Only 1049 saves and restores cursor state, which includes charsets. | |
| 215 | + | s.exitAlternateScreen(mode == 1049) | |
| 191 | 216 | } | |
| 192 | 217 | case 2026: | |
| 193 | 218 | s.syncUpdateActive = enabled | |
| @@ -228,6 +253,8 @@ func (s *Screen) enterAlternateScreen() { | |||
| 228 | 253 | originMode: s.originMode, | |
| 229 | 254 | insertMode: s.insertMode, | |
| 230 | 255 | kittyKeyboard: s.kittyKeyboard, | |
| 256 | + | charsets: s.charsets, | |
| 257 | + | shiftOut: s.shiftOut, | |
| 231 | 258 | } | |
| 232 | 259 | // The main and alternate screens keep independent keyboard stacks. | |
| 233 | 260 | s.kittyKeyboard = nil | |
| @@ -243,7 +270,7 @@ func (s *Screen) enterAlternateScreen() { | |||
| 243 | 270 | s.fullRedraw() | |
| 244 | 271 | } | |
| 245 | 272 | ||
| 246 | - | func (s *Screen) exitAlternateScreen() { | |
| 273 | + | func (s *Screen) exitAlternateScreen(restoreCharsets bool) { | |
| 247 | 274 | if s.alternate == nil { | |
| 248 | 275 | return | |
| 249 | 276 | } | |
| @@ -261,6 +288,10 @@ func (s *Screen) exitAlternateScreen() { | |||
| 261 | 288 | s.originMode = state.originMode | |
| 262 | 289 | s.insertMode = state.insertMode | |
| 263 | 290 | s.kittyKeyboard = state.kittyKeyboard | |
| 291 | + | if restoreCharsets { | |
| 292 | + | s.charsets = state.charsets | |
| 293 | + | s.shiftOut = state.shiftOut | |
| 294 | + | } | |
| 264 | 295 | s.alternate = nil | |
| 265 | 296 | s.fullRedraw() | |
| 266 | 297 | } | |
| @@ -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) | ||
| @@ -121,10 +123,32 @@ func (s *Screen) reset() { | |||||
| 121 | s.bracketedPaste = false | 123 | s.bracketedPaste = false | ||
| 122 | s.kittyKeyboard = nil | 124 | s.kittyKeyboard = nil | ||
| 123 | s.colorSchemeMode = false | 125 | s.colorSchemeMode = false | ||
| 126 | + | s.charsets = [2]byte{} | |||
| 127 | + | s.shiftOut = false | |||
| 128 | + | s.lastPrinted = 0 | |||
| 124 | s.resetScrollRegion() | 129 | s.resetScrollRegion() | ||
| 125 | s.fullRedraw() | 130 | s.fullRedraw() | ||
| 126 | } | 131 | } | ||
| 127 | 132 | ||||
| 133 | + | // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins, | |||
| 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. | |||
| 137 | + | func (s *Screen) softReset() { | |||
| 138 | + | s.Style = renderer.DefaultStyle() | |||
| 139 | + | s.originMode = false | |||
| 140 | + | s.insertMode = false | |||
| 141 | + | s.autoWrapMode = true | |||
| 142 | + | s.applicationCursorMode = false | |||
| 143 | + | s.cursorVisible = true | |||
| 144 | + | s.cursorStyle = 0 | |||
| 145 | + | s.cursorStyleSet = false | |||
| 146 | + | s.charsets = [2]byte{} | |||
| 147 | + | s.shiftOut = false | |||
| 148 | + | s.savedCursor = cursorState{style: s.Style, autoWrapMode: true, saved: true} | |||
| 149 | + | s.resetScrollRegion() | |||
| 150 | + | } | |||
| 151 | + | ||||
| 128 | func (s *Screen) setScrollRegion(parts []int) { | 152 | func (s *Screen) setScrollRegion(parts []int) { | ||
| 129 | if s.frame.Height == 0 { | 153 | if s.frame.Height == 0 { | ||
| 130 | return | 154 | return | ||
| @@ -187,7 +211,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||||
| 187 | if enabled { | 211 | if enabled { | ||
| 188 | s.enterAlternateScreen() | 212 | s.enterAlternateScreen() | ||
| 189 | } else { | 213 | } else { | ||
| 190 | - | s.exitAlternateScreen() | 214 | + | // Only 1049 saves and restores cursor state, which includes charsets. |
| 215 | + | s.exitAlternateScreen(mode == 1049) | |||
| 191 | } | 216 | } | ||
| 192 | case 2026: | 217 | case 2026: | ||
| 193 | s.syncUpdateActive = enabled | 218 | s.syncUpdateActive = enabled | ||
| @@ -228,6 +253,8 @@ func (s *Screen) enterAlternateScreen() { | |||||
| 228 | originMode: s.originMode, | 253 | originMode: s.originMode, | ||
| 229 | insertMode: s.insertMode, | 254 | insertMode: s.insertMode, | ||
| 230 | kittyKeyboard: s.kittyKeyboard, | 255 | kittyKeyboard: s.kittyKeyboard, | ||
| 256 | + | charsets: s.charsets, | |||
| 257 | + | shiftOut: s.shiftOut, | |||
| 231 | } | 258 | } | ||
| 232 | // The main and alternate screens keep independent keyboard stacks. | 259 | // The main and alternate screens keep independent keyboard stacks. | ||
| 233 | s.kittyKeyboard = nil | 260 | s.kittyKeyboard = nil | ||
| @@ -243,7 +270,7 @@ func (s *Screen) enterAlternateScreen() { | |||||
| 243 | s.fullRedraw() | 270 | s.fullRedraw() | ||
| 244 | } | 271 | } | ||
| 245 | 272 | ||||
| 246 | - | func (s *Screen) exitAlternateScreen() { | 273 | + | func (s *Screen) exitAlternateScreen(restoreCharsets bool) { |
| 247 | if s.alternate == nil { | 274 | if s.alternate == nil { | ||
| 248 | return | 275 | return | ||
| 249 | } | 276 | } | ||
| @@ -261,6 +288,10 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 261 | s.originMode = state.originMode | 288 | s.originMode = state.originMode | ||
| 262 | s.insertMode = state.insertMode | 289 | s.insertMode = state.insertMode | ||
| 263 | s.kittyKeyboard = state.kittyKeyboard | 290 | s.kittyKeyboard = state.kittyKeyboard | ||
| 291 | + | if restoreCharsets { | |||
| 292 | + | s.charsets = state.charsets | |||
| 293 | + | s.shiftOut = state.shiftOut | |||
| 294 | + | } | |||
| 264 | s.alternate = nil | 295 | s.alternate = nil | ||
| 265 | s.fullRedraw() | 296 | s.fullRedraw() | ||
| 266 | } | 297 | } | ||
M screen_write.go +13 -2
| @@ -3,13 +3,23 @@ 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 { | |
| 9 | 13 | s.OnBell() | |
| 10 | 14 | } | |
| 11 | 15 | return | |
| 12 | - | case 0x00, 0x0e, 0x0f, 0x7f: | |
| 16 | + | case 0x0e: | |
| 17 | + | s.shiftOut = true | |
| 18 | + | return | |
| 19 | + | case 0x0f: | |
| 20 | + | s.shiftOut = false | |
| 21 | + | return | |
| 22 | + | case 0x00, 0x7f: | |
| 13 | 23 | return | |
| 14 | 24 | case '\r': | |
| 15 | 25 | s.Col = 0 | |
| @@ -35,7 +45,7 @@ func (s *Screen) putRune(r rune) { | |||
| 35 | 45 | return | |
| 36 | 46 | } | |
| 37 | 47 | if r >= 0x20 { | |
| 38 | - | s.putPrintable(r) | |
| 48 | + | s.putPrintable(s.translateCharset(r)) | |
| 39 | 49 | } | |
| 40 | 50 | } | |
| 41 | 51 | } | |
| @@ -61,6 +71,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 61 | 71 | if s.frame.Width == 0 || s.frame.Height == 0 { | |
| 62 | 72 | return | |
| 63 | 73 | } | |
| 74 | + | s.lastPrinted = r | |
| 64 | 75 | // Deferred wrap: cursor sits past the last column. Disabling DECAWM | |
| 65 | 76 | // cancels a pending wrap and leaves subsequent output at the right margin. | |
| 66 | 77 | if s.Col >= s.frame.Width { | |
| @@ -3,13 +3,23 @@ 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 { | ||
| 9 | s.OnBell() | 13 | s.OnBell() | ||
| 10 | } | 14 | } | ||
| 11 | return | 15 | return | ||
| 12 | - | case 0x00, 0x0e, 0x0f, 0x7f: | 16 | + | case 0x0e: |
| 17 | + | s.shiftOut = true | |||
| 18 | + | return | |||
| 19 | + | case 0x0f: | |||
| 20 | + | s.shiftOut = false | |||
| 21 | + | return | |||
| 22 | + | case 0x00, 0x7f: | |||
| 13 | return | 23 | return | ||
| 14 | case '\r': | 24 | case '\r': | ||
| 15 | s.Col = 0 | 25 | s.Col = 0 | ||
| @@ -35,7 +45,7 @@ func (s *Screen) putRune(r rune) { | |||||
| 35 | return | 45 | return | ||
| 36 | } | 46 | } | ||
| 37 | if r >= 0x20 { | 47 | if r >= 0x20 { | ||
| 38 | - | s.putPrintable(r) | 48 | + | s.putPrintable(s.translateCharset(r)) |
| 39 | } | 49 | } | ||
| 40 | } | 50 | } | ||
| 41 | } | 51 | } | ||
| @@ -61,6 +71,7 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 61 | if s.frame.Width == 0 || s.frame.Height == 0 { | 71 | if s.frame.Width == 0 || s.frame.Height == 0 { | ||
| 62 | return | 72 | return | ||
| 63 | } | 73 | } | ||
| 74 | + | s.lastPrinted = r | |||
| 64 | // Deferred wrap: cursor sits past the last column. Disabling DECAWM | 75 | // Deferred wrap: cursor sits past the last column. Disabling DECAWM | ||
| 65 | // cancels a pending wrap and leaves subsequent output at the right margin. | 76 | // cancels a pending wrap and leaves subsequent output at the right margin. | ||
| 66 | if s.Col >= s.frame.Width { | 77 | if s.Col >= s.frame.Width { | ||