Add support for hyperlinks in preview window

Close #2165
This commit is contained in:
Junegunn Choi 2024-08-14 08:43:27 +09:00
parent 2c8a96bb27
commit d90a969c00
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
6 changed files with 80 additions and 10 deletions

View File

@ -1,6 +1,7 @@
package fzf package fzf
import ( import (
"fmt"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -13,22 +14,28 @@ type ansiOffset struct {
color ansiState color ansiState
} }
type url struct {
uri string
params string
}
type ansiState struct { type ansiState struct {
fg tui.Color fg tui.Color
bg tui.Color bg tui.Color
attr tui.Attr attr tui.Attr
lbg tui.Color lbg tui.Color
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 return s.fg != -1 || s.bg != -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 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 { func (s *ansiState) ToString() string {
@ -60,7 +67,11 @@ func (s *ansiState) ToString() string {
} }
ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40) 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 { func toAnsiString(color tui.Color, offset int) string {
@ -98,10 +109,19 @@ func matchOperatingSystemCommand(s string) int {
if s[i] == '\x07' { if s[i] == '\x07' {
return i + 1 return i + 1
} }
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------
if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' { if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' {
return i + 2 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 return -1
} }
@ -328,13 +348,21 @@ func parseAnsiCode(s string, delimiter byte) (int, byte, string) {
func interpretCode(ansiCode string, prevState *ansiState) ansiState { func interpretCode(ansiCode string, prevState *ansiState) ansiState {
var state ansiState var state ansiState
if prevState == nil { if prevState == nil {
state = ansiState{-1, -1, 0, -1} state = ansiState{-1, -1, 0, -1, nil}
} else { } 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 ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' {
if prevState != nil && strings.HasSuffix(ansiCode, "0K") { if prevState != nil && strings.HasSuffix(ansiCode, "0K") {
state.lbg = prevState.bg 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 return state
} }

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}}, {[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1}}, {[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, 0, -1}}, {[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1}}}}} {[2]int32{33, 40}, ansiState{4, 8, 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)

View File

@ -1068,7 +1068,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{-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 { 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]) {
@ -2668,12 +2668,21 @@ Loop:
var fillRet tui.FillReturn var fillRet tui.FillReturn
prefixWidth := 0 prefixWidth := 0
var url *url
_, _, ansi = extractColor(line, ansi, func(str string, ansi *ansiState) bool { _, _, ansi = extractColor(line, ansi, func(str string, ansi *ansiState) bool {
trimmed := []rune(str) trimmed := []rune(str)
isTrimmed := false isTrimmed := false
if !t.previewOpts.wrap { if !t.previewOpts.wrap {
trimmed, isTrimmed = t.trimRight(trimmed, maxWidth-t.pwindow.X()) 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) str, width := t.processTabs(trimmed, prefixWidth)
if width > prefixWidth { if width > prefixWidth {
prefixWidth = width prefixWidth = width
@ -2687,6 +2696,9 @@ Loop:
return !isTrimmed && return !isTrimmed &&
(fillRet == tui.FillContinue || t.previewOpts.wrap && fillRet == tui.FillNextLine) (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() t.previewer.scrollable = t.previewer.scrollable || t.pwindow.Y() == height-1 && t.pwindow.X() == t.pwindow.Width()
if fillRet == tui.FillNextLine { if fillRet == tui.FillNextLine {
continue continue

View File

@ -1118,6 +1118,14 @@ func (w *LightWindow) setBg() string {
return "\x1b[m" 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 { func (w *LightWindow) Fill(text string) FillReturn {
w.Move(w.posy, w.posx) w.Move(w.posy, w.posx)
code := w.setBg() code := w.setBg()

View File

@ -4,6 +4,7 @@ package tui
import ( import (
"os" "os"
"regexp"
"time" "time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@ -49,6 +50,8 @@ type TcellWindow struct {
lastY int lastY int
moveCursor bool moveCursor bool
borderStyle BorderStyle borderStyle BorderStyle
uri *string
params *string
} }
func (w *TcellWindow) Top() int { func (w *TcellWindow) Top() int {
@ -666,6 +669,13 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
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 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) gr := uniseg.NewGraphemes(text)
Loop: Loop:
for gr.Next() { for gr.Next() {
@ -716,6 +726,16 @@ func (w *TcellWindow) Fill(str string) FillReturn {
return w.fillString(str, w.normal) return w.fillString(str, w.normal)
} }
func (w *TcellWindow) LinkBegin(uri string, params string) {
w.uri = &uri
w.params = &params
}
func (w *TcellWindow) LinkEnd() {
w.uri = 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, a Attr, str string) FillReturn {
if fg == colDefault { if fg == colDefault {
fg = w.normal.Fg() fg = w.normal.Fg()

View File

@ -564,6 +564,8 @@ type Window interface {
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, attr Attr, text string) FillReturn
LinkBegin(uri string, params string)
LinkEnd()
Erase() Erase()
EraseMaybe() bool EraseMaybe() bool
} }