From d754cfab8767d60e9c1b946e6ffc5b6e2e711cf2 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 18 Apr 2026 19:34:56 +0900 Subject: [PATCH] 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. --- CHANGELOG.md | 16 ++ man/man1/fzf.1 | 17 +- src/options.go | 25 ++- src/terminal.go | 368 ++++++++++++++++++++++++++++++++++++-------- src/tui/light.go | 233 +++++++++++++++------------- src/tui/tcell.go | 187 +++++++++++++++------- src/tui/tui.go | 88 ++++++++++- test/lib/common.rb | 60 ++++++++ test/test_layout.rb | 154 ++++++++++++++++++ 9 files changed, 906 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9586991a..7091e770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ 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. + - Sections stack. Example combining all three: + ```sh + ps -ef | fzf --reverse --style full:double \ + --header 'Select a process' --header-lines 1 \ + --bind 'load:transform-footer:echo $FZF_TOTAL_COUNT processes' \ + --header-border=inline --header-lines-border=inline \ + --footer-border=inline + ``` + - `--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..800b99a1 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,10 @@ 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, falls back to \fBline\fR otherwise, and is not +compatible with \fB\-\-header\-first\fR. .SS FOOTER @@ -1129,7 +1139,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..9e721144 100644 --- a/src/options.go +++ b/src/options.go @@ -178,10 +178,11 @@ Usage: fzf [options] --header-first Print header before the prompt line --header-border[=STYLE] Draw border around the header section [rounded|sharp|bold|block|thinblock|double|horizontal|vertical| - top|bottom|left|right|line|none] (default: rounded) + top|bottom|left|right|line|inline|none] (default: rounded) --header-lines-border[=STYLE] Display header from --header-lines with a separate border. Pass 'none' to still separate it but without a border. + Pass 'inline' to embed it inside the list frame. --header-label=LABEL Label to print on the header border --header-label-pos=COL Position of the header label [POSITIVE_INTEGER: columns from left| @@ -192,7 +193,7 @@ Usage: fzf [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) + top|bottom|left|right|line|inline|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| @@ -953,6 +954,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 +986,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 +3613,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..199bb22f 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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: diff --git a/src/tui/light.go b/src/tui/light.go index dd980901..2c661808 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -1129,127 +1129,142 @@ func (w *LightWindow) DrawHBorder() { w.drawBorder(true) } +// drawHLine fills row `row` with `line` between optional left/right caps. +// A zero rune means "no cap"; caps are placed at the very edges of `w`. +func (w *LightWindow) drawHLine(row int, line, leftCap, rightCap rune, color ColorPair) { + w.Move(row, 0) + hw := runeWidth(line) + width := w.width + if leftCap != 0 { + w.CPrint(color, string(leftCap)) + width -= runeWidth(leftCap) + } + if rightCap != 0 { + width -= runeWidth(rightCap) + } + if width < 0 { + width = 0 + } + inner := width / hw + rem := width - inner*hw + w.CPrint(color, repeat(line, inner)+repeat(' ', rem)) + if rightCap != 0 { + w.CPrint(color, string(rightCap)) + } +} + +func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) { + if w.height == 0 { + return + } + shape := w.border.shape + if shape == BorderNone { + return + } + color := BorderColor(windowType) + line := w.border.top + if useBottom { + line = w.border.bottom + } + var leftCap, rightCap rune + if shape.HasLeft() || shape.HasRight() { + leftCap = w.border.leftMid + rightCap = w.border.rightMid + } + w.drawHLine(row, line, leftCap, rightCap, color) +} + +func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) { + if w.height == 0 || w.border.shape == BorderNone { + return + } + color := BorderColor(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)) + } + } + if edge == SectionEdgeTop && shape.HasTop() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.border.topLeft + } + if hasRight { + rightCap = w.border.topRight + } + w.drawHLine(0, w.border.top, leftCap, rightCap, color) + } + if edge == SectionEdgeBottom && shape.HasBottom() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.border.bottomLeft + } + if hasRight { + rightCap = w.border.bottomRight + } + w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color) + } +} + func (w *LightWindow) drawBorder(onlyHorizontal bool) { if w.height == 0 { return } - switch w.border.shape { - case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble: - w.drawBorderAround(onlyHorizontal) - case BorderHorizontal: - w.drawBorderHorizontal(true, true) - case BorderVertical: - if onlyHorizontal { - return - } - w.drawBorderVertical(true, true) - case BorderTop: - w.drawBorderHorizontal(true, false) - case BorderBottom: - w.drawBorderHorizontal(false, true) - case BorderLeft: - if onlyHorizontal { - return - } - w.drawBorderVertical(true, false) - case BorderRight: - if onlyHorizontal { - return - } - w.drawBorderVertical(false, true) + shape := w.border.shape + if shape == BorderNone { + return } -} + color := BorderColor(w.windowType) + hasLeft := shape.HasLeft() + hasRight := shape.HasRight() -func (w *LightWindow) drawBorderHorizontal(top, bottom bool) { - color := ColBorder - switch w.windowType { - case WindowList: - color = ColListBorder - case WindowInput: - color = ColInputBorder - case WindowHeader: - color = ColHeaderBorder - case WindowFooter: - color = ColFooterBorder - case WindowPreview: - color = ColPreviewBorder - } - hw := runeWidth(w.border.top) - if top { - w.Move(0, 0) - w.CPrint(color, repeat(w.border.top, w.width/hw)) - } - - if bottom { - w.Move(w.height-1, 0) - w.CPrint(color, repeat(w.border.bottom, w.width/hw)) - } -} - -func (w *LightWindow) drawBorderVertical(left, right bool) { - vw := runeWidth(w.border.left) - color := ColBorder - switch w.windowType { - case WindowList: - color = ColListBorder - case WindowInput: - color = ColInputBorder - case WindowHeader: - color = ColHeaderBorder - case WindowFooter: - color = ColFooterBorder - case WindowPreview: - color = ColPreviewBorder - } - for y := 0; y < w.height; y++ { - if left { - w.Move(y, 0) - w.CPrint(color, string(w.border.left)) - w.CPrint(color, " ") // Margin + if shape.HasTop() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.border.topLeft } - if right { - w.Move(y, w.width-vw-1) - w.CPrint(color, " ") // Margin - w.CPrint(color, string(w.border.right)) + if hasRight { + rightCap = w.border.topRight } + w.drawHLine(0, w.border.top, leftCap, rightCap, color) } -} - -func (w *LightWindow) drawBorderAround(onlyHorizontal bool) { - w.Move(0, 0) - color := ColBorder - switch w.windowType { - case WindowList: - color = ColListBorder - case WindowInput: - color = ColInputBorder - case WindowHeader: - color = ColHeaderBorder - case WindowFooter: - color = ColFooterBorder - case WindowPreview: - color = ColPreviewBorder - } - hw := runeWidth(w.border.top) - tcw := runeWidth(w.border.topLeft) + runeWidth(w.border.topRight) - bcw := runeWidth(w.border.bottomLeft) + runeWidth(w.border.bottomRight) - rem := (w.width - tcw) % hw - w.CPrint(color, string(w.border.topLeft)+repeat(w.border.top, (w.width-tcw)/hw)+repeat(' ', rem)+string(w.border.topRight)) - if !onlyHorizontal { + if !onlyHorizontal && (hasLeft || hasRight) { vw := runeWidth(w.border.left) - for y := 1; y < w.height-1; y++ { - w.Move(y, 0) - w.CPrint(color, string(w.border.left)) - w.CPrint(color, " ") // Margin - - w.Move(y, w.width-vw-1) - w.CPrint(color, " ") // Margin - w.CPrint(color, string(w.border.right)) + for y := 0; y < w.height; y++ { + // Corner rows are already painted by drawHLine above / below. + if (y == 0 && shape.HasTop()) || (y == w.height-1 && shape.HasBottom()) { + continue + } + if hasLeft { + w.Move(y, 0) + w.CPrint(color, string(w.border.left)+" ") + } + if hasRight { + w.Move(y, w.width-vw-1) + w.CPrint(color, " "+string(w.border.right)) + } } } - w.Move(w.height-1, 0) - rem = (w.width - bcw) % hw - w.CPrint(color, string(w.border.bottomLeft)+repeat(w.border.bottom, (w.width-bcw)/hw)+repeat(' ', rem)+string(w.border.bottomRight)) + if shape.HasBottom() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.border.bottomLeft + } + if hasRight { + rightCap = w.border.bottomRight + } + w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color) + } } func (w *LightWindow) csi(code string) string { diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 720187fa..55256592 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -1017,6 +1017,113 @@ func (w *TcellWindow) DrawHBorder() { w.drawBorder(true) } +// borderStyleFor returns the tcell.Style used to draw borders for `wt`, honoring +// whether the window is rendering with colors. +func (w *TcellWindow) borderStyleFor(wt WindowType) tcell.Style { + if !w.color { + return w.normal.style() + } + return BorderColor(wt).style() +} + +// drawHLine fills row `y` with `line` between optional left/right caps. +// A zero rune means "no cap"; caps are placed at the very edges of `w`. +// tcell has an issue displaying two overlapping wide runes, so the line +// stops before the cap position rather than overpainting. +func (w *TcellWindow) drawHLine(y int, line, leftCap, rightCap rune, style tcell.Style) { + left := w.left + right := left + w.width + hw := runeWidth(line) + lw := 0 + rw := 0 + if leftCap != 0 { + lw = runeWidth(leftCap) + } + if rightCap != 0 { + rw = runeWidth(rightCap) + } + for x := left + lw; x <= right-rw-hw; x += hw { + _screen.SetContent(x, y, line, nil, style) + } + if leftCap != 0 { + _screen.SetContent(left, y, leftCap, nil, style) + } + if rightCap != 0 { + _screen.SetContent(right-rw, y, rightCap, nil, style) + } +} + +func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) { + if w.height == 0 { + return + } + shape := w.borderStyle.shape + if shape == BorderNone { + return + } + style := w.borderStyleFor(windowType) + line := w.borderStyle.top + if useBottom { + line = w.borderStyle.bottom + } + var leftCap, rightCap rune + if shape.HasLeft() || shape.HasRight() { + leftCap = w.borderStyle.leftMid + rightCap = w.borderStyle.rightMid + } + w.drawHLine(w.top+row, line, leftCap, rightCap, style) +} + +func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) { + if w.height == 0 { + return + } + shape := w.borderStyle.shape + if shape == BorderNone { + return + } + style := w.borderStyleFor(windowType) + 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) + } + } + if edge == SectionEdgeTop && shape.HasTop() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.borderStyle.topLeft + } + if hasRight { + rightCap = w.borderStyle.topRight + } + w.drawHLine(w.top, w.borderStyle.top, leftCap, rightCap, style) + } + if edge == SectionEdgeBottom && shape.HasBottom() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.borderStyle.bottomLeft + } + if hasRight { + rightCap = w.borderStyle.bottomRight + } + w.drawHLine(w.top+w.height-1, w.borderStyle.bottom, leftCap, rightCap, style) + } +} + func (w *TcellWindow) drawBorder(onlyHorizontal bool) { if w.height == 0 { return @@ -1031,72 +1138,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) { top := w.top bot := top + w.height - var style tcell.Style - if w.color { - switch w.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() - } + style := w.borderStyleFor(w.windowType) - hw := runeWidth(w.borderStyle.top) - switch shape { - case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop: - max := right - 2*hw - if shape == BorderHorizontal || shape == BorderTop { - max = right - hw + hasLeft := shape.HasLeft() + hasRight := shape.HasRight() + + if shape.HasTop() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.borderStyle.topLeft } - // tcell has an issue displaying two overlapping wide runes - // e.g. SetContent( HH ) - // SetContent( TR ) - // ================== - // ( HH ) => TR is ignored - for x := left; x <= max; x += hw { - _screen.SetContent(x, top, w.borderStyle.top, nil, style) + if hasRight { + rightCap = w.borderStyle.topRight } + w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style) } - switch shape { - case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom: - max := right - 2*hw - if shape == BorderHorizontal || shape == BorderBottom { - max = right - hw + if shape.HasBottom() { + var leftCap, rightCap rune + if hasLeft { + leftCap = w.borderStyle.bottomLeft } - for x := left; x <= max; x += hw { - _screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style) + if hasRight { + rightCap = w.borderStyle.bottomRight } + w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style) } if !onlyHorizontal { - switch shape { - case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft: - for y := top; y < bot; y++ { + vw := runeWidth(w.borderStyle.right) + for y := top; y < bot; y++ { + // Corner rows are already painted by drawHLine above / below. + if (y == top && shape.HasTop()) || (y == bot-1 && shape.HasBottom()) { + continue + } + if hasLeft { _screen.SetContent(left, y, w.borderStyle.left, nil, style) } - } - switch shape { - case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight: - vw := runeWidth(w.borderStyle.right) - for y := top; y < bot; y++ { + if hasRight { _screen.SetContent(right-vw, y, w.borderStyle.right, nil, style) } } } - switch shape { - case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble: - _screen.SetContent(left, top, w.borderStyle.topLeft, nil, style) - _screen.SetContent(right-runeWidth(w.borderStyle.topRight), top, w.borderStyle.topRight, nil, style) - _screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style) - _screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), bot-1, w.borderStyle.bottomRight, nil, style) - } } diff --git a/src/tui/tui.go b/src/tui/tui.go index c45e866f..9dc1e7d4 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -595,11 +595,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 +608,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 +616,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 +624,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 +644,8 @@ type BorderStyle struct { topRight rune bottomLeft rune bottomRight rune + leftMid rune + rightMid rune } type BorderCharacter int @@ -658,7 +661,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topLeft: ' ', topRight: ' ', bottomLeft: ' ', - bottomRight: ' '} + bottomRight: ' ', + leftMid: ' ', + rightMid: ' '} } if !unicode { return BorderStyle{ @@ -671,6 +676,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '+', bottomLeft: '+', bottomRight: '+', + leftMid: '+', + rightMid: '+', } } switch shape { @@ -685,6 +692,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '┐', bottomLeft: '└', bottomRight: '┘', + leftMid: '├', + rightMid: '┤', } case BorderBold: return BorderStyle{ @@ -697,6 +706,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '┓', bottomLeft: '┗', bottomRight: '┛', + leftMid: '┣', + rightMid: '┫', } case BorderBlock: // ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ @@ -712,6 +723,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '▜', bottomLeft: '▙', bottomRight: '▟', + leftMid: '▌', + rightMid: '▐', } case BorderThinBlock: @@ -728,6 +741,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '🭾', bottomLeft: '🭼', bottomRight: '🭿', + leftMid: '▏', + rightMid: '▕', } case BorderDouble: @@ -741,6 +756,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '╗', bottomLeft: '╚', bottomRight: '╝', + leftMid: '╠', + rightMid: '╣', } } return BorderStyle{ @@ -753,6 +770,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '╮', bottomLeft: '╰', bottomRight: '╯', + leftMid: '├', + rightMid: '┤', } } @@ -774,6 +793,35 @@ const ( WindowFooter ) +// BorderColor returns the ColorPair used to draw borders for the given WindowType. +func BorderColor(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 +} + +// SectionEdge selects which outer edge of the frame an inline section +// should claim when PaintSectionFrame overpaints its adjacent border. +// SectionEdgeNone paints only the inner verticals (for sections that +// don't touch the outer top or bottom). +type SectionEdge int + +const ( + SectionEdgeNone SectionEdge = iota + SectionEdgeTop + SectionEdgeBottom +) + type Renderer interface { DefaultTheme() *ColorTheme Init() error @@ -811,6 +859,19 @@ type Window interface { DrawBorder() DrawHBorder() + // DrawHSeparator draws an inline horizontal separator at `row` (relative to the + // window's top) using the color for `windowType`. The separator is conceptually + // the section's inner edge (e.g. the bottom border of an inline header), so the + // whole row including junctions carries the section's fg + bg. When useBottom is + // true the `bottom` horizontal char is used instead of `top`; for thinblock/block + // styles this keeps the thin line bonded to the list content on the opposite side. + 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 edge is SectionEdgeTop / SectionEdgeBottom, the + // corresponding outer horizontal (+ corners) is also painted, letting the + // inline section claim that edge of the outer frame. + PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) Refresh() FinishFill() @@ -1166,7 +1227,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 +1361,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/lib/common.rb b/test/lib/common.rb index 0b98e480..f81e246a 100644 --- a/test/lib/common.rb +++ b/test/lib/common.rb @@ -130,6 +130,66 @@ class Tmux go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse end + # 3-bit ANSI bg code (40..47) -> color name used in --color options. + BG_NAMES = %w[black red green yellow blue magenta cyan white].freeze + + # Parse `tmux capture-pane -e` output into per-row bg ranges. Each row is an + # array of [col_start, col_end, bg] tuples where bg is one of: + # 'default' + # 'red' / 'green' / 'blue' / ... (3-bit names) + # 'bright-red' / ... (bright variants) + # '256:' (256-color fallback) + # ANSI state persists across rows, matching real terminal behavior. + def bg_ranges + raw = go(%W[capture-pane -p -J -e -t #{win}]) + bg = 'default' + raw.map do |row| + cells = [] + i = 0 + len = row.length + while i < len + c = row[i] + if c == "\e" && row[i + 1] == '[' + j = i + 2 + j += 1 while j < len && row[j] != 'm' + parts = row[i + 2...j].split(';') + k = 0 + while k < parts.length + p = parts[k].to_i + case p + when 0, 49 then bg = 'default' + when 40..47 then bg = BG_NAMES[p - 40] + when 100..107 then bg = "bright-#{BG_NAMES[p - 100]}" + when 48 + if parts[k + 1] == '5' + bg = "256:#{parts[k + 2]}" + k += 2 + elsif parts[k + 1] == '2' + bg = "rgb:#{parts[k + 2]}:#{parts[k + 3]}:#{parts[k + 4]}" + k += 4 + end + end + k += 1 + end + i = j + 1 + else + cells << bg + i += 1 + end + end + ranges = [] + start = 0 + cells.each_with_index do |b, idx| + if idx.positive? && b != cells[idx - 1] + ranges << [start, idx - 1, cells[idx - 1]] + start = idx + end + end + ranges << [start, cells.length - 1, cells.last] unless cells.empty? + ranges + end + end + def until(refresh = false, timeout: DEFAULT_TIMEOUT) lines = nil begin diff --git a/test/test_layout.rb b/test/test_layout.rb index 2a521bf6..f5388259 100644 --- a/test/test_layout.rb +++ b/test/test_layout.rb @@ -1392,5 +1392,159 @@ 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' --header-border=inline) + 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 --list-border=horizontal --header $'Aaa\\nBbb\\nCcc' --header-border=inline) + 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 --header-lines-border=inline) + 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' --footer-border=inline) + 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 + + # Without a line-drawing --list-border, --header-border=inline must silently + # fall back to the `line` style (documented behavior). + def test_inline_falls_back_without_list_border + tmux.send_keys %(seq 5 | #{FZF} --list-border=none --header HEADER --header-border=inline), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + lines = tmux.capture + assert(lines.any? { |l| l.include?('HEADER') }, "header missing: #{lines.inspect}") + # Neither list frame corners (rounded/sharp) nor T-junction runes appear, + # since we've fallen back to a plain line separator. + assert(lines.none? { |l| l.match?(/[╭╮╰╯┌┐└┘├┤]/) }, "unexpected frame glyphs: #{lines.inspect}") + tmux.send_keys 'Escape' + end + + # Invalid inline combinations must be rejected at startup. + def test_inline_rejected_on_unsupported_options + [ + ['--border=inline', 'inline border is only supported'], + ['--list-border=inline', 'inline border is only supported'], + ['--input-border=inline', 'inline border is only supported'], + ['--preview-window=border-inline --preview :', 'invalid preview window option: border-inline'], + ['--header-first --header-border=inline', '--header-first is not compatible'], + ['--header-first --header-lines-border=inline --header-lines=1', '--header-first is not compatible'], + ['--header-border=inline --header-lines-border=sharp --header-lines=1', + '--header-border=inline requires --header-lines-border to be inline or unset'] + ].each do |args, expected| + output = `#{FZF} #{args} < /dev/null 2>&1` + refute_equal 0, $CHILD_STATUS.exitstatus, "expected non-zero exit for: #{args}" + assert_includes output, expected, "wrong error for: #{args}" + end + end + + private + + # Count rows whose entire width is a single `color` range. + def count_full_rows(ranges_by_row, color) + ranges_by_row.count { |r| r.length == 1 && r[0][2] == color } + end + + # Wait until `tmux.bg_ranges` has at least `count` fully-`color` rows; return them. + def wait_for_full_rows(color, count) + ranges = nil + tmux.until do |_| + ranges = tmux.bg_ranges + count_full_rows(ranges, color) >= count + end + ranges + end + + public + + # Inline header's entire section (outer edge + content-row verticals + separator) + # carries the header-bg color; list rows below carry list-bg. + def test_inline_header_bg_color + tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + # 3 fully-red rows: top edge, header content, separator. + ranges = wait_for_full_rows('red', 3) + assert_equal_org(3, count_full_rows(ranges, 'red')) + # List rows below (>=5) are fully green. + assert_operator count_full_rows(ranges, 'green'), :>=, 5 + tmux.send_keys 'Escape' + end + + # Regression: when --header-lines-border=inline is the only inline section + # (no --header-border), the section must still use header-bg, not list-bg. + def test_inline_header_lines_bg_without_main_header + tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header-lines 2 --header-lines-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + # Top edge + 2 content rows + separator = 4 fully-red rows. + ranges = wait_for_full_rows('red', 4) + assert_equal_org(4, count_full_rows(ranges, 'red')) + tmux.send_keys 'Escape' + end + + # Inline footer's entire section carries footer-bg; list rows above carry list-bg. + def test_inline_footer_bg_color + tmux.send_keys %(seq 5 | #{FZF} --list-border --footer FOOTER --footer-border=inline --color=bg:-1,footer-border:white,list-border:white,footer-bg:blue,list-bg:green), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + ranges = wait_for_full_rows('blue', 3) + assert_equal_org(3, count_full_rows(ranges, 'blue')) + tmux.send_keys 'Escape' + end + + # The list-label's bg is swapped to match the adjacent inline section so it reads as + # part of the section frame rather than a list-colored island on a section-colored edge. + def test_list_label_bg_on_inline_section_edge + tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --list-label=LL --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green,list-label:yellow:bold), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + # The label sits on the header-owned top edge, so the entire row must be a + # single red run (no green breaks where the label cells are). + ranges = wait_for_full_rows('red', 3) + assert_operator count_full_rows(ranges, 'red'), :>=, 3 + tmux.send_keys 'Escape' end end