diff --git a/tools/tui/sgr/insert-formatting.go b/tools/tui/sgr/insert-formatting.go new file mode 100644 index 000000000..a26b6c1f8 --- /dev/null +++ b/tools/tui/sgr/insert-formatting.go @@ -0,0 +1,506 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package sgr + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" + + "kitty/tools/utils" + "kitty/tools/utils/style" + "kitty/tools/wcswidth" + + "golang.org/x/exp/slices" +) + +var _ = fmt.Print + +type UnderlineStyle uint8 + +const ( + No_underline UnderlineStyle = iota + Straight_underline + Double_underline + Curly_underline + Dotted_underline + Dashed_underline +) + +type Color struct { + Red, Green, Blue uint8 + Is_numbered bool +} + +func (self *Color) Set(val any) (err error) { + switch v := val.(type) { + case int: + self.Is_numbered = true + self.Red = uint8(v) + case style.RGBA: + self.Is_numbered = false + self.Red, self.Green, self.Blue = v.Red, v.Red, v.Blue + case string: + rgba, err := style.ParseColor(v) + if err != nil { + return err + } + self.Is_numbered = false + self.Red, self.Green, self.Blue = rgba.Red, rgba.Red, rgba.Blue + default: + return fmt.Errorf("Unknown type to set color from: %T", v) + } + return nil +} + +func (self Color) AsCSI(base int) string { + if self.Is_numbered && base < 50 { + if self.Red < 8 { + return strconv.Itoa(base + int(self.Red)) + } + if self.Red < 16 { + return strconv.Itoa(base + 52 + int(self.Red)) + } + return fmt.Sprintf("%d:5:%d", base+8, self.Red) + } + return fmt.Sprintf("%d:2:%d:%d:%d", base+8, self.Red, self.Green, self.Blue) +} + +func (self *Color) FromNumber(n uint8) { + self.Is_numbered, self.Red = true, n +} + +func (self *Color) FromExtended(nums ...int) bool { + switch nums[0] { + case 5: + if len(nums) > 1 { + self.Red = uint8(nums[1]) + self.Is_numbered = true + return true + } + case 2: + if len(nums) > 3 { + self.Is_numbered = false + self.Red, self.Green, self.Blue = uint8(nums[1]), uint8(nums[2]), uint8(nums[3]) + return true + } + } + return false +} + +type BoolVal struct{ Is_set, Val bool } + +type UnderlineStyleVal struct { + Is_set bool + Val UnderlineStyle +} +type ColorVal struct { + Is_set, Is_default bool + Val Color +} + +type SGR struct { + Italic, Reverse, Bold, Dim, Strikethrough BoolVal + Underline_style UnderlineStyleVal + Foreground, Background, Underline_color ColorVal +} + +func (self *SGR) AsCSI(for_close bool) string { + ans := make([]byte, 0, 16) + if for_close { + if self.Bold.Is_set || self.Dim.Is_set { + ans = append(ans, '2', '2', ';') + } + if self.Italic.Is_set { + ans = append(ans, '2', '3', ';') + } + if self.Reverse.Is_set { + ans = append(ans, '2', '7', ';') + } + if self.Strikethrough.Is_set { + ans = append(ans, '2', '9', ';') + } + if self.Underline_style.Is_set { + ans = append(ans, '4', ':', '0', ';') + } + if self.Foreground.Is_set { + ans = append(ans, '3', '9', ';') + } + if self.Background.Is_set { + ans = append(ans, '4', '9', ';') + } + if self.Underline_color.Is_set { + ans = append(ans, '5', '9', ';') + } + } else { + if self.Bold.Is_set { + ans = append(ans, '1', ';') + } + if self.Dim.Is_set { + ans = append(ans, '2', ';') + } + if self.Italic.Is_set { + ans = append(ans, '3', ';') + } + if self.Reverse.Is_set { + ans = append(ans, '7', ';') + } + if self.Strikethrough.Is_set { + ans = append(ans, '9', ';') + } + if self.Underline_style.Is_set { + ans = append(ans, fmt.Sprintf("4:%d;", self.Underline_style.Val)...) + } + if self.Foreground.Is_set { + if self.Foreground.Is_default { + ans = append(ans, '3', '9', ';') + } else { + ans = append(ans, self.Foreground.Val.AsCSI(30)...) + ans = append(ans, ';') + } + } + if self.Background.Is_set { + if self.Background.Is_default { + ans = append(ans, '4', '9', ';') + } else { + ans = append(ans, self.Background.Val.AsCSI(40)...) + ans = append(ans, ';') + } + } + if self.Underline_color.Is_set { + if self.Underline_color.Is_default { + ans = append(ans, '5', '9', ';') + } else { + ans = append(ans, self.Underline_color.Val.AsCSI(50)...) + ans = append(ans, ';') + } + } + } + + if len(ans) > 0 { + ans = ans[:len(ans)-1] + ans = append(ans, 'm') + } + return utils.UnsafeBytesToString(ans) +} + +func (self *SGR) IsEmpty() bool { + return !(self.Foreground.Is_set || self.Background.Is_set || self.Underline_color.Is_set || self.Underline_style.Is_set || self.Italic.Is_set || self.Bold.Is_set || self.Reverse.Is_set || self.Dim.Is_set || self.Strikethrough.Is_set) +} + +func (self *SGR) ApplyMask(other SGR) { + if other.Italic.Is_set { + self.Italic.Is_set = false + } + if other.Reverse.Is_set { + self.Reverse.Is_set = false + } + if other.Bold.Is_set { + self.Bold.Is_set = false + } + if other.Dim.Is_set { + self.Dim.Is_set = false + } + if other.Strikethrough.Is_set { + self.Strikethrough.Is_set = false + } + if other.Underline_style.Is_set { + self.Underline_style.Is_set = false + } + if other.Foreground.Is_set { + self.Foreground.Is_set = false + } + if other.Background.Is_set { + self.Background.Is_set = false + } + if other.Underline_color.Is_set { + self.Underline_color.Is_set = false + } +} + +func (self *SGR) ApplySGR(other SGR) { + if other.Italic.Is_set { + self.Italic = other.Italic + } + if other.Reverse.Is_set { + self.Reverse = other.Reverse + } + if other.Bold.Is_set { + self.Bold = other.Bold + } + if other.Dim.Is_set { + self.Dim = other.Dim + } + if other.Strikethrough.Is_set { + self.Strikethrough = other.Strikethrough + } + if other.Underline_style.Is_set { + self.Underline_style = other.Underline_style + } + if other.Foreground.Is_set { + self.Foreground = other.Foreground + } + if other.Background.Is_set { + self.Background = other.Background + } + if other.Underline_color.Is_set { + self.Underline_color = other.Underline_color + } +} + +func SGRFromCSI(csi string) (ans SGR) { + if !strings.HasSuffix(csi, "m") { + return + } + csi = csi[:len(csi)-1] + if csi == "" { + csi = "0" + } + parts := strings.Split(csi, ";") + nums := make([]int, 0, 8) + for _, part := range parts { + subparts := strings.Split(part, ":") + nums = nums[:0] + for _, b := range subparts { + q, err := strconv.Atoi(b) + if err == nil { + nums = append(nums, q) + } + } + if len(nums) == 0 { + continue + } + switch nums[0] { + case 0: + ans = SGR{} + case 1: + ans.Dim.Val, ans.Bold.Val = false, true + ans.Dim.Is_set, ans.Bold.Is_set = true, true + case 2: + ans.Dim.Val, ans.Bold.Val = true, false + ans.Dim.Is_set, ans.Bold.Is_set = true, true + case 22: + ans.Dim.Val, ans.Bold.Val = false, false + ans.Dim.Is_set, ans.Bold.Is_set = true, true + case 3: + ans.Italic.Is_set, ans.Italic.Val = true, true + case 23: + ans.Italic.Is_set, ans.Italic.Val = true, false + case 7: + ans.Reverse.Is_set, ans.Reverse.Val = true, true + case 27: + ans.Reverse.Is_set, ans.Reverse.Val = true, false + case 9: + ans.Strikethrough.Is_set, ans.Strikethrough.Val = true, true + case 29: + ans.Strikethrough.Is_set, ans.Strikethrough.Val = true, false + case 24: + ans.Underline_style.Is_set, ans.Underline_style.Val = true, No_underline + case 4: + us := 1 + if len(nums) > 1 { + us = nums[1] + } + switch us { + case 0: + ans.Underline_style.Is_set, ans.Underline_style.Val = true, No_underline + case 1: + ans.Underline_style.Is_set, ans.Underline_style.Val = true, Straight_underline + case 2: + ans.Underline_style.Is_set, ans.Underline_style.Val = true, Double_underline + case 3: + ans.Underline_style.Is_set, ans.Underline_style.Val = true, Curly_underline + case 4: + ans.Underline_style.Is_set, ans.Underline_style.Val = true, Dotted_underline + case 5: + ans.Underline_style.Is_set, ans.Underline_style.Val = true, Dashed_underline + } + case 30, 31, 32, 33, 34, 35, 36, 37: + ans.Foreground.Is_set, ans.Foreground.Is_default = true, false + ans.Foreground.Val.FromNumber(uint8(nums[0] - 30)) + case 90, 91, 92, 93, 94, 95, 96, 97: + ans.Foreground.Is_set, ans.Foreground.Is_default = true, false + ans.Foreground.Val.FromNumber(uint8(nums[0] - 82)) + case 38: + if ans.Foreground.Val.FromExtended(nums[1:]...) { + ans.Foreground.Is_set, ans.Foreground.Is_default = true, false + } + case 39: + ans.Foreground.Is_set, ans.Foreground.Is_default = true, true + case 40, 41, 42, 43, 44, 45, 46, 47: + ans.Background.Is_set, ans.Background.Is_default = true, false + ans.Background.Val.FromNumber(uint8(nums[0] - 40)) + case 100, 101, 102, 103, 104, 105, 106, 107: + ans.Background.Is_set, ans.Background.Is_default = true, false + ans.Background.Val.FromNumber(uint8(nums[0] - 92)) + case 48: + if ans.Background.Val.FromExtended(nums[1:]...) { + ans.Background.Is_set, ans.Background.Is_default = true, false + } + case 49: + ans.Background.Is_set, ans.Background.Is_default = true, true + case 58: + if ans.Underline_color.Val.FromExtended(nums[1:]...) { + ans.Underline_color.Is_set, ans.Underline_color.Is_default = true, false + } + case 59: + ans.Underline_color.Is_set, ans.Underline_color.Is_default = true, true + } + } + + return +} + +type Span struct { + Offset, Size int // in bytes + SGR SGR +} + +func NewSpan(offset, size int) *Span { + return &Span{Offset: offset, Size: size} +} + +func (self *BoolVal) Set(val bool) { + self.Is_set = true + self.Val = val +} + +func (self *ColorVal) Set(val any) { + self.Is_set = true + if val == nil { + self.Is_default = true + } else { + self.Is_default = false + if err := self.Val.Set(val); err != nil { + panic(err) + } + } +} + +func (self *Span) SetForeground(val any) *Span { + self.SGR.Foreground.Set(val) + return self +} + +func (self *Span) SetBackground(val any) *Span { + self.SGR.Background.Set(val) + return self +} + +func (self *Span) SetUnderlineColor(val any) *Span { + self.SGR.Underline_color.Set(val) + return self +} + +func (self *Span) SetItalic(val bool) *Span { + self.SGR.Italic.Set(val) + return self +} + +func (self *Span) SetBold(val bool) *Span { + self.SGR.Bold.Set(val) + return self +} +func (self *Span) SetReverse(val bool) *Span { + self.SGR.Reverse.Set(val) + return self +} +func (self *Span) SetDim(val bool) *Span { + self.SGR.Dim.Set(val) + return self +} +func (self *Span) SetStrikethrough(val bool) *Span { + self.SGR.Strikethrough.Set(val) + return self +} +func (self *Span) SetUnderlineStyle(val UnderlineStyle) *Span { + self.SGR.Underline_style.Is_set = true + self.SGR.Underline_style.Val = val + return self +} + +// Insert formatting into text at the specified offsets, overriding any existing formatting, and restoring +// existing formatting after the replaced sections. +func InsertFormatting(text string, spans ...*Span) string { + var in_span *Span + ans := make([]byte, 0, 2*len(text)) + var overall_sgr_state SGR + slices.SortFunc(spans, func(a, b *Span) bool { return a.Offset < b.Offset }) + text_len := 0 + var ep *wcswidth.EscapeCodeParser + + write_csi := func(csi string) { + if csi != "" { + ans = append(ans, 0x1b, '[') + ans = append(ans, csi...) + } + } + open_span := func() { + in_span = spans[0] + spans = spans[1:] + if in_span.Size > 0 { + write_csi(in_span.SGR.AsCSI(false)) + } else { + in_span = nil + } + + } + + close_span := func() { + write_csi(in_span.SGR.AsCSI(true)) + write_csi(overall_sgr_state.AsCSI(false)) + in_span = nil + } + + ep = &wcswidth.EscapeCodeParser{ + HandleRune: func(ch rune) error { + var rlen int + if in_span == nil { + if len(spans) > 0 && text_len >= spans[0].Offset { + open_span() + return ep.HandleRune(ch) + } + before := len(ans) + ans = utf8.AppendRune(ans, ch) + rlen = len(ans) - before + } else { + rlen = utf8.RuneLen(ch) + if text_len+rlen > in_span.Offset+in_span.Size { + close_span() + } + ans = utf8.AppendRune(ans, ch) + } + text_len += rlen + return nil + }, + HandleCSI: func(csib []byte) error { + csi := utils.UnsafeBytesToString(csib) + if len(csi) == 0 || csi[len(csi)-1] != 'm' { + write_csi(csi) + return nil + } + sgr := SGRFromCSI(csi) + overall_sgr_state.ApplySGR(sgr) + if in_span == nil { + write_csi(csi) + } else { + sgr.ApplyMask(in_span.SGR) + csi := sgr.AsCSI(false) + write_csi(csi) + } + return nil + }, + HandleOSC: func(osc []byte) error { + ans = append(ans, 0x1b, ']') + ans = append(ans, osc...) + ans = append(ans, 0x1b, '\\') + return nil + }, + } + ep.ParseString(text) + if in_span != nil { + close_span() + } + return utils.UnsafeBytesToString(ans) +} diff --git a/tools/tui/sgr/insert-formatting_test.go b/tools/tui/sgr/insert-formatting_test.go new file mode 100644 index 000000000..91411f159 --- /dev/null +++ b/tools/tui/sgr/insert-formatting_test.go @@ -0,0 +1,31 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package sgr + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var _ = fmt.Print + +func TestInsertFormatting(t *testing.T) { + test := func(src, expected string, spans ...*Span) { + actual := InsertFormatting(src, spans...) + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("Failed with %#v:\n%#v != %#v\n%s", src, expected, actual, diff) + } + } + test( + "\x1b[44m abcd \x1b[49m", + "\x1b[44m a\x1b[33;41mbc\x1b[39;49m\x1b[44md \x1b[49m", + NewSpan(2, 2).SetForeground(3).SetBackground(1), + ) + test( + "abcd", + "a\x1b[92mbcd\x1b[39m", + NewSpan(1, 11).SetForeground(10), + ) +}