summaryrefslogtreecommitdiff
path: root/src/terminal.go
diff options
context:
space:
mode:
authorJunegunn Choi <junegunn.c@gmail.com>2025-06-10 00:26:57 +0900
committerJunegunn Choi <junegunn.c@gmail.com>2025-06-10 23:02:23 +0900
commit3b68dcdd81394f1ac9f743e1f74ff754f95eef9e (patch)
treee4856fba0fee27eb1ae98ca15844ec04bf60ff5a /src/terminal.go
parent39db02616158020b180a56dc8f1bdcf9f8365945 (diff)
downloadfzf-3b68dcdd81394f1ac9f743e1f74ff754f95eef9e.tar.gz
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.
Diffstat (limited to 'src/terminal.go')
-rw-r--r--src/terminal.go232
1 files changed, 210 insertions, 22 deletions
diff --git a/src/terminal.go b/src/terminal.go
index a89551da..898d34c7 100644
--- a/src/terminal.go
+++ b/src/terminal.go
@@ -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 {