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..bbf8640c 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,81 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } } + // 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 + } + // Slices are ordered outer-to-inner: index 0 touches wborder's edge. + var inlineTop []inlineSlot + var inlineBottom []inlineSlot + + // Returns (onTop: top stack vs bottom; windowType; isInner: adjacent to list content). + // 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. + inlineMetaFor := func(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 + } + + // 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 := 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 +2453,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 +2619,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 +2663,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 +2695,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 +2733,68 @@ 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) + } + + placeInlineSection := func(win tui.Window, role inlineRole) { + switch role { + case inlineRoleHeader: + t.headerWindow = win + case inlineRoleHeaderLines: + t.headerLinesWindow = win + case inlineRoleFooter: + t.footerWindow = win + } + } + // 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. + placeInlineStack := func(slots []inlineSlot, startRow int, onTop bool) { + firstEdge := tui.SectionEdgeTop + if !onTop { + firstEdge = tui.SectionEdgeBottom + } + 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) + 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 + } + } + } + if len(inlineTop)+len(inlineBottom) > 0 && t.wborder != nil { + placeInlineStack(inlineTop, t.window.Top()-inlineTopLines, true) + placeInlineStack(inlineBottom, t.window.Top()+t.window.Height()+inlineBottomLines-1, false) } if len(t.scrollbar) == 0 { @@ -2700,7 +2876,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 +2904,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 +2937,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 +2954,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 +2976,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 +3410,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 +3475,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 +6153,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..37af0954 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -1122,127 +1122,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..32d3cc8e 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,42 @@ 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: + if hasLeft { for y := top; y < bot; y++ { _screen.SetContent(left, y, w.borderStyle.left, nil, style) } } - switch shape { - case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight: + if hasRight { vw := runeWidth(w.borderStyle.right) for y := top; y < bot; y++ { _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/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