Add underline style variants and underline color support
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled

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.
This commit is contained in:
Junegunn Choi
2026-02-15 00:59:16 +09:00
parent 49ab253555
commit b56d614ba2
11 changed files with 371 additions and 64 deletions
+33
View File
@@ -1,6 +1,39 @@
CHANGELOG 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 0.67.0
------ ------
- Added `--freeze-left=N` option to keep the leftmost N columns always visible. - Added `--freeze-left=N` option to keep the leftmost N columns always visible.
+6 -2
View File
@@ -326,10 +326,14 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fB#rrggbb \fR24-bit colors \fB#rrggbb \fR24-bit colors
.B ANSI ATTRIBUTES: (Only applies to foreground colors) .B ANSI ATTRIBUTES: (Only applies to foreground colors)
\fBregular \fRClear previously set attributes; should precede the other ones \fBregular \fRClear previously set attributes; should precede the other ones
\fBstrip \fRRemove colors \fBstrip \fRRemove colors
\fBbold\fR \fBbold\fR
\fBunderline\fR \fBunderline\fR
\fBunderline-double\fR
\fBunderline-curly\fR
\fBunderline-dotted\fR
\fBunderline-dashed\fR
\fBreverse\fR \fBreverse\fR
\fBdim\fR \fBdim\fR
\fBitalic\fR \fBitalic\fR
+81 -17
View File
@@ -22,20 +22,21 @@ type url struct {
type ansiState struct { type ansiState struct {
fg tui.Color fg tui.Color
bg tui.Color bg tui.Color
ul tui.Color
attr tui.Attr attr tui.Attr
lbg tui.Color lbg tui.Color
url *url url *url
} }
func (s *ansiState) colored() bool { 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 { func (s *ansiState) equals(t *ansiState) bool {
if t == nil { if t == nil {
return !s.colored() 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 { func (s *ansiState) ToString() string {
@@ -54,7 +55,18 @@ func (s *ansiState) ToString() string {
ret += "3;" ret += "3;"
} }
if s.attr&tui.Underline > 0 { 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 { if s.attr&tui.Blink > 0 {
ret += "5;" ret += "5;"
@@ -66,6 +78,9 @@ func (s *ansiState) ToString() string {
ret += "9;" ret += "9;"
} }
ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40) ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40)
if s.ul != -1 {
ret += toAnsiStringUl(s.ul)
}
ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m" ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m"
if s.url != nil { if s.url != nil {
@@ -74,6 +89,20 @@ func (s *ansiState) ToString() string {
return ret 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 { func toAnsiString(color tui.Color, offset int) string {
col := int(color) col := int(color)
ret := "" ret := ""
@@ -338,15 +367,19 @@ func extractColor(str string, state *ansiState, proc func(string, *ansiState) bo
return trimmed, nil, state return trimmed, nil, state
} }
func parseAnsiCode(s string) (int, string) { func parseAnsiCode(s string) (int, byte, string) {
var remaining string var remaining string
var i int var sep byte
// Faster than strings.IndexAny(";:") // Find the first separator (either ; or :)
i = strings.IndexByte(s, ';') i := -1
if i < 0 { for j := 0; j < len(s); j++ {
i = strings.IndexByte(s, ':') if s[j] == ';' || s[j] == ':' {
i = j
break
}
} }
if i >= 0 { if i >= 0 {
sep = s[i]
remaining = s[i+1:] remaining = s[i+1:]
s = s[:i] s = s[:i]
} }
@@ -358,14 +391,14 @@ func parseAnsiCode(s string) (int, string) {
for _, ch := range stringBytes(s) { for _, ch := range stringBytes(s) {
ch -= '0' ch -= '0'
if ch > 9 { if ch > 9 {
return -1, remaining return -1, sep, remaining
} }
code = code*10 + int(ch) 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 { func interpretCode(ansiCode string, prevState *ansiState) ansiState {
@@ -373,14 +406,14 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
if prevState != nil { if prevState != nil {
return *prevState return *prevState
} }
return ansiState{-1, -1, 0, -1, nil} return ansiState{-1, -1, -1, 0, -1, nil}
} }
var state ansiState var state ansiState
if prevState == nil { if prevState == nil {
state = ansiState{-1, -1, 0, -1, nil} state = ansiState{-1, -1, -1, 0, -1, nil}
} else { } 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 ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' {
if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) { if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) {
@@ -405,6 +438,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
reset := func() { reset := func() {
state.fg = -1 state.fg = -1
state.bg = -1 state.bg = -1
state.ul = -1
state.attr = 0 state.attr = 0
} }
@@ -420,7 +454,8 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
count := 0 count := 0
for len(ansiCode) != 0 { for len(ansiCode) != 0 {
var num int var num int
if num, ansiCode = parseAnsiCode(ansiCode); num != -1 { var sep byte
if num, sep, ansiCode = parseAnsiCode(ansiCode); num != -1 {
count++ count++
switch state256 { switch state256 {
case 0: case 0:
@@ -431,10 +466,15 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 48: case 48:
ptr = &state.bg ptr = &state.bg
state256++ state256++
case 58:
ptr = &state.ul
state256++
case 39: case 39:
state.fg = -1 state.fg = -1
case 49: case 49:
state.bg = -1 state.bg = -1
case 59:
state.ul = -1
case 1: case 1:
state.attr = state.attr | tui.Bold state.attr = state.attr | tui.Bold
case 2: case 2:
@@ -442,7 +482,30 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 3: case 3:
state.attr = state.attr | tui.Italic state.attr = state.attr | tui.Italic
case 4: 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: case 5:
state.attr = state.attr | tui.Blink state.attr = state.attr | tui.Blink
case 7: case 7:
@@ -456,6 +519,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
state.attr = state.attr &^ tui.Italic state.attr = state.attr &^ tui.Italic
case 24: // tput rmul case 24: // tput rmul
state.attr = state.attr &^ tui.Underline state.attr = state.attr &^ tui.Underline
state.attr = state.attr &^ tui.UnderlineStyleMask
case 25: case 25:
state.attr = state.attr &^ tui.Blink state.attr = state.attr &^ tui.Blink
case 27: case 27:
+123 -17
View File
@@ -369,10 +369,10 @@ func TestAnsiCodeStringConversion(t *testing.T) {
} }
} }
assert("\x1b[m", nil, "") assert("\x1b[m", nil, "")
assert("\x1b[m", &ansiState{attr: tui.Blink, lbg: -1}, "") assert("\x1b[m", &ansiState{attr: tui.Blink, ul: -1, lbg: -1}, "")
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, lbg: -1}, "") assert("\x1b[0m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "") assert("\x1b[;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "") assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[31m", nil, "\x1b[31;49m") assert("\x1b[31m", nil, "\x1b[31;49m")
assert("\x1b[41m", nil, "\x1b[39;41m") 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[92m", nil, "\x1b[92;49m")
assert("\x1b[102m", nil, "\x1b[39;102m") assert("\x1b[102m", nil, "\x1b[39;102m")
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, lbg: -1}, "\x1b[31;44m") 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, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m") 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[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;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;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;7m", 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") "\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) { func TestParseAnsiCode(t *testing.T) {
tests := []struct { tests := []struct {
In, Exp string In string
N int Exp string
N int
Sep byte
}{ }{
{"123", "", 123}, {"123", "", 123, 0},
{"1a", "", -1}, {"1a", "", -1, 0},
{"1a;12", "12", -1}, {"1a;12", "12", -1, ';'},
{"12;a", "a", 12}, {"12;a", "a", 12, ';'},
{"-2", "", -1}, {"-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 { for _, x := range tests {
n, s := parseAnsiCode(x.In) n, sep, s := parseAnsiCode(x.In)
if n != x.N || s != x.Exp { if n != x.N || s != x.Exp || sep != x.Sep {
t.Fatalf("%q: got: (%d %q) want: (%d %q)", x.In, n, s, x.N, x.Exp) 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 // kernel/bpf/preload/iterators/README
const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" + 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/" + "\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" +
+8
View File
@@ -1407,6 +1407,14 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
cattr.Attr |= tui.Italic cattr.Attr |= tui.Italic
case "underline": case "underline":
cattr.Attr |= tui.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": case "blink":
cattr.Attr |= tui.Blink cattr.Attr |= tui.Blink
case "reverse": case "reverse":
+1 -1
View File
@@ -206,7 +206,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if bg == -1 { if bg == -1 {
bg = colBase.Bg() 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 var colors []colorOffset
add := func(idx int) { add := func(idx int) {
+4 -4
View File
@@ -124,10 +124,10 @@ func TestColorOffset(t *testing.T) {
item := Result{ item := Result{
item: &Item{ item: &Item{
colors: &[]ansiOffset{ colors: &[]ansiOffset{
{[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}}, {[2]int32{0, 20}, ansiState{1, 5, -1, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}}, {[2]int32{22, 27}, ansiState{2, 6, -1, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}}, {[2]int32{30, 32}, ansiState{3, 7, -1, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1, nil}}}}} {[2]int32{33, 40}, ansiState{4, 8, -1, tui.Bold, -1, nil}}}}}
colBase := tui.NewColorPair(89, 189, tui.AttrUndefined) colBase := tui.NewColorPair(89, 189, tui.AttrUndefined)
colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined) colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
+5 -5
View File
@@ -1549,7 +1549,7 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
// // unless the part has a non-default ANSI state // // unless the part has a non-default ANSI state
loc := whiteSuffix.FindStringIndex(trimmed) loc := whiteSuffix.FindStringIndex(trimmed)
if loc != nil { 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 { if item.colors != nil {
lastColor := (*item.colors)[len(*item.colors)-1] lastColor := (*item.colors)[len(*item.colors)-1]
if lastColor.offset[1] < int32(loc[1]) { if lastColor.offset[1] < int32(loc[1]) {
@@ -4144,7 +4144,7 @@ Loop:
top := true top := true
for ; y < height; y++ { for ; y < height; y++ {
t.pwindow.MoveAndClear(y, 0) 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 top = false
} }
wireframe = true wireframe = true
@@ -4209,13 +4209,13 @@ Loop:
prefixWidth = width prefixWidth = width
colored := ansi != nil && ansi.colored() colored := ansi != nil && ansi.colored()
if t.theme.Colored && 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 { } else {
attr := tui.AttrRegular attr := tui.AttrRegular
if colored { if colored {
attr = ansi.attr 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 && return !isTrimmed &&
@@ -4235,7 +4235,7 @@ Loop:
break break
} }
if t.theme.Colored && lbg >= 0 { 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") strings.Repeat(" ", t.pwindow.Width()-t.pwindow.X())+"\n")
} else { } else {
fillRet = t.pwindow.Fill("\n") fillRet = t.pwindow.Fill("\n")
+37 -7
View File
@@ -1323,7 +1323,18 @@ func attrCodes(attr Attr) []string {
codes = append(codes, "3") codes = append(codes, "3")
} }
if (attr & Underline) > 0 { 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 { if (attr & Blink) > 0 {
codes = append(codes, "5") codes = append(codes, "5")
@@ -1361,8 +1372,27 @@ func colorCodes(fg Color, bg Color) []string {
return codes 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)...) codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode)
}
code := w.csi(";" + strings.Join(codes, ";") + "m") code := w.csi(";" + strings.Join(codes, ";") + "m")
return len(codes) > 0, code return len(codes) > 0, code
} }
@@ -1376,13 +1406,13 @@ func cleanse(str string) string {
} }
func (w *LightWindow) CPrint(pair ColorPair, text 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.stderrInternal(cleanse(text), false, code)
w.csi("0m") w.csi("0m")
} }
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { 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 { if hasColors {
defer w.csi("0m") defer w.csi("0m")
} }
@@ -1472,7 +1502,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
func (w *LightWindow) setBg() string { func (w *LightWindow) setBg() string {
if w.bg != colDefault { if w.bg != colDefault {
_, code := w.csiColor(colDefault, w.bg, AttrRegular) _, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
return code return code
} }
// Should clear dim attribute after ␍ in the preview window // 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) 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) w.Move(w.posy, w.posx)
if fg == colDefault { if fg == colDefault {
fg = w.fg fg = w.fg
@@ -1502,7 +1532,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillRetu
if bg == colDefault { if bg == colDefault {
bg = w.bg 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") defer w.csi("0m")
return w.fill(text, resetCode) return w.fill(text, resetCode)
} }
+33 -4
View File
@@ -825,6 +825,21 @@ func (w *TcellWindow) withUrl(style tcell.Style) tcell.Style {
return 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) { func (w *TcellWindow) printString(text string, pair ColorPair) {
lx := 0 lx := 0
a := pair.Attr() a := pair.Attr()
@@ -833,11 +848,18 @@ func (w *TcellWindow) printString(text string, pair ColorPair) {
if a&AttrClear == 0 { if a&AttrClear == 0 {
style = style. style = style.
Reverse(a&Attr(tcell.AttrReverse) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0). Italic(a&Attr(tcell.AttrItalic) != 0).
Blink(a&Attr(tcell.AttrBlink) != 0). Blink(a&Attr(tcell.AttrBlink) != 0).
Dim(a&Attr(tcell.AttrDim) != 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) 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). Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0).
Dim(a&Attr(tcell.AttrDim) != 0). Dim(a&Attr(tcell.AttrDim) != 0).
Reverse(a&Attr(tcell.AttrReverse) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 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) style = w.withUrl(style)
gr := uniseg.NewGraphemes(text) gr := uniseg.NewGraphemes(text)
@@ -967,14 +996,14 @@ func (w *TcellWindow) LinkEnd() {
w.params = nil 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 { if fg == colDefault {
fg = w.normal.Fg() fg = w.normal.Fg()
} }
if bg == colDefault { if bg == colDefault {
bg = w.normal.Bg() 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() { func (w *TcellWindow) DrawBorder() {
+40 -7
View File
@@ -17,15 +17,34 @@ const (
BoldForce = Attr(1 << 10) BoldForce = Attr(1 << 10)
FullBg = Attr(1 << 11) FullBg = Attr(1 << 11)
Strip = Attr(1 << 12) 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 { func (a Attr) Merge(b Attr) Attr {
if b&AttrRegular > 0 { if b&AttrRegular > 0 {
// Only keep bold attribute set by the system // Only keep bold attribute set by the system
return (b &^ AttrRegular) | (a & BoldForce) 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 // Types of user action
@@ -352,6 +371,7 @@ const (
type ColorPair struct { type ColorPair struct {
fg Color fg Color
bg Color bg Color
ul Color
attr Attr attr Attr
} }
@@ -363,11 +383,11 @@ func HexToColor(rrggbb string) Color {
} }
func NewColorPair(fg Color, bg Color, attr Attr) ColorPair { func NewColorPair(fg Color, bg Color, attr Attr) ColorPair {
return ColorPair{fg, bg, attr} return ColorPair{fg, bg, colDefault, attr}
} }
func NoColorPair() ColorPair { func NoColorPair() ColorPair {
return ColorPair{-1, -1, 0} return ColorPair{-1, -1, -1, 0}
} }
func (p ColorPair) Fg() Color { func (p ColorPair) Fg() Color {
@@ -378,6 +398,16 @@ func (p ColorPair) Bg() Color {
return p.bg 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 { func (p ColorPair) Attr() Attr {
return p.attr return p.attr
} }
@@ -404,6 +434,9 @@ func (p ColorPair) merge(other ColorPair, except Color) ColorPair {
if other.bg != except { if other.bg != except {
dup.bg = other.bg dup.bg = other.bg
} }
if other.ul != except {
dup.ul = other.ul
}
return dup return dup
} }
@@ -415,13 +448,13 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair {
func (p ColorPair) WithFg(fg ColorAttr) ColorPair { func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
dup := p dup := p
fgPair := ColorPair{fg.Color, colUndefined, fg.Attr} fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr}
return dup.Merge(fgPair) return dup.Merge(fgPair)
} }
func (p ColorPair) WithBg(bg ColorAttr) ColorPair { func (p ColorPair) WithBg(bg ColorAttr) ColorPair {
dup := p dup := p
bgPair := ColorPair{colUndefined, bg.Color, bg.Attr} bgPair := ColorPair{colUndefined, bg.Color, colUndefined, bg.Attr}
return dup.Merge(bgPair) return dup.Merge(bgPair)
} }
@@ -783,7 +816,7 @@ type Window interface {
Print(text string) Print(text string)
CPrint(color ColorPair, text string) CPrint(color ColorPair, text string)
Fill(text string) FillReturn 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) LinkBegin(uri string, params string)
LinkEnd() LinkEnd()
Erase() Erase()
@@ -1271,7 +1304,7 @@ func initPalette(theme *ColorTheme) {
if fg.Color == colDefault && (fg.Attr&Reverse) > 0 { if fg.Color == colDefault && (fg.Attr&Reverse) > 0 {
bg.Color = colDefault bg.Color = colDefault
} }
return ColorPair{fg.Color, bg.Color, fg.Attr} return ColorPair{fg.Color, bg.Color, colDefault, fg.Attr}
} }
blank := theme.ListFg blank := theme.ListFg
blank.Attr = AttrRegular blank.Attr = AttrRegular