detached 66991a3

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
commit
66991a36d9ac0b85340bb1b986f9a4aecd3e961e
author
bnema · · 2026-10-15 03:01 UTC
merge of
36c429d + f053137 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
7 files · +210 -5
M screen.go +20 -0
@@ -106,6 +106,12 @@ type Screen struct {
106106 colorSchemeMode bool
107107 colorSchemeLight bool
108108 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
109115 }
110116
111117 func NewScreen(width, height int) *Screen {
@@ -178,6 +184,11 @@ func (s *Screen) Write(data []byte) {
178184 if len(s.escapeBuf) == 0 {
179185 s.trimEscapeBuffers()
180186 } 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+ }
181192 prefix := s.escapeBuf
182193 s.escapeBuf = nil
183194 data = s.continueKittyEscape(prefix, data)
@@ -212,6 +223,7 @@ func (s *Screen) Write(data []byte) {
212223 }
213224 }
214225 if s.kittyDiscard {
226+ s.lastPrinted = 0
215227 consumed := s.consumeKittyDiscard(data)
216228 data = data[consumed:]
217229 if s.kittyDiscard {
@@ -223,6 +235,8 @@ func (s *Screen) Write(data []byte) {
223235 if data[0] == 0x1b {
224236 consumed, partial := s.consumeEscape(data)
225237 if consumed > 0 {
238+ // A completed sequence (including REP itself) ends REP eligibility.
239+ s.lastPrinted = 0
226240 data = data[consumed:]
227241 continue
228242 }
@@ -424,6 +438,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
424438 if len(data) < 3 {
425439 return 0, true
426440 }
441+ switch data[1] {
442+ case '(':
443+ s.charsets[0] = data[2]
444+ case ')':
445+ s.charsets[1] = data[2]
446+ }
427447 return 3, false
428448 default:
429449 if data[1] >= 0x30 && data[1] <= 0x7e {
@@ -106,6 +106,12 @@ type Screen struct {
106 colorSchemeMode bool106 colorSchemeMode bool
107 colorSchemeLight bool107 colorSchemeLight bool
108 colorSchemeSet bool108 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.escapeBuf192 prefix := s.escapeBuf
182 s.escapeBuf = nil193 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 continue241 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, true439 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, false447 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) {
6363 s.respond(resp)
6464 }
6565 case 'p':
66+ if params == "!" {
67+ s.softReset()
68+ return
69+ }
6670 if modeText, ok := strings.CutSuffix(params, "$"); ok {
6771 if private {
6872 modeText = strings.TrimPrefix(modeText, "?")
@@ -83,6 +87,15 @@ func (s *Screen) applyCSI(params string, cmd byte) {
8387 resp = append(resp, "$y"...)
8488 s.respond(resp)
8589 }
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+ }
8699 case 'm':
87100 if strings.HasPrefix(params, ">") {
88101 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 return101 return
M screen_cursor.go +6 -1
@@ -14,6 +14,8 @@ type cursorState struct {
1414 originMode bool
1515 insertMode bool
1616 autoWrapMode bool
17+ charsets [2]byte
18+ shiftOut bool
1719 saved bool
1820 }
1921
@@ -78,7 +80,8 @@ func (s *Screen) saveCursor() {
7880 s.savedCursor = cursorState{
7981 row: s.Row, col: s.Col, style: s.Style,
8082 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,
8285 }
8386 }
8487
@@ -92,4 +95,6 @@ func (s *Screen) restoreCursor() {
9295 s.originMode = s.savedCursor.originMode
9396 s.insertMode = s.savedCursor.insertMode
9497 s.autoWrapMode = s.savedCursor.autoWrapMode
98+ s.charsets = s.savedCursor.charsets
99+ s.shiftOut = s.savedCursor.shiftOut
95100 }
@@ -14,6 +14,8 @@ type cursorState struct {
14 originMode bool14 originMode bool
15 insertMode bool15 insertMode bool
16 autoWrapMode bool16 autoWrapMode bool
17+ charsets [2]byte
18+ shiftOut bool
17 saved bool19 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.originMode95 s.originMode = s.savedCursor.originMode
93 s.insertMode = s.savedCursor.insertMode96 s.insertMode = s.savedCursor.insertMode
94 s.autoWrapMode = s.savedCursor.autoWrapMode97 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 {
1818 originMode bool
1919 insertMode bool
2020 kittyKeyboard kittyKeyboardStack
21+ charsets [2]byte
22+ shiftOut bool
2123 }
2224
2325 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
@@ -121,10 +123,32 @@ func (s *Screen) reset() {
121123 s.bracketedPaste = false
122124 s.kittyKeyboard = nil
123125 s.colorSchemeMode = false
126+ s.charsets = [2]byte{}
127+ s.shiftOut = false
128+ s.lastPrinted = 0
124129 s.resetScrollRegion()
125130 s.fullRedraw()
126131 }
127132
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+
128152 func (s *Screen) setScrollRegion(parts []int) {
129153 if s.frame.Height == 0 {
130154 return
@@ -187,7 +211,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
187211 if enabled {
188212 s.enterAlternateScreen()
189213 } else {
190- s.exitAlternateScreen()
214+ // Only 1049 saves and restores cursor state, which includes charsets.
215+ s.exitAlternateScreen(mode == 1049)
191216 }
192217 case 2026:
193218 s.syncUpdateActive = enabled
@@ -228,6 +253,8 @@ func (s *Screen) enterAlternateScreen() {
228253 originMode: s.originMode,
229254 insertMode: s.insertMode,
230255 kittyKeyboard: s.kittyKeyboard,
256+ charsets: s.charsets,
257+ shiftOut: s.shiftOut,
231258 }
232259 // The main and alternate screens keep independent keyboard stacks.
233260 s.kittyKeyboard = nil
@@ -243,7 +270,7 @@ func (s *Screen) enterAlternateScreen() {
243270 s.fullRedraw()
244271 }
245272
246-func (s *Screen) exitAlternateScreen() {
273+func (s *Screen) exitAlternateScreen(restoreCharsets bool) {
247274 if s.alternate == nil {
248275 return
249276 }
@@ -261,6 +288,10 @@ func (s *Screen) exitAlternateScreen() {
261288 s.originMode = state.originMode
262289 s.insertMode = state.insertMode
263290 s.kittyKeyboard = state.kittyKeyboard
291+ if restoreCharsets {
292+ s.charsets = state.charsets
293+ s.shiftOut = state.shiftOut
294+ }
264295 s.alternate = nil
265296 s.fullRedraw()
266297 }
@@ -18,6 +18,8 @@ type screenState struct {
18 originMode bool18 originMode bool
19 insertMode bool19 insertMode bool
20 kittyKeyboard kittyKeyboardStack20 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 = false123 s.bracketedPaste = false
122 s.kittyKeyboard = nil124 s.kittyKeyboard = nil
123 s.colorSchemeMode = false125 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 return154 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 = enabled218 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 = nil260 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 return275 return
249 }276 }
@@ -261,6 +288,10 @@ func (s *Screen) exitAlternateScreen() {
261 s.originMode = state.originMode288 s.originMode = state.originMode
262 s.insertMode = state.insertMode289 s.insertMode = state.insertMode
263 s.kittyKeyboard = state.kittyKeyboard290 s.kittyKeyboard = state.kittyKeyboard
291+ if restoreCharsets {
292+ s.charsets = state.charsets
293+ s.shiftOut = state.shiftOut
294+ }
264 s.alternate = nil295 s.alternate = nil
265 s.fullRedraw()296 s.fullRedraw()
266 }297 }
M screen_write.go +13 -2
@@ -3,13 +3,23 @@ package vt
33 import renderer "github.com/bnema/vev-vt/core"
44
55 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+ }
610 switch r {
711 case '\a':
812 if s.OnBell != nil {
913 s.OnBell()
1014 }
1115 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:
1323 return
1424 case '\r':
1525 s.Col = 0
@@ -35,7 +45,7 @@ func (s *Screen) putRune(r rune) {
3545 return
3646 }
3747 if r >= 0x20 {
38- s.putPrintable(r)
48+ s.putPrintable(s.translateCharset(r))
3949 }
4050 }
4151 }
@@ -61,6 +71,7 @@ func (s *Screen) putPrintable(r rune) {
6171 if s.frame.Width == 0 || s.frame.Height == 0 {
6272 return
6373 }
74+ s.lastPrinted = r
6475 // Deferred wrap: cursor sits past the last column. Disabling DECAWM
6576 // cancels a pending wrap and leaves subsequent output at the right margin.
6677 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 return15 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 return23 return
14 case '\r':24 case '\r':
15 s.Col = 025 s.Col = 0
@@ -35,7 +45,7 @@ func (s *Screen) putRune(r rune) {
35 return45 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 return72 return
63 }73 }
74+ s.lastPrinted = r
64 // Deferred wrap: cursor sits past the last column. Disabling DECAWM75 // 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close