@ 261102.1

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

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.

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

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:

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/" +

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":

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) {

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)

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")

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) 	}

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() {

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