Add --header-border=inline / --header-lines-border=inline / --footer-border=inline
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled

Adds a new BorderShape, BorderInline, accepted as a value for
--header-border, --header-lines-border, and --footer-border. When the
surrounding --list-border has both top and bottom horizontals (rounded,
sharp, bold, double, thinblock, block, horizontal), the corresponding
section is rendered inside the list frame separated from the list
content by a horizontal line whose endpoints join the list border as
T-junctions. Without a compatible list border, the shape falls back to
BorderLine.

Supports:
  - All three layouts (default, reverse, reverse-list).
  - Any combination of the three inline sections, producing stacked
    separators.
  - --header-label and --footer-label rendered on their separator row.
  - Section colors: the portion of the list frame adjacent to an inline
    section (left/right verticals on the section's content rows plus the
    outer top/bottom edge + corners when the section is at the edge)
    inherits the section's --color *-border and *-bg, giving each section
    a uniform color block. The separator itself carries the section's
    colors since it acts as the section's inner edge.
  - When --color header-border / --color footer-border is not set, the
    inline section inherits --color list-border so the default palette
    stays coherent.
  - thinblock / block styles pick the horizontal char (top vs bottom)
    based on which side of the list content the separator sits on, so
    the thin line visually hugs the list content.

Rejects combinations that do not make sense:
  - --input-border=inline / --list-border=inline / --preview-border=inline
  - --header-first + (--header-border=inline | --header-lines-border=inline)
  - --header-border=inline with a non-inline --header-lines-border
    (inline has to propagate inward toward the list content).
This commit is contained in:
Junegunn Choi
2026-04-18 19:34:56 +09:00
parent f56bdd2ca9
commit 2ace9db71d
8 changed files with 714 additions and 71 deletions
+293 -60
View File
@@ -1168,7 +1168,9 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
baseTheme = renderer.DefaultTheme()
}
// This should be called before accessing tui.Color*
tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible())
headerInline := opts.HeaderBorderShape == tui.BorderInline || opts.HeaderLinesShape == tui.BorderInline
footerInline := opts.FooterBorderShape == tui.BorderInline
tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible(), headerInline, footerInline)
// Gutter character
var gutterChar, gutterRawChar string
@@ -1227,6 +1229,22 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
}
}
// Inline borders are embedded between the list's top and bottom horizontals.
// Shapes missing either one (none/phantom/line/single-sided) fall back to a plain
// horizontal separator (same as BorderLine).
inlineSupported := t.listBorderShape.HasTop() && t.listBorderShape.HasBottom()
if !inlineSupported {
if t.headerBorderShape == tui.BorderInline {
t.headerBorderShape = tui.BorderLine
}
if t.headerLinesShape == tui.BorderInline {
t.headerLinesShape = tui.BorderLine
}
if t.footerBorderShape == tui.BorderInline {
t.footerBorderShape = tui.BorderLine
}
}
// Determine header border shape
if t.headerBorderShape == tui.BorderLine {
if t.layout == layoutReverse {
@@ -2353,6 +2371,63 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
}
// Inline borders live inside the list frame instead of consuming shift/shrink/availableLines.
// Tracked here and applied when positioning t.window and the component windows.
const (
inlineRoleHeader = 1
inlineRoleHeaderLines = 2
inlineRoleFooter = 3
)
type inlineSlot struct {
role int
windowType tui.WindowType
contentLines int // 0 means the section had no budget; a 0-height placeholder
// window is still created so t.headerWindow / t.footerWindow stay non-nil,
// but no separator is drawn and the slot consumes no rows inside wborder.
}
var inlineTop []inlineSlot // ordered outer-to-inner (index 0 = closest to top border of wborder)
var inlineBottom []inlineSlot // ordered outer-to-inner (index 0 = closest to bottom border of wborder)
// Helper: reserve space inside wborder for a component. isInner indicates whether the
// section is adjacent to the list content (true) or to the outer list border (false).
// contentLines is capped against the remaining space inside wborder so oversized
// requests (e.g. a huge --header-lines) cannot push the list window to a negative
// height. When no budget is left, contentLines is set to 0 and the slot becomes a
// no-op placeholder so downstream code can always dereference the section window.
addInline := func(onTop bool, contentLines int, windowType tui.WindowType, role int, isInner bool) {
// Remaining inline budget: wborder inner rows minus already-committed inline
// sections and separators, leaving at least 1 row for list content.
used := 0
for _, s := range inlineTop {
if s.contentLines > 0 {
used += s.contentLines + 1
}
}
for _, s := range inlineBottom {
if s.contentLines > 0 {
used += s.contentLines + 1
}
}
remaining := availableLines - borderLines(t.listBorderShape) - used - 1
if remaining < 2 {
// Not enough room for at least 1 content line plus a separator; placeholder only.
contentLines = 0
} else {
// Each visible slot consumes contentLines+1 (the extra 1 is the separator row).
contentLines = util.Constrain(contentLines, 1, remaining-1)
}
slot := inlineSlot{role: role, windowType: windowType, contentLines: contentLines}
target := &inlineTop
if !onTop {
target = &inlineBottom
}
if isInner {
*target = append(*target, slot)
} else {
*target = append([]inlineSlot{slot}, *target...)
}
}
// Adjust position and size of the list window if header border is set
headerBorderHeight := 0
if hasHeaderWindow {
@@ -2360,37 +2435,73 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if hasHeaderLinesWindow {
headerWindowHeight -= t.headerLines
}
headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines)
if t.layout == layoutReverse {
shift += headerBorderHeight
shrink += headerBorderHeight
if t.headerBorderShape == tui.BorderInline {
// Reverse: header on top of list frame. Default/reverseList: header below.
// Header is the outer section (furthest from list) when paired with header-lines.
onTop := t.layout == layoutReverse
addInline(onTop, headerWindowHeight, tui.WindowHeader, inlineRoleHeader, false)
} else {
shrink += headerBorderHeight
headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines)
if t.layout == layoutReverse {
shift += headerBorderHeight
shrink += headerBorderHeight
} else {
shrink += headerBorderHeight
}
availableLines -= headerBorderHeight
}
availableLines -= headerBorderHeight
}
headerLinesHeight := 0
if hasHeaderLinesWindow {
headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines)
if t.layout != layoutDefault {
shift += headerLinesHeight
shrink += headerLinesHeight
if headerLinesShape == tui.BorderInline {
// Header-lines always sits adjacent to list content (inner).
// Reverse/reverseList: above list; default: below.
onTop := t.layout != layoutDefault
addInline(onTop, t.headerLines, tui.WindowHeader, inlineRoleHeaderLines, true)
} else {
shrink += headerLinesHeight
headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines)
if t.layout != layoutDefault {
shift += headerLinesHeight
shrink += headerLinesHeight
} else {
shrink += headerLinesHeight
}
availableLines -= headerLinesHeight
}
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
if t.footerBorderShape == tui.BorderInline {
// Reverse: footer below list (alone, inner). Default: footer above list (alone, inner).
// ReverseList: footer above list, outer of header-lines when header-lines is also inline.
onTop := t.layout != layoutReverse
isInner := t.layout != layoutReverseList
addInline(onTop, len(t.footer), tui.WindowFooter, inlineRoleFooter, isInner)
} else {
// 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
}
}
// Compute total rows consumed inside wborder for inline sections (content + 1 separator per section).
inlineTopLines := 0
for _, s := range inlineTop {
if s.contentLines > 0 {
inlineTopLines += s.contentLines + 1
}
}
inlineBottomLines := 0
for _, s := range inlineBottom {
if s.contentLines > 0 {
inlineBottomLines += s.contentLines + 1
}
availableLines -= footerBorderHeight
}
// Set up list border
@@ -2501,12 +2612,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if previewOpts.position == posUp {
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
t.window = t.tui.NewWindow(
innerMarginInt[0]+pheight+shift, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink, tui.WindowList, noBorder, true)
innerMarginInt[0]+pheight+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
createPreviewWindow(marginInt[0], marginInt[3], width, pheight)
} else {
innerBorderFn(marginInt[0], marginInt[3], width, height-pheight)
t.window = t.tui.NewWindow(
innerMarginInt[0]+shift, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink, tui.WindowList, noBorder, true)
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 posLeft, posRight:
@@ -2545,7 +2656,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
m = 1
}
t.window = t.tui.NewWindow(
innerMarginInt[0]+shift, innerMarginInt[3]+pwidth+m, innerWidth-pwidth-m, innerHeight-shrink, tui.WindowList, noBorder, true)
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3]+pwidth+m, innerWidth-pwidth-m, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
// Clear characters on the margin
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1
@@ -2577,7 +2688,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
innerBorderFn(marginInt[0], marginInt[3], width-pwidth, height)
t.window = t.tui.NewWindow(
innerMarginInt[0]+shift, innerMarginInt[3], innerWidth-pwidth, innerHeight-shrink, tui.WindowList, noBorder, true)
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth-pwidth, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
x := marginInt[3] + width - pwidth
createPreviewWindow(marginInt[0], x, pwidth, height)
}
@@ -2615,10 +2726,77 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
innerBorderFn(marginInt[0], marginInt[3], width, height)
t.window = t.tui.NewWindow(
innerMarginInt[0]+shift,
innerMarginInt[0]+shift+inlineTopLines,
innerMarginInt[3],
innerWidth,
innerHeight-shrink, tui.WindowList, noBorder, true)
innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
}
// Place inline section windows, paint adjacent frame cells in the section's colors,
// and draw T-junction separators. Labels are printed directly on the separator row.
placeInlineSection := func(win tui.Window, role int) {
switch role {
case inlineRoleHeader:
t.headerWindow = win
case inlineRoleHeaderLines:
t.headerLinesWindow = win
case inlineRoleFooter:
t.footerWindow = win
}
}
printInlineSepLabel := func(role int, sepRow int) {
switch role {
case inlineRoleHeader:
t.printLabelAt(t.wborder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, sepRow)
case inlineRoleFooter:
t.printLabelAt(t.wborder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, sepRow)
}
}
if len(inlineTop)+len(inlineBottom) > 0 && t.wborder != nil {
// Inline sub-windows align with t.window horizontally (preview narrows t.window too)
// and stack immediately above or below it inside wborder.
subLeft := t.window.Left()
subWidth := t.window.Width()
cursor := t.window.Top() - inlineTopLines
for i, s := range inlineTop {
// Ghost slots get a 0-height placeholder at subLeft so t.headerWindow /
// t.footerWindow stay non-nil; no frame painting, no separator.
win := t.tui.NewWindow(cursor, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true)
placeInlineSection(win, s.role)
if s.contentLines == 0 {
continue
}
secTop := cursor - t.wborder.Top()
secBottom := secTop + s.contentLines - 1
// Paint the frame cells adjacent to this section in the section's color
// (left/right verticals on the content rows; outer top edge if this is
// the outermost section).
t.wborder.PaintSectionFrame(secTop, secBottom, s.windowType, i == 0, false)
cursor += s.contentLines
sepRow := cursor - t.wborder.Top()
// Separator sits above list content; hug the list below by using the
// bottom char (matters for thinblock/block where top and bottom differ).
t.wborder.DrawHSeparator(sepRow, s.windowType, true)
printInlineSepLabel(s.role, sepRow)
cursor++
}
cursor = t.window.Top() + t.window.Height() + inlineBottomLines - 1
for i, s := range inlineBottom {
top := cursor - s.contentLines + 1
win := t.tui.NewWindow(top, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true)
placeInlineSection(win, s.role)
if s.contentLines == 0 {
continue
}
secTop := top - t.wborder.Top()
secBottom := secTop + s.contentLines - 1
t.wborder.PaintSectionFrame(secTop, secBottom, s.windowType, false, i == 0)
sepRow := top - 1 - t.wborder.Top()
// Separator sits below list content; hug the list above with the top char.
t.wborder.DrawHSeparator(sepRow, s.windowType, false)
printInlineSepLabel(s.role, sepRow)
cursor = top - 2
}
}
if len(t.scrollbar) == 0 {
@@ -2700,7 +2878,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
// Set up header border
if hasHeaderWindow {
if hasHeaderWindow && t.headerBorderShape != tui.BorderInline {
var btop int
if hasInputWindow && t.headerFirst {
if t.layout == layoutReverse {
@@ -2728,7 +2906,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
// Set up header lines border
if hasHeaderLinesWindow {
if hasHeaderLinesWindow && headerLinesShape != tui.BorderInline {
var btop int
// NOTE: We still have to handle --header-first here in case
// --header-lines-border is set. Can't we just use header window instead
@@ -2761,7 +2939,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
// Set up footer
if hasFooterWindow {
if hasFooterWindow && t.footerBorderShape != tui.BorderInline {
var btop int
if t.layout == layoutReverse {
btop = w.Top() + w.Height()
@@ -2778,8 +2956,34 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.footerWindow = createInnerWindow(t.footerBorder, t.footerBorderShape, tui.WindowFooter, 0)
}
// Print border label
// Print border label. When the list label's edge is owned by an inline section,
// temporarily swap the label background to match, so the label reads as part of
// the section's frame rather than a list-colored island on a section-colored edge.
// Foreground stays at --color list-label.
sectionBg := func(wt tui.WindowType) (tui.Color, bool) {
switch wt {
case tui.WindowHeader:
return tui.ColHeaderBorder.Bg(), true
case tui.WindowFooter:
return tui.ColFooterBorder.Bg(), true
}
return 0, false
}
var overrideBg tui.Color
overrideListLabelBg := false
if t.listLabelOpts.bottom && len(inlineBottom) > 0 {
overrideBg, overrideListLabelBg = sectionBg(inlineBottom[0].windowType)
} else if !t.listLabelOpts.bottom && len(inlineTop) > 0 {
overrideBg, overrideListLabelBg = sectionBg(inlineTop[0].windowType)
}
savedListLabel := tui.ColListLabel
if overrideListLabelBg {
tui.ColListLabel = tui.ColListLabel.WithBgColor(overrideBg)
}
t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, false)
if overrideListLabelBg {
tui.ColListLabel = savedListLabel
}
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)
@@ -2787,37 +2991,39 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false)
}
// printLabelAt positions and renders a label at the given row of `window`. Shared by
// printLabel (which computes row from the border shape) and the inline-section label
// code (which uses an explicit separator row).
func (t *Terminal) printLabelAt(window tui.Window, render labelPrinter, opts labelOpts, length int, row int) {
if window == nil || render == nil || window.Height() == 0 {
return
}
var col int
if opts.column == 0 {
col = max(0, (window.Width()-length)/2)
} else if opts.column < 0 {
col = max(0, window.Width()+opts.column+1-length)
} else {
col = min(opts.column-1, window.Width()-length)
}
window.Move(row, col)
render(window, window.Width())
}
func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts labelOpts, length int, borderShape tui.BorderShape, redrawBorder bool) {
if window == nil {
if window == nil || window.Height() == 0 {
return
}
if window.Height() == 0 {
return
}
switch borderShape {
case tui.BorderHorizontal, tui.BorderTop, tui.BorderBottom, tui.BorderRounded, tui.BorderSharp, tui.BorderBold, tui.BorderBlock, tui.BorderThinBlock, tui.BorderDouble:
if redrawBorder {
window.DrawHBorder()
}
if render == nil {
return
}
var col int
if opts.column == 0 {
col = max(0, (window.Width()-length)/2)
} else if opts.column < 0 {
col = max(0, window.Width()+opts.column+1-length)
} else {
col = min(opts.column-1, window.Width()-length)
}
row := 0
if borderShape == tui.BorderBottom || opts.bottom {
row = window.Height() - 1
}
window.Move(row, col)
render(window, window.Width())
t.printLabelAt(window, render, opts, length, row)
}
}
@@ -3219,14 +3425,21 @@ func (t *Terminal) printHeader() {
return
}
t.withWindow(t.headerWindow, func() {
var headerItems []Item
if !t.hasHeaderLinesWindow() {
headerItems = t.header
}
t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems)
})
if w, shape := t.determineHeaderLinesShape(); w {
// When an inline section was requested but addInline had no budget, its window is
// nil. Don't fall through to withWindow — that would leak header content into the
// list window. A nil window is only legitimate when the shape is NOT inline (e.g.
// header combined with the list when --no-list-border is in effect).
if !(t.headerBorderShape == tui.BorderInline && t.headerWindow == nil) {
t.withWindow(t.headerWindow, func() {
var headerItems []Item
if !t.hasHeaderLinesWindow() {
headerItems = t.header
}
t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems)
})
}
if w, shape := t.determineHeaderLinesShape(); w &&
!(shape == tui.BorderInline && t.headerLinesWindow == nil) {
t.withWindow(t.headerLinesWindow, func() {
t.printHeaderImpl(t.headerLinesWindow, shape, nil, t.header)
})
@@ -3277,7 +3490,10 @@ func (t *Terminal) headerIndentImpl(base int, borderShape tui.BorderShape) int {
if t.listBorderShape.HasLeft() {
indentSize += 1 + t.borderWidth
}
if borderShape.HasLeft() {
// Section borders with their own left side skip past the list border's left column.
// Inline sections also skip it, but only when the list border actually has a left,
// since otherwise the inline window starts flush with the list window.
if borderShape.HasLeft() || (borderShape == tui.BorderInline && t.listBorderShape.HasLeft()) {
indentSize -= 1 + t.borderWidth
if indentSize < 0 {
indentSize = 0
@@ -5952,11 +6168,28 @@ func (t *Terminal) Loop() error {
case reqRedrawInputLabel:
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)
if t.headerBorderShape == tui.BorderInline {
// Inline labels sit on the separator inside wborder; re-run the
// full layout to repaint the separator + label together.
t.printAll()
} else {
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)
if t.footerBorderShape == tui.BorderInline {
t.printAll()
} else {
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)
// When inline sections are active, the label's bg depends on which
// section owns the adjacent edge. Rerun the layout to reuse that
// logic rather than duplicating it here.
if t.headerBorderShape == tui.BorderInline || t.headerLinesShape == tui.BorderInline || t.footerBorderShape == tui.BorderInline {
t.printAll()
} else {
t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true)
}
case reqRedrawBorderLabel:
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true)
case reqRedrawPreviewLabel: