detached 348a1c1

Merge pull request #55 from bnema/fix/sgr-attribute-fidelity

fix(renderer): preserve extended SGR attributes
commit
348a1c1c6c8ede6c8558dc2e6ea8d10b8adb7ede
author
bnema · · 2026-07-09 16:23 UTC
committer
GitHub · · 2026-07-09 16:23 UTC
merge of
3dd0941 + 79bbf61 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +211 -5
M pkg/renderer/renderer.go +31 -0
@@ -319,12 +319,35 @@ func writeStyle(out *bytes.Buffer, style Style) {
319319 if style.Bold {
320320 out.WriteString(";1")
321321 }
322+ if style.Attrs&AttrDim != 0 {
323+ out.WriteString(";2")
324+ }
322325 if style.Italic {
323326 out.WriteString(";3")
324327 }
328+ if style.Attrs&AttrUnderline != 0 {
329+ switch style.UnderlineStyle {
330+ case UnderlineDouble:
331+ out.WriteString(";21")
332+ case UnderlineCurly:
333+ out.WriteString(";4:3")
334+ case UnderlineDotted:
335+ out.WriteString(";4:4")
336+ case UnderlineDashed:
337+ out.WriteString(";4:5")
338+ default:
339+ out.WriteString(";4")
340+ }
341+ }
342+ if style.Attrs&AttrBlink != 0 {
343+ out.WriteString(";5")
344+ }
325345 if style.Inverse {
326346 out.WriteString(";7")
327347 }
348+ if style.Attrs&AttrStrikethrough != 0 {
349+ out.WriteString(";9")
350+ }
328351 var b [16]byte
329352 if style.HasForegroundRGB {
330353 out.WriteString(";38;2;")
@@ -342,6 +365,14 @@ func writeStyle(out *bytes.Buffer, style Style) {
342365 n := strconv.AppendInt(b[:0], int64(style.Background), 10)
343366 out.Write(n)
344367 }
368+ if style.HasUnderlineColorRGB {
369+ out.WriteString(";58;2;")
370+ writeRGB(out, &b, style.UnderlineColorRGB)
371+ } else if style.HasUnderlineColor {
372+ out.WriteString(";58;5;")
373+ n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
374+ out.Write(n)
375+ }
345376 out.WriteByte('m')
346377 }
347378
@@ -319,12 +319,35 @@ func writeStyle(out *bytes.Buffer, style Style) {
319 if style.Bold {319 if style.Bold {
320 out.WriteString(";1")320 out.WriteString(";1")
321 }321 }
322+ if style.Attrs&AttrDim != 0 {
323+ out.WriteString(";2")
324+ }
322 if style.Italic {325 if style.Italic {
323 out.WriteString(";3")326 out.WriteString(";3")
324 }327 }
328+ if style.Attrs&AttrUnderline != 0 {
329+ switch style.UnderlineStyle {
330+ case UnderlineDouble:
331+ out.WriteString(";21")
332+ case UnderlineCurly:
333+ out.WriteString(";4:3")
334+ case UnderlineDotted:
335+ out.WriteString(";4:4")
336+ case UnderlineDashed:
337+ out.WriteString(";4:5")
338+ default:
339+ out.WriteString(";4")
340+ }
341+ }
342+ if style.Attrs&AttrBlink != 0 {
343+ out.WriteString(";5")
344+ }
325 if style.Inverse {345 if style.Inverse {
326 out.WriteString(";7")346 out.WriteString(";7")
327 }347 }
348+ if style.Attrs&AttrStrikethrough != 0 {
349+ out.WriteString(";9")
350+ }
328 var b [16]byte351 var b [16]byte
329 if style.HasForegroundRGB {352 if style.HasForegroundRGB {
330 out.WriteString(";38;2;")353 out.WriteString(";38;2;")
@@ -342,6 +365,14 @@ func writeStyle(out *bytes.Buffer, style Style) {
342 n := strconv.AppendInt(b[:0], int64(style.Background), 10)365 n := strconv.AppendInt(b[:0], int64(style.Background), 10)
343 out.Write(n)366 out.Write(n)
344 }367 }
368+ if style.HasUnderlineColorRGB {
369+ out.WriteString(";58;2;")
370+ writeRGB(out, &b, style.UnderlineColorRGB)
371+ } else if style.HasUnderlineColor {
372+ out.WriteString(";58;5;")
373+ n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
374+ out.Write(n)
375+ }
345 out.WriteByte('m')376 out.WriteByte('m')
346 }377 }
347 378
M pkg/renderer/renderer_test.go +10 -0
@@ -282,6 +282,16 @@ func TestRendererEmitsSGR(t *testing.T) {
282282 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
283283 wantAll: []string{"\x1b[0;1;3;7m", "X"},
284284 },
285+ {
286+ name: "extended SGR attributes and underline color",
287+ width: 1,
288+ height: 1,
289+ setup: func(frame Frame) {
290+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}})
291+ },
292+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
293+ wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"},
294+ },
285295 }
286296
287297 for _, tt := range tests {
@@ -282,6 +282,16 @@ func TestRendererEmitsSGR(t *testing.T) {
282 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},282 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
283 wantAll: []string{"\x1b[0;1;3;7m", "X"},283 wantAll: []string{"\x1b[0;1;3;7m", "X"},
284 },284 },
285+ {
286+ name: "extended SGR attributes and underline color",
287+ width: 1,
288+ height: 1,
289+ setup: func(frame Frame) {
290+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}})
291+ },
292+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
293+ wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"},
294+ },
285 }295 }
286 296
287 for _, tt := range tests {297 for _, tt := range tests {
M pkg/renderer/style.go +44 -4
@@ -6,10 +6,31 @@ type RGB struct {
66 B uint8
77 }
88
9+type StyleAttrs uint16
10+
11+const (
12+ AttrDim StyleAttrs = 1 << iota
13+ AttrUnderline
14+ AttrBlink
15+ AttrStrikethrough
16+)
17+
18+type UnderlineStyle uint8
19+
20+const (
21+ UnderlineNone UnderlineStyle = iota
22+ UnderlineSingle
23+ UnderlineDouble
24+ UnderlineCurly
25+ UnderlineDotted
26+ UnderlineDashed
27+)
28+
929 type Style struct {
1030 Bold bool
1131 Italic bool
1232 Inverse bool
33+ Attrs StyleAttrs
1334 Foreground int // -1 means unset; ignored when HasForegroundRGB is true
1435 Background int // -1 means unset; ignored when HasBackgroundRGB is true
1536
@@ -17,15 +38,21 @@ type Style struct {
1738 ForegroundRGB RGB
1839 HasBackgroundRGB bool
1940 BackgroundRGB RGB
41+
42+ UnderlineStyle UnderlineStyle
43+ HasUnderlineColor bool
44+ UnderlineColor int // ignored when HasUnderlineColorRGB is true
45+ HasUnderlineColorRGB bool
46+ UnderlineColorRGB RGB
2047 }
2148
2249 func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }
2350
2451 func (s Style) Equal(other Style) bool {
25- if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse {
52+ if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle {
2653 return false
2754 }
28- if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB {
55+ if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB || s.HasUnderlineColorRGB != other.HasUnderlineColorRGB {
2956 return false
3057 }
3158 if s.HasForegroundRGB {
@@ -36,7 +63,20 @@ func (s Style) Equal(other Style) bool {
3663 return false
3764 }
3865 if s.HasBackgroundRGB {
39- return s.BackgroundRGB == other.BackgroundRGB
66+ if s.BackgroundRGB != other.BackgroundRGB {
67+ return false
68+ }
69+ } else if s.Background != other.Background {
70+ return false
71+ }
72+ if s.HasUnderlineColorRGB {
73+ return s.UnderlineColorRGB == other.UnderlineColorRGB
74+ }
75+ if s.HasUnderlineColor != other.HasUnderlineColor {
76+ return false
77+ }
78+ if s.HasUnderlineColor {
79+ return s.UnderlineColor == other.UnderlineColor
4080 }
41- return s.Background == other.Background
81+ return true
4282 }
@@ -6,10 +6,31 @@ type RGB struct {
6 B uint86 B uint8
7 }7 }
8 8
9+type StyleAttrs uint16
10+
11+const (
12+ AttrDim StyleAttrs = 1 << iota
13+ AttrUnderline
14+ AttrBlink
15+ AttrStrikethrough
16+)
17+
18+type UnderlineStyle uint8
19+
20+const (
21+ UnderlineNone UnderlineStyle = iota
22+ UnderlineSingle
23+ UnderlineDouble
24+ UnderlineCurly
25+ UnderlineDotted
26+ UnderlineDashed
27+)
28+
9 type Style struct {29 type Style struct {
10 Bold bool30 Bold bool
11 Italic bool31 Italic bool
12 Inverse bool32 Inverse bool
33+ Attrs StyleAttrs
13 Foreground int // -1 means unset; ignored when HasForegroundRGB is true34 Foreground int // -1 means unset; ignored when HasForegroundRGB is true
14 Background int // -1 means unset; ignored when HasBackgroundRGB is true35 Background int // -1 means unset; ignored when HasBackgroundRGB is true
15 36
@@ -17,15 +38,21 @@ type Style struct {
17 ForegroundRGB RGB38 ForegroundRGB RGB
18 HasBackgroundRGB bool39 HasBackgroundRGB bool
19 BackgroundRGB RGB40 BackgroundRGB RGB
41+
42+ UnderlineStyle UnderlineStyle
43+ HasUnderlineColor bool
44+ UnderlineColor int // ignored when HasUnderlineColorRGB is true
45+ HasUnderlineColorRGB bool
46+ UnderlineColorRGB RGB
20 }47 }
21 48
22 func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }49 func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }
23 50
24 func (s Style) Equal(other Style) bool {51 func (s Style) Equal(other Style) bool {
25- if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse {52+ if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle {
26 return false53 return false
27 }54 }
28- if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB {55+ if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB || s.HasUnderlineColorRGB != other.HasUnderlineColorRGB {
29 return false56 return false
30 }57 }
31 if s.HasForegroundRGB {58 if s.HasForegroundRGB {
@@ -36,7 +63,20 @@ func (s Style) Equal(other Style) bool {
36 return false63 return false
37 }64 }
38 if s.HasBackgroundRGB {65 if s.HasBackgroundRGB {
39- return s.BackgroundRGB == other.BackgroundRGB66+ if s.BackgroundRGB != other.BackgroundRGB {
67+ return false
68+ }
69+ } else if s.Background != other.Background {
70+ return false
71+ }
72+ if s.HasUnderlineColorRGB {
73+ return s.UnderlineColorRGB == other.UnderlineColorRGB
74+ }
75+ if s.HasUnderlineColor != other.HasUnderlineColor {
76+ return false
77+ }
78+ if s.HasUnderlineColor {
79+ return s.UnderlineColor == other.UnderlineColor
40 }80 }
41- return s.Background == other.Background81+ return true
42 }82 }
M pkg/vt/screen.go +71 -1
@@ -1036,6 +1036,8 @@ func (s *Screen) applyCursorStyle(params string) {
10361036 s.cursorStyleSet = true
10371037 }
10381038
1039+const sgrUnderlineStyleMarker = -1000
1040+
10391041 func (s *Screen) applySGR(params string) {
10401042 if params == "" {
10411043 s.Style = renderer.DefaultStyle()
@@ -1048,16 +1050,46 @@ func (s *Screen) applySGR(params string) {
10481050 s.Style = renderer.DefaultStyle()
10491051 case 1:
10501052 s.Style.Bold = true
1053+ case 2:
1054+ s.Style.Attrs |= renderer.AttrDim
10511055 case 3:
10521056 s.Style.Italic = true
1057+ case 4:
1058+ s.Style.Attrs |= renderer.AttrUnderline
1059+ s.Style.UnderlineStyle = renderer.UnderlineSingle
1060+ if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
1061+ underlineStyle := sgrUnderlineStyle(parts[i+2])
1062+ if underlineStyle == renderer.UnderlineNone {
1063+ s.Style.Attrs &^= renderer.AttrUnderline
1064+ } else {
1065+ s.Style.Attrs |= renderer.AttrUnderline
1066+ }
1067+ s.Style.UnderlineStyle = underlineStyle
1068+ i += 2
1069+ }
1070+ case 5, 6:
1071+ s.Style.Attrs |= renderer.AttrBlink
10531072 case 7:
10541073 s.Style.Inverse = true
1074+ case 9:
1075+ s.Style.Attrs |= renderer.AttrStrikethrough
1076+ case 21:
1077+ s.Style.Attrs |= renderer.AttrUnderline
1078+ s.Style.UnderlineStyle = renderer.UnderlineDouble
10551079 case 22:
10561080 s.Style.Bold = false
1081+ s.Style.Attrs &^= renderer.AttrDim
10571082 case 23:
10581083 s.Style.Italic = false
1084+ case 24:
1085+ s.Style.Attrs &^= renderer.AttrUnderline
1086+ s.Style.UnderlineStyle = renderer.UnderlineNone
1087+ case 25:
1088+ s.Style.Attrs &^= renderer.AttrBlink
10591089 case 27:
10601090 s.Style.Inverse = false
1091+ case 29:
1092+ s.Style.Attrs &^= renderer.AttrStrikethrough
10611093 case 30, 31, 32, 33, 34, 35, 36, 37:
10621094 s.Style.Foreground = parts[i] - 30
10631095 s.Style.HasForegroundRGB = false
@@ -1098,6 +1130,21 @@ func (s *Screen) applySGR(params string) {
10981130 s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
10991131 i += 4
11001132 }
1133+ case 58:
1134+ if i+2 < len(parts) && parts[i+1] == 5 {
1135+ s.Style.UnderlineColor = parts[i+2]
1136+ s.Style.HasUnderlineColor = true
1137+ s.Style.HasUnderlineColorRGB = false
1138+ i += 2
1139+ } else if i+4 < len(parts) && parts[i+1] == 2 {
1140+ s.Style.HasUnderlineColor = false
1141+ s.Style.HasUnderlineColorRGB = true
1142+ s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1143+ i += 4
1144+ }
1145+ case 59:
1146+ s.Style.HasUnderlineColor = false
1147+ s.Style.HasUnderlineColorRGB = false
11011148 }
11021149 }
11031150 }
@@ -1110,6 +1157,23 @@ func sgrRGB(r, g, b int) renderer.RGB {
11101157 }
11111158 }
11121159
1160+func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
1161+ switch param {
1162+ case 0:
1163+ return renderer.UnderlineNone
1164+ case 2:
1165+ return renderer.UnderlineDouble
1166+ case 3:
1167+ return renderer.UnderlineCurly
1168+ case 4:
1169+ return renderer.UnderlineDotted
1170+ case 5:
1171+ return renderer.UnderlineDashed
1172+ default:
1173+ return renderer.UnderlineSingle
1174+ }
1175+}
1176+
11131177 func (s *Screen) saveCursor() {
11141178 s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
11151179 }
@@ -1482,7 +1546,13 @@ func appendSGRGroup(out []int, group string) []int {
14821546
14831547 parts := countSGRColonFields(group)
14841548 code := parseCSIInt(sgrColonField(group, 0))
1485- if code != 38 && code != 48 {
1549+ if code == 4 {
1550+ if parts < 2 {
1551+ return append(out, code)
1552+ }
1553+ return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
1554+ }
1555+ if code != 38 && code != 48 && code != 58 {
14861556 return append(out, code)
14871557 }
14881558 mode := 0
@@ -1036,6 +1036,8 @@ func (s *Screen) applyCursorStyle(params string) {
1036 s.cursorStyleSet = true1036 s.cursorStyleSet = true
1037 }1037 }
1038 1038
1039+const sgrUnderlineStyleMarker = -1000
1040+
1039 func (s *Screen) applySGR(params string) {1041 func (s *Screen) applySGR(params string) {
1040 if params == "" {1042 if params == "" {
1041 s.Style = renderer.DefaultStyle()1043 s.Style = renderer.DefaultStyle()
@@ -1048,16 +1050,46 @@ func (s *Screen) applySGR(params string) {
1048 s.Style = renderer.DefaultStyle()1050 s.Style = renderer.DefaultStyle()
1049 case 1:1051 case 1:
1050 s.Style.Bold = true1052 s.Style.Bold = true
1053+ case 2:
1054+ s.Style.Attrs |= renderer.AttrDim
1051 case 3:1055 case 3:
1052 s.Style.Italic = true1056 s.Style.Italic = true
1057+ case 4:
1058+ s.Style.Attrs |= renderer.AttrUnderline
1059+ s.Style.UnderlineStyle = renderer.UnderlineSingle
1060+ if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
1061+ underlineStyle := sgrUnderlineStyle(parts[i+2])
1062+ if underlineStyle == renderer.UnderlineNone {
1063+ s.Style.Attrs &^= renderer.AttrUnderline
1064+ } else {
1065+ s.Style.Attrs |= renderer.AttrUnderline
1066+ }
1067+ s.Style.UnderlineStyle = underlineStyle
1068+ i += 2
1069+ }
1070+ case 5, 6:
1071+ s.Style.Attrs |= renderer.AttrBlink
1053 case 7:1072 case 7:
1054 s.Style.Inverse = true1073 s.Style.Inverse = true
1074+ case 9:
1075+ s.Style.Attrs |= renderer.AttrStrikethrough
1076+ case 21:
1077+ s.Style.Attrs |= renderer.AttrUnderline
1078+ s.Style.UnderlineStyle = renderer.UnderlineDouble
1055 case 22:1079 case 22:
1056 s.Style.Bold = false1080 s.Style.Bold = false
1081+ s.Style.Attrs &^= renderer.AttrDim
1057 case 23:1082 case 23:
1058 s.Style.Italic = false1083 s.Style.Italic = false
1084+ case 24:
1085+ s.Style.Attrs &^= renderer.AttrUnderline
1086+ s.Style.UnderlineStyle = renderer.UnderlineNone
1087+ case 25:
1088+ s.Style.Attrs &^= renderer.AttrBlink
1059 case 27:1089 case 27:
1060 s.Style.Inverse = false1090 s.Style.Inverse = false
1091+ case 29:
1092+ s.Style.Attrs &^= renderer.AttrStrikethrough
1061 case 30, 31, 32, 33, 34, 35, 36, 37:1093 case 30, 31, 32, 33, 34, 35, 36, 37:
1062 s.Style.Foreground = parts[i] - 301094 s.Style.Foreground = parts[i] - 30
1063 s.Style.HasForegroundRGB = false1095 s.Style.HasForegroundRGB = false
@@ -1098,6 +1130,21 @@ func (s *Screen) applySGR(params string) {
1098 s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])1130 s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1099 i += 41131 i += 4
1100 }1132 }
1133+ case 58:
1134+ if i+2 < len(parts) && parts[i+1] == 5 {
1135+ s.Style.UnderlineColor = parts[i+2]
1136+ s.Style.HasUnderlineColor = true
1137+ s.Style.HasUnderlineColorRGB = false
1138+ i += 2
1139+ } else if i+4 < len(parts) && parts[i+1] == 2 {
1140+ s.Style.HasUnderlineColor = false
1141+ s.Style.HasUnderlineColorRGB = true
1142+ s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1143+ i += 4
1144+ }
1145+ case 59:
1146+ s.Style.HasUnderlineColor = false
1147+ s.Style.HasUnderlineColorRGB = false
1101 }1148 }
1102 }1149 }
1103 }1150 }
@@ -1110,6 +1157,23 @@ func sgrRGB(r, g, b int) renderer.RGB {
1110 }1157 }
1111 }1158 }
1112 1159
1160+func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
1161+ switch param {
1162+ case 0:
1163+ return renderer.UnderlineNone
1164+ case 2:
1165+ return renderer.UnderlineDouble
1166+ case 3:
1167+ return renderer.UnderlineCurly
1168+ case 4:
1169+ return renderer.UnderlineDotted
1170+ case 5:
1171+ return renderer.UnderlineDashed
1172+ default:
1173+ return renderer.UnderlineSingle
1174+ }
1175+}
1176+
1113 func (s *Screen) saveCursor() {1177 func (s *Screen) saveCursor() {
1114 s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}1178 s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
1115 }1179 }
@@ -1482,7 +1546,13 @@ func appendSGRGroup(out []int, group string) []int {
1482 1546
1483 parts := countSGRColonFields(group)1547 parts := countSGRColonFields(group)
1484 code := parseCSIInt(sgrColonField(group, 0))1548 code := parseCSIInt(sgrColonField(group, 0))
1485- if code != 38 && code != 48 {1549+ if code == 4 {
1550+ if parts < 2 {
1551+ return append(out, code)
1552+ }
1553+ return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
1554+ }
1555+ if code != 38 && code != 48 && code != 58 {
1486 return append(out, code)1556 return append(out, code)
1487 }1557 }
1488 mode := 01558 mode := 0
M pkg/vt/screen_test.go +55 -0
@@ -790,6 +790,61 @@ func TestSGR(t *testing.T) {
790790 }
791791 },
792792 },
793+ {
794+ name: "SGR preserves dim underline blink and strikethrough with resets",
795+ seq: "\x1b[2;4;5;9mA\x1b[22;24;25;29mB",
796+ check: func(t *testing.T, s *Screen) {
797+ first := cellAt(s, 0, 0).Style
798+ if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough {
799+ t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs)
800+ }
801+ second := cellAt(s, 1, 0).Style
802+ if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 {
803+ t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs)
804+ }
805+ },
806+ },
807+ {
808+ name: "SGR 22 resets both bold and dim",
809+ seq: "\x1b[1;2m\x1b[22m",
810+ check: func(t *testing.T, s *Screen) {
811+ if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 {
812+ t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs)
813+ }
814+ },
815+ },
816+ {
817+ name: "SGR underline style and color are preserved and reset",
818+ seq: "\x1b[4:3;58:2::255:0:0mA\x1b[59mB",
819+ check: func(t *testing.T, s *Screen) {
820+ first := cellAt(s, 0, 0).Style
821+ if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
822+ t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
823+ }
824+ want := renderer.RGB{R: 255, G: 0, B: 0}
825+ if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want {
826+ t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want)
827+ }
828+ second := cellAt(s, 1, 0).Style
829+ if second.HasUnderlineColorRGB || second.HasUnderlineColor {
830+ t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB)
831+ }
832+ },
833+ },
834+ {
835+ name: "SGR 4 colon zero clears underline",
836+ seq: "\x1b[4:3mA\x1b[4:0mB",
837+ check: func(t *testing.T, s *Screen) {
838+ first := cellAt(s, 0, 0).Style
839+ if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
840+ t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
841+ }
842+ second := cellAt(s, 1, 0).Style
843+ if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone {
844+ t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle)
845+ }
846+ },
847+ },
793848 {
794849 name: "empty SGR params reset style",
795850 seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset
@@ -790,6 +790,61 @@ func TestSGR(t *testing.T) {
790 }790 }
791 },791 },
792 },792 },
793+ {
794+ name: "SGR preserves dim underline blink and strikethrough with resets",
795+ seq: "\x1b[2;4;5;9mA\x1b[22;24;25;29mB",
796+ check: func(t *testing.T, s *Screen) {
797+ first := cellAt(s, 0, 0).Style
798+ if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough {
799+ t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs)
800+ }
801+ second := cellAt(s, 1, 0).Style
802+ if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 {
803+ t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs)
804+ }
805+ },
806+ },
807+ {
808+ name: "SGR 22 resets both bold and dim",
809+ seq: "\x1b[1;2m\x1b[22m",
810+ check: func(t *testing.T, s *Screen) {
811+ if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 {
812+ t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs)
813+ }
814+ },
815+ },
816+ {
817+ name: "SGR underline style and color are preserved and reset",
818+ seq: "\x1b[4:3;58:2::255:0:0mA\x1b[59mB",
819+ check: func(t *testing.T, s *Screen) {
820+ first := cellAt(s, 0, 0).Style
821+ if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
822+ t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
823+ }
824+ want := renderer.RGB{R: 255, G: 0, B: 0}
825+ if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want {
826+ t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want)
827+ }
828+ second := cellAt(s, 1, 0).Style
829+ if second.HasUnderlineColorRGB || second.HasUnderlineColor {
830+ t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB)
831+ }
832+ },
833+ },
834+ {
835+ name: "SGR 4 colon zero clears underline",
836+ seq: "\x1b[4:3mA\x1b[4:0mB",
837+ check: func(t *testing.T, s *Screen) {
838+ first := cellAt(s, 0, 0).Style
839+ if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
840+ t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
841+ }
842+ second := cellAt(s, 1, 0).Style
843+ if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone {
844+ t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle)
845+ }
846+ },
847+ },
793 {848 {
794 name: "empty SGR params reset style",849 name: "empty SGR params reset style",
795 seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset850 seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset

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