(Experimental) Improve Sixel graphics support (#2544)

Progress:

* Sixel image can now be displayed with other text, and is scrollable
* If an image can't be displayed entirely due to the scroll offset, fzf
  will render a wireframe to indicate that an image should be displayed
* Renamed $FZF_PREVIEW_{WIDTH,HEIGHT} to $FZF_PREVIEW_PIXEL_{WIDTH,HEIGHT}
  for clarity
* Added bin/fzf-preview.sh script to demonstrate how to display an image
  using Kitty or Sixel protocol

An example:

  ls *.jpg | fzf --preview='seq $((FZF_PREVIEW_LINES*9/10)); fzf-preview.sh {}; seq 100'

A known issue:

* If you reduce the size of the preview window, the image may extend
  beyond the preview window
This commit is contained in:
Junegunn Choi 2023-10-26 00:22:28 +09:00
parent bac385b59c
commit d02b9442a5
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
10 changed files with 169 additions and 93 deletions

View File

@ -3,27 +3,21 @@ CHANGELOG
0.43.1
------
- (Experimental) Added support for Sixel graphics in the preview window
```sh
# 1. $FZF_PREVIEW_WIDTH and $FZF_PREVIEW_HEIGHT will be set to the pixel width
# and height of the preview window
# 2. Special preview window flag 'clear' is added to always completely
# erase the preview window. This is similar to https://github.com/vifm/vifm/issues/588.
fzf --preview='
if file --mime-type {} | grep -qvF image/; then
bat --color=always {}
elif [[ -n $FZF_PREVIEW_WIDTH ]]; then
convert {} -resize ${FZF_PREVIEW_WIDTH}x${FZF_PREVIEW_HEIGHT} sixel:-
else
echo "Cannot display image data (unsupported platform)"
fi
' --preview-window clear
```
- (Experimental) Sixel image support in preview window (not available on Windows)
- `$FZF_PREVIEW_PIXEL_WIDTH` and `$FZF_PREVIEW_PIXEL_HEIGHT` are set to
the pixel width and height of the preview window
- [bin/fzf-preview.sh](bin/fzf-preview.sh) is added to demonstrate how to
display an image using Kitty image protocol or Sixel. You can use it
like so:
```sh
fzf --preview='fzf-preview.sh {}'
```
- Bug fixes
0.43.0
------
- (Experimental) Added support for Kitty image protocol in the preview window
(not available on Windows)
```sh
fzf --preview='
if file --mime-type {} | grep -qF image/; then

30
bin/fzf-preview.sh Executable file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# The purpose of this script is to demonstrate how to preview a file or an
# image in the preview window of fzf.
file=$1
type=$(file --mime-type "$file")
if [[ ! $type =~ image/ ]]; then
# Sometimes bat is installed as batcat.
if command -v batcat > /dev/null; then
batname="batcat"
elif command -v bat > /dev/null; then
batname="bat"
else
cat "$1"
exit
fi
${batname} --style="${BAT_STYLE:-numbers}" --color=always --pager=never -- "$file"
elif [[ $KITTY_WINDOW_ID ]]; then
# 'memory' is the fastest option but if you want the image to be scrollable,
# you have to use 'stream'
kitty icat --clear --transfer-mode=memory --stdin=no --place="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}@0x0" "$file" | sed \$d
echo -en "\e[m"
elif [[ -n $FZF_PREVIEW_PIXEL_WIDTH ]]; then
convert "$file" -resize "${FZF_PREVIEW_PIXEL_WIDTH}x${FZF_PREVIEW_PIXEL_HEIGHT}>" -dither FloydSteinberg sixel:-
else
file "$file"
fi

View File

@ -592,34 +592,12 @@ e.g.
sleep 0.01
done'\fR
Since 0.43.0, fzf has experimental support for Kitty graphics protocol,
so if you use Kitty, you can make fzf display an image in the preview window.
fzf has experimental support for Kitty graphics protocol and Sixel graphics.
The following example uses https://github.com/junegunn/fzf/blob/master/bin/fzf-preview.sh
script to render an image using either of the protocols inside the preview window.
e.g.
\fBfzf --preview='
if file --mime-type {} | grep -qF "image/"; then
kitty icat --clear --transfer-mode=memory --stdin=no --place=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}@0x0 {} | sed \\$d
else
bat --color=always {}
fi
'\fR
fzf also has experimental support for Sixel graphics.
e.g.
\fB# 1. $FZF_PREVIEW_WIDTH and $FZF_PREVIEW_HEIGHT will be set to
# the pixel width and height of the preview window
# 2. Special preview window flag 'clear' is needed to always completely
# erase the preview window
fzf --preview='
if file --mime-type {} | grep -qvF image/; then
bat --color=always {}
elif [[ -n $FZF_PREVIEW_WIDTH ]]; then
convert {} -resize ${FZF_PREVIEW_WIDTH}x${FZF_PREVIEW_HEIGHT} sixel:-
else
echo "Cannot display image data (unsupported platform)"
fi
' --preview-window clear\fR
\fBfzf --preview='fzf-preview.sh {}'
.RE

View File

@ -219,7 +219,6 @@ type previewOpts struct {
scroll string
hidden bool
wrap bool
clear bool
cycle bool
follow bool
border tui.BorderShape
@ -341,7 +340,7 @@ type Options struct {
}
func defaultPreviewOpts(command string) previewOpts {
return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, false, tui.DefaultBorderShape, 0, 0, nil}
return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, tui.DefaultBorderShape, 0, 0, nil}
}
func defaultOptions() *Options {
@ -1455,10 +1454,6 @@ func parsePreviewWindowImpl(opts *previewOpts, input string, exit func(string))
opts.wrap = true
case "nowrap":
opts.wrap = false
case "clear":
opts.clear = true
case "noclear":
opts.clear = false
case "cycle":
opts.cycle = true
case "nocycle":
@ -1793,7 +1788,7 @@ func parseOptions(opts *Options, allArgs []string) {
opts.Preview.command = ""
case "--preview-window":
parsePreviewWindow(&opts.Preview,
nextString(allArgs, &i, "preview window layout required: [up|down|left|right][,SIZE[%]][,border-BORDER_OPT][,wrap][,clear][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]"))
nextString(allArgs, &i, "preview window layout required: [up|down|left|right][,SIZE[%]][,border-BORDER_OPT][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]"))
case "--height":
opts.Height = parseHeight(nextString(allArgs, &i, "height required: [~]HEIGHT[%]"))
case "--min-height":

View File

@ -121,10 +121,12 @@ type previewer struct {
}
type previewed struct {
version int64
numLines int
offset int
filled bool
version int64
numLines int
offset int
filled bool
wipe bool
wireframe bool
}
type eachLine struct {
@ -278,6 +280,7 @@ type Terminal struct {
theme *tui.ColorTheme
tui tui.Renderer
executing *util.AtomicBool
termSize tui.TermSize
}
type selectedItem struct {
@ -308,6 +311,7 @@ const (
reqRefresh
reqReinit
reqFullRedraw
reqResize
reqRedrawBorderLabel
reqRedrawPreviewLabel
reqClose
@ -447,7 +451,7 @@ type searchRequest struct {
type previewRequest struct {
template string
pwindow tui.Window
pwindowSize tui.TermSize
scrollOffset int
list []*Item
}
@ -687,7 +691,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
initialPreviewOpts: opts.Preview,
previewOpts: opts.Preview,
previewer: previewer{0, []string{}, 0, false, true, disabledState, "", []bool{}},
previewed: previewed{0, 0, 0, false},
previewed: previewed{0, 0, 0, false, false, false},
previewBox: previewBox,
eventBox: eventBox,
mutex: sync.Mutex{},
@ -1930,7 +1934,7 @@ func (t *Terminal) renderPreviewSpinner() {
}
func (t *Terminal) renderPreviewArea(unchanged bool) {
if t.previewOpts.clear {
if t.previewed.wipe && t.previewed.version != t.previewer.version {
t.pwindow.Erase()
} else if unchanged {
t.pwindow.MoveAndClear(0, 0) // Clear scroll offset display
@ -1951,15 +1955,11 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
body = t.previewer.lines[headerLines:]
// Always redraw header
t.renderPreviewText(height, header, 0, false)
if t.previewOpts.clear {
t.pwindow.Move(t.pwindow.Y(), 0)
} else {
t.pwindow.MoveAndClear(t.pwindow.Y(), 0)
}
t.pwindow.MoveAndClear(t.pwindow.Y(), 0)
}
t.renderPreviewText(height, body, -t.previewer.offset+headerLines, unchanged)
if !unchanged && !t.previewOpts.clear {
if !unchanged {
t.pwindow.FinishFill()
}
@ -1972,10 +1972,29 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
t.renderPreviewScrollbar(headerLines, barLength, barStart)
}
func (t *Terminal) makeImageBorder(width int, top bool) string {
tl := "┌"
tr := "┐"
v := "╎"
h := "╌"
if !t.unicode {
tl = "+"
tr = "+"
h = "-"
v = "|"
}
repeat := util.Max(0, width-2)
if top {
return tl + strings.Repeat(h, repeat) + tr
}
return v + strings.Repeat(" ", repeat) + v
}
func (t *Terminal) renderPreviewText(height int, lines []string, lineNo int, unchanged bool) {
maxWidth := t.pwindow.Width()
var ansi *ansiState
spinnerRedraw := t.pwindow.Y() == 0
Loop:
for _, line := range lines {
var lbg tui.Color = -1
if ansi != nil {
@ -1993,16 +2012,59 @@ func (t *Terminal) renderPreviewText(height int, lines []string, lineNo int, unc
t.previewer.scrollable = true
break
} else if lineNo >= 0 {
x := t.pwindow.X()
y := t.pwindow.Y()
if spinnerRedraw && lineNo > 0 {
spinnerRedraw = false
y := t.pwindow.Y()
x := t.pwindow.X()
t.renderPreviewSpinner()
t.pwindow.Move(y, x)
}
for _, passThrough := range passThroughs {
// Handling Sixel output
requiredLines := 0
if strings.HasPrefix(passThrough, "\x1bP") {
t.previewed.wipe = true
if t.termSize.PxHeight > 0 {
rows := util.Max(0, strings.Count(passThrough, "-")-1)
requiredLines = int(math.Ceil(float64(rows*6*t.termSize.Lines) / float64(t.termSize.PxHeight)))
}
}
// Overflow
if requiredLines > 0 && y+requiredLines > height {
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))
top = false
}
t.previewed.wireframe = true
t.previewed.filled = true
t.previewer.scrollable = true
continue
}
if t.previewed.wireframe {
t.previewed.wireframe = false
for i := y + 1; i < height; i++ {
t.pwindow.MoveAndClear(i, 0)
}
}
t.pwindow.MoveAndClear(y, x)
t.tui.PassThrough(passThrough)
if requiredLines > 0 {
if y+requiredLines == height {
t.pwindow.Move(y+requiredLines, 0)
t.previewed.filled = true
t.previewer.scrollable = true
break Loop
} else {
t.pwindow.MoveAndClear(y+requiredLines, 0)
}
}
}
if len(passThroughs) > 0 && len(line) == 0 {
continue
}
@ -2100,6 +2162,7 @@ func (t *Terminal) printPreview() {
t.previewed.numLines = numLines
t.previewed.version = t.previewer.version
t.previewed.offset = t.previewer.offset
t.previewed.wipe = false
}
func (t *Terminal) printPreviewDelayed() {
@ -2580,6 +2643,19 @@ func (t *Terminal) cancelPreview() {
t.killPreview(exitCancel)
}
func (t *Terminal) pwindowSize() tui.TermSize {
if t.pwindow == nil {
return tui.TermSize{}
}
size := tui.TermSize{Lines: t.pwindow.Height(), Columns: t.pwindow.Width()}
if t.termSize.PxWidth > 0 {
size.PxWidth = size.Columns * t.termSize.PxWidth / t.termSize.Columns
size.PxHeight = size.Lines * t.termSize.PxHeight / t.termSize.Lines
}
return size
}
// Loop is called to start Terminal I/O
func (t *Terminal) Loop() {
// prof := profile.Start(profile.ProfilePath("/tmp/"))
@ -2631,12 +2707,13 @@ func (t *Terminal) Loop() {
go func() {
for {
<-resizeChan
t.reqBox.Set(reqFullRedraw, nil)
t.reqBox.Set(reqResize, nil)
}
}()
t.mutex.Lock()
t.initFunc()
t.termSize = t.tui.Size()
t.resizeWindows(false)
t.printPrompt()
t.printInfo()
@ -2669,7 +2746,7 @@ func (t *Terminal) Loop() {
for {
var items []*Item
var commandTemplate string
var pwindow tui.Window
var pwindowSize tui.TermSize
initialOffset := 0
t.previewBox.Wait(func(events *util.Events) {
for req, value := range *events {
@ -2679,7 +2756,7 @@ func (t *Terminal) Loop() {
commandTemplate = request.template
initialOffset = request.scrollOffset
items = request.list
pwindow = request.pwindow
pwindowSize = request.pwindowSize
}
}
events.Clear()
@ -2691,18 +2768,16 @@ func (t *Terminal) Loop() {
command := t.replacePlaceholder(commandTemplate, false, string(query), items)
cmd := util.ExecCommand(command, true)
env := t.environ()
if pwindow != nil {
height := pwindow.Height()
lines := fmt.Sprintf("LINES=%d", height)
columns := fmt.Sprintf("COLUMNS=%d", pwindow.Width())
if pwindowSize.Lines > 0 {
lines := fmt.Sprintf("LINES=%d", pwindowSize.Lines)
columns := fmt.Sprintf("COLUMNS=%d", pwindowSize.Columns)
env = append(env, lines)
env = append(env, "FZF_PREVIEW_"+lines)
env = append(env, columns)
env = append(env, "FZF_PREVIEW_"+columns)
size, err := t.tui.Size()
if err == nil {
env = append(env, fmt.Sprintf("FZF_PREVIEW_WIDTH=%d", pwindow.Width()*size.Width/size.Columns))
env = append(env, fmt.Sprintf("FZF_PREVIEW_HEIGHT=%d", height*size.Height/size.Lines))
if pwindowSize.PxWidth > 0 {
env = append(env, fmt.Sprintf("FZF_PREVIEW_PIXEL_WIDTH=%d", pwindowSize.PxWidth))
env = append(env, fmt.Sprintf("FZF_PREVIEW_PIXEL_HEIGHT=%d", pwindowSize.PxHeight))
}
}
cmd.Env = env
@ -2831,7 +2906,7 @@ func (t *Terminal) Loop() {
if len(command) > 0 && t.canPreview() {
_, list := t.buildPlusList(command, false)
t.cancelPreview()
t.previewBox.Set(reqPreviewEnqueue, previewRequest{command, t.pwindow, t.evaluateScrollOffset(), list})
t.previewBox.Set(reqPreviewEnqueue, previewRequest{command, t.pwindowSize(), t.evaluateScrollOffset(), list})
}
}
@ -2899,7 +2974,10 @@ func (t *Terminal) Loop() {
case reqReinit:
t.tui.Resume(t.fullscreen, t.sigstop)
t.redraw()
case reqFullRedraw:
case reqResize, reqFullRedraw:
if req == reqResize {
t.termSize = t.tui.Size()
}
wasHidden := t.pwindow == nil
t.redraw()
if wasHidden && t.hasPreviewWindow() {
@ -3116,7 +3194,7 @@ func (t *Terminal) Loop() {
if valid {
t.cancelPreview()
t.previewBox.Set(reqPreviewEnqueue,
previewRequest{t.previewOpts.command, t.pwindow, t.evaluateScrollOffset(), list})
previewRequest{t.previewOpts.command, t.pwindowSize(), t.evaluateScrollOffset(), list})
}
} else {
// Discard the preview content so that it won't accidentally appear

View File

@ -38,9 +38,7 @@ func (r *FullscreenRenderer) Clear() {}
func (r *FullscreenRenderer) NeedScrollbarRedraw() bool { return false }
func (r *FullscreenRenderer) Refresh() {}
func (r *FullscreenRenderer) Close() {}
func (r *FullscreenRenderer) Size() (termSize, error) {
return termSize{}, nil
}
func (r *FullscreenRenderer) Size() TermSize { return TermSize{} }
func (r *FullscreenRenderer) GetChar() Event { return Event{} }
func (r *FullscreenRenderer) MaxX() int { return 0 }

View File

@ -1092,7 +1092,9 @@ func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillRetu
}
func (w *LightWindow) FinishFill() {
w.MoveAndClear(w.posy, w.posx)
if w.posy < w.height {
w.MoveAndClear(w.posy, w.posx)
}
for y := w.posy + 1; y < w.height; y++ {
w.MoveAndClear(y, 0)
}

View File

@ -110,10 +110,10 @@ func (r *LightRenderer) getch(nonblock bool) (int, bool) {
return int(b[0]), true
}
func (r *LightRenderer) Size() (termSize, error) {
func (r *LightRenderer) Size() TermSize {
ws, err := unix.IoctlGetWinsize(int(r.ttyin.Fd()), unix.TIOCGWINSZ)
if err != nil {
return termSize{}, err
return TermSize{}
}
return termSize{int(ws.Row), int(ws.Col), int(ws.Xpixel), int(ws.Ypixel)}, nil
return TermSize{int(ws.Row), int(ws.Col), int(ws.Xpixel), int(ws.Ypixel)}
}

View File

@ -203,9 +203,10 @@ func (r *FullscreenRenderer) Refresh() {
// noop
}
func (r *FullscreenRenderer) Size() (termSize, error) {
// TODO: Pixel width and height not implemented
func (r *FullscreenRenderer) Size() TermSize {
cols, lines := _screen.Size()
return termSize{lines, cols, 0, 0}, error("Not implemented")
return TermSize{lines, cols, 0, 0}
}
func (r *FullscreenRenderer) GetChar() Event {

View File

@ -473,11 +473,11 @@ func MakeTransparentBorder() BorderStyle {
bottomRight: ' '}
}
type termSize struct {
Lines int
Columns int
Width int
Height int
type TermSize struct {
Lines int
Columns int
PxWidth int
PxHeight int
}
type Renderer interface {
@ -497,7 +497,7 @@ type Renderer interface {
MaxX() int
MaxY() int
Size() (termSize, error)
Size() TermSize
NewWindow(top int, left int, width int, height int, preview bool, borderStyle BorderStyle) Window
}