detached a3aae45

fix(screen): support DEC special graphics charsets, REP and DECSTR

commit
a3aae4548101cb114ec74e65ff456b10ed2558a5
author
brice · · 2026-10-15 02:29 UTC
parent
36c429d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +137 -3
M screen.go +12 -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 {
@@ -424,6 +430,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
424430 if len(data) < 3 {
425431 return 0, true
426432 }
433+ switch data[1] {
434+ case '(':
435+ s.charsets[0] = data[2]
436+ case ')':
437+ s.charsets[1] = data[2]
438+ }
427439 return 3, false
428440 default:
429441 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 {
@@ -424,6 +430,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
424 if len(data) < 3 {430 if len(data) < 3 {
425 return 0, true431 return 0, true
426 }432 }
433+ switch data[1] {
434+ case '(':
435+ s.charsets[0] = data[2]
436+ case ')':
437+ s.charsets[1] = data[2]
438+ }
427 return 3, false439 return 3, false
428 default:440 default:
429 if data[1] >= 0x30 && data[1] <= 0x7e {441 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 +53 -0
@@ -0,0 +1,53 @@
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 control repeats last graphic", input: "ab\r\x1b[2b", want: "bb"},
25+ {name: "partial charset escape across writes", input: "\x1b(", want: ""},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ s := NewScreen(10, 3)
30+ s.Write([]byte(tt.input))
31+ if got := strings.TrimRight(lineText(s, 0), " "); got != tt.want {
32+ t.Fatalf("line = %q, want %q", got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestCharsetDesignationSplitAcrossWrites(t *testing.T) {
39+ s := NewScreen(10, 1)
40+ s.Write([]byte("\x1b("))
41+ s.Write([]byte("0q"))
42+ if got := strings.TrimRight(lineText(s, 0), " "); got != "─" {
43+ t.Fatalf("line = %q, want %q", got, "─")
44+ }
45+}
46+
47+func TestRepeatIsBounded(t *testing.T) {
48+ s := NewScreen(4, 2)
49+ s.Write([]byte("x\x1b[999999999b"))
50+ if got := lineText(s, 0); got != "xxxx" {
51+ t.Fatalf("line = %q, want %q", got, "xxxx")
52+ }
53+}
@@ -0,0 +1,53 @@
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 control repeats last graphic", input: "ab\r\x1b[2b", want: "bb"},
25+ {name: "partial charset escape across writes", input: "\x1b(", want: ""},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ s := NewScreen(10, 3)
30+ s.Write([]byte(tt.input))
31+ if got := strings.TrimRight(lineText(s, 0), " "); got != tt.want {
32+ t.Fatalf("line = %q, want %q", got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestCharsetDesignationSplitAcrossWrites(t *testing.T) {
39+ s := NewScreen(10, 1)
40+ s.Write([]byte("\x1b("))
41+ s.Write([]byte("0q"))
42+ if got := strings.TrimRight(lineText(s, 0), " "); got != "─" {
43+ t.Fatalf("line = %q, want %q", got, "─")
44+ }
45+}
46+
47+func TestRepeatIsBounded(t *testing.T) {
48+ s := NewScreen(4, 2)
49+ s.Write([]byte("x\x1b[999999999b"))
50+ if got := lineText(s, 0); got != "xxxx" {
51+ t.Fatalf("line = %q, want %q", got, "xxxx")
52+ }
53+}
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 +18 -0
@@ -121,10 +121,28 @@ func (s *Screen) reset() {
121121 s.bracketedPaste = false
122122 s.kittyKeyboard = nil
123123 s.colorSchemeMode = false
124+ s.charsets = [2]byte{}
125+ s.shiftOut = false
126+ s.lastPrinted = 0
124127 s.resetScrollRegion()
125128 s.fullRedraw()
126129 }
127130
131+// softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins,
132+// and character sets without touching screen contents or the cursor position.
133+func (s *Screen) softReset() {
134+ s.Style = renderer.DefaultStyle()
135+ s.savedCursor = cursorState{}
136+ s.originMode = false
137+ s.insertMode = false
138+ s.autoWrapMode = true
139+ s.applicationCursorMode = false
140+ s.cursorVisible = true
141+ s.charsets = [2]byte{}
142+ s.shiftOut = false
143+ s.resetScrollRegion()
144+}
145+
128146 func (s *Screen) setScrollRegion(parts []int) {
129147 if s.frame.Height == 0 {
130148 return
@@ -121,10 +121,28 @@ func (s *Screen) reset() {
121 s.bracketedPaste = false121 s.bracketedPaste = false
122 s.kittyKeyboard = nil122 s.kittyKeyboard = nil
123 s.colorSchemeMode = false123 s.colorSchemeMode = false
124+ s.charsets = [2]byte{}
125+ s.shiftOut = false
126+ s.lastPrinted = 0
124 s.resetScrollRegion()127 s.resetScrollRegion()
125 s.fullRedraw()128 s.fullRedraw()
126 }129 }
127 130
131+// softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins,
132+// and character sets without touching screen contents or the cursor position.
133+func (s *Screen) softReset() {
134+ s.Style = renderer.DefaultStyle()
135+ s.savedCursor = cursorState{}
136+ s.originMode = false
137+ s.insertMode = false
138+ s.autoWrapMode = true
139+ s.applicationCursorMode = false
140+ s.cursorVisible = true
141+ s.charsets = [2]byte{}
142+ s.shiftOut = false
143+ s.resetScrollRegion()
144+}
145+
128 func (s *Screen) setScrollRegion(parts []int) {146 func (s *Screen) setScrollRegion(parts []int) {
129 if s.frame.Height == 0 {147 if s.frame.Height == 0 {
130 return148 return
M screen_write.go +9 -2
@@ -9,7 +9,13 @@ func (s *Screen) putRune(r rune) {
99 s.OnBell()
1010 }
1111 return
12- case 0x00, 0x0e, 0x0f, 0x7f:
12+ case 0x0e:
13+ s.shiftOut = true
14+ return
15+ case 0x0f:
16+ s.shiftOut = false
17+ return
18+ case 0x00, 0x7f:
1319 return
1420 case '\r':
1521 s.Col = 0
@@ -35,7 +41,7 @@ func (s *Screen) putRune(r rune) {
3541 return
3642 }
3743 if r >= 0x20 {
38- s.putPrintable(r)
44+ s.putPrintable(s.translateCharset(r))
3945 }
4046 }
4147 }
@@ -61,6 +67,7 @@ func (s *Screen) putPrintable(r rune) {
6167 if s.frame.Width == 0 || s.frame.Height == 0 {
6268 return
6369 }
70+ s.lastPrinted = r
6471 // Deferred wrap: cursor sits past the last column. Disabling DECAWM
6572 // cancels a pending wrap and leaves subsequent output at the right margin.
6673 if s.Col >= s.frame.Width {
@@ -9,7 +9,13 @@ func (s *Screen) putRune(r rune) {
9 s.OnBell()9 s.OnBell()
10 }10 }
11 return11 return
12- case 0x00, 0x0e, 0x0f, 0x7f:12+ case 0x0e:
13+ s.shiftOut = true
14+ return
15+ case 0x0f:
16+ s.shiftOut = false
17+ return
18+ case 0x00, 0x7f:
13 return19 return
14 case '\r':20 case '\r':
15 s.Col = 021 s.Col = 0
@@ -35,7 +41,7 @@ func (s *Screen) putRune(r rune) {
35 return41 return
36 }42 }
37 if r >= 0x20 {43 if r >= 0x20 {
38- s.putPrintable(r)44+ s.putPrintable(s.translateCharset(r))
39 }45 }
40 }46 }
41 }47 }
@@ -61,6 +67,7 @@ func (s *Screen) putPrintable(r rune) {
61 if s.frame.Width == 0 || s.frame.Height == 0 {67 if s.frame.Width == 0 || s.frame.Height == 0 {
62 return68 return
63 }69 }
70+ s.lastPrinted = r
64 // Deferred wrap: cursor sits past the last column. Disabling DECAWM71 // Deferred wrap: cursor sits past the last column. Disabling DECAWM
65 // cancels a pending wrap and leaves subsequent output at the right margin.72 // cancels a pending wrap and leaves subsequent output at the right margin.
66 if s.Col >= s.frame.Width {73 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