m/fzf
1
0
mirror of https://github.com/junegunn/fzf.git synced 2025-11-16 07:13:48 -05:00

Add footer

Options:
  --footer=STR             String to print as footer
  --footer-border[=STYLE]  Draw border around the footer section
                           [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
                            top|bottom|left|right|line|none] (default: line)
  --footer-label=LABEL     Label to print on the footer border
  --footer-label-pos=COL   Position of the footer label
                           [POSITIVE_INTEGER: columns from left|
                            NEGATIVE_INTEGER: columns from right][:bottom]
                           (default: 0 or center)

The default border type for footer is 'line', which draws a single
separator between the footer and the list. It changes its position
depending on `--layout`, so you don't have to manually switch between
'top' and 'bottom'

The 'line' style is now supported by other border types as well.
`--list-border` is the only exception.
This commit is contained in:
Junegunn Choi
2025-06-10 00:26:57 +09:00
parent 39db026161
commit 3b68dcdd81
11 changed files with 721 additions and 182 deletions

View File

@@ -180,14 +180,18 @@ type itemLine struct {
other bool
}
func (t *Terminal) inListWindow() bool {
return t.window != t.inputWindow && t.window != t.headerWindow && t.window != t.headerLinesWindow && t.window != t.footerWindow
}
func (t *Terminal) markEmptyLine(line int) {
if t.window != t.inputWindow && t.window != t.headerWindow {
if t.inListWindow() {
t.prevLines[line] = itemLine{valid: true, firstLine: line, empty: true}
}
}
func (t *Terminal) markOtherLine(line int) {
if t.window != t.inputWindow && t.window != t.headerWindow {
if t.inListWindow() {
t.prevLines[line] = itemLine{valid: true, firstLine: line, other: true}
}
}
@@ -254,6 +258,9 @@ type Terminal struct {
headerLabel labelPrinter
headerLabelLen int
headerLabelOpts labelOpts
footerLabel labelPrinter
footerLabelLen int
footerLabelOpts labelOpts
pointer string
pointerLen int
pointerEmpty string
@@ -301,6 +308,7 @@ type Terminal struct {
headerLines int
header []string
header0 []string
footer []string
ellipsis string
scrollbar string
previewScrollbar string
@@ -322,6 +330,7 @@ type Terminal struct {
inputBorderShape tui.BorderShape
headerBorderShape tui.BorderShape
headerLinesShape tui.BorderShape
footerBorderShape tui.BorderShape
listLabel labelPrinter
listLabelLen int
listLabelOpts labelOpts
@@ -337,6 +346,8 @@ type Terminal struct {
headerBorder tui.Window
headerLinesWindow tui.Window
headerLinesBorder tui.Window
footerWindow tui.Window
footerBorder tui.Window
wborder tui.Window
pborder tui.Window
pwindow tui.Window
@@ -426,6 +437,7 @@ const (
reqPrompt util.EventType = iota
reqInfo
reqHeader
reqFooter
reqList
reqJump
reqActivate
@@ -434,6 +446,7 @@ const (
reqResize
reqRedrawInputLabel
reqRedrawHeaderLabel
reqRedrawFooterLabel
reqRedrawListLabel
reqRedrawBorderLabel
reqRedrawPreviewLabel
@@ -479,7 +492,9 @@ const (
actChangeBorderLabel
actChangeGhost
actChangeHeader
actChangeFooter
actChangeHeaderLabel
actChangeFooterLabel
actChangeInputLabel
actChangeListLabel
actChangeMulti
@@ -550,7 +565,9 @@ const (
actTransformBorderLabel
actTransformGhost
actTransformHeader
actTransformFooter
actTransformHeaderLabel
actTransformFooterLabel
actTransformInputLabel
actTransformListLabel
actTransformNth
@@ -907,6 +924,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
inputBorderShape: opts.InputBorderShape,
headerBorderShape: opts.HeaderBorderShape,
headerLinesShape: opts.HeaderLinesShape,
footerBorderShape: opts.FooterBorderShape,
borderWidth: 1,
listLabel: nil,
listLabelOpts: opts.ListLabel,
@@ -918,6 +936,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
inputLabelOpts: opts.InputLabel,
headerLabel: nil,
headerLabelOpts: opts.HeaderLabel,
footerLabel: nil,
footerLabelOpts: opts.FooterLabel,
cleanExit: opts.ClearOnExit,
executor: executor,
paused: opts.Phony,
@@ -929,6 +949,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
headerLines: opts.HeaderLines,
gap: opts.Gap,
header: []string{},
footer: opts.Footer,
header0: opts.Header,
ansi: opts.Ansi,
nthAttr: opts.Theme.Nth.Attr,
@@ -992,6 +1013,52 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
t.previewLabel, t.previewLabelLen = t.ansiLabelPrinter(opts.PreviewLabel.label, &tui.ColPreviewLabel, false)
t.inputLabel, t.inputLabelLen = t.ansiLabelPrinter(opts.InputLabel.label, &tui.ColInputLabel, false)
t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(opts.HeaderLabel.label, &tui.ColHeaderLabel, false)
t.footerLabel, t.footerLabelLen = t.ansiLabelPrinter(opts.FooterLabel.label, &tui.ColFooterLabel, false)
// Determine border shape
if t.borderShape == tui.BorderLine {
if t.fullscreen {
t.borderShape = tui.BorderNone
} else {
t.borderShape = tui.BorderTop
}
}
// Determine input border shape
if t.inputBorderShape == tui.BorderLine {
if t.layout == layoutReverse {
t.inputBorderShape = tui.BorderBottom
} else {
t.inputBorderShape = tui.BorderTop
}
}
// Determine header border shape
if t.headerBorderShape == tui.BorderLine {
if t.layout == layoutReverse {
t.headerBorderShape = tui.BorderBottom
} else {
t.headerBorderShape = tui.BorderTop
}
}
// Determine header lines border shape
if t.headerLinesShape == tui.BorderLine {
if t.layout == layoutDefault {
t.headerLinesShape = tui.BorderTop
} else {
t.headerLinesShape = tui.BorderBottom
}
}
// Determine footer border shape
if t.footerBorderShape == tui.BorderLine {
if t.layout == layoutReverse {
t.footerBorderShape = tui.BorderTop
} else {
t.footerBorderShape = tui.BorderBottom
}
}
// Disable separator by default if input border is set
if opts.Separator == nil && !t.inputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 {
@@ -1208,6 +1275,10 @@ func (t *Terminal) extraLines() int {
}
extra += t.headerLines
}
if len(t.footer) > 0 {
extra += borderLines(t.footerBorderShape)
extra += len(t.footer)
}
return extra
}
@@ -1475,6 +1546,16 @@ func (t *Terminal) changeHeader(header string) bool {
return needFullRedraw
}
func (t *Terminal) changeFooter(footer string) bool {
var lines []string
if len(footer) > 0 {
lines = strings.Split(strings.TrimSuffix(footer, "\n"), "\n")
}
needFullRedraw := len(t.footer) != len(lines)
t.footer = lines
return needFullRedraw
}
// UpdateHeader updates the header
func (t *Terminal) UpdateHeader(header []string) {
t.mutex.Lock()
@@ -1835,6 +1916,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if t.headerBorder != nil {
t.headerBorder = nil
}
if t.footerWindow != nil {
t.footerWindow = nil
}
if t.footerBorder != nil {
t.footerBorder = nil
}
if t.headerLinesWindow != nil {
t.headerLinesWindow = nil
}
@@ -1889,17 +1976,19 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
// Adjust position and size of the list window if input border is set
inputBorderHeight := 0
availableLines := height
shift := 0
shrink := 0
hasHeaderWindow := t.hasHeaderWindow()
hasFooterWindow := len(t.footer) > 0
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape()
hasInputWindow := !t.inputless && (t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow)
inputWindowHeight := 2
if t.noSeparatorLine() {
inputWindowHeight--
}
if hasInputWindow {
inputWindowHeight := 2
if t.noSeparatorLine() {
inputWindowHeight--
}
inputBorderHeight = util.Min(availableLines, borderLines(t.inputBorderShape)+inputWindowHeight)
inputBorderHeight = util.Constrain(borderLines(t.inputBorderShape)+inputWindowHeight, 0, availableLines)
if t.layout == layoutReverse {
shift = inputBorderHeight
shrink = inputBorderHeight
@@ -1907,6 +1996,17 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
shrink = inputBorderHeight
}
availableLines -= inputBorderHeight
} else if !t.inputless {
availableLines -= inputWindowHeight
}
// FIXME: Needed?
if t.needPreviewWindow() {
_, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts)
switch t.activePreviewOpts.position {
case posUp, posDown:
availableLines -= minPreviewHeight
}
}
// Adjust position and size of the list window if header border is set
@@ -1916,7 +2016,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if hasHeaderLinesWindow {
headerWindowHeight -= t.headerLines
}
headerBorderHeight = util.Min(availableLines, borderLines(t.headerBorderShape)+headerWindowHeight)
headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines)
if t.layout == layoutReverse {
shift += headerBorderHeight
shrink += headerBorderHeight
@@ -1928,7 +2028,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
headerLinesHeight := 0
if hasHeaderLinesWindow {
headerLinesHeight = util.Min(availableLines, borderLines(headerLinesShape)+t.headerLines)
headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines)
if t.layout != layoutDefault {
shift += headerLinesHeight
shrink += headerLinesHeight
@@ -1938,6 +2038,17 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
availableLines -= headerLinesHeight
}
footerBorderHeight := 0
if hasFooterWindow {
// Footer lines should not take all available lines
footerBorderHeight = util.Constrain(borderLines(t.footerBorderShape)+len(t.footer), 0, availableLines)
shrink += footerBorderHeight
if t.layout != layoutReverse {
shift += footerBorderHeight
}
availableLines -= footerBorderHeight
}
// Set up list border
hasListBorder := t.listBorderShape.Visible()
innerWidth := width
@@ -2041,13 +2152,8 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
width++
}
maxPreviewLines := availableLines
if t.wborder != nil {
maxPreviewLines -= t.wborder.Height()
} else {
maxPreviewLines -= util.Max(0, innerHeight-pheight-shrink)
}
pheight = util.Min(pheight, maxPreviewLines)
pheight = util.Constrain(pheight, minPreviewHeight, availableLines)
if previewOpts.position == posUp {
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
t.window = t.tui.NewWindow(
@@ -2210,7 +2316,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
case layoutDefault:
btop = w.Top() + w.Height() + headerBorderHeight + headerLinesHeight
case layoutReverse:
btop = w.Top() - shrink
btop = w.Top() - shrink + footerBorderHeight
case layoutReverseList:
btop = w.Top() + w.Height() + headerBorderHeight
}
@@ -2238,7 +2344,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
var btop int
if hasInputWindow && t.headerFirst {
if t.layout == layoutReverse {
btop = w.Top() - shrink
btop = w.Top() - shrink + footerBorderHeight
} else if t.layout == layoutReverseList {
btop = w.Top() + w.Height() + inputBorderHeight
} else {
@@ -2294,12 +2400,31 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.headerLinesWindow = createInnerWindow(t.headerLinesBorder, headerLinesShape, tui.WindowHeader, 0)
}
// Set up footer
if hasFooterWindow {
var btop int
if t.layout == layoutReverse {
btop = w.Top() + w.Height()
} else if t.layout == layoutReverseList {
btop = w.Top() - footerBorderHeight - headerLinesHeight
} else {
btop = w.Top() - footerBorderHeight
}
t.footerBorder = t.tui.NewWindow(
btop,
w.Left(),
w.Width(),
footerBorderHeight, tui.WindowFooter, tui.MakeBorderStyle(t.footerBorderShape, t.unicode), true)
t.footerWindow = createInnerWindow(t.footerBorder, t.footerBorderShape, tui.WindowFooter, 0)
}
// Print border label
t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.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.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)
}
func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts labelOpts, length int, borderShape tui.BorderShape, redrawBorder bool) {
@@ -2343,7 +2468,7 @@ func (t *Terminal) move(y int, x int, clear bool) {
case layoutDefault:
y = h - y - 1
case layoutReverseList:
if t.window == t.inputWindow || t.window == t.headerWindow {
if !t.inListWindow() && t.window != t.headerLinesWindow {
// From bottom to top
y = h - y - 1
} else {
@@ -2690,8 +2815,10 @@ func (t *Terminal) resizeIfNeeded() bool {
if t.hasHeaderLinesWindow() {
primaryHeaderLines -= t.headerLines
}
// FIXME: Full redraw is triggered if there are too many lines in the header
// so that the header window cannot display all of them.
needHeaderLinesWindow := t.hasHeaderLinesWindow()
if (t.headerBorderShape.Visible() || t.hasHeaderLinesWindow()) &&
if (t.headerBorderShape.Visible() || needHeaderLinesWindow) &&
(t.headerWindow == nil && primaryHeaderLines > 0 || t.headerWindow != nil && primaryHeaderLines != t.headerWindow.Height()) ||
needHeaderLinesWindow && (t.headerLinesWindow == nil || t.headerLinesWindow != nil && t.headerLines != t.headerLinesWindow.Height()) ||
!needHeaderLinesWindow && t.headerLinesWindow != nil {
@@ -2720,6 +2847,41 @@ func (t *Terminal) printHeader() {
}
}
func (t *Terminal) printFooter() {
if len(t.footer) == 0 {
return
}
indentSize := t.headerIndent(t.footerBorderShape)
indent := strings.Repeat(" ", indentSize)
max := util.Min(len(t.footer), t.footerWindow.Height())
// Wrapping is not supported for footer
wrap := t.wrap
t.wrap = false
t.withWindow(t.footerWindow, func() {
var state *ansiState
for idx, lineStr := range t.footer[:max] {
line := idx
if t.layout != layoutReverse {
line = max - idx - 1
}
trimmed, colors, newState := extractColor(lineStr, state, nil)
state = newState
item := &Item{
text: util.ToChars([]byte(trimmed)),
colors: colors}
t.printHighlighted(Result{item: item},
tui.ColFooter, tui.ColFooter, false, false, line, line, true,
func(markerClass) int {
t.footerWindow.Print(indent)
return indentSize
}, nil)
}
})
t.wrap = wrap
}
func (t *Terminal) headerIndent(borderShape tui.BorderShape) int {
indentSize := t.pointerLen + t.markerLen
if t.listBorderShape.HasLeft() {
@@ -2792,7 +2954,7 @@ func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShap
}
func (t *Terminal) canSpanMultiLines() bool {
return t.multiLine || t.wrap || t.gap > 0
return (t.multiLine || t.wrap || t.gap > 0) && t.inListWindow()
}
func (t *Terminal) renderBar(line int, barRange [2]int) {
@@ -3767,6 +3929,7 @@ func (t *Terminal) printAll() {
t.printPrompt()
t.printInfo()
t.printHeader()
t.printFooter()
t.printPreview()
}
@@ -4515,6 +4678,7 @@ func (t *Terminal) Loop() error {
t.reqBox.Set(reqPrompt, nil)
t.reqBox.Set(reqInfo, nil)
t.reqBox.Set(reqHeader, nil)
t.reqBox.Set(reqFooter, nil)
if t.initDelay > 0 {
go func() {
timer := time.NewTimer(t.initDelay)
@@ -4797,6 +4961,10 @@ func (t *Terminal) Loop() error {
if !t.resizeIfNeeded() {
t.printHeader()
}
case reqFooter:
if !t.resizeIfNeeded() {
t.printFooter()
}
case reqActivate:
t.suppress = false
if t.hasPreviewer() {
@@ -4806,6 +4974,8 @@ func (t *Terminal) Loop() error {
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, true)
case reqRedrawHeaderLabel:
t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, true)
case reqRedrawFooterLabel:
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true)
case reqRedrawListLabel:
t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true)
case reqRedrawBorderLabel:
@@ -4996,7 +5166,7 @@ func (t *Terminal) Loop() error {
}
updatePreviewWindow := func(forcePreview bool) {
t.resizeWindows(forcePreview, false)
req(reqPrompt, reqList, reqInfo, reqHeader)
req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter)
}
toggle := func() bool {
current := t.currentItem()
@@ -5271,6 +5441,16 @@ func (t *Terminal) Loop() error {
} else {
req(reqHeader)
}
case actChangeFooter, actTransformFooter:
footer := a.a
if a.t == actTransformFooter {
footer = t.captureLines(a.a)
}
if t.changeFooter(footer) {
req(reqFullRedraw)
} else {
req(reqFooter)
}
case actChangeHeaderLabel, actTransformHeaderLabel:
label := a.a
if a.t == actTransformHeaderLabel {
@@ -5279,6 +5459,14 @@ func (t *Terminal) Loop() error {
t.headerLabelOpts.label = label
t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(label, &tui.ColHeaderLabel, false)
req(reqRedrawHeaderLabel)
case actChangeFooterLabel, actTransformFooterLabel:
label := a.a
if a.t == actTransformFooterLabel {
label = t.captureLine(a.a)
}
t.footerLabelOpts.label = label
t.footerLabel, t.footerLabelLen = t.ansiLabelPrinter(label, &tui.ColFooterLabel, false)
req(reqRedrawFooterLabel)
case actChangeInputLabel, actTransformInputLabel:
label := a.a
if a.t == actTransformInputLabel {