From 2ace9db71dcbe21fc41f6e68c4815c7ad00da81a Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 18 Apr 2026 19:34:56 +0900 Subject: [PATCH] Add --header-border=inline / --header-lines-border=inline / --footer-border=inline 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). --- CHANGELOG.md | 19 +++ man/man1/fzf.1 | 16 +- src/options.go | 20 ++- src/terminal.go | 353 ++++++++++++++++++++++++++++++++++++-------- src/tui/light.go | 119 +++++++++++++++ src/tui/tcell.go | 133 +++++++++++++++++ src/tui/tui.go | 66 ++++++++- test/test_layout.rb | 59 ++++++++ 8 files changed, 714 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9586991a..10a905a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ CHANGELOG ========= +0.72.0 +------ +- `--header-border`, `--header-lines-border`, and `--footer-border` now accept + a new `inline` style that embeds the section inside the list frame, + separated from the list content by a horizontal line whose endpoints join + the surrounding list border as T-junctions. + - Requires `--list-border` with a line-drawing shape (rounded / sharp / + bold / double / horizontal); falls back to `line` otherwise. + - Works in every layout and supports stacking, e.g. + `--header-border=inline --header-lines-border=inline --footer-border=inline` + produces up to three internal separators inside the list frame. + - `--header-label` and `--footer-label` render on their respective + separator row. + - The separator inherits `--color list-border` when the section's own + border color is not explicitly set. + - `--header-first` is not compatible with `--header-border=inline` or + `--header-lines-border=inline`; `--header-border=inline` requires + `--header-lines-border` to be `inline` or unset. + 0.71.0 ------ _Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_ diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index a77b7a66..79214743 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -1100,7 +1100,13 @@ Print header before the prompt line. When both normal header and header lines .TP .BI "\-\-header\-border" [=STYLE] Draw border around the header section. \fBline\fR style draws a single -separator line between the header window and the list section. +separator line between the header window and the list section. \fBinline\fR +style embeds the header inside the list border frame, joined to the list +section by a horizontal separator with T-junctions; it requires a +line-drawing \fB\-\-list\-border\fR (rounded / sharp / bold / double / +horizontal) and falls back to \fBline\fR otherwise. Not compatible with +\fB\-\-header\-first\fR, and when \fB\-\-header\-lines\fR is also set +\fB\-\-header\-lines\-border\fR must also be \fBinline\fR. .TP .BI "\-\-header\-label" [=LABEL] @@ -1116,6 +1122,9 @@ Display header from \fB--header\-lines\fR with a separate border. Pass \fBnone\fR to still separate the header lines but without a border. To combine two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws a single separator line between the header lines and the list section. +\fBinline\fR style embeds the header lines inside the list border frame +with a T-junction separator; it requires a line-drawing +\fB\-\-list\-border\fR and is not compatible with \fB\-\-header\-first\fR. .SS FOOTER @@ -1129,7 +1138,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe .TP .BI "\-\-footer\-border" [=STYLE] Draw border around the footer section. \fBline\fR style draws a single -separator line between the footer and the list section. +separator line between the footer and the list section. \fBinline\fR style +embeds the footer inside the list border frame with a T-junction separator; +it requires a line-drawing \fB\-\-list\-border\fR and falls back to +\fBline\fR otherwise. .TP .BI "\-\-footer\-label" [=LABEL] diff --git a/src/options.go b/src/options.go index 4a012a04..40fe3a94 100644 --- a/src/options.go +++ b/src/options.go @@ -953,6 +953,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) { switch str { case "line": return tui.BorderLine, nil + case "inline": + return tui.BorderInline, nil case "rounded": return tui.BorderRounded, nil case "sharp": @@ -983,7 +985,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) { if optional && str == "" { return defaultBorderShape, nil } - return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)") + return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|line|inline|none)") } func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) { @@ -3610,6 +3612,22 @@ func validateOptions(opts *Options) error { return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)") } + if opts.BorderShape == tui.BorderInline || + opts.ListBorderShape == tui.BorderInline || + opts.InputBorderShape == tui.BorderInline || + opts.Preview.border == tui.BorderInline { + return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border") + } + if opts.HeaderFirst && (opts.HeaderBorderShape == tui.BorderInline || opts.HeaderLinesShape == tui.BorderInline) { + return errors.New("--header-first is not compatible with --header-border=inline or --header-lines-border=inline") + } + if opts.HeaderBorderShape == tui.BorderInline && + opts.HeaderLinesShape != tui.BorderInline && + opts.HeaderLinesShape != tui.BorderUndefined && + opts.HeaderLinesShape != tui.BorderNone { + return errors.New("--header-border=inline requires --header-lines-border to be inline or unset") + } + return nil } diff --git a/src/terminal.go b/src/terminal.go index 723a7827..9bc528ab 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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: diff --git a/src/tui/light.go b/src/tui/light.go index 77f15b9a..4fa4bf5d 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -1122,6 +1122,125 @@ func (w *LightWindow) DrawHBorder() { w.drawBorder(true) } +func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) { + if w.height == 0 { + return + } + if w.border.shape == BorderNone { + return + } + colorFor := func(wt WindowType) ColorPair { + switch wt { + case WindowList: + return ColListBorder + case WindowInput: + return ColInputBorder + case WindowHeader: + return ColHeaderBorder + case WindowFooter: + return ColFooterBorder + case WindowPreview: + return ColPreviewBorder + } + return ColBorder + } + // The separator is conceptually the section's inner edge (e.g. the bottom border of + // an inline header), so the whole row, junctions included, carries the section's + // fg + bg. + lineColor := colorFor(windowType) + junctionColor := lineColor + lineChar := w.border.top + if useBottom { + lineChar = w.border.bottom + } + hw := runeWidth(lineChar) + w.Move(row, 0) + if !w.border.shape.HasLeft() && !w.border.shape.HasRight() { + // No verticals to join, so draw a continuous horizontal across the full width. + full := max(0, w.width/hw) + rem := w.width - full*hw + w.CPrint(lineColor, repeat(lineChar, full)+repeat(' ', rem)) + return + } + lw := runeWidth(w.border.leftMid) + rw := runeWidth(w.border.rightMid) + inner := max(0, (w.width-lw-rw)/hw) + rem := (w.width - lw - rw) - inner*hw + w.CPrint(junctionColor, string(w.border.leftMid)) + w.CPrint(lineColor, repeat(lineChar, inner)+repeat(' ', rem)) + w.CPrint(junctionColor, string(w.border.rightMid)) +} + +func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, paintTopEdge, paintBottomEdge bool) { + if w.height == 0 || w.border.shape == BorderNone { + return + } + colorFor := func(wt WindowType) ColorPair { + switch wt { + case WindowList: + return ColListBorder + case WindowInput: + return ColInputBorder + case WindowHeader: + return ColHeaderBorder + case WindowFooter: + return ColFooterBorder + case WindowPreview: + return ColPreviewBorder + } + return ColBorder + } + color := colorFor(windowType) + shape := w.border.shape + hasLeft := shape.HasLeft() + hasRight := shape.HasRight() + rightW := runeWidth(w.border.right) + // Content rows: overpaint left/right verticals + their 1-char margin. + for row := topContent; row <= bottomContent; row++ { + if hasLeft { + w.Move(row, 0) + w.CPrint(color, string(w.border.left)+" ") + } + if hasRight { + w.Move(row, w.width-rightW-1) + w.CPrint(color, " "+string(w.border.right)) + } + } + // Top / bottom edges span the full width minus corner widths (no interior margin). + hw := runeWidth(w.border.top) + edgeW := w.width + if hasLeft { + edgeW -= runeWidth(w.border.topLeft) + } + if hasRight { + edgeW -= runeWidth(w.border.topRight) + } + if paintTopEdge && shape.HasTop() { + w.Move(0, 0) + if hasLeft { + w.CPrint(color, string(w.border.topLeft)) + } + inner := max(0, edgeW/hw) + rem := edgeW - inner*hw + w.CPrint(color, repeat(w.border.top, inner)+repeat(' ', rem)) + if hasRight { + w.CPrint(color, string(w.border.topRight)) + } + } + if paintBottomEdge && shape.HasBottom() { + w.Move(w.height-1, 0) + if hasLeft { + w.CPrint(color, string(w.border.bottomLeft)) + } + inner := max(0, edgeW/hw) + rem := edgeW - inner*hw + w.CPrint(color, repeat(w.border.bottom, inner)+repeat(' ', rem)) + if hasRight { + w.CPrint(color, string(w.border.bottomRight)) + } + } +} + func (w *LightWindow) drawBorder(onlyHorizontal bool) { if w.height == 0 { return diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 720187fa..460ef690 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -1017,6 +1017,139 @@ func (w *TcellWindow) DrawHBorder() { w.drawBorder(true) } +func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) { + if w.height == 0 { + return + } + shape := w.borderStyle.shape + if shape == BorderNone { + return + } + styleFor := func(wt WindowType) tcell.Style { + if !w.color { + return w.normal.style() + } + switch wt { + case WindowBase: + return ColBorder.style() + case WindowList: + return ColListBorder.style() + case WindowHeader: + return ColHeaderBorder.style() + case WindowFooter: + return ColFooterBorder.style() + case WindowInput: + return ColInputBorder.style() + case WindowPreview: + return ColPreviewBorder.style() + } + return w.normal.style() + } + // The separator is conceptually the section's inner edge (e.g. the bottom border of + // an inline header), so the whole row, junctions included, carries the section's + // fg + bg. + lineStyle := styleFor(windowType) + junctionStyle := lineStyle + lineChar := w.borderStyle.top + if useBottom { + lineChar = w.borderStyle.bottom + } + y := w.top + row + left := w.left + right := left + w.width + hw := runeWidth(lineChar) + hasVert := shape.HasLeft() || shape.HasRight() + if !hasVert { + // No verticals to join, so draw a continuous horizontal across the full width. + for x := left; x <= right-hw; x += hw { + _screen.SetContent(x, y, lineChar, nil, lineStyle) + } + return + } + leftMidW := runeWidth(w.borderStyle.leftMid) + rightMidW := runeWidth(w.borderStyle.rightMid) + max := right - leftMidW - rightMidW + for x := left + leftMidW; x <= max; x += hw { + _screen.SetContent(x, y, lineChar, nil, lineStyle) + } + _screen.SetContent(left, y, w.borderStyle.leftMid, nil, junctionStyle) + _screen.SetContent(right-rightMidW, y, w.borderStyle.rightMid, nil, junctionStyle) +} + +func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, paintTopEdge, paintBottomEdge bool) { + if w.height == 0 { + return + } + shape := w.borderStyle.shape + if shape == BorderNone { + return + } + var style tcell.Style + if w.color { + switch windowType { + case WindowBase: + style = ColBorder.style() + case WindowList: + style = ColListBorder.style() + case WindowHeader: + style = ColHeaderBorder.style() + case WindowFooter: + style = ColFooterBorder.style() + case WindowInput: + style = ColInputBorder.style() + case WindowPreview: + style = ColPreviewBorder.style() + } + } else { + style = w.normal.style() + } + left := w.left + right := left + w.width + hasLeft := shape.HasLeft() + hasRight := shape.HasRight() + leftW := runeWidth(w.borderStyle.left) + rightW := runeWidth(w.borderStyle.right) + // Content rows: overpaint the left and right verticals (+ their 1-char margin) in + // the section's color. Inner margin stays at whatever bg the sub-window set. + for row := topContent; row <= bottomContent; row++ { + y := w.top + row + if hasLeft { + _screen.SetContent(left, y, w.borderStyle.left, nil, style) + _screen.SetContent(left+leftW, y, ' ', nil, style) + } + if hasRight { + _screen.SetContent(right-rightW-1, y, ' ', nil, style) + _screen.SetContent(right-rightW, y, w.borderStyle.right, nil, style) + } + } + // Top / bottom outer edge: overpaint the horizontal run and corners. + hw := runeWidth(w.borderStyle.top) + if paintTopEdge && shape.HasTop() { + y := w.top + for x := left; x <= right-hw; x += hw { + _screen.SetContent(x, y, w.borderStyle.top, nil, style) + } + if hasLeft { + _screen.SetContent(left, y, w.borderStyle.topLeft, nil, style) + } + if hasRight { + _screen.SetContent(right-runeWidth(w.borderStyle.topRight), y, w.borderStyle.topRight, nil, style) + } + } + if paintBottomEdge && shape.HasBottom() { + y := w.top + w.height - 1 + for x := left; x <= right-hw; x += hw { + _screen.SetContent(x, y, w.borderStyle.bottom, nil, style) + } + if hasLeft { + _screen.SetContent(left, y, w.borderStyle.bottomLeft, nil, style) + } + if hasRight { + _screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), y, w.borderStyle.bottomRight, nil, style) + } + } +} + func (w *TcellWindow) drawBorder(onlyHorizontal bool) { if w.height == 0 { return diff --git a/src/tui/tui.go b/src/tui/tui.go index c45e866f..5c06c06a 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -409,6 +409,14 @@ func (p ColorPair) WithUl(ul Color) ColorPair { return dup } +// WithBgColor replaces just the background color, leaving fg/ul/attr alone. +// (Named differently from WithBg, which expects a full ColorAttr and merges attrs.) +func (p ColorPair) WithBgColor(bg Color) ColorPair { + dup := p + dup.bg = bg + return dup +} + func (p ColorPair) Attr() Attr { return p.attr } @@ -595,11 +603,12 @@ const ( BorderBottom BorderLeft BorderRight + BorderInline ) func (s BorderShape) HasLeft() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left return false } return true @@ -607,7 +616,7 @@ func (s BorderShape) HasLeft() bool { func (s BorderShape) HasRight() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right return false } return true @@ -615,7 +624,7 @@ func (s BorderShape) HasRight() bool { func (s BorderShape) HasTop() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top return false } return true @@ -623,7 +632,7 @@ func (s BorderShape) HasTop() bool { func (s BorderShape) HasBottom() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom return false } return true @@ -643,6 +652,8 @@ type BorderStyle struct { topRight rune bottomLeft rune bottomRight rune + leftMid rune + rightMid rune } type BorderCharacter int @@ -658,7 +669,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topLeft: ' ', topRight: ' ', bottomLeft: ' ', - bottomRight: ' '} + bottomRight: ' ', + leftMid: ' ', + rightMid: ' '} } if !unicode { return BorderStyle{ @@ -671,6 +684,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '+', bottomLeft: '+', bottomRight: '+', + leftMid: '+', + rightMid: '+', } } switch shape { @@ -685,6 +700,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '┐', bottomLeft: '└', bottomRight: '┘', + leftMid: '├', + rightMid: '┤', } case BorderBold: return BorderStyle{ @@ -697,6 +714,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '┓', bottomLeft: '┗', bottomRight: '┛', + leftMid: '┣', + rightMid: '┫', } case BorderBlock: // ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ @@ -712,6 +731,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '▜', bottomLeft: '▙', bottomRight: '▟', + leftMid: '▌', + rightMid: '▐', } case BorderThinBlock: @@ -728,6 +749,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '🭾', bottomLeft: '🭼', bottomRight: '🭿', + leftMid: '▏', + rightMid: '▕', } case BorderDouble: @@ -741,6 +764,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '╗', bottomLeft: '╚', bottomRight: '╝', + leftMid: '╠', + rightMid: '╣', } } return BorderStyle{ @@ -753,6 +778,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '╮', bottomLeft: '╰', bottomRight: '╯', + leftMid: '├', + rightMid: '┤', } } @@ -811,6 +838,18 @@ type Window interface { DrawBorder() DrawHBorder() + // DrawHSeparator draws an inline horizontal separator at `row` (relative to the + // window's top) using the color for `windowType`. When useBottom is true, the + // `bottom` horizontal char is used instead of `top`. For thinblock/block styles + // where the two characters differ, this keeps the thin line visually bonded to + // the list content that sits on the opposite side of the separator. + DrawHSeparator(row int, windowType WindowType, useBottom bool) + // PaintSectionFrame overpaints the border cells around the rows [topContent, + // bottomContent] (inclusive, relative to the window's top) with the color for + // `windowType`. When paintTopEdge / paintBottomEdge are true, the top / bottom + // edge rows of the frame (outer horizontal + corners) are also painted, letting + // an inline section claim the adjacent corner of the outer frame. + PaintSectionFrame(topContent, bottomContent int, windowType WindowType, paintTopEdge, paintBottomEdge bool) Refresh() FinishFill() @@ -1166,7 +1205,7 @@ func init() { } } -func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool) { +func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool, headerInline bool, footerInline bool) { if forceBlack { theme.Bg = ColorAttr{colBlack, AttrUndefined} } @@ -1300,11 +1339,22 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac } else { theme.HeaderBg = o(theme.Bg, theme.ListBg) } - theme.HeaderBorder = o(theme.Border, theme.HeaderBorder) + // Inline header/footer borders sit inside the list frame, so default their color + // to the list-border color when the user has not explicitly set it. The inline + // separator then matches the surrounding frame. + headerBorderFallback := theme.Border + if headerInline { + headerBorderFallback = theme.ListBorder + } + theme.HeaderBorder = o(headerBorderFallback, theme.HeaderBorder) theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel) theme.FooterBg = o(theme.Bg, theme.FooterBg) - theme.FooterBorder = o(theme.Border, theme.FooterBorder) + footerBorderFallback := theme.Border + if footerInline { + footerBorderFallback = theme.ListBorder + } + theme.FooterBorder = o(footerBorderFallback, theme.FooterBorder) theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel) if theme.Nomatch.IsUndefined() { diff --git a/test/test_layout.rb b/test/test_layout.rb index 2a521bf6..85a0e36b 100644 --- a/test/test_layout.rb +++ b/test/test_layout.rb @@ -1392,5 +1392,64 @@ class TestLayout < TestInteractive input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)", clicks: clicks) end + + # Inline header inside a rounded list border. + define_method(:"test_click_header_border_inline_#{slug}") do + opts = %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc' ) + verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS) + end + + # Inline header inside a horizontal list border (top+bottom only, no T-junctions). + define_method(:"test_click_header_border_inline_horizontal_list_#{slug}") do + opts = %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc' ) + verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS) + end + + # Inline header-lines inside a rounded list border. + define_method(:"test_click_header_lines_border_inline_#{slug}") do + clicks_hl = if layout == 'default' + [%w[Xaa 3], %w[Ybb 2], %w[Zcc 1]] + else + [%w[Xaa 1], %w[Ybb 2], %w[Zcc 3]] + end + opts = %(--layout=#{layout} --style full --header-lines 3 ) + verify_clicks(kind: :header, opts: opts, + input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)", + clicks: clicks_hl) + end + + # Inline footer inside a rounded list border. + define_method(:"test_click_footer_border_inline_#{slug}") do + opts = %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz' ) + verify_clicks(kind: :footer, opts: opts, input: 'seq 5', + clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]]) + end + end + + # An inline section requesting far more rows than the terminal can fit must not + # break the layout. The list frame must still render inside the pane with both + # corners visible and the prompt line present. + def test_inline_header_lines_oversized + tmux.send_keys %(seq 10000 | #{FZF} --style full --header-border inline --header-lines 9999), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + lines = tmux.capture + # Rounded (light) and sharp (tcell) default border glyphs. + top_corners = /[╭┌]/ + bottom_corners = /[╰└]/ + assert(lines.any? { |l| l.match?(top_corners) }, "list frame top missing: #{lines.inspect}") + assert(lines.any? { |l| l.match?(bottom_corners) }, "list frame bottom missing: #{lines.inspect}") + assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}") + tmux.send_keys 'Escape' + end + + # A non-inline section that consumes all available rows must still render without + # crashing when another section is inline but has no budget. The inline section's + # content is clipped to 0 but the layout proceeds. + def test_inline_footer_starved_by_non_inline_header + tmux.send_keys %(seq 10000 | #{FZF} --style full --footer-border inline --footer "$(seq 1000)" --header "$(seq 1000)"), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + lines = tmux.capture + assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}") + tmux.send_keys 'Escape' end end