diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa95437..706fda9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bin/fzf-preview.sh b/bin/fzf-preview.sh new file mode 100755 index 00000000..82c0b25b --- /dev/null +++ b/bin/fzf-preview.sh @@ -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 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 735fa7f6..8666ec02 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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 diff --git a/src/options.go b/src/options.go index 83258820..b4f74e95 100644 --- a/src/options.go +++ b/src/options.go @@ -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": diff --git a/src/terminal.go b/src/terminal.go index ab385e2d..504f4fd1 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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 diff --git a/src/tui/dummy.go b/src/tui/dummy.go index d893a747..13c2aeec 100644 --- a/src/tui/dummy.go +++ b/src/tui/dummy.go @@ -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 } diff --git a/src/tui/light.go b/src/tui/light.go index 6b7eaaf4..e5080d1d 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -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) } diff --git a/src/tui/light_unix.go b/src/tui/light_unix.go index 4ca847b4..46188869 100644 --- a/src/tui/light_unix.go +++ b/src/tui/light_unix.go @@ -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)} } diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 54feaf16..cd723e32 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -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 { diff --git a/src/tui/tui.go b/src/tui/tui.go index 69ae8a1a..2ebb5e72 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -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 }