(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
pull/3495/head
Junegunn Choi 7 months ago
parent bac385b59c
commit d02b9442a5
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627

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

@ -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

@ -592,34 +592,12 @@ e.g.
sleep 0.01 sleep 0.01
done'\fR done'\fR
Since 0.43.0, fzf has experimental support for Kitty graphics protocol, fzf has experimental support for Kitty graphics protocol and Sixel graphics.
so if you use Kitty, you can make fzf display an image in the preview window. 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. e.g.
\fBfzf --preview=' \fBfzf --preview='fzf-preview.sh {}'
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
.RE .RE

@ -219,7 +219,6 @@ type previewOpts struct {
scroll string scroll string
hidden bool hidden bool
wrap bool wrap bool
clear bool
cycle bool cycle bool
follow bool follow bool
border tui.BorderShape border tui.BorderShape
@ -341,7 +340,7 @@ type Options struct {
} }
func defaultPreviewOpts(command string) previewOpts { 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 { func defaultOptions() *Options {
@ -1455,10 +1454,6 @@ func parsePreviewWindowImpl(opts *previewOpts, input string, exit func(string))
opts.wrap = true opts.wrap = true
case "nowrap": case "nowrap":
opts.wrap = false opts.wrap = false
case "clear":
opts.clear = true
case "noclear":
opts.clear = false
case "cycle": case "cycle":
opts.cycle = true opts.cycle = true
case "nocycle": case "nocycle":
@ -1793,7 +1788,7 @@ func parseOptions(opts *Options, allArgs []string) {
opts.Preview.command = "" opts.Preview.command = ""
case "--preview-window": case "--preview-window":
parsePreviewWindow(&opts.Preview, 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": case "--height":
opts.Height = parseHeight(nextString(allArgs, &i, "height required: [~]HEIGHT[%]")) opts.Height = parseHeight(nextString(allArgs, &i, "height required: [~]HEIGHT[%]"))
case "--min-height": case "--min-height":

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

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

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

@ -110,10 +110,10 @@ func (r *LightRenderer) getch(nonblock bool) (int, bool) {
return int(b[0]), true 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) ws, err := unix.IoctlGetWinsize(int(r.ttyin.Fd()), unix.TIOCGWINSZ)
if err != nil { 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)}
} }

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

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

Loading…
Cancel
Save