fix(renderer): preserve extended SGR attributes
- commit
- 79bbf613f321ad4efa26c24d3dfcac0a100ba2b8
- author
- brice · · 2026-07-09 16:23 UTC
- parent
- a95d4db
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +211 -5
M pkg/renderer/renderer.go +31 -0
| @@ -319,12 +319,35 @@ func writeStyle(out *bytes.Buffer, style Style) { | |||
| 319 | 319 | if style.Bold { | |
| 320 | 320 | out.WriteString(";1") | |
| 321 | 321 | } | |
| 322 | + | if style.Attrs&AttrDim != 0 { | |
| 323 | + | out.WriteString(";2") | |
| 324 | + | } | |
| 322 | 325 | if style.Italic { | |
| 323 | 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 | 345 | if style.Inverse { | |
| 326 | 346 | out.WriteString(";7") | |
| 327 | 347 | } | |
| 348 | + | if style.Attrs&AttrStrikethrough != 0 { | |
| 349 | + | out.WriteString(";9") | |
| 350 | + | } | |
| 328 | 351 | var b [16]byte | |
| 329 | 352 | if style.HasForegroundRGB { | |
| 330 | 353 | out.WriteString(";38;2;") | |
| @@ -342,6 +365,14 @@ func writeStyle(out *bytes.Buffer, style Style) { | |||
| 342 | 365 | n := strconv.AppendInt(b[:0], int64(style.Background), 10) | |
| 343 | 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 | 376 | out.WriteByte('m') | |
| 346 | 377 | } | |
| 347 | 378 | ||
| @@ -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]byte | 351 | 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) { | |||
| 282 | 282 | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |
| 283 | 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 | 297 | 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 { | |||
| 6 | 6 | 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 | 29 | type Style struct { | |
| 10 | 30 | Bold bool | |
| 11 | 31 | Italic bool | |
| 12 | 32 | Inverse bool | |
| 33 | + | Attrs StyleAttrs | |
| 13 | 34 | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | |
| 14 | 35 | Background int // -1 means unset; ignored when HasBackgroundRGB is true | |
| 15 | 36 | ||
| @@ -17,15 +38,21 @@ type Style struct { | |||
| 17 | 38 | ForegroundRGB RGB | |
| 18 | 39 | HasBackgroundRGB bool | |
| 19 | 40 | 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 | 49 | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | |
| 23 | 50 | ||
| 24 | 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 | 53 | 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 | 56 | return false | |
| 30 | 57 | } | |
| 31 | 58 | if s.HasForegroundRGB { | |
| @@ -36,7 +63,20 @@ func (s Style) Equal(other Style) bool { | |||
| 36 | 63 | return false | |
| 37 | 64 | } | |
| 38 | 65 | 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 | |
| 40 | 80 | } | |
| 41 | - | return s.Background == other.Background | |
| 81 | + | return true | |
| 42 | 82 | } | |
| @@ -6,10 +6,31 @@ type RGB struct { | |||||
| 6 | B uint8 | 6 | 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 bool | 30 | Bold bool | ||
| 11 | Italic bool | 31 | Italic bool | ||
| 12 | Inverse bool | 32 | Inverse bool | ||
| 33 | + | Attrs StyleAttrs | |||
| 13 | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | 34 | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | ||
| 14 | Background int // -1 means unset; ignored when HasBackgroundRGB is true | 35 | Background int // -1 means unset; ignored when HasBackgroundRGB is true | ||
| 15 | 36 | ||||
| @@ -17,15 +38,21 @@ type Style struct { | |||||
| 17 | ForegroundRGB RGB | 38 | ForegroundRGB RGB | ||
| 18 | HasBackgroundRGB bool | 39 | HasBackgroundRGB bool | ||
| 19 | BackgroundRGB RGB | 40 | 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 false | 53 | 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 false | 56 | 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 false | 63 | return false | ||
| 37 | } | 64 | } | ||
| 38 | if s.HasBackgroundRGB { | 65 | 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 | |||
| 40 | } | 80 | } | ||
| 41 | - | return s.Background == other.Background | 81 | + | return true |
| 42 | } | 82 | } | ||
M pkg/vt/screen.go +71 -1
| @@ -1036,6 +1036,8 @@ func (s *Screen) applyCursorStyle(params string) { | |||
| 1036 | 1036 | s.cursorStyleSet = true | |
| 1037 | 1037 | } | |
| 1038 | 1038 | ||
| 1039 | + | const sgrUnderlineStyleMarker = -1000 | |
| 1040 | + | ||
| 1039 | 1041 | func (s *Screen) applySGR(params string) { | |
| 1040 | 1042 | if params == "" { | |
| 1041 | 1043 | s.Style = renderer.DefaultStyle() | |
| @@ -1048,16 +1050,46 @@ func (s *Screen) applySGR(params string) { | |||
| 1048 | 1050 | s.Style = renderer.DefaultStyle() | |
| 1049 | 1051 | case 1: | |
| 1050 | 1052 | s.Style.Bold = true | |
| 1053 | + | case 2: | |
| 1054 | + | s.Style.Attrs |= renderer.AttrDim | |
| 1051 | 1055 | case 3: | |
| 1052 | 1056 | 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 | 1072 | case 7: | |
| 1054 | 1073 | 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 | 1079 | case 22: | |
| 1056 | 1080 | s.Style.Bold = false | |
| 1081 | + | s.Style.Attrs &^= renderer.AttrDim | |
| 1057 | 1082 | case 23: | |
| 1058 | 1083 | 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 | 1089 | case 27: | |
| 1060 | 1090 | s.Style.Inverse = false | |
| 1091 | + | case 29: | |
| 1092 | + | s.Style.Attrs &^= renderer.AttrStrikethrough | |
| 1061 | 1093 | case 30, 31, 32, 33, 34, 35, 36, 37: | |
| 1062 | 1094 | s.Style.Foreground = parts[i] - 30 | |
| 1063 | 1095 | s.Style.HasForegroundRGB = false | |
| @@ -1098,6 +1130,21 @@ func (s *Screen) applySGR(params string) { | |||
| 1098 | 1130 | s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |
| 1099 | 1131 | 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 | 1177 | func (s *Screen) saveCursor() { | |
| 1114 | 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 | 1547 | parts := countSGRColonFields(group) | |
| 1484 | 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 | 1556 | return append(out, code) | |
| 1487 | 1557 | } | |
| 1488 | 1558 | mode := 0 | |
| @@ -1036,6 +1036,8 @@ func (s *Screen) applyCursorStyle(params string) { | |||||
| 1036 | s.cursorStyleSet = true | 1036 | 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 = true | 1052 | s.Style.Bold = true | ||
| 1053 | + | case 2: | |||
| 1054 | + | s.Style.Attrs |= renderer.AttrDim | |||
| 1051 | case 3: | 1055 | case 3: | ||
| 1052 | s.Style.Italic = true | 1056 | 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 = true | 1073 | 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 = false | 1080 | s.Style.Bold = false | ||
| 1081 | + | s.Style.Attrs &^= renderer.AttrDim | |||
| 1057 | case 23: | 1082 | case 23: | ||
| 1058 | s.Style.Italic = false | 1083 | 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 = false | 1090 | 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] - 30 | 1094 | s.Style.Foreground = parts[i] - 30 | ||
| 1063 | s.Style.HasForegroundRGB = false | 1095 | 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 += 4 | 1131 | 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 := 0 | 1558 | mode := 0 | ||
M pkg/vt/screen_test.go +55 -0
| @@ -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 | 849 | name: "empty SGR params reset style", | |
| 795 | 850 | 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 → reset | 850 | seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset | ||