Add --{header,header-lines,footer}-border=inline

New BorderShape that embeds the section inside the --list-border
frame, joined to the list content by a horizontal separator with
T-junctions where the list shape has side borders. Requires a list
border with both top and bottom segments; falls back to 'line'
otherwise. Stacks when multiple sections are inline.

Sections inherit --color list-border by default and are colored as a
uniform block via their own --color *-border and *-bg.

Incompatible with --header-first. --header-border=inline requires
--header-lines-border to be inline or unset.
This commit is contained in:
Junegunn Choi
2026-04-20 00:47:16 +09:00
parent ef6eba1b89
commit d754cfab87
9 changed files with 906 additions and 242 deletions
+302 -66
View File
@@ -1168,7 +1168,10 @@ 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
hasHeader := opts.HeaderBorderShape.Visible() || opts.HeaderLinesShape.Visible()
tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), hasHeader, headerInline, footerInline)
// Gutter character
var gutterChar, gutterRawChar string
@@ -1227,6 +1230,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 {
@@ -2237,6 +2256,98 @@ func (t *Terminal) determineHeaderLinesShape() (bool, tui.BorderShape) {
return false, tui.BorderNone
}
// Inline sections live inside wborder rather than consuming shift/shrink/availableLines.
type inlineRole int
const (
inlineRoleHeader inlineRole = iota
inlineRoleHeaderLines
inlineRoleFooter
)
type inlineSlot struct {
role inlineRole
windowType tui.WindowType
contentLines int // 0 when out of budget: a ghost placeholder is created so
// t.headerWindow / t.footerWindow stay non-nil but no frame is painted.
label labelPrinter
labelOpts labelOpts
labelLen int
}
// inlineMetaFor returns (onTop: top stack vs bottom; windowType; isInner:
// adjacent to list content) for the given inline role, derived from the layout.
// Header is outer when paired with header-lines; header-lines is always inner;
// footer is inner except in reverseList where it sits outside header-lines.
func (t *Terminal) inlineMetaFor(role inlineRole) (onTop bool, windowType tui.WindowType, isInner bool) {
switch role {
case inlineRoleHeader:
return t.layout == layoutReverse, tui.WindowHeader, false
case inlineRoleHeaderLines:
return t.layout != layoutDefault, tui.WindowHeader, true
case inlineRoleFooter:
return t.layout != layoutReverse, tui.WindowFooter, t.layout != layoutReverseList
}
return false, tui.WindowBase, false
}
func (t *Terminal) placeInlineSection(win tui.Window, role inlineRole) {
switch role {
case inlineRoleHeader:
t.headerWindow = win
case inlineRoleHeaderLines:
t.headerLinesWindow = win
case inlineRoleFooter:
t.footerWindow = win
}
}
// placeInlineStack walks `slots` in outer-to-inner order. Only the outermost
// slot (index 0) claims the adjacent wborder edge; later slots paint side-only
// frames so the T-junction separator lands between sections.
func (t *Terminal) placeInlineStack(slots []inlineSlot, startRow int, onTop bool) {
firstEdge := tui.SectionEdgeTop
if !onTop {
firstEdge = tui.SectionEdgeBottom
}
noBorder := tui.MakeBorderStyle(tui.BorderNone, t.unicode)
subLeft := t.window.Left()
subWidth := t.window.Width()
cursor := startRow
for i, s := range slots {
var windowTop, sepRow int
if onTop {
windowTop = cursor
sepRow = cursor + s.contentLines - t.wborder.Top()
} else {
windowTop = cursor - s.contentLines + 1
sepRow = windowTop - 1 - t.wborder.Top()
}
// 0-height placeholder keeps t.headerWindow / t.footerWindow non-nil.
win := t.tui.NewWindow(windowTop, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true)
t.placeInlineSection(win, s.role)
if s.contentLines == 0 {
continue
}
secTop := windowTop - t.wborder.Top()
secBottom := secTop + s.contentLines - 1
edge := tui.SectionEdgeNone
if i == 0 {
edge = firstEdge
}
t.wborder.PaintSectionFrame(secTop, secBottom, s.windowType, edge)
// useBottom=onTop so the separator always hugs the list (inner side).
// Matters for thinblock/block where top != bottom char.
t.wborder.DrawHSeparator(sepRow, s.windowType, onTop)
t.printLabelAt(t.wborder, s.label, s.labelOpts, s.labelLen, sepRow)
if onTop {
cursor += s.contentLines + 1
} else {
cursor -= s.contentLines + 1
}
}
}
func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.clearNumLinesCache()
t.forcePreview = forcePreview
@@ -2353,6 +2464,50 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
}
// Slices are ordered outer-to-inner: index 0 touches wborder's edge.
var inlineTop []inlineSlot
var inlineBottom []inlineSlot
// Caps contentLines against remaining wborder space. Oversized requests (e.g. a
// huge --header-lines) become 0-height placeholders rather than pushing the list
// window to negative height.
addInline := func(contentLines int, role inlineRole) {
onTop, windowType, isInner := t.inlineMetaFor(role)
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 {
contentLines = 0
} else {
contentLines = util.Constrain(contentLines, 1, remaining-1)
}
slot := inlineSlot{role: role, windowType: windowType, contentLines: contentLines}
switch role {
case inlineRoleHeader:
slot.label, slot.labelOpts, slot.labelLen = t.headerLabel, t.headerLabelOpts, t.headerLabelLen
case inlineRoleFooter:
slot.label, slot.labelOpts, slot.labelLen = t.footerLabel, t.footerLabelOpts, t.footerLabelLen
}
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 +2515,62 @@ 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 {
addInline(headerWindowHeight, inlineRoleHeader)
} 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 {
addInline(t.headerLines, inlineRoleHeaderLines)
} 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 {
addInline(len(t.footer), inlineRoleFooter)
} 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
}
}
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 +2681,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 +2725,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 +2757,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 +2795,15 @@ 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)
}
if len(inlineTop)+len(inlineBottom) > 0 && t.wborder != nil {
t.placeInlineStack(inlineTop, t.window.Top()-inlineTopLines, true)
t.placeInlineStack(inlineBottom, t.window.Top()+t.window.Height()+inlineBottomLines-1, false)
}
if len(t.scrollbar) == 0 {
@@ -2700,7 +2885,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 +2913,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 +2946,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 +2963,21 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
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)
// When the list label lands on an edge owned by an inline section, swap its bg
// so the label reads as part of that section's frame. Fg stays at list-label.
listLabel, listLabelLen := t.listLabel, t.listLabelLen
var adjacentSection *inlineSlot
if t.listLabelOpts.bottom && len(inlineBottom) > 0 {
adjacentSection = &inlineBottom[0]
} else if !t.listLabelOpts.bottom && len(inlineTop) > 0 {
adjacentSection = &inlineTop[0]
}
if adjacentSection != nil {
bg := tui.BorderColor(adjacentSection.windowType).Bg()
custom := tui.ColListLabel.WithBg(tui.ColorAttr{Color: bg, Attr: tui.AttrUndefined})
listLabel, listLabelLen = t.ansiLabelPrinter(t.listLabelOpts.label, &custom, false)
}
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.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false)
@@ -2787,37 +2985,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)
}
}
@@ -3185,8 +3385,19 @@ func (t *Terminal) resizeIfNeeded() bool {
return true
}
// Inline sections are budget-capped inside wborder, so window.Height() may be
// smaller than the requested content length. Treat "capped" (height < want) as
// a no-op to avoid triggering a full redraw on every info/header/footer event
// when the user has requested more content than fits.
mismatch := func(shape tui.BorderShape, height, want int) bool {
if shape == tui.BorderInline && height < want {
return false
}
return height != want
}
// Check footer window
if len(t.footer) > 0 && (t.footerWindow == nil || t.footerWindow.Height() != len(t.footer)) ||
if len(t.footer) > 0 && (t.footerWindow == nil || mismatch(t.footerBorderShape, t.footerWindow.Height(), len(t.footer))) ||
len(t.footer) == 0 && t.footerWindow != nil {
t.printAll()
return true
@@ -3200,14 +3411,12 @@ func (t *Terminal) resizeIfNeeded() bool {
if needHeaderLinesWindow {
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.
if (needHeaderWindow && t.headerWindow == nil) ||
(!needHeaderWindow && t.headerWindow != nil) ||
(needHeaderWindow && t.headerWindow != nil && primaryHeaderLines != t.headerWindow.Height()) ||
(needHeaderWindow && t.headerWindow != nil && mismatch(t.headerBorderShape, t.headerWindow.Height(), primaryHeaderLines)) ||
(needHeaderLinesWindow && t.headerLinesWindow == nil) ||
(!needHeaderLinesWindow && t.headerLinesWindow != nil) ||
(needHeaderLinesWindow && t.headerLinesWindow != nil && t.headerLines != t.headerLinesWindow.Height()) {
(needHeaderLinesWindow && t.headerLinesWindow != nil && mismatch(t.headerLinesShape, t.headerLinesWindow.Height(), t.headerLines)) {
t.printAll()
return true
}
@@ -3219,14 +3428,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 +3493,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 +6171,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: