diff --git a/src/ansi.go b/src/ansi.go index 5f7c8be4..65168f7e 100644 --- a/src/ansi.go +++ b/src/ansi.go @@ -1,6 +1,7 @@ package fzf import ( + "fmt" "strconv" "strings" "unicode/utf8" @@ -13,22 +14,28 @@ type ansiOffset struct { color ansiState } +type url struct { + uri string + params string +} + type ansiState struct { fg tui.Color bg 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 + return s.fg != -1 || s.bg != -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 + return s.fg == t.fg && s.bg == t.bg && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url } func (s *ansiState) ToString() string { @@ -60,7 +67,11 @@ func (s *ansiState) ToString() string { } ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40) - return "\x1b[" + strings.TrimSuffix(ret, ";") + "m" + ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m" + if s.url != nil { + ret = fmt.Sprintf("\x1b]8;%s;%s\x1b\\%s\x1b]8;;\x1b", s.url.params, s.url.uri, ret) + } + return ret } func toAnsiString(color tui.Color, offset int) string { @@ -98,10 +109,19 @@ func matchOperatingSystemCommand(s string) int { if s[i] == '\x07' { return i + 1 } + // `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b` + // ------ if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' { return i + 2 } } + + // `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b` + // ------------ + if i < len(s) && s[:i+1] == "\x1b]8;;\x1b" { + return i + 1 + } + return -1 } @@ -328,13 +348,21 @@ func parseAnsiCode(s string, delimiter byte) (int, byte, string) { func interpretCode(ansiCode string, prevState *ansiState) ansiState { var state ansiState if prevState == nil { - state = ansiState{-1, -1, 0, -1} + state = ansiState{-1, -1, 0, -1, nil} } else { - state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg} + state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg, prevState.url} } if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' { if prevState != nil && strings.HasSuffix(ansiCode, "0K") { state.lbg = prevState.bg + } else if ansiCode == "\x1b]8;;\x1b" { // End of a hyperlink + state.url = nil + } else if strings.HasPrefix(ansiCode, "\x1b]8;") && strings.HasSuffix(ansiCode, "\x1b\\") { + if paramsEnd := strings.IndexRune(ansiCode[4:], ';'); paramsEnd >= 0 { + params := ansiCode[4 : 4+paramsEnd] + uri := ansiCode[5+paramsEnd : len(ansiCode)-2] + state.url = &url{uri: uri, params: params} + } } return state } diff --git a/src/result_test.go b/src/result_test.go index 2f818a9b..c11e1ab5 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}}, - {[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1}}, - {[2]int32{30, 32}, ansiState{3, 7, 0, -1}}, - {[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1}}}}} + {[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}}}}} 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 29df02b2..62d5ae92 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -1068,7 +1068,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{-1, -1, tui.AttrClear, -1}} + blankState := ansiOffset{[2]int32{int32(loc[0]), int32(loc[1])}, ansiState{-1, -1, tui.AttrClear, -1, nil}} if item.colors != nil { lastColor := (*item.colors)[len(*item.colors)-1] if lastColor.offset[1] < int32(loc[1]) { @@ -2668,12 +2668,21 @@ Loop: var fillRet tui.FillReturn prefixWidth := 0 + var url *url _, _, ansi = extractColor(line, ansi, func(str string, ansi *ansiState) bool { trimmed := []rune(str) isTrimmed := false if !t.previewOpts.wrap { trimmed, isTrimmed = t.trimRight(trimmed, maxWidth-t.pwindow.X()) } + if url == nil && ansi != nil && ansi.url != nil { + url = ansi.url + t.pwindow.LinkBegin(url.uri, url.params) + } + if url != nil && (ansi == nil || ansi.url == nil) { + url = nil + t.pwindow.LinkEnd() + } str, width := t.processTabs(trimmed, prefixWidth) if width > prefixWidth { prefixWidth = width @@ -2687,6 +2696,9 @@ Loop: return !isTrimmed && (fillRet == tui.FillContinue || t.previewOpts.wrap && fillRet == tui.FillNextLine) }) + if url != nil { + t.pwindow.LinkEnd() + } t.previewer.scrollable = t.previewer.scrollable || t.pwindow.Y() == height-1 && t.pwindow.X() == t.pwindow.Width() if fillRet == tui.FillNextLine { continue diff --git a/src/tui/light.go b/src/tui/light.go index 3ed24f71..187ac667 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -1118,6 +1118,14 @@ func (w *LightWindow) setBg() string { return "\x1b[m" } +func (w *LightWindow) LinkBegin(uri string, params string) { + w.renderer.queued.WriteString("\x1b]8;" + params + ";" + uri + "\x1b\\") +} + +func (w *LightWindow) LinkEnd() { + w.renderer.queued.WriteString("\x1b]8;;\x1b\\") +} + func (w *LightWindow) Fill(text string) FillReturn { w.Move(w.posy, w.posx) code := w.setBg() diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 16ce452d..d80cd58d 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -4,6 +4,7 @@ package tui import ( "os" + "regexp" "time" "github.com/gdamore/tcell/v2" @@ -49,6 +50,8 @@ type TcellWindow struct { lastY int moveCursor bool borderStyle BorderStyle + uri *string + params *string } func (w *TcellWindow) Top() int { @@ -666,6 +669,13 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn { StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). Italic(a&Attr(tcell.AttrItalic) != 0) + if w.uri != nil { + style = style.Url(*w.uri) + if md := regexp.MustCompile(`id=([^:]+)`).FindStringSubmatch(*w.params); len(md) > 1 { + style = style.UrlId(md[1]) + } + } + gr := uniseg.NewGraphemes(text) Loop: for gr.Next() { @@ -716,6 +726,16 @@ func (w *TcellWindow) Fill(str string) FillReturn { return w.fillString(str, w.normal) } +func (w *TcellWindow) LinkBegin(uri string, params string) { + w.uri = &uri + w.params = ¶ms +} + +func (w *TcellWindow) LinkEnd() { + w.uri = nil + w.params = nil +} + func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn { if fg == colDefault { fg = w.normal.Fg() diff --git a/src/tui/tui.go b/src/tui/tui.go index f3e58f41..240f7ce7 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -564,6 +564,8 @@ type Window interface { CPrint(color ColorPair, text string) Fill(text string) FillReturn CFill(fg Color, bg Color, attr Attr, text string) FillReturn + LinkBegin(uri string, params string) + LinkEnd() Erase() EraseMaybe() bool }