detached 56d89b0

perf(vt): parse CSI and SGR params without split

commit
56d89b09f6a84d3116b7aa733a30c62a713d9655
author
brice · · 2026-07-08 14:31 UTC
committer
brice · · 2026-07-08 20:59 UTC
parent
eff9002
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +161 -44
M pkg/vt/screen.go +104 -44
@@ -81,8 +81,10 @@ type Screen struct {
8181 defaultBG renderer.RGB
8282 defaultColorsKnown bool
8383
84- damage []renderer.Damage
85- escapeBuf []byte
84+ damage []renderer.Damage
85+ escapeBuf []byte
86+ csiScratch []int
87+ sgrScratch []int
8688
8789 scrollTop int
8890 scrollBottom int
@@ -784,7 +786,7 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
784786
785787 func (s *Screen) applyCSI(params string, cmd byte) {
786788 private := strings.HasPrefix(params, "?")
787- parts := parseCSIInts(params)
789+ parts := s.parseCSIInts(params)
788790 switch cmd {
789791 case 'c':
790792 switch params {
@@ -952,7 +954,7 @@ func (s *Screen) applySGR(params string) {
952954 s.Style = renderer.DefaultStyle()
953955 return
954956 }
955- parts := parseSGRInts(params)
957+ parts := s.parseSGRInts(params)
956958 for i := 0; i < len(parts); i++ {
957959 switch parts[i] {
958960 case 0:
@@ -1309,66 +1311,124 @@ func (s *Screen) clampCursor() {
13091311 s.Col = clamp(s.Col, 0, s.Frame.Width-1)
13101312 }
13111313
1314+func (s *Screen) parseCSIInts(params string) []int {
1315+ s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
1316+ return s.csiScratch
1317+}
1318+
13121319 func parseCSIInts(params string) []int {
1320+ return parseCSIIntsInto(nil, params)
1321+}
1322+
1323+func parseCSIIntsInto(out []int, params string) []int {
13131324 if params == "" {
1314- return nil
1325+ return out
13151326 }
13161327 params = strings.TrimPrefix(params, "?")
13171328 params = strings.TrimPrefix(params, ">")
1318- parts := strings.Split(params, ";")
1319- out := make([]int, 0, len(parts))
1320- for _, p := range parts {
1321- out = append(out, parseCSIInt(p))
1329+ start := 0
1330+ for i := 0; i <= len(params); i++ {
1331+ if i == len(params) || params[i] == ';' {
1332+ out = append(out, parseCSIInt(params[start:i]))
1333+ start = i + 1
1334+ }
13221335 }
13231336 return out
13241337 }
13251338
1339+func (s *Screen) parseSGRInts(params string) []int {
1340+ s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
1341+ return s.sgrScratch
1342+}
1343+
13261344 func parseSGRInts(params string) []int {
1345+ return parseSGRIntsInto(nil, params)
1346+}
1347+
1348+func parseSGRIntsInto(out []int, params string) []int {
13271349 if params == "" {
1328- return nil
1350+ return out
13291351 }
1330- groups := strings.Split(params, ";")
1331- out := make([]int, 0, len(groups))
1332- for _, group := range groups {
1333- if !strings.Contains(group, ":") {
1334- out = append(out, parseCSIInt(group))
1335- continue
1352+ start := 0
1353+ for i := 0; i <= len(params); i++ {
1354+ if i == len(params) || params[i] == ';' {
1355+ out = appendSGRGroup(out, params[start:i])
1356+ start = i + 1
13361357 }
1337- sub := strings.Split(group, ":")
1338- code := parseCSIInt(sub[0])
1339- if code != 38 && code != 48 {
1340- out = append(out, code)
1341- continue
1358+ }
1359+ return out
1360+}
1361+
1362+func appendSGRGroup(out []int, group string) []int {
1363+ colon := false
1364+ for i := 0; i < len(group); i++ {
1365+ if group[i] == ':' {
1366+ colon = true
1367+ break
13421368 }
1343- mode := 0
1344- if len(sub) > 1 {
1345- mode = parseCSIInt(sub[1])
1369+ }
1370+ if !colon {
1371+ return append(out, parseCSIInt(group))
1372+ }
1373+
1374+ parts := countSGRColonFields(group)
1375+ code := parseCSIInt(sgrColonField(group, 0))
1376+ if code != 38 && code != 48 {
1377+ return append(out, code)
1378+ }
1379+ mode := 0
1380+ if parts > 1 {
1381+ mode = parseCSIInt(sgrColonField(group, 1))
1382+ }
1383+ switch mode {
1384+ case 5:
1385+ if parts < 3 {
1386+ return out
13461387 }
1347- switch mode {
1348- case 5:
1349- if len(sub) < 3 {
1350- continue
1351- }
1352- out = append(out, code, mode, parseCSIInt(sub[2]))
1353- case 2:
1354- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1355- // the colorspace slot; code:mode:R:G:B omits that slot.
1356- start := 2
1357- if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) {
1358- start = 3
1359- }
1360- if len(sub) < start+3 {
1361- continue
1362- }
1363- out = append(out, code, mode)
1364- for i := 0; i < 3; i++ {
1365- out = append(out, parseCSIInt(sub[start+i]))
1366- }
1388+ out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
1389+ case 2:
1390+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1391+ // the colorspace slot; code:mode:R:G:B omits that slot.
1392+ start := 2
1393+ if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
1394+ start = 3
1395+ }
1396+ if parts < start+3 {
1397+ return out
1398+ }
1399+ out = append(out, code, mode)
1400+ for i := 0; i < 3; i++ {
1401+ out = append(out, parseCSIInt(sgrColonField(group, start+i)))
13671402 }
13681403 }
13691404 return out
13701405 }
13711406
1407+func countSGRColonFields(group string) int {
1408+ count := 1
1409+ for i := 0; i < len(group); i++ {
1410+ if group[i] == ':' {
1411+ count++
1412+ }
1413+ }
1414+ return count
1415+}
1416+
1417+func sgrColonField(group string, index int) string {
1418+ start := 0
1419+ field := 0
1420+ for i := 0; i <= len(group); i++ {
1421+ if i == len(group) || group[i] == ':' {
1422+ if field == index {
1423+ return group[start:i]
1424+ }
1425+ field++
1426+ start = i + 1
1427+ }
1428+ }
1429+ return ""
1430+}
1431+
13721432 func parseCSIInt(param string) int {
13731433 if param == "" {
13741434 return 0
@@ -81,8 +81,10 @@ type Screen struct {
81 defaultBG renderer.RGB81 defaultBG renderer.RGB
82 defaultColorsKnown bool82 defaultColorsKnown bool
83 83
84- damage []renderer.Damage84+ damage []renderer.Damage
85- escapeBuf []byte85+ escapeBuf []byte
86+ csiScratch []int
87+ sgrScratch []int
86 88
87 scrollTop int89 scrollTop int
88 scrollBottom int90 scrollBottom int
@@ -784,7 +786,7 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
784 786
785 func (s *Screen) applyCSI(params string, cmd byte) {787 func (s *Screen) applyCSI(params string, cmd byte) {
786 private := strings.HasPrefix(params, "?")788 private := strings.HasPrefix(params, "?")
787- parts := parseCSIInts(params)789+ parts := s.parseCSIInts(params)
788 switch cmd {790 switch cmd {
789 case 'c':791 case 'c':
790 switch params {792 switch params {
@@ -952,7 +954,7 @@ func (s *Screen) applySGR(params string) {
952 s.Style = renderer.DefaultStyle()954 s.Style = renderer.DefaultStyle()
953 return955 return
954 }956 }
955- parts := parseSGRInts(params)957+ parts := s.parseSGRInts(params)
956 for i := 0; i < len(parts); i++ {958 for i := 0; i < len(parts); i++ {
957 switch parts[i] {959 switch parts[i] {
958 case 0:960 case 0:
@@ -1309,66 +1311,124 @@ func (s *Screen) clampCursor() {
1309 s.Col = clamp(s.Col, 0, s.Frame.Width-1)1311 s.Col = clamp(s.Col, 0, s.Frame.Width-1)
1310 }1312 }
1311 1313
1314+func (s *Screen) parseCSIInts(params string) []int {
1315+ s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
1316+ return s.csiScratch
1317+}
1318+
1312 func parseCSIInts(params string) []int {1319 func parseCSIInts(params string) []int {
1320+ return parseCSIIntsInto(nil, params)
1321+}
1322+
1323+func parseCSIIntsInto(out []int, params string) []int {
1313 if params == "" {1324 if params == "" {
1314- return nil1325+ return out
1315 }1326 }
1316 params = strings.TrimPrefix(params, "?")1327 params = strings.TrimPrefix(params, "?")
1317 params = strings.TrimPrefix(params, ">")1328 params = strings.TrimPrefix(params, ">")
1318- parts := strings.Split(params, ";")1329+ start := 0
1319- out := make([]int, 0, len(parts))1330+ for i := 0; i <= len(params); i++ {
1320- for _, p := range parts {1331+ if i == len(params) || params[i] == ';' {
1321- out = append(out, parseCSIInt(p))1332+ out = append(out, parseCSIInt(params[start:i]))
1333+ start = i + 1
1334+ }
1322 }1335 }
1323 return out1336 return out
1324 }1337 }
1325 1338
1339+func (s *Screen) parseSGRInts(params string) []int {
1340+ s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
1341+ return s.sgrScratch
1342+}
1343+
1326 func parseSGRInts(params string) []int {1344 func parseSGRInts(params string) []int {
1345+ return parseSGRIntsInto(nil, params)
1346+}
1347+
1348+func parseSGRIntsInto(out []int, params string) []int {
1327 if params == "" {1349 if params == "" {
1328- return nil1350+ return out
1329 }1351 }
1330- groups := strings.Split(params, ";")1352+ start := 0
1331- out := make([]int, 0, len(groups))1353+ for i := 0; i <= len(params); i++ {
1332- for _, group := range groups {1354+ if i == len(params) || params[i] == ';' {
1333- if !strings.Contains(group, ":") {1355+ out = appendSGRGroup(out, params[start:i])
1334- out = append(out, parseCSIInt(group))1356+ start = i + 1
1335- continue
1336 }1357 }
1337- sub := strings.Split(group, ":")1358+ }
1338- code := parseCSIInt(sub[0])1359+ return out
1339- if code != 38 && code != 48 {1360+}
1340- out = append(out, code)1361+
1341- continue1362+func appendSGRGroup(out []int, group string) []int {
1363+ colon := false
1364+ for i := 0; i < len(group); i++ {
1365+ if group[i] == ':' {
1366+ colon = true
1367+ break
1342 }1368 }
1343- mode := 01369+ }
1344- if len(sub) > 1 {1370+ if !colon {
1345- mode = parseCSIInt(sub[1])1371+ return append(out, parseCSIInt(group))
1372+ }
1373+
1374+ parts := countSGRColonFields(group)
1375+ code := parseCSIInt(sgrColonField(group, 0))
1376+ if code != 38 && code != 48 {
1377+ return append(out, code)
1378+ }
1379+ mode := 0
1380+ if parts > 1 {
1381+ mode = parseCSIInt(sgrColonField(group, 1))
1382+ }
1383+ switch mode {
1384+ case 5:
1385+ if parts < 3 {
1386+ return out
1346 }1387 }
1347- switch mode {1388+ out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
1348- case 5:1389+ case 2:
1349- if len(sub) < 3 {1390+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1350- continue1391+ // the colorspace slot; code:mode:R:G:B omits that slot.
1351- }1392+ start := 2
1352- out = append(out, code, mode, parseCSIInt(sub[2]))1393+ if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
1353- case 2:1394+ start = 3
1354- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after1395+ }
1355- // the colorspace slot; code:mode:R:G:B omits that slot.1396+ if parts < start+3 {
1356- start := 21397+ return out
1357- if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) {1398+ }
1358- start = 31399+ out = append(out, code, mode)
1359- }1400+ for i := 0; i < 3; i++ {
1360- if len(sub) < start+3 {1401+ out = append(out, parseCSIInt(sgrColonField(group, start+i)))
1361- continue
1362- }
1363- out = append(out, code, mode)
1364- for i := 0; i < 3; i++ {
1365- out = append(out, parseCSIInt(sub[start+i]))
1366- }
1367 }1402 }
1368 }1403 }
1369 return out1404 return out
1370 }1405 }
1371 1406
1407+func countSGRColonFields(group string) int {
1408+ count := 1
1409+ for i := 0; i < len(group); i++ {
1410+ if group[i] == ':' {
1411+ count++
1412+ }
1413+ }
1414+ return count
1415+}
1416+
1417+func sgrColonField(group string, index int) string {
1418+ start := 0
1419+ field := 0
1420+ for i := 0; i <= len(group); i++ {
1421+ if i == len(group) || group[i] == ':' {
1422+ if field == index {
1423+ return group[start:i]
1424+ }
1425+ field++
1426+ start = i + 1
1427+ }
1428+ }
1429+ return ""
1430+}
1431+
1372 func parseCSIInt(param string) int {1432 func parseCSIInt(param string) int {
1373 if param == "" {1433 if param == "" {
1374 return 01434 return 0
M pkg/vt/screen_test.go +57 -0
@@ -74,6 +74,63 @@ func lineText(s *Screen, y int) string {
7474 return string(out)
7575 }
7676
77+func TestParseCSIInts(t *testing.T) {
78+ t.Parallel()
79+
80+ tests := []struct {
81+ name string
82+ params string
83+ want []int
84+ }{
85+ {name: "empty", params: "", want: nil},
86+ {name: "single", params: "5", want: []int{5}},
87+ {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
88+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
89+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
90+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
91+ {name: "question prefix", params: "?25", want: []int{25}},
92+ {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
93+ {name: "prefix order", params: "?>7", want: []int{7}},
94+ {name: "junk token", params: "12x;4", want: []int{0, 4}},
95+ }
96+
97+ for _, tt := range tests {
98+ t.Run(tt.name, func(t *testing.T) {
99+ require.Equal(t, tt.want, parseCSIInts(tt.params))
100+ })
101+ }
102+}
103+
104+func TestParseSGRInts(t *testing.T) {
105+ t.Parallel()
106+
107+ tests := []struct {
108+ name string
109+ params string
110+ want []int
111+ }{
112+ {name: "empty", params: "", want: nil},
113+ {name: "single", params: "1", want: []int{1}},
114+ {name: "multiple", params: "1;31", want: []int{1, 31}},
115+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
116+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
117+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
118+ {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
119+ {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
120+ {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
121+ {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
122+ {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
123+ {name: "non color colon group", params: "1:2:3", want: []int{1}},
124+ {name: "junk token", params: "abc;31", want: []int{0, 31}},
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ require.Equal(t, tt.want, parseSGRInts(tt.params))
130+ })
131+ }
132+}
133+
77134 func rowString(row []renderer.Cell) string {
78135 out := make([]rune, len(row))
79136 for i, cell := range row {
@@ -74,6 +74,63 @@ func lineText(s *Screen, y int) string {
74 return string(out)74 return string(out)
75 }75 }
76 76
77+func TestParseCSIInts(t *testing.T) {
78+ t.Parallel()
79+
80+ tests := []struct {
81+ name string
82+ params string
83+ want []int
84+ }{
85+ {name: "empty", params: "", want: nil},
86+ {name: "single", params: "5", want: []int{5}},
87+ {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
88+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
89+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
90+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
91+ {name: "question prefix", params: "?25", want: []int{25}},
92+ {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
93+ {name: "prefix order", params: "?>7", want: []int{7}},
94+ {name: "junk token", params: "12x;4", want: []int{0, 4}},
95+ }
96+
97+ for _, tt := range tests {
98+ t.Run(tt.name, func(t *testing.T) {
99+ require.Equal(t, tt.want, parseCSIInts(tt.params))
100+ })
101+ }
102+}
103+
104+func TestParseSGRInts(t *testing.T) {
105+ t.Parallel()
106+
107+ tests := []struct {
108+ name string
109+ params string
110+ want []int
111+ }{
112+ {name: "empty", params: "", want: nil},
113+ {name: "single", params: "1", want: []int{1}},
114+ {name: "multiple", params: "1;31", want: []int{1, 31}},
115+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
116+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
117+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
118+ {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
119+ {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
120+ {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
121+ {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
122+ {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
123+ {name: "non color colon group", params: "1:2:3", want: []int{1}},
124+ {name: "junk token", params: "abc;31", want: []int{0, 31}},
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ require.Equal(t, tt.want, parseSGRInts(tt.params))
130+ })
131+ }
132+}
133+
77 func rowString(row []renderer.Cell) string {134 func rowString(row []renderer.Cell) string {
78 out := make([]rune, len(row))135 out := make([]rune, len(row))
79 for i, cell := range row {136 for i, cell := range row {

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 12

Tags 11

↑↓ moveenter openesc close