From b56d614ba2f901c64eed454f181badec0b1cedff Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 15 Feb 2026 00:59:16 +0900 Subject: [PATCH] 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. --- CHANGELOG.md | 33 +++++++++++ man/man1/fzf.1 | 8 ++- src/ansi.go | 98 +++++++++++++++++++++++++------ src/ansi_test.go | 140 +++++++++++++++++++++++++++++++++++++++------ src/options.go | 8 +++ src/result.go | 2 +- src/result_test.go | 8 +-- src/terminal.go | 10 ++-- src/tui/light.go | 44 +++++++++++--- src/tui/tcell.go | 37 ++++++++++-- src/tui/tui.go | 47 ++++++++++++--- 11 files changed, 371 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4432fb..98db6270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 7d763729..b0ff851d 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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 diff --git a/src/ansi.go b/src/ansi.go index 61137e1f..9f398103 100644 --- a/src/ansi.go +++ b/src/ansi.go @@ -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: diff --git a/src/ansi_test.go b/src/ansi_test.go index c112cce7..8ed7b86e 100644 --- a/src/ansi_test.go +++ b/src/ansi_test.go @@ -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/" + diff --git a/src/options.go b/src/options.go index 1c7de52d..51822240 100644 --- a/src/options.go +++ b/src/options.go @@ -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": diff --git a/src/result.go b/src/result.go index 190ffc25..bfc5e9d3 100644 --- a/src/result.go +++ b/src/result.go @@ -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) { diff --git a/src/result_test.go b/src/result_test.go index 1f6a003e..6ff53f92 100644 --- a/src/result_test.go +++ b/src/result_test.go @@ -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) diff --git a/src/terminal.go b/src/terminal.go index 04e202c6..10731c62 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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") diff --git a/src/tui/light.go b/src/tui/light.go index 97d1effe..83a736ca 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -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) } diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 1ea4c8f5..9bfb9f3f 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -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() { diff --git a/src/tui/tui.go b/src/tui/tui.go index 5eb8356b..d5f9e969 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -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