From 677e854850a70b7243fd35703d4f03b6651961b3 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 23 May 2026 10:32:19 +0900 Subject: [PATCH] Add --preview-window=next position (#4801) Places preview adjacent to input on the list side: above input in the default layout, below it in --layout=reverse. fzf --preview 'cat {}' --preview-window=next Close #4798 --- CHANGELOG.md | 1 + man/man1/fzf.1 | 5 + src/options.go | 17 ++- src/terminal.go | 311 +++++++++++++++++++++++++++++--------------- test/test_layout.rb | 235 ++++++++++++++++++++++++--------- 5 files changed, 397 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc0700b..0cd8bea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ CHANGELOG else echo accept fi' ``` +- New `--preview-window=next` position that places the preview adjacent to the input section, on the list side: above the input in the default layout, below it in `--layout=reverse` (#4798). - Bug fixes - `change-preview-window` no longer resets `wrap` / `wrap-word` state set via `toggle-preview-wrap` / `toggle-preview-wrap-word`. Layout fields still snap to the preset, so cycling and the empty-token reset behave as before. The new spec can still override by including `wrap` or `nowrap` explicitly. (#4791) diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index ad5c83c3..93f157fe 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -993,9 +993,14 @@ border line. \fBdown \fBleft \fBright + \fBnext \fRDetermines the layout of the preview window. +* \fBnext\fR places the preview window adjacent to the input section, on +the list side: above the input in the default layout, below the input +in \fB\-\-layout=reverse\fR. + * If the argument contains \fB:hidden\fR, the preview window will be hidden by default until \fBtoggle\-preview\fR action is triggered. diff --git a/src/options.go b/src/options.go index 0ebca52b..cbf58f58 100644 --- a/src/options.go +++ b/src/options.go @@ -160,7 +160,7 @@ Usage: fzf [options] PREVIEW WINDOW --preview=COMMAND Command to preview highlighted line ({}) --preview-window=OPT Preview window layout (default: right:50%) - [up|down|left|right][,SIZE[%]] + [up|down|left|right|next][,SIZE[%]] [,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info] [,[no]hidden][,border-STYLE] [,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES] @@ -332,6 +332,7 @@ const ( posLeft posRight posCenter + posNext // adjacent to the input section, on the list side ) type tmuxOptions struct { @@ -391,7 +392,7 @@ func (o *previewOpts) Toggle() { o.hidden = !o.hidden } -func (o *previewOpts) Border() tui.BorderShape { +func (o *previewOpts) Border(layout layoutType) tui.BorderShape { shape := o.border if shape == tui.BorderLine { switch o.position { @@ -403,6 +404,12 @@ func (o *previewOpts) Border() tui.BorderShape { shape = tui.BorderRight case posRight: shape = tui.BorderLeft + case posNext: + if layout == layoutReverse { + shape = tui.BorderBottom + } else { + shape = tui.BorderTop + } } } return shape @@ -512,7 +519,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error { } func (a previewOpts) aboveOrBelow() bool { - return a.size.size > 0 && (a.position == posUp || a.position == posDown) + return a.size.size > 0 && (a.position == posUp || a.position == posDown || a.position == posNext) } type previewOptsCompare int @@ -2352,6 +2359,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error { opts.position = posLeft case "right": opts.position = posRight + case "next": + opts.position = posNext case "rounded", "border", "border-rounded": opts.border = tui.BorderRounded case "border-line": @@ -3158,7 +3167,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { case "--no-preview": opts.Preview.command = "" case "--preview-window": - str, err := nextString("preview window layout required: [up|down|left|right][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]") + str, err := nextString("preview window layout required: [up|down|left|right|next][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]") if err != nil { return err } diff --git a/src/terminal.go b/src/terminal.go index db4ca9e7..2a11ea37 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -988,7 +988,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor // Minimum height required to render fzf excluding margin and padding effectiveMinHeight := minHeight if previewBox != nil && opts.Preview.aboveOrBelow() { - effectiveMinHeight += 1 + borderLines(opts.Preview.Border()) + effectiveMinHeight += 1 + borderLines(opts.Preview.Border(opts.Layout)) } if opts.noSeparatorLine() { effectiveMinHeight-- @@ -1652,12 +1652,11 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) { wrap := t.wrap t.wrap = false t.withWindow(t.inputWindow, func() { - line := t.promptLine() preTask := func(markerClass) int { return 1 } t.printHighlighted( - Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, false, line, line, true, preTask, nil, 0) + Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, false, 0, 0, true, preTask, nil, 0) }) t.wrap = wrap } @@ -2105,12 +2104,13 @@ func calculateSize(base int, size sizeSpec, occupied int, minSize int) int { } func (t *Terminal) minPreviewSize(opts *previewOpts) (int, int) { - minPreviewWidth := 1 + borderColumns(opts.Border(), t.borderWidth) - minPreviewHeight := 1 + borderLines(opts.Border()) + border := opts.Border(t.layout) + minPreviewWidth := 1 + borderColumns(border, t.borderWidth) + minPreviewHeight := 1 + borderLines(border) switch opts.position { case posLeft, posRight: - if len(t.scrollbar) > 0 && !opts.Border().HasRight() { + if len(t.scrollbar) > 0 && !border.HasRight() { // Need a column to show scrollbar minPreviewWidth++ } @@ -2195,7 +2195,7 @@ func (t *Terminal) adjustMarginAndPadding() (int, int, [4]int, [4]int) { if t.needPreviewWindow() { minPreviewWidth, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts) switch t.activePreviewOpts.position { - case posUp, posDown: + case posUp, posDown, posNext: minAreaHeight += minPreviewHeight minAreaWidth = max(minPreviewWidth, minAreaWidth) case posLeft, posRight: @@ -2220,7 +2220,7 @@ func (t *Terminal) hasHeaderWindow() bool { if t.hasHeaderLinesWindow() { return len(t.header0) > 0 } - if t.headerBorderShape.Visible() { + if t.headerBorderShape.Visible() || t.headerFirst { return len(t.header0)+t.headerLines > 0 } return t.inputBorderShape.Visible() @@ -2258,6 +2258,9 @@ func (t *Terminal) determineHeaderLinesShape() (bool, tui.BorderShape) { // Use header window instead if len(t.header0) == 0 { + if t.headerFirst && shape == tui.BorderPhantom { + return true, shape + } return false, t.headerBorderShape } @@ -2450,7 +2453,56 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { hasHeaderWindow := t.hasHeaderWindow() hasFooterWindow := len(t.footer) > 0 hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape() - hasInputWindow := !t.inputless && (t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow) + // computePreviewSize returns the size resizePreviewWindows will compute + // for opts and the minimum size for that axis: height/minPreviewHeight + // for vertical positions, width/minPreviewWidth for horizontal. + computePreviewSize := func(opts *previewOpts) (int, int) { + minPreviewWidth, minPreviewHeight := t.minPreviewSize(opts) + switch opts.position { + case posUp, posDown, posNext: + minWindowHeight := minHeight + if t.inputless { + minWindowHeight-- + } + if t.noSeparatorLine() { + minWindowHeight-- + } + return calculateSize(height, opts.size, minWindowHeight, minPreviewHeight), minPreviewHeight + case posLeft, posRight: + minListWidth := minWidth + if t.listBorderShape.HasLeft() { + minListWidth += 2 + } + if t.listBorderShape.HasRight() { + minListWidth++ + } + return calculateSize(width, opts.size, minListWidth, minPreviewWidth), minPreviewWidth + } + return 0, 0 + } + // Walk the threshold chain to determine the previewOpts that + // resizePreviewWindows will actually settle on. We need this here + // because hasInputWindow and the availableLines adjustment below run + // before resizePreviewWindows, and t.activePreviewOpts still holds the + // previous frame's resolution. + effectivePreviewOpts := &t.previewOpts + if t.needPreviewWindow() { + opts := &t.previewOpts + for { + if opts.size.size == 0 || opts.threshold <= 0 || opts.alternative == nil { + break + } + if actual, _ := computePreviewSize(opts); actual >= opts.threshold { + break + } + opts = opts.alternative + if opts.hidden { + break + } + } + effectivePreviewOpts = opts + } + hasInputWindow := !t.inputless && (t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow || effectivePreviewOpts.position == posNext) inputWindowHeight := 2 if t.noSeparatorLine() { inputWindowHeight-- @@ -2470,9 +2522,9 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { // FIXME: Needed? if t.needPreviewWindow() { - _, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts) - switch t.activePreviewOpts.position { - case posUp, posDown: + switch effectivePreviewOpts.position { + case posUp, posDown, posNext: + _, minPreviewHeight := t.minPreviewSize(effectivePreviewOpts) availableLines -= minPreviewHeight } } @@ -2624,6 +2676,50 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { // Set up preview window noBorder := tui.MakeBorderStyle(tui.BorderNone, t.unicode) cleanLeft := []int{} + // previewNextSize is pheight when the preview is placed adjacent to + // the input (position == "next"); inputBorderTop() reads it through the + // closure to push input past the preview band. + previewNextSize := 0 + // inputBorderTop returns the canvas Y at which the input border window + // should be placed. It depends on wborder/t.window (set by the preview + // case), the layout, --header-first, and previewNextSize (set when + // posNext is active). Used both for placing the preview adjacent to + // input and later for placing the input window itself. + inputBorderTop := func() int { + w := t.wborder + if w == nil { + w = t.window + } + hasSeparateHeader := hasHeaderWindow && t.headerBorderShape != tui.BorderInline + hasSeparateHeaderLines := hasHeaderLinesWindow && headerLinesShape != tui.BorderInline + if (hasSeparateHeader || hasSeparateHeaderLines) && t.headerFirst { + switch t.layout { + case layoutDefault: + btop := w.Top() + w.Height() + previewNextSize + if hasHeaderWindow && hasHeaderLinesWindow { + btop += headerLinesHeight + } + return btop + case layoutReverse: + btop := w.Top() - inputBorderHeight - previewNextSize + if hasHeaderWindow && hasHeaderLinesWindow { + btop -= headerLinesHeight + } + return btop + case layoutReverseList: + return w.Top() + w.Height() + previewNextSize + } + } + switch t.layout { + case layoutDefault: + return w.Top() + w.Height() + headerBorderHeight + headerLinesHeight + previewNextSize + case layoutReverse: + return w.Top() - shrink + footerBorderHeight - previewNextSize + case layoutReverseList: + return w.Top() + w.Height() + headerBorderHeight + previewNextSize + } + return 0 + } if forcePreview || t.needPreviewWindow() { var resizePreviewWindows func(previewOpts *previewOpts) resizePreviewWindows = func(previewOpts *previewOpts) { @@ -2635,7 +2731,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { createPreviewWindow := func(y int, x int, w int, h int) { pwidth := w pheight := h - shape := previewOpts.Border() + shape := previewOpts.Border(t.layout) previewBorder := tui.MakeBorderStyle(shape, t.unicode) t.pborder = t.tui.NewWindow(y, x, w, h, tui.WindowPreview, previewBorder, false) pwidth -= borderColumns(shape, bw) @@ -2656,17 +2752,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { t.pwindow.Erase() } } - minPreviewWidth, minPreviewHeight := t.minPreviewSize(previewOpts) - switch previewOpts.position { - case posUp, posDown: - minWindowHeight := minHeight - if t.inputless { - minWindowHeight-- - } - if t.noSeparatorLine() { - minWindowHeight-- - } - pheight := calculateSize(height, previewOpts.size, minWindowHeight, minPreviewHeight) + // Shared boilerplate for vertical positions (posUp/posDown/posNext): + // compute pheight, apply the threshold alternative, honor hidden, + // and update listStickToRight. Returns (pheight, true) when the + // caller should return early. + computeVerticalSize := func() (int, bool) { + pheight, minPreviewHeight := computePreviewSize(previewOpts) if hasThreshold && pheight < previewOpts.threshold { t.activePreviewOpts = previewOpts.alternative if forcePreview { @@ -2675,22 +2766,27 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { if !previewOpts.alternative.hidden { resizePreviewWindows(previewOpts.alternative) } - return + return 0, true } if forcePreview { previewOpts.hidden = false } if previewOpts.hidden { - return + return 0, true } - - listStickToRight = listStickToRight && !previewOpts.Border().HasRight() + listStickToRight = listStickToRight && !previewOpts.Border(t.layout).HasRight() if listStickToRight { innerWidth++ width++ } - - pheight = util.Constrain(pheight, minPreviewHeight, availableLines) + return util.Constrain(pheight, minPreviewHeight, availableLines), false + } + switch previewOpts.position { + case posUp, posDown: + pheight, done := computeVerticalSize() + if done { + return + } if previewOpts.position == posUp { innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight) @@ -2703,15 +2799,32 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) createPreviewWindow(marginInt[0]+height-pheight, marginInt[3], width, pheight) } + case posNext: + pheight, done := computeVerticalSize() + if done { + return + } + previewNextSize = pheight + + if t.layout == layoutReverse { + // [(header)][input][preview]([header])[list]: reuse posUp's + // wborder/list math; input is pulled back up by pheight in + // its positioning. Preview sits directly below input. + innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight) + t.window = t.tui.NewWindow( + innerMarginInt[0]+pheight+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) + createPreviewWindow(inputBorderTop()+inputBorderHeight, marginInt[3], width, pheight) + } else { + // [list]([header])[preview][input][(header)]: reuse posDown's + // wborder/list math; input is pushed down by pheight in its + // positioning. Preview sits directly above input. + innerBorderFn(marginInt[0], marginInt[3], width, height-pheight) + t.window = t.tui.NewWindow( + innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) + createPreviewWindow(inputBorderTop()-pheight, marginInt[3], width, pheight) + } case posLeft, posRight: - minListWidth := minWidth - if t.listBorderShape.HasLeft() { - minListWidth += 2 - } - if t.listBorderShape.HasRight() { - minListWidth++ - } - pwidth := calculateSize(width, previewOpts.size, minListWidth, minPreviewWidth) + pwidth, _ := computePreviewSize(previewOpts) if hasThreshold && pwidth < previewOpts.threshold { t.activePreviewOpts = previewOpts.alternative if forcePreview { @@ -2743,7 +2856,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { // Clear characters on the margin // fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1,border-none --footer-border --footer f --header h --header-border - if !previewOpts.Border().HasRight() { + if !previewOpts.Border(t.layout).HasRight() { cleanLeft = append(cleanLeft, -2) } // fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1 --footer-border --footer f --header h --header-border @@ -2758,7 +2871,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { // fzf --preview 'seq 500' --preview-window border-left --border // fzf --preview 'seq 500' --preview-window border-left --border --list-border // fzf --preview 'seq 500' --preview-window border-left --border --input-border - listStickToRight = t.borderShape.HasRight() && !previewOpts.Border().HasRight() + listStickToRight = t.borderShape.HasRight() && !previewOpts.Border(t.layout).HasRight() if listStickToRight { innerWidth++ width++ @@ -2847,37 +2960,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } if hasInputWindow { - var btop int - // Inline sections live inside the list frame, so they don't participate - // in --header-first repositioning; only non-inline sections do. - hasNonInlineHeader := hasHeaderWindow && t.headerBorderShape != tui.BorderInline - hasNonInlineHeaderLines := hasHeaderLinesWindow && headerLinesShape != tui.BorderInline - if (hasNonInlineHeader || hasNonInlineHeaderLines) && t.headerFirst { - switch t.layout { - case layoutDefault: - btop = w.Top() + w.Height() - // If both headers are present, the header lines are displayed with the list - if hasHeaderWindow && hasHeaderLinesWindow { - btop += headerLinesHeight - } - case layoutReverse: - btop = w.Top() - inputBorderHeight - if hasHeaderWindow && hasHeaderLinesWindow { - btop -= headerLinesHeight - } - case layoutReverseList: - btop = w.Top() + w.Height() - } - } else { - switch t.layout { - case layoutDefault: - btop = w.Top() + w.Height() + headerBorderHeight + headerLinesHeight - case layoutReverse: - btop = w.Top() - shrink + footerBorderHeight - case layoutReverseList: - btop = w.Top() + w.Height() + headerBorderHeight - } - } + btop := inputBorderTop() shift := 0 if !t.inputBorderShape.HasLeft() && t.listBorderShape.HasLeft() { shift += t.borderWidth + 1 @@ -2901,11 +2984,11 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { var btop int if hasInputWindow && t.headerFirst { if t.layout == layoutReverse { - btop = w.Top() - shrink + footerBorderHeight + btop = w.Top() - shrink + footerBorderHeight - previewNextSize } else if t.layout == layoutReverseList { - btop = w.Top() + w.Height() + inputBorderHeight + btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize } else { - btop = w.Top() + w.Height() + inputBorderHeight + headerLinesHeight + btop = w.Top() + w.Height() + inputBorderHeight + headerLinesHeight + previewNextSize } } else { if t.layout == layoutReverse { @@ -2936,7 +3019,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { if headerFirst { if t.layout == layoutDefault { - btop = w.Top() + w.Height() + inputBorderHeight + btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize } else if t.layout == layoutReverse { btop = w.Top() - headerLinesHeight - inputBorderHeight } else { @@ -3004,7 +3087,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } t.printLabel(t.wborder, listLabel, t.listLabelOpts, listLabelLen, t.listBorderShape, false) t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, false) - t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(), false) + t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(t.layout), false) t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false) t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, false) t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false) @@ -3113,23 +3196,6 @@ func (t *Terminal) updatePromptOffset() ([]rune, []rune) { return before, after } -func (t *Terminal) promptLine() int { - if t.inputWindow != nil { - return 0 - } - if t.headerFirst { - max := t.window.Height() - 1 - if max <= 0 { // Extremely short terminal - return 0 - } - if !t.noSeparatorLine() { - max-- - } - return min(t.visibleHeaderLinesInList(), max) - } - return 0 -} - func (t *Terminal) placeCursor() { if t.inputless { return @@ -3145,7 +3211,7 @@ func (t *Terminal) placeCursor() { return } x = min(x, t.window.Width()-1) - t.move(t.promptLine(), x, false) + t.move(0, x, false) } func (t *Terminal) printPrompt() { @@ -3199,7 +3265,7 @@ func (t *Terminal) printInfoImpl() { return } pos := 0 - line := t.promptLine() + line := 0 maxHeight := t.window.Height() move := func(y int, x int, clear bool) bool { if y < 0 || y >= maxHeight { @@ -3534,12 +3600,6 @@ func (t *Terminal) headerIndentImpl(base int, borderShape tui.BorderShape) int { func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShape, lines1 []string, lines2 []Item) { max := t.window.Height() - if !t.inputless && t.inputWindow == nil && window == nil && t.headerFirst { - max-- - if !t.noSeparatorLine() { - max-- - } - } var state *ansiState needReverse := false switch t.layout { @@ -4883,11 +4943,11 @@ func (t *Terminal) renderPreviewScrollbar(yoff int, barLength int, barStart int) t.previewer.xw = xw } xshift := -1 - t.borderWidth - if !t.activePreviewOpts.Border().HasRight() { + if !t.activePreviewOpts.Border(t.layout).HasRight() { xshift = -1 } yshift := 1 - if !t.activePreviewOpts.Border().HasTop() { + if !t.activePreviewOpts.Border(t.layout).HasTop() { yshift = 0 } for i := yoff; i < height; i++ { @@ -5864,13 +5924,13 @@ func (t *Terminal) Loop() error { if t.activePreviewOpts.aboveOrBelow() { if t.activePreviewOpts.size.percent { newContentHeight := int(float64(contentHeight) * 100. / (100. - t.activePreviewOpts.size.size)) - contentHeight = max(contentHeight+1+borderLines(t.activePreviewOpts.Border()), newContentHeight) + contentHeight = max(contentHeight+1+borderLines(t.activePreviewOpts.Border(t.layout)), newContentHeight) } else { - contentHeight += int(t.activePreviewOpts.size.size) + borderLines(t.activePreviewOpts.Border()) + contentHeight += int(t.activePreviewOpts.size.size) + borderLines(t.activePreviewOpts.Border(t.layout)) } } else { // Minimum height if preview window can appear - contentHeight = max(contentHeight, 1+borderLines(t.activePreviewOpts.Border())) + contentHeight = max(contentHeight, 1+borderLines(t.activePreviewOpts.Border(t.layout))) } } return min(termHeight, contentHeight+pad) @@ -6252,7 +6312,7 @@ func (t *Terminal) Loop() error { case reqRedrawBorderLabel: t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true) case reqRedrawPreviewLabel: - t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(), true) + t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(t.layout), true) case reqReinit, reqResize, reqFullRedraw, reqRedraw: if req == reqReinit { t.tui.Resume(t.fullscreen, true) @@ -7585,6 +7645,20 @@ func (t *Terminal) Loop() error { } else if t.listBorderShape.HasRight() && t.pborder.EncloseY(my) && mx == t.wborder.Left()+t.wborder.Width()-1 { pborderDragging = 1 } + case posNext: + if t.layout == layoutReverse { + if t.pborder.Enclose(my, mx) && my == t.pborder.Top()+t.pborder.Height()-1 { + pborderDragging = 0 + } else if t.listBorderShape.HasTop() && t.pborder.EncloseX(mx) && my == t.wborder.Top() { + pborderDragging = 1 + } + } else { + if t.pborder.Enclose(my, mx) && my == t.pborder.Top() { + pborderDragging = 0 + } else if t.listBorderShape.HasBottom() && t.pborder.EncloseX(mx) && my == t.wborder.Top()+t.wborder.Height()-1 { + pborderDragging = 1 + } + } } } @@ -7608,6 +7682,27 @@ func (t *Terminal) Loop() error { prevSize = t.pwindow.Width() offset := mx - t.pborder.Left() newSize = prevSize - offset + case posNext: + prevSize = t.pwindow.Height() + // In posNext, header/header-lines sections may sit + // between preview and list. When the list border is + // dragged (pborderDragging == 1), subtract that gap + // so the initial click does not jump. + headerGap := 0 + if pborderDragging == 1 && t.wborder != nil { + if t.layout == layoutReverse { + headerGap = t.wborder.Top() - (t.pborder.Top() + t.pborder.Height()) + } else { + headerGap = t.pborder.Top() - (t.wborder.Top() + t.wborder.Height()) + } + } + if t.layout == layoutReverse { + diff := t.pborder.Height() - prevSize + newSize = my - t.pborder.Top() - diff + 1 - headerGap + } else { + offset := my - t.pborder.Top() + newSize = prevSize - offset - headerGap + } } newSize -= pborderDragging if newSize < 1 { @@ -7740,7 +7835,7 @@ func (t *Terminal) Loop() error { if me.Down { mxCons := util.Constrain(mx-t.promptLen, 0, len(t.input)) - if !t.inputless && t.inputWindow == nil && my == t.promptLine() && mxCons >= 0 { + if !t.inputless && t.inputWindow == nil && my == 0 && mxCons >= 0 { // Prompt t.cx = mxCons + t.xoffset } else if my >= min { diff --git a/test/test_layout.rb b/test/test_layout.rb index fa94087b..3e4f3a7a 100644 --- a/test/test_layout.rb +++ b/test/test_layout.rb @@ -243,6 +243,90 @@ class TestLayout < TestInteractive tmux.until { assert_block(expected, it) } end + def test_preview_window_next_reverse + # https://github.com/junegunn/fzf/issues/4798 + tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter + expected = <<~OUTPUT + line2$ > + 5/5 ─── + ╭──────── + │ PREVIEW + │ + │ + ╰──────── + > 1 + OUTPUT + tmux.until { assert_block(expected, it) } + end + + def test_preview_window_next_default + tmux.send_keys %(seq 5 | #{FZF} --preview 'echo PREVIEW' --preview-window=next:3), :Enter + expected = <<~OUTPUT + > 1 + ╭──────── + │ PREVIEW + │ + │ + ╰──────── + 5/5 ─── + > + OUTPUT + tmux.until { assert_block(expected, it) } + end + + def test_preview_window_next_border_line_at_runtime + # change-preview-window to next,border-line should resolve BorderLine + # to a single horizontal separator, matching the behavior + # when next,border-line is the initial spec. + tmux.send_keys %(seq 5 | #{FZF} --preview 'echo PREVIEW' --bind 'space:change-preview-window:next:3,border-line'), :Enter + tmux.until { |lines| assert_equal 5, lines.match_count } + tmux.send_keys :Space + expected = <<~OUTPUT + > 1 + ─────── + PREVIEW + OUTPUT + tmux.until do |lines| + cursor = lines.index { it.start_with?('> 1') } + assert(cursor) + assert_block(expected, lines[cursor..]) + end + end + + def test_header_first_change_header_at_runtime + # --header-first with no initial --header content needs to grow a + # header window when change-header adds content at runtime, so the + # new header lands below the prompt (not on top of it). + tmux.send_keys %(seq 5 | #{FZF} --header-first --bind 'space:change-header:foo'), :Enter + tmux.until { |lines| assert_equal 5, lines.match_count } + tmux.send_keys :Space + expected = <<~OUTPUT + > + foo + OUTPUT + tmux.until do |lines| + prompt = lines.index { it.start_with?('>') } + assert(prompt) + assert_block(expected, lines[prompt..]) + end + end + + def test_preview_window_next_style_full_line + tmux.send_keys %(seq 5 | #{FZF} --reverse --preview 'echo PREVIEW' --preview-window=next:3 --header foo --footer bar --style full:line), :Enter + expected = <<~OUTPUT + > + ─────── + PREVIEW + + + ─────── + foo + ─────── + > 1 + OUTPUT + tmux.until { assert_block(expected, it) } + end + def test_height_range_overflow tmux.send_keys 'seq 100 | fzf --height ~5 --info=inline --border rounded', :Enter expected = <<~OUTPUT @@ -1227,75 +1311,106 @@ class TestLayout < TestInteractive def test_combinations skip unless ENV['LONGTEST'] - base = [ - '--pointer=@', - '--exact', - '--query=123', - '--header="$(seq 101 103)"', - '--header-lines=3', - '--footer "$(seq 201 203)"', - '--preview "echo foobar"' - ] - options = [ - ['--separator==', '--no-separator'], - ['--info=default', '--info=inline', '--info=inline-right'], - ['--no-input-border', '--input-border'], - ['--no-header-border', '--header-border=none', '--header-border'], - ['--no-header-lines-border', '--header-lines-border'], - ['--no-footer-border', '--footer-border'], - ['--no-list-border', '--list-border'], - ['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left'], - ['--header-first', '--no-header-first'], - ['--layout=default', '--layout=reverse', '--layout=reverse-list'] - ] - # Combination of all options - combinations = options[0].product(*options.drop(1)) - combinations.each_with_index do |combination, index| - opts = base + combination - command = %(seq 1001 2000 | #{FZF} #{opts.join(' ')}) - puts "# #{index + 1}/#{combinations.length}\n#{command}" - tmux.send_keys command, :Enter - tmux.until do |lines| - layout = combination.find { it.start_with?('--layout=') }.split('=').last - header_first = combination.include?('--header-first') + begin + base = [ + '--pointer=@', + '--exact', + '--query=123', + '--header="$(seq 101 103)"', + '--header-lines=3', + '--footer "$(seq 201 203)"', + '--preview "echo foobar"' + ] + options = [ + ['--separator==', '--no-separator'], + ['--info=default', '--info=inline', '--info=inline-right'], + ['--no-input-border', '--input-border'], + ['--no-header-border', '--header-border=none', '--header-border'], + ['--no-header-lines-border', '--header-lines-border'], + ['--no-footer-border', '--footer-border'], + ['--no-list-border', '--list-border'], + ['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left', '--preview-window=next'], + ['--header-first', '--no-header-first'], + ['--layout=default', '--layout=reverse', '--layout=reverse-list'] + ] + # Combination of all options + combinations = options[0].product(*options.drop(1)) - # Input - input = lines.index { it.include?('> 123') } - assert(input) + # Run workers in parallel, each with its own pre-created tmux window. + # Tmux setup/teardown is serialized in the main thread to avoid racing + # `tmux new-window` and `tmux kill-window` calls on the tmux server. + workers = 10 + tmuxes = Array.new(workers) { Tmux.new } + failures = [] + mutex = Mutex.new + queue = Queue.new + index = 0 + threads = tmuxes.map do |local_tmux| + Thread.new do + command = nil + loop do + combination = queue.pop or break - # Info - info = lines.index { it.include?('11/997') } - assert(info) + opts = base + combination + command = %(seq 1001 2000 | #{FZF} #{opts.join(' ')}) + mutex.synchronize do + print("\r#{index += 1}/#{combinations.length}") + end + local_tmux.send_keys command, :Enter + local_tmux.until do |lines| + layout = combination.find { it.start_with?('--layout=') }.split('=').last + header_first = combination.include?('--header-first') - assert(layout == 'reverse' ? input <= info : input >= info) + # Input + input = lines.index { it.include?('> 123') } + assert(input) - # List - item1 = lines.index { it.include?('1230') } - item2 = lines.index { it.include?('1231') } - assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1) + # Info + info = lines.index { it.include?('11/997') } + assert(info) - # Preview - assert(lines.any? { it.include?('foobar') }) + assert(layout == 'reverse' ? input <= info : input >= info) - # Header - header1 = lines.index { it.include?('101') } - header2 = lines.index { it.include?('102') } - assert_equal(header2, header1 + 1) - assert((layout == 'reverse') == header_first ? input > header1 : input < header1) + # List + item1 = lines.index { it.include?('1230') } + item2 = lines.index { it.include?('1231') } + assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1) - # Footer - footer1 = lines.index { it.include?('201') } - footer2 = lines.index { it.include?('202') } - assert_equal(footer2, footer1 + 1) - assert(layout == 'reverse' ? footer1 > item2 : footer1 < item2) + # Preview + assert(lines.any? { it.include?('foobar') }) - # Header lines - hline1 = lines.index { it.include?('1001') } - hline2 = lines.index { it.include?('1002') } - assert_equal(hline1, layout == 'default' ? hline2 + 1 : hline2 - 1) - assert(layout == 'reverse' ? hline1 > header1 : hline1 < header1) + # Header + header1 = lines.index { it.include?('101') } + header2 = lines.index { it.include?('102') } + assert_equal(header2, header1 + 1) + assert((layout == 'reverse') == header_first ? input > header1 : input < header1) + + # Footer + footer1 = lines.index { it.include?('201') } + footer2 = lines.index { it.include?('202') } + assert_equal(footer2, footer1 + 1) + assert(layout == 'reverse' ? footer1 > item2 : footer1 < item2) + + # Header lines + hline1 = lines.index { it.include?('1001') } + hline2 = lines.index { it.include?('1002') } + assert_equal(hline1, layout == 'default' ? hline2 + 1 : hline2 - 1) + assert(layout == 'reverse' ? hline1 > header1 : hline1 < header1) + end + local_tmux.send_keys :Enter + end + rescue StandardError, Minitest::Assertion => e + mutex.synchronize { failures << [command, e] } + end end - tmux.send_keys :Enter + combinations.each { queue << it } + queue.close + + threads.each(&:join) + raise failures.inspect unless failures.empty? + ensure + # Reverse so any tmux window renumbering does not leave stale indices behind. + tmuxes&.reverse_each(&:kill) end end