260215.2 · b56d614ba2f901c64eed454f181badec0b1cedff
Add underline style variants and underline color support
Support double, curly, dotted, and dashed underline styles via --color (e.g. underline-curly) and ANSI passthrough (SGR 4:N, 58, 59) with --ansi.
Close #4633 Close #4678
Thanks to @shtse8 for the test cases.
reading declarations
@@ -1,6 +1,39 @@ CHANGELOG ========= +0.68.0+------+- Added support for underline style variants in `--color`:+ `underline-double`, `underline-curly`, `underline-dotted`, `underline-dashed`+ ```sh+ fzf --color 'fg:underline-curly,current-fg:underline-dashed'+ ```+- Added support for underline styles (`4:N`) and underline colors (SGR 58/59)+ ```sh+ # In the list section+ printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n' | fzf --ansi++ # In the preview window+ fzf --preview "printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n'"+ ```+- Added `alt-gutter` color option (#4602) (@hedgieinsocks)+- Added fish completion support (#4605) (@lalvarezt)+- zsh: Handle multi-line history selection (#4595) (@LangLangBart)+- Bug fixes+ - Fixed symlinks to directories being returned as files (#4676) (@skk64)+ - Fixed SIGHUP signal handling (#4668) (@LangLangBart)+ - Fixed preview process not killed on exit+ - Fixed coloring of items with zero-width characters+ - Fixed `track-current` unset after a combined movement action+ - Fixed `--accept-nth` being ignored in filter mode (#4636) (@charemma)+ - Fixed display width calculation with `maxWidth` (#4596) (@LangLangBart)+ - Fixed clearing of the rest of the current line on start+ - Fixed `x-api-key` header not required for GET requests+ - Fixed key reading not cancelled when `execute` triggered via a server request (#4653)+ - Fixed rebind of readline command `redraw-current-line` (#4635) (@jameslazo)+ - Fixed `fzf-tmux` `TERM` quoting and added `mktemp` usage (#4664) (@Goofygiraffe06)+ - Do not allow very long queries in `FuzzyMatchV2`+ 0.67.0 ------ - Added `--freeze-left=N` option to keep the leftmost N columns always visible.
@@ -326,10 +326,14 @@ color mappings. Each entry is separated by a comma and/or whitespaces. \fB#rrggbb \fR24-bit colors .B ANSI ATTRIBUTES: (Only applies to foreground colors)- \fBregular \fRClear previously set attributes; should precede the other ones- \fBstrip \fRRemove colors+ \fBregular \fRClear previously set attributes; should precede the other ones+ \fBstrip \fRRemove colors \fBbold\fR \fBunderline\fR+ \fBunderline-double\fR+ \fBunderline-curly\fR+ \fBunderline-dotted\fR+ \fBunderline-dashed\fR \fBreverse\fR \fBdim\fR \fBitalic\fR
@@ -22,20 +22,21 @@ type url struct { type ansiState struct { fg tui.Color bg tui.Color+ ul tui.Color attr tui.Attr lbg tui.Color url *url } func (s *ansiState) colored() bool {- return s.fg != -1 || s.bg != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil+ return s.fg != -1 || s.bg != -1 || s.ul != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil } func (s *ansiState) equals(t *ansiState) bool { if t == nil { return !s.colored() }- return s.fg == t.fg && s.bg == t.bg && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url+ return s.fg == t.fg && s.bg == t.bg && s.ul == t.ul && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url } func (s *ansiState) ToString() string {@@ -54,7 +55,18 @@ func (s *ansiState) ToString() string { ret += "3;" } if s.attr&tui.Underline > 0 {- ret += "4;"+ switch s.attr.UnderlineStyle() {+ case tui.UlStyleDouble:+ ret += "4:2;"+ case tui.UlStyleCurly:+ ret += "4:3;"+ case tui.UlStyleDotted:+ ret += "4:4;"+ case tui.UlStyleDashed:+ ret += "4:5;"+ default:+ ret += "4;"+ } } if s.attr&tui.Blink > 0 { ret += "5;"@@ -66,6 +78,9 @@ func (s *ansiState) ToString() string { ret += "9;" } ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40)+ if s.ul != -1 {+ ret += toAnsiStringUl(s.ul)+ } ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m" if s.url != nil {@@ -74,6 +89,20 @@ func (s *ansiState) ToString() string { return ret } +func toAnsiStringUl(color tui.Color) string {+ col := int(color)+ if col < 0 {+ return ""+ }+ if col >= (1 << 24) {+ r := strconv.Itoa((col >> 16) & 0xff)+ g := strconv.Itoa((col >> 8) & 0xff)+ b := strconv.Itoa(col & 0xff)+ return "58;2;" + r + ";" + g + ";" + b + ";"+ }+ return "58;5;" + strconv.Itoa(col) + ";"+}+ func toAnsiString(color tui.Color, offset int) string { col := int(color) ret := ""@@ -338,15 +367,19 @@ func extractColor(str string, state *ansiState, proc func(string, *ansiState) bo return trimmed, nil, state } -func parseAnsiCode(s string) (int, string) {+func parseAnsiCode(s string) (int, byte, string) { var remaining string- var i int- // Faster than strings.IndexAny(";:")- i = strings.IndexByte(s, ';')- if i < 0 {- i = strings.IndexByte(s, ':')+ var sep byte+ // Find the first separator (either ; or :)+ i := -1+ for j := 0; j < len(s); j++ {+ if s[j] == ';' || s[j] == ':' {+ i = j+ break+ } } if i >= 0 {+ sep = s[i] remaining = s[i+1:] s = s[:i] }@@ -358,14 +391,14 @@ func parseAnsiCode(s string) (int, string) { for _, ch := range stringBytes(s) { ch -= '0' if ch > 9 {- return -1, remaining+ return -1, sep, remaining } code = code*10 + int(ch) }- return code, remaining+ return code, sep, remaining } - return -1, remaining+ return -1, sep, remaining } func interpretCode(ansiCode string, prevState *ansiState) ansiState {@@ -373,14 +406,14 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState { if prevState != nil { return *prevState }- return ansiState{-1, -1, 0, -1, nil}+ return ansiState{-1, -1, -1, 0, -1, nil} } var state ansiState if prevState == nil {- state = ansiState{-1, -1, 0, -1, nil}+ state = ansiState{-1, -1, -1, 0, -1, nil} } else {- state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg, prevState.url}+ state = ansiState{prevState.fg, prevState.bg, prevState.ul, prevState.attr, prevState.lbg, prevState.url} } if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' { if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) {@@ -405,6 +438,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState { reset := func() { state.fg = -1 state.bg = -1+ state.ul = -1 state.attr = 0 } @@ -420,7 +454,8 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState { count := 0 for len(ansiCode) != 0 { var num int- if num, ansiCode = parseAnsiCode(ansiCode); num != -1 {+ var sep byte+ if num, sep, ansiCode = parseAnsiCode(ansiCode); num != -1 { count++ switch state256 { case 0:@@ -431,10 +466,15 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState { case 48: ptr = &state.bg state256+++ case 58:+ ptr = &state.ul+ state256++ case 39: state.fg = -1 case 49: state.bg = -1+ case 59:+ state.ul = -1 case 1: state.attr = state.attr | tui.Bold case 2:@@ -442,7 +482,30 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState { case 3: state.attr = state.attr | tui.Italic case 4:- state.attr = state.attr | tui.Underline+ if sep == ':' {+ // SGR 4:N — underline style sub-parameter+ var subNum int+ subNum, _, ansiCode = parseAnsiCode(ansiCode)+ state.attr = state.attr &^ tui.UnderlineStyleMask+ switch subNum {+ case 0:+ state.attr = state.attr &^ tui.Underline+ case 1:+ state.attr = state.attr | tui.Underline+ case 2:+ state.attr = state.attr | tui.Underline | tui.UlStyleDouble+ case 3:+ state.attr = state.attr | tui.Underline | tui.UlStyleCurly+ case 4:+ state.attr = state.attr | tui.Underline | tui.UlStyleDotted+ case 5:+ state.attr = state.attr | tui.Underline | tui.UlStyleDashed+ default:+ state.attr = state.attr | tui.Underline+ }+ } else {+ state.attr = state.attr | tui.Underline+ } case 5: state.attr = state.attr | tui.Blink case 7:@@ -456,6 +519,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState { state.attr = state.attr &^ tui.Italic case 24: // tput rmul state.attr = state.attr &^ tui.Underline+ state.attr = state.attr &^ tui.UnderlineStyleMask case 25: state.attr = state.attr &^ tui.Blink case 27:
@@ -369,10 +369,10 @@ func TestAnsiCodeStringConversion(t *testing.T) { } } assert("\x1b[m", nil, "")- assert("\x1b[m", &ansiState{attr: tui.Blink, lbg: -1}, "")- assert("\x1b[0m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")- assert("\x1b[;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")- assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")+ assert("\x1b[m", &ansiState{attr: tui.Blink, ul: -1, lbg: -1}, "")+ assert("\x1b[0m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")+ assert("\x1b[;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")+ assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "") assert("\x1b[31m", nil, "\x1b[31;49m") assert("\x1b[41m", nil, "\x1b[39;41m")@@ -380,36 +380,142 @@ func TestAnsiCodeStringConversion(t *testing.T) { assert("\x1b[92m", nil, "\x1b[92;49m") assert("\x1b[102m", nil, "\x1b[39;102m") - assert("\x1b[31m", &ansiState{fg: 4, bg: 4, lbg: -1}, "\x1b[31;44m")- assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")+ assert("\x1b[31m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "\x1b[31;44m")+ assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, ul: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m") assert("\x1b[38;5;100;48;5;200m", nil, "\x1b[38;5;100;48;5;200m") assert("\x1b[38:5:100:48:5:200m", nil, "\x1b[38;5;100;48;5;200m") assert("\x1b[48;5;100;38;5;200m", nil, "\x1b[38;5;200;48;5;100m") assert("\x1b[48;5;100;38;2;10;20;30;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m") assert("\x1b[48;5;100;38;2;10;20;30;7m",- &ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1},+ &ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1, ul: -1}, "\x1b[2;3;7;38;2;10;20;30;48;5;100m")++ // Underline styles+ assert("\x1b[4:3m", nil, "\x1b[4:3;39;49m")+ assert("\x1b[4:2m", nil, "\x1b[4:2;39;49m")+ assert("\x1b[4:4m", nil, "\x1b[4:4;39;49m")+ assert("\x1b[4:5m", nil, "\x1b[4:5;39;49m")+ assert("\x1b[4:1m", nil, "\x1b[4;39;49m")++ // Underline color (256-color)+ assert("\x1b[4;58;5;100m", nil, "\x1b[4;39;49;58;5;100m")+ // Underline color (24-bit)+ assert("\x1b[4;58;2;255;0;128m", nil, "\x1b[4;39;49;58;2;255;0;128m")+ // Curly underline + underline color+ assert("\x1b[4:3;58;2;255;0;0m", nil, "\x1b[4:3;39;49;58;2;255;0;0m")+ // SGR 59 resets underline color+ assert("\x1b[59m", &ansiState{fg: 1, bg: -1, ul: 100, lbg: -1}, "\x1b[31;49m") } func TestParseAnsiCode(t *testing.T) { tests := []struct {- In, Exp string- N int+ In string+ Exp string+ N int+ Sep byte }{- {"123", "", 123},- {"1a", "", -1},- {"1a;12", "12", -1},- {"12;a", "a", 12},- {"-2", "", -1},+ {"123", "", 123, 0},+ {"1a", "", -1, 0},+ {"1a;12", "12", -1, ';'},+ {"12;a", "a", 12, ';'},+ {"-2", "", -1, 0},+ // Colon sub-parameters: earliest separator wins (@shtse8)+ {"4:3", "3", 4, ':'},+ {"4:3;31", "3;31", 4, ':'},+ {"38:2:255:0:0", "2:255:0:0", 38, ':'},+ {"58:5:200", "5:200", 58, ':'},+ // Semicolon before colon+ {"4;38:2:0:0:0", "38:2:0:0:0", 4, ';'}, } for _, x := range tests {- n, s := parseAnsiCode(x.In)- if n != x.N || s != x.Exp {- t.Fatalf("%q: got: (%d %q) want: (%d %q)", x.In, n, s, x.N, x.Exp)+ n, sep, s := parseAnsiCode(x.In)+ if n != x.N || s != x.Exp || sep != x.Sep {+ t.Fatalf("%q: got: (%d %q %q) want: (%d %q %q)", x.In, n, s, string(sep), x.N, x.Exp, string(x.Sep)) } } } +// Test cases adapted from @shtse8 (PR #4678)+func TestInterpretCodeUnderlineStyles(t *testing.T) {+ // 4:0 = no underline+ state := interpretCode("\x1b[4:0m", nil)+ if state.attr&tui.Underline != 0 {+ t.Error("4:0 should not set underline")+ }++ // 4:1 = single underline+ state = interpretCode("\x1b[4:1m", nil)+ if state.attr&tui.Underline == 0 {+ t.Error("4:1 should set underline")+ }++ // 4:3 = curly underline+ state = interpretCode("\x1b[4:3m", nil)+ if state.attr&tui.Underline == 0 {+ t.Error("4:3 should set underline")+ }+ if state.attr.UnderlineStyle() != tui.UlStyleCurly {+ t.Error("4:3 should set curly underline style")+ }++ // 4:3 should NOT set italic (3 is a sub-param, not SGR 3)+ if state.attr&tui.Italic != 0 {+ t.Error("4:3 should not set italic")+ }++ // 4:2;31 = double underline + red fg+ state = interpretCode("\x1b[4:2;31m", nil)+ if state.attr&tui.Underline == 0 {+ t.Error("4:2;31 should set underline")+ }+ if state.fg != 1 {+ t.Errorf("4:2;31 should set fg to red (1), got %d", state.fg)+ }+ if state.attr&tui.Dim != 0 {+ t.Error("4:2;31 should not set dim")+ }++ // Plain 4 still works+ state = interpretCode("\x1b[4m", nil)+ if state.attr&tui.Underline == 0 {+ t.Error("4 should set underline")+ }++ // 4;2 (semicolon) = underline + dim+ state = interpretCode("\x1b[4;2m", nil)+ if state.attr&tui.Underline == 0 {+ t.Error("4;2 should set underline")+ }+ if state.attr&tui.Dim == 0 {+ t.Error("4;2 should set dim")+ }+}++// Test cases adapted from @shtse8 (PR #4678)+func TestInterpretCodeUnderlineColor(t *testing.T) {+ // 58:2:R:G:B should not affect fg or bg+ state := interpretCode("\x1b[58:2:255:0:0m", nil)+ if state.fg != -1 || state.bg != -1 {+ t.Errorf("58:2:R:G:B should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)+ }++ // 58:5:200 should not affect fg or bg+ state = interpretCode("\x1b[58:5:200m", nil)+ if state.fg != -1 || state.bg != -1 {+ t.Errorf("58:5:N should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)+ }++ // 58:2:R:G:B combined with 38:2:R:G:B should only set fg+ state = interpretCode("\x1b[58:2:255:0:0;38:2:0:255:0m", nil)+ expectedFg := tui.Color(1<<24 | 0<<16 | 255<<8 | 0)+ if state.fg != expectedFg {+ t.Errorf("expected fg=%d, got %d", expectedFg, state.fg)+ }+ if state.bg != -1 {+ t.Errorf("bg should be -1, got %d", state.bg)+ }+}+ // kernel/bpf/preload/iterators/README const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" + "\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" +
@@ -1407,6 +1407,14 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui cattr.Attr |= tui.Italic case "underline": cattr.Attr |= tui.Underline+ case "underline-double":+ cattr.Attr |= tui.Underline | tui.UlStyleDouble+ case "underline-curly":+ cattr.Attr |= tui.Underline | tui.UlStyleCurly+ case "underline-dotted":+ cattr.Attr |= tui.Underline | tui.UlStyleDotted+ case "underline-dashed":+ cattr.Attr |= tui.Underline | tui.UlStyleDashed case "blink": cattr.Attr |= tui.Blink case "reverse":
@@ -206,7 +206,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t if bg == -1 { bg = colBase.Bg() }- return tui.NewColorPair(fg, bg, ansi.color.attr).MergeAttr(base)+ return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base) } var colors []colorOffset add := func(idx int) {
@@ -124,10 +124,10 @@ func TestColorOffset(t *testing.T) { item := Result{ item: &Item{ colors: &[]ansiOffset{- {[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}},- {[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}},- {[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}},- {[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1, nil}}}}}+ {[2]int32{0, 20}, ansiState{1, 5, -1, 0, -1, nil}},+ {[2]int32{22, 27}, ansiState{2, 6, -1, tui.Bold, -1, nil}},+ {[2]int32{30, 32}, ansiState{3, 7, -1, 0, -1, nil}},+ {[2]int32{33, 40}, ansiState{4, 8, -1, tui.Bold, -1, nil}}}}} colBase := tui.NewColorPair(89, 189, tui.AttrUndefined) colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
@@ -1549,7 +1549,7 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) { // // unless the part has a non-default ANSI state loc := whiteSuffix.FindStringIndex(trimmed) if loc != nil {- blankState := ansiOffset{[2]int32{int32(loc[0]), int32(loc[1])}, ansiState{tui.ColPrompt.Fg(), tui.ColPrompt.Bg(), tui.AttrClear, -1, nil}}+ blankState := ansiOffset{[2]int32{int32(loc[0]), int32(loc[1])}, ansiState{tui.ColPrompt.Fg(), tui.ColPrompt.Bg(), -1, tui.AttrClear, -1, nil}} if item.colors != nil { lastColor := (*item.colors)[len(*item.colors)-1] if lastColor.offset[1] < int32(loc[1]) {@@ -4144,7 +4144,7 @@ Loop: top := true for ; y < height; y++ { t.pwindow.MoveAndClear(y, 0)- t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), tui.AttrRegular, t.makeImageBorder(maxWidth, top))+ t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, tui.AttrRegular, t.makeImageBorder(maxWidth, top)) top = false } wireframe = true@@ -4209,13 +4209,13 @@ Loop: prefixWidth = width colored := ansi != nil && ansi.colored() if t.theme.Colored && colored {- fillRet = t.pwindow.CFill(ansi.fg, ansi.bg, ansi.attr, str)+ fillRet = t.pwindow.CFill(ansi.fg, ansi.bg, ansi.ul, ansi.attr, str) } else { attr := tui.AttrRegular if colored { attr = ansi.attr }- fillRet = t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), attr, str)+ fillRet = t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, attr, str) } } return !isTrimmed &&@@ -4235,7 +4235,7 @@ Loop: break } if t.theme.Colored && lbg >= 0 {- fillRet = t.pwindow.CFill(-1, lbg, tui.AttrRegular,+ fillRet = t.pwindow.CFill(-1, lbg, -1, tui.AttrRegular, strings.Repeat(" ", t.pwindow.Width()-t.pwindow.X())+"\n") } else { fillRet = t.pwindow.Fill("\n")
@@ -1323,7 +1323,18 @@ func attrCodes(attr Attr) []string { codes = append(codes, "3") } if (attr & Underline) > 0 {- codes = append(codes, "4")+ switch attr.UnderlineStyle() {+ case UlStyleDouble:+ codes = append(codes, "4:2")+ case UlStyleCurly:+ codes = append(codes, "4:3")+ case UlStyleDotted:+ codes = append(codes, "4:4")+ case UlStyleDashed:+ codes = append(codes, "4:5")+ default:+ codes = append(codes, "4")+ } } if (attr & Blink) > 0 { codes = append(codes, "5")@@ -1361,8 +1372,27 @@ func colorCodes(fg Color, bg Color) []string { return codes } -func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) (bool, string) {+func ulColorCode(c Color) string {+ if c == colDefault {+ return ""+ }+ if c.is24() {+ r := (c >> 16) & 0xff+ g := (c >> 8) & 0xff+ b := (c) & 0xff+ return fmt.Sprintf("58;2;%d;%d;%d", r, g, b)+ }+ if c >= 0 && c < 256 {+ return fmt.Sprintf("58;5;%d", c)+ }+ return ""+}++func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) { codes := append(attrCodes(attr), colorCodes(fg, bg)...)+ if ulCode := ulColorCode(ul); ulCode != "" {+ codes = append(codes, ulCode)+ } code := w.csi(";" + strings.Join(codes, ";") + "m") return len(codes) > 0, code }@@ -1376,13 +1406,13 @@ func cleanse(str string) string { } func (w *LightWindow) CPrint(pair ColorPair, text string) {- _, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Attr())+ _, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr()) w.stderrInternal(cleanse(text), false, code) w.csi("0m") } func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {- hasColors, code := w.csiColor(fg, bg, attr)+ hasColors, code := w.csiColor(fg, bg, colDefault, attr) if hasColors { defer w.csi("0m") }@@ -1472,7 +1502,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn { func (w *LightWindow) setBg() string { if w.bg != colDefault {- _, code := w.csiColor(colDefault, w.bg, AttrRegular)+ _, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular) return code } // Should clear dim attribute after ␍ in the preview window@@ -1494,7 +1524,7 @@ func (w *LightWindow) Fill(text string) FillReturn { return w.fill(text, code) } -func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillReturn {+func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn { w.Move(w.posy, w.posx) if fg == colDefault { fg = w.fg@@ -1502,7 +1532,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillRetu if bg == colDefault { bg = w.bg }- if hasColors, resetCode := w.csiColor(fg, bg, attr); hasColors {+ if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors { defer w.csi("0m") return w.fill(text, resetCode) }
@@ -825,6 +825,21 @@ func (w *TcellWindow) withUrl(style tcell.Style) tcell.Style { return style } +func underlineStyleFromAttr(a Attr) tcell.UnderlineStyle {+ switch a.UnderlineStyle() {+ case UlStyleDouble:+ return tcell.UnderlineStyleDouble+ case UlStyleCurly:+ return tcell.UnderlineStyleCurly+ case UlStyleDotted:+ return tcell.UnderlineStyleDotted+ case UlStyleDashed:+ return tcell.UnderlineStyleDashed+ default:+ return tcell.UnderlineStyleSolid+ }+}+ func (w *TcellWindow) printString(text string, pair ColorPair) { lx := 0 a := pair.Attr()@@ -833,11 +848,18 @@ func (w *TcellWindow) printString(text string, pair ColorPair) { if a&AttrClear == 0 { style = style. Reverse(a&Attr(tcell.AttrReverse) != 0).- Underline(a&Attr(tcell.AttrUnderline) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). Italic(a&Attr(tcell.AttrItalic) != 0). Blink(a&Attr(tcell.AttrBlink) != 0). Dim(a&Attr(tcell.AttrDim) != 0)+ if a&Attr(tcell.AttrUnderline) != 0 {+ style = style.Underline(underlineStyleFromAttr(a))+ if pair.Ul() != colDefault {+ style = style.Underline(asTcellColor(pair.Ul()))+ }+ } else {+ style = style.Underline(false)+ } } style = w.withUrl(style) @@ -887,9 +909,16 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn { Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0). Dim(a&Attr(tcell.AttrDim) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0).- Underline(a&Attr(tcell.AttrUnderline) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). Italic(a&Attr(tcell.AttrItalic) != 0)+ if a&Attr(tcell.AttrUnderline) != 0 {+ style = style.Underline(underlineStyleFromAttr(a))+ if pair.Ul() != colDefault {+ style = style.Underline(asTcellColor(pair.Ul()))+ }+ } else {+ style = style.Underline(false)+ } style = w.withUrl(style) gr := uniseg.NewGraphemes(text)@@ -967,14 +996,14 @@ func (w *TcellWindow) LinkEnd() { w.params = nil } -func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn {+func (w *TcellWindow) CFill(fg Color, bg Color, ul Color, a Attr, str string) FillReturn { if fg == colDefault { fg = w.normal.Fg() } if bg == colDefault { bg = w.normal.Bg() }- return w.fillString(str, NewColorPair(fg, bg, a))+ return w.fillString(str, NewColorPair(fg, bg, a).WithUl(ul)) } func (w *TcellWindow) DrawBorder() {
@@ -17,15 +17,34 @@ const ( BoldForce = Attr(1 << 10) FullBg = Attr(1 << 11) Strip = Attr(1 << 12)++ // Underline style stored in bits 13-15 (3 bits, values 0-4)+ // Only meaningful when the Underline attribute bit is also set.+ // 0 = solid (default)+ UnderlineStyleShift = 13+ UnderlineStyleMask = Attr(0b111 << UnderlineStyleShift)+ UlStyleDouble = Attr(0b001 << UnderlineStyleShift)+ UlStyleCurly = Attr(0b010 << UnderlineStyleShift)+ UlStyleDotted = Attr(0b011 << UnderlineStyleShift)+ UlStyleDashed = Attr(0b100 << UnderlineStyleShift) ) +func (a Attr) UnderlineStyle() Attr {+ return a & UnderlineStyleMask+}+ func (a Attr) Merge(b Attr) Attr { if b&AttrRegular > 0 { // Only keep bold attribute set by the system return (b &^ AttrRegular) | (a & BoldForce) } - return (a &^ AttrRegular) | b+ merged := (a &^ AttrRegular) | b+ // When b sets Underline, use b's underline style instead of OR'ing+ if b&Underline > 0 {+ merged = (merged &^ UnderlineStyleMask) | (b & UnderlineStyleMask)+ }+ return merged } // Types of user action@@ -352,6 +371,7 @@ const ( type ColorPair struct { fg Color bg Color+ ul Color attr Attr } @@ -363,11 +383,11 @@ func HexToColor(rrggbb string) Color { } func NewColorPair(fg Color, bg Color, attr Attr) ColorPair {- return ColorPair{fg, bg, attr}+ return ColorPair{fg, bg, colDefault, attr} } func NoColorPair() ColorPair {- return ColorPair{-1, -1, 0}+ return ColorPair{-1, -1, -1, 0} } func (p ColorPair) Fg() Color {@@ -378,6 +398,16 @@ func (p ColorPair) Bg() Color { return p.bg } +func (p ColorPair) Ul() Color {+ return p.ul+}++func (p ColorPair) WithUl(ul Color) ColorPair {+ dup := p+ dup.ul = ul+ return dup+}+ func (p ColorPair) Attr() Attr { return p.attr }@@ -404,6 +434,9 @@ func (p ColorPair) merge(other ColorPair, except Color) ColorPair { if other.bg != except { dup.bg = other.bg }+ if other.ul != except {+ dup.ul = other.ul+ } return dup } @@ -415,13 +448,13 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair { func (p ColorPair) WithFg(fg ColorAttr) ColorPair { dup := p- fgPair := ColorPair{fg.Color, colUndefined, fg.Attr}+ fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr} return dup.Merge(fgPair) } func (p ColorPair) WithBg(bg ColorAttr) ColorPair { dup := p- bgPair := ColorPair{colUndefined, bg.Color, bg.Attr}+ bgPair := ColorPair{colUndefined, bg.Color, colUndefined, bg.Attr} return dup.Merge(bgPair) } @@ -783,7 +816,7 @@ type Window interface { Print(text string) CPrint(color ColorPair, text string) Fill(text string) FillReturn- CFill(fg Color, bg Color, attr Attr, text string) FillReturn+ CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn LinkBegin(uri string, params string) LinkEnd() Erase()@@ -1271,7 +1304,7 @@ func initPalette(theme *ColorTheme) { if fg.Color == colDefault && (fg.Attr&Reverse) > 0 { bg.Color = colDefault }- return ColorPair{fg.Color, bg.Color, fg.Attr}+ return ColorPair{fg.Color, bg.Color, colDefault, fg.Attr} } blank := theme.ListFg blank.Attr = AttrRegular