diff --git a/CHANGELOG.md b/CHANGELOG.md index 9586991a..10a905a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ CHANGELOG ========= +0.72.0 +------ +- `--header-border`, `--header-lines-border`, and `--footer-border` now accept + a new `inline` style that embeds the section inside the list frame, + separated from the list content by a horizontal line whose endpoints join + the surrounding list border as T-junctions. + - Requires `--list-border` with a line-drawing shape (rounded / sharp / + bold / double / horizontal); falls back to `line` otherwise. + - Works in every layout and supports stacking, e.g. + `--header-border=inline --header-lines-border=inline --footer-border=inline` + produces up to three internal separators inside the list frame. + - `--header-label` and `--footer-label` render on their respective + separator row. + - The separator inherits `--color list-border` when the section's own + border color is not explicitly set. + - `--header-first` is not compatible with `--header-border=inline` or + `--header-lines-border=inline`; `--header-border=inline` requires + `--header-lines-border` to be `inline` or unset. + 0.71.0 ------ _Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_ diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index a77b7a66..79214743 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -1100,7 +1100,13 @@ Print header before the prompt line. When both normal header and header lines .TP .BI "\-\-header\-border" [=STYLE] Draw border around the header section. \fBline\fR style draws a single -separator line between the header window and the list section. +separator line between the header window and the list section. \fBinline\fR +style embeds the header inside the list border frame, joined to the list +section by a horizontal separator with T-junctions; it requires a +line-drawing \fB\-\-list\-border\fR (rounded / sharp / bold / double / +horizontal) and falls back to \fBline\fR otherwise. Not compatible with +\fB\-\-header\-first\fR, and when \fB\-\-header\-lines\fR is also set +\fB\-\-header\-lines\-border\fR must also be \fBinline\fR. .TP .BI "\-\-header\-label" [=LABEL] @@ -1116,6 +1122,9 @@ Display header from \fB--header\-lines\fR with a separate border. Pass \fBnone\fR to still separate the header lines but without a border. To combine two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws a single separator line between the header lines and the list section. +\fBinline\fR style embeds the header lines inside the list border frame +with a T-junction separator; it requires a line-drawing +\fB\-\-list\-border\fR and is not compatible with \fB\-\-header\-first\fR. .SS FOOTER @@ -1129,7 +1138,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe .TP .BI "\-\-footer\-border" [=STYLE] Draw border around the footer section. \fBline\fR style draws a single -separator line between the footer and the list section. +separator line between the footer and the list section. \fBinline\fR style +embeds the footer inside the list border frame with a T-junction separator; +it requires a line-drawing \fB\-\-list\-border\fR and falls back to +\fBline\fR otherwise. .TP .BI "\-\-footer\-label" [=LABEL] diff --git a/src/options.go b/src/options.go index 4a012a04..40fe3a94 100644 --- a/src/options.go +++ b/src/options.go @@ -953,6 +953,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) { switch str { case "line": return tui.BorderLine, nil + case "inline": + return tui.BorderInline, nil case "rounded": return tui.BorderRounded, nil case "sharp": @@ -983,7 +985,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) { if optional && str == "" { return defaultBorderShape, nil } - return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)") + return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|line|inline|none)") } func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) { @@ -3610,6 +3612,22 @@ func validateOptions(opts *Options) error { return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)") } + if opts.BorderShape == tui.BorderInline || + opts.ListBorderShape == tui.BorderInline || + opts.InputBorderShape == tui.BorderInline || + opts.Preview.border == tui.BorderInline { + return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border") + } + if opts.HeaderFirst && (opts.HeaderBorderShape == tui.BorderInline || opts.HeaderLinesShape == tui.BorderInline) { + return errors.New("--header-first is not compatible with --header-border=inline or --header-lines-border=inline") + } + if opts.HeaderBorderShape == tui.BorderInline && + opts.HeaderLinesShape != tui.BorderInline && + opts.HeaderLinesShape != tui.BorderUndefined && + opts.HeaderLinesShape != tui.BorderNone { + return errors.New("--header-border=inline requires --header-lines-border to be inline or unset") + } + return nil } diff --git a/src/terminal.go b/src/terminal.go index 723a7827..9bc528ab 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -1168,7 +1168,9 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor baseTheme = renderer.DefaultTheme() } // This should be called before accessing tui.Color* - tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible()) + headerInline := opts.HeaderBorderShape == tui.BorderInline || opts.HeaderLinesShape == tui.BorderInline + footerInline := opts.FooterBorderShape == tui.BorderInline + tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible(), headerInline, footerInline) // Gutter character var gutterChar, gutterRawChar string @@ -1227,6 +1229,22 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor } } + // Inline borders are embedded between the list's top and bottom horizontals. + // Shapes missing either one (none/phantom/line/single-sided) fall back to a plain + // horizontal separator (same as BorderLine). + inlineSupported := t.listBorderShape.HasTop() && t.listBorderShape.HasBottom() + if !inlineSupported { + if t.headerBorderShape == tui.BorderInline { + t.headerBorderShape = tui.BorderLine + } + if t.headerLinesShape == tui.BorderInline { + t.headerLinesShape = tui.BorderLine + } + if t.footerBorderShape == tui.BorderInline { + t.footerBorderShape = tui.BorderLine + } + } + // Determine header border shape if t.headerBorderShape == tui.BorderLine { if t.layout == layoutReverse { @@ -2353,6 +2371,63 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } } + // Inline borders live inside the list frame instead of consuming shift/shrink/availableLines. + // Tracked here and applied when positioning t.window and the component windows. + const ( + inlineRoleHeader = 1 + inlineRoleHeaderLines = 2 + inlineRoleFooter = 3 + ) + type inlineSlot struct { + role int + windowType tui.WindowType + contentLines int // 0 means the section had no budget; a 0-height placeholder + // window is still created so t.headerWindow / t.footerWindow stay non-nil, + // but no separator is drawn and the slot consumes no rows inside wborder. + } + var inlineTop []inlineSlot // ordered outer-to-inner (index 0 = closest to top border of wborder) + var inlineBottom []inlineSlot // ordered outer-to-inner (index 0 = closest to bottom border of wborder) + + // Helper: reserve space inside wborder for a component. isInner indicates whether the + // section is adjacent to the list content (true) or to the outer list border (false). + // contentLines is capped against the remaining space inside wborder so oversized + // requests (e.g. a huge --header-lines) cannot push the list window to a negative + // height. When no budget is left, contentLines is set to 0 and the slot becomes a + // no-op placeholder so downstream code can always dereference the section window. + addInline := func(onTop bool, contentLines int, windowType tui.WindowType, role int, isInner bool) { + // Remaining inline budget: wborder inner rows minus already-committed inline + // sections and separators, leaving at least 1 row for list content. + used := 0 + for _, s := range inlineTop { + if s.contentLines > 0 { + used += s.contentLines + 1 + } + } + for _, s := range inlineBottom { + if s.contentLines > 0 { + used += s.contentLines + 1 + } + } + remaining := availableLines - borderLines(t.listBorderShape) - used - 1 + if remaining < 2 { + // Not enough room for at least 1 content line plus a separator; placeholder only. + contentLines = 0 + } else { + // Each visible slot consumes contentLines+1 (the extra 1 is the separator row). + contentLines = util.Constrain(contentLines, 1, remaining-1) + } + slot := inlineSlot{role: role, windowType: windowType, contentLines: contentLines} + target := &inlineTop + if !onTop { + target = &inlineBottom + } + if isInner { + *target = append(*target, slot) + } else { + *target = append([]inlineSlot{slot}, *target...) + } + } + // Adjust position and size of the list window if header border is set headerBorderHeight := 0 if hasHeaderWindow { @@ -2360,37 +2435,73 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { if hasHeaderLinesWindow { headerWindowHeight -= t.headerLines } - headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines) - if t.layout == layoutReverse { - shift += headerBorderHeight - shrink += headerBorderHeight + if t.headerBorderShape == tui.BorderInline { + // Reverse: header on top of list frame. Default/reverseList: header below. + // Header is the outer section (furthest from list) when paired with header-lines. + onTop := t.layout == layoutReverse + addInline(onTop, headerWindowHeight, tui.WindowHeader, inlineRoleHeader, false) } else { - shrink += headerBorderHeight + headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines) + if t.layout == layoutReverse { + shift += headerBorderHeight + shrink += headerBorderHeight + } else { + shrink += headerBorderHeight + } + availableLines -= headerBorderHeight } - availableLines -= headerBorderHeight } headerLinesHeight := 0 if hasHeaderLinesWindow { - headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines) - if t.layout != layoutDefault { - shift += headerLinesHeight - shrink += headerLinesHeight + if headerLinesShape == tui.BorderInline { + // Header-lines always sits adjacent to list content (inner). + // Reverse/reverseList: above list; default: below. + onTop := t.layout != layoutDefault + addInline(onTop, t.headerLines, tui.WindowHeader, inlineRoleHeaderLines, true) } else { - shrink += headerLinesHeight + headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines) + if t.layout != layoutDefault { + shift += headerLinesHeight + shrink += headerLinesHeight + } else { + shrink += headerLinesHeight + } + availableLines -= headerLinesHeight } - availableLines -= headerLinesHeight } footerBorderHeight := 0 if hasFooterWindow { - // Footer lines should not take all available lines - footerBorderHeight = util.Constrain(borderLines(t.footerBorderShape)+len(t.footer), 0, availableLines) - shrink += footerBorderHeight - if t.layout != layoutReverse { - shift += footerBorderHeight + if t.footerBorderShape == tui.BorderInline { + // Reverse: footer below list (alone, inner). Default: footer above list (alone, inner). + // ReverseList: footer above list, outer of header-lines when header-lines is also inline. + onTop := t.layout != layoutReverse + isInner := t.layout != layoutReverseList + addInline(onTop, len(t.footer), tui.WindowFooter, inlineRoleFooter, isInner) + } else { + // Footer lines should not take all available lines + footerBorderHeight = util.Constrain(borderLines(t.footerBorderShape)+len(t.footer), 0, availableLines) + shrink += footerBorderHeight + if t.layout != layoutReverse { + shift += footerBorderHeight + } + availableLines -= footerBorderHeight + } + } + + // Compute total rows consumed inside wborder for inline sections (content + 1 separator per section). + inlineTopLines := 0 + for _, s := range inlineTop { + if s.contentLines > 0 { + inlineTopLines += s.contentLines + 1 + } + } + inlineBottomLines := 0 + for _, s := range inlineBottom { + if s.contentLines > 0 { + inlineBottomLines += s.contentLines + 1 } - availableLines -= footerBorderHeight } // Set up list border @@ -2501,12 +2612,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { if previewOpts.position == posUp { innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight) t.window = t.tui.NewWindow( - innerMarginInt[0]+pheight+shift, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink, tui.WindowList, noBorder, true) + innerMarginInt[0]+pheight+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) createPreviewWindow(marginInt[0], marginInt[3], width, pheight) } else { innerBorderFn(marginInt[0], marginInt[3], width, height-pheight) t.window = t.tui.NewWindow( - innerMarginInt[0]+shift, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink, tui.WindowList, noBorder, true) + innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) createPreviewWindow(marginInt[0]+height-pheight, marginInt[3], width, pheight) } case posLeft, posRight: @@ -2545,7 +2656,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { m = 1 } t.window = t.tui.NewWindow( - innerMarginInt[0]+shift, innerMarginInt[3]+pwidth+m, innerWidth-pwidth-m, innerHeight-shrink, tui.WindowList, noBorder, true) + innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3]+pwidth+m, innerWidth-pwidth-m, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) // Clear characters on the margin // fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1 @@ -2577,7 +2688,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } innerBorderFn(marginInt[0], marginInt[3], width-pwidth, height) t.window = t.tui.NewWindow( - innerMarginInt[0]+shift, innerMarginInt[3], innerWidth-pwidth, innerHeight-shrink, tui.WindowList, noBorder, true) + innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth-pwidth, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) x := marginInt[3] + width - pwidth createPreviewWindow(marginInt[0], x, pwidth, height) } @@ -2615,10 +2726,77 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } innerBorderFn(marginInt[0], marginInt[3], width, height) t.window = t.tui.NewWindow( - innerMarginInt[0]+shift, + innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, - innerHeight-shrink, tui.WindowList, noBorder, true) + innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true) + } + + // Place inline section windows, paint adjacent frame cells in the section's colors, + // and draw T-junction separators. Labels are printed directly on the separator row. + placeInlineSection := func(win tui.Window, role int) { + switch role { + case inlineRoleHeader: + t.headerWindow = win + case inlineRoleHeaderLines: + t.headerLinesWindow = win + case inlineRoleFooter: + t.footerWindow = win + } + } + printInlineSepLabel := func(role int, sepRow int) { + switch role { + case inlineRoleHeader: + t.printLabelAt(t.wborder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, sepRow) + case inlineRoleFooter: + t.printLabelAt(t.wborder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, sepRow) + } + } + if len(inlineTop)+len(inlineBottom) > 0 && t.wborder != nil { + // Inline sub-windows align with t.window horizontally (preview narrows t.window too) + // and stack immediately above or below it inside wborder. + subLeft := t.window.Left() + subWidth := t.window.Width() + cursor := t.window.Top() - inlineTopLines + for i, s := range inlineTop { + // Ghost slots get a 0-height placeholder at subLeft so t.headerWindow / + // t.footerWindow stay non-nil; no frame painting, no separator. + win := t.tui.NewWindow(cursor, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true) + placeInlineSection(win, s.role) + if s.contentLines == 0 { + continue + } + secTop := cursor - t.wborder.Top() + secBottom := secTop + s.contentLines - 1 + // Paint the frame cells adjacent to this section in the section's color + // (left/right verticals on the content rows; outer top edge if this is + // the outermost section). + t.wborder.PaintSectionFrame(secTop, secBottom, s.windowType, i == 0, false) + cursor += s.contentLines + sepRow := cursor - t.wborder.Top() + // Separator sits above list content; hug the list below by using the + // bottom char (matters for thinblock/block where top and bottom differ). + t.wborder.DrawHSeparator(sepRow, s.windowType, true) + printInlineSepLabel(s.role, sepRow) + cursor++ + } + cursor = t.window.Top() + t.window.Height() + inlineBottomLines - 1 + for i, s := range inlineBottom { + top := cursor - s.contentLines + 1 + win := t.tui.NewWindow(top, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true) + placeInlineSection(win, s.role) + if s.contentLines == 0 { + continue + } + secTop := top - t.wborder.Top() + secBottom := secTop + s.contentLines - 1 + t.wborder.PaintSectionFrame(secTop, secBottom, s.windowType, false, i == 0) + sepRow := top - 1 - t.wborder.Top() + // Separator sits below list content; hug the list above with the top char. + t.wborder.DrawHSeparator(sepRow, s.windowType, false) + printInlineSepLabel(s.role, sepRow) + cursor = top - 2 + } } if len(t.scrollbar) == 0 { @@ -2700,7 +2878,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } // Set up header border - if hasHeaderWindow { + if hasHeaderWindow && t.headerBorderShape != tui.BorderInline { var btop int if hasInputWindow && t.headerFirst { if t.layout == layoutReverse { @@ -2728,7 +2906,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } // Set up header lines border - if hasHeaderLinesWindow { + if hasHeaderLinesWindow && headerLinesShape != tui.BorderInline { var btop int // NOTE: We still have to handle --header-first here in case // --header-lines-border is set. Can't we just use header window instead @@ -2761,7 +2939,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { } // Set up footer - if hasFooterWindow { + if hasFooterWindow && t.footerBorderShape != tui.BorderInline { var btop int if t.layout == layoutReverse { btop = w.Top() + w.Height() @@ -2778,8 +2956,34 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { t.footerWindow = createInnerWindow(t.footerBorder, t.footerBorderShape, tui.WindowFooter, 0) } - // Print border label + // Print border label. When the list label's edge is owned by an inline section, + // temporarily swap the label background to match, so the label reads as part of + // the section's frame rather than a list-colored island on a section-colored edge. + // Foreground stays at --color list-label. + sectionBg := func(wt tui.WindowType) (tui.Color, bool) { + switch wt { + case tui.WindowHeader: + return tui.ColHeaderBorder.Bg(), true + case tui.WindowFooter: + return tui.ColFooterBorder.Bg(), true + } + return 0, false + } + var overrideBg tui.Color + overrideListLabelBg := false + if t.listLabelOpts.bottom && len(inlineBottom) > 0 { + overrideBg, overrideListLabelBg = sectionBg(inlineBottom[0].windowType) + } else if !t.listLabelOpts.bottom && len(inlineTop) > 0 { + overrideBg, overrideListLabelBg = sectionBg(inlineTop[0].windowType) + } + savedListLabel := tui.ColListLabel + if overrideListLabelBg { + tui.ColListLabel = tui.ColListLabel.WithBgColor(overrideBg) + } t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, false) + if overrideListLabelBg { + tui.ColListLabel = savedListLabel + } t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, false) t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(), false) t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false) @@ -2787,37 +2991,39 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false) } +// printLabelAt positions and renders a label at the given row of `window`. Shared by +// printLabel (which computes row from the border shape) and the inline-section label +// code (which uses an explicit separator row). +func (t *Terminal) printLabelAt(window tui.Window, render labelPrinter, opts labelOpts, length int, row int) { + if window == nil || render == nil || window.Height() == 0 { + return + } + var col int + if opts.column == 0 { + col = max(0, (window.Width()-length)/2) + } else if opts.column < 0 { + col = max(0, window.Width()+opts.column+1-length) + } else { + col = min(opts.column-1, window.Width()-length) + } + window.Move(row, col) + render(window, window.Width()) +} + func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts labelOpts, length int, borderShape tui.BorderShape, redrawBorder bool) { - if window == nil { + if window == nil || window.Height() == 0 { return } - - if window.Height() == 0 { - return - } - switch borderShape { case tui.BorderHorizontal, tui.BorderTop, tui.BorderBottom, tui.BorderRounded, tui.BorderSharp, tui.BorderBold, tui.BorderBlock, tui.BorderThinBlock, tui.BorderDouble: if redrawBorder { window.DrawHBorder() } - if render == nil { - return - } - var col int - if opts.column == 0 { - col = max(0, (window.Width()-length)/2) - } else if opts.column < 0 { - col = max(0, window.Width()+opts.column+1-length) - } else { - col = min(opts.column-1, window.Width()-length) - } row := 0 if borderShape == tui.BorderBottom || opts.bottom { row = window.Height() - 1 } - window.Move(row, col) - render(window, window.Width()) + t.printLabelAt(window, render, opts, length, row) } } @@ -3219,14 +3425,21 @@ func (t *Terminal) printHeader() { return } - t.withWindow(t.headerWindow, func() { - var headerItems []Item - if !t.hasHeaderLinesWindow() { - headerItems = t.header - } - t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems) - }) - if w, shape := t.determineHeaderLinesShape(); w { + // When an inline section was requested but addInline had no budget, its window is + // nil. Don't fall through to withWindow — that would leak header content into the + // list window. A nil window is only legitimate when the shape is NOT inline (e.g. + // header combined with the list when --no-list-border is in effect). + if !(t.headerBorderShape == tui.BorderInline && t.headerWindow == nil) { + t.withWindow(t.headerWindow, func() { + var headerItems []Item + if !t.hasHeaderLinesWindow() { + headerItems = t.header + } + t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems) + }) + } + if w, shape := t.determineHeaderLinesShape(); w && + !(shape == tui.BorderInline && t.headerLinesWindow == nil) { t.withWindow(t.headerLinesWindow, func() { t.printHeaderImpl(t.headerLinesWindow, shape, nil, t.header) }) @@ -3277,7 +3490,10 @@ func (t *Terminal) headerIndentImpl(base int, borderShape tui.BorderShape) int { if t.listBorderShape.HasLeft() { indentSize += 1 + t.borderWidth } - if borderShape.HasLeft() { + // Section borders with their own left side skip past the list border's left column. + // Inline sections also skip it, but only when the list border actually has a left, + // since otherwise the inline window starts flush with the list window. + if borderShape.HasLeft() || (borderShape == tui.BorderInline && t.listBorderShape.HasLeft()) { indentSize -= 1 + t.borderWidth if indentSize < 0 { indentSize = 0 @@ -5952,11 +6168,28 @@ func (t *Terminal) Loop() error { case reqRedrawInputLabel: t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, true) case reqRedrawHeaderLabel: - t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, true) + if t.headerBorderShape == tui.BorderInline { + // Inline labels sit on the separator inside wborder; re-run the + // full layout to repaint the separator + label together. + t.printAll() + } else { + t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, true) + } case reqRedrawFooterLabel: - t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true) + if t.footerBorderShape == tui.BorderInline { + t.printAll() + } else { + t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true) + } case reqRedrawListLabel: - t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true) + // When inline sections are active, the label's bg depends on which + // section owns the adjacent edge. Rerun the layout to reuse that + // logic rather than duplicating it here. + if t.headerBorderShape == tui.BorderInline || t.headerLinesShape == tui.BorderInline || t.footerBorderShape == tui.BorderInline { + t.printAll() + } else { + t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true) + } case reqRedrawBorderLabel: t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true) case reqRedrawPreviewLabel: diff --git a/src/tui/light.go b/src/tui/light.go index 77f15b9a..4fa4bf5d 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -1122,6 +1122,125 @@ func (w *LightWindow) DrawHBorder() { w.drawBorder(true) } +func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) { + if w.height == 0 { + return + } + if w.border.shape == BorderNone { + return + } + colorFor := func(wt WindowType) ColorPair { + switch wt { + case WindowList: + return ColListBorder + case WindowInput: + return ColInputBorder + case WindowHeader: + return ColHeaderBorder + case WindowFooter: + return ColFooterBorder + case WindowPreview: + return ColPreviewBorder + } + return ColBorder + } + // The separator is conceptually the section's inner edge (e.g. the bottom border of + // an inline header), so the whole row, junctions included, carries the section's + // fg + bg. + lineColor := colorFor(windowType) + junctionColor := lineColor + lineChar := w.border.top + if useBottom { + lineChar = w.border.bottom + } + hw := runeWidth(lineChar) + w.Move(row, 0) + if !w.border.shape.HasLeft() && !w.border.shape.HasRight() { + // No verticals to join, so draw a continuous horizontal across the full width. + full := max(0, w.width/hw) + rem := w.width - full*hw + w.CPrint(lineColor, repeat(lineChar, full)+repeat(' ', rem)) + return + } + lw := runeWidth(w.border.leftMid) + rw := runeWidth(w.border.rightMid) + inner := max(0, (w.width-lw-rw)/hw) + rem := (w.width - lw - rw) - inner*hw + w.CPrint(junctionColor, string(w.border.leftMid)) + w.CPrint(lineColor, repeat(lineChar, inner)+repeat(' ', rem)) + w.CPrint(junctionColor, string(w.border.rightMid)) +} + +func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, paintTopEdge, paintBottomEdge bool) { + if w.height == 0 || w.border.shape == BorderNone { + return + } + colorFor := func(wt WindowType) ColorPair { + switch wt { + case WindowList: + return ColListBorder + case WindowInput: + return ColInputBorder + case WindowHeader: + return ColHeaderBorder + case WindowFooter: + return ColFooterBorder + case WindowPreview: + return ColPreviewBorder + } + return ColBorder + } + color := colorFor(windowType) + shape := w.border.shape + hasLeft := shape.HasLeft() + hasRight := shape.HasRight() + rightW := runeWidth(w.border.right) + // Content rows: overpaint left/right verticals + their 1-char margin. + for row := topContent; row <= bottomContent; row++ { + if hasLeft { + w.Move(row, 0) + w.CPrint(color, string(w.border.left)+" ") + } + if hasRight { + w.Move(row, w.width-rightW-1) + w.CPrint(color, " "+string(w.border.right)) + } + } + // Top / bottom edges span the full width minus corner widths (no interior margin). + hw := runeWidth(w.border.top) + edgeW := w.width + if hasLeft { + edgeW -= runeWidth(w.border.topLeft) + } + if hasRight { + edgeW -= runeWidth(w.border.topRight) + } + if paintTopEdge && shape.HasTop() { + w.Move(0, 0) + if hasLeft { + w.CPrint(color, string(w.border.topLeft)) + } + inner := max(0, edgeW/hw) + rem := edgeW - inner*hw + w.CPrint(color, repeat(w.border.top, inner)+repeat(' ', rem)) + if hasRight { + w.CPrint(color, string(w.border.topRight)) + } + } + if paintBottomEdge && shape.HasBottom() { + w.Move(w.height-1, 0) + if hasLeft { + w.CPrint(color, string(w.border.bottomLeft)) + } + inner := max(0, edgeW/hw) + rem := edgeW - inner*hw + w.CPrint(color, repeat(w.border.bottom, inner)+repeat(' ', rem)) + if hasRight { + w.CPrint(color, string(w.border.bottomRight)) + } + } +} + func (w *LightWindow) drawBorder(onlyHorizontal bool) { if w.height == 0 { return diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 720187fa..460ef690 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -1017,6 +1017,139 @@ func (w *TcellWindow) DrawHBorder() { w.drawBorder(true) } +func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) { + if w.height == 0 { + return + } + shape := w.borderStyle.shape + if shape == BorderNone { + return + } + styleFor := func(wt WindowType) tcell.Style { + if !w.color { + return w.normal.style() + } + switch wt { + case WindowBase: + return ColBorder.style() + case WindowList: + return ColListBorder.style() + case WindowHeader: + return ColHeaderBorder.style() + case WindowFooter: + return ColFooterBorder.style() + case WindowInput: + return ColInputBorder.style() + case WindowPreview: + return ColPreviewBorder.style() + } + return w.normal.style() + } + // The separator is conceptually the section's inner edge (e.g. the bottom border of + // an inline header), so the whole row, junctions included, carries the section's + // fg + bg. + lineStyle := styleFor(windowType) + junctionStyle := lineStyle + lineChar := w.borderStyle.top + if useBottom { + lineChar = w.borderStyle.bottom + } + y := w.top + row + left := w.left + right := left + w.width + hw := runeWidth(lineChar) + hasVert := shape.HasLeft() || shape.HasRight() + if !hasVert { + // No verticals to join, so draw a continuous horizontal across the full width. + for x := left; x <= right-hw; x += hw { + _screen.SetContent(x, y, lineChar, nil, lineStyle) + } + return + } + leftMidW := runeWidth(w.borderStyle.leftMid) + rightMidW := runeWidth(w.borderStyle.rightMid) + max := right - leftMidW - rightMidW + for x := left + leftMidW; x <= max; x += hw { + _screen.SetContent(x, y, lineChar, nil, lineStyle) + } + _screen.SetContent(left, y, w.borderStyle.leftMid, nil, junctionStyle) + _screen.SetContent(right-rightMidW, y, w.borderStyle.rightMid, nil, junctionStyle) +} + +func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, paintTopEdge, paintBottomEdge bool) { + if w.height == 0 { + return + } + shape := w.borderStyle.shape + if shape == BorderNone { + return + } + var style tcell.Style + if w.color { + switch windowType { + case WindowBase: + style = ColBorder.style() + case WindowList: + style = ColListBorder.style() + case WindowHeader: + style = ColHeaderBorder.style() + case WindowFooter: + style = ColFooterBorder.style() + case WindowInput: + style = ColInputBorder.style() + case WindowPreview: + style = ColPreviewBorder.style() + } + } else { + style = w.normal.style() + } + left := w.left + right := left + w.width + hasLeft := shape.HasLeft() + hasRight := shape.HasRight() + leftW := runeWidth(w.borderStyle.left) + rightW := runeWidth(w.borderStyle.right) + // Content rows: overpaint the left and right verticals (+ their 1-char margin) in + // the section's color. Inner margin stays at whatever bg the sub-window set. + for row := topContent; row <= bottomContent; row++ { + y := w.top + row + if hasLeft { + _screen.SetContent(left, y, w.borderStyle.left, nil, style) + _screen.SetContent(left+leftW, y, ' ', nil, style) + } + if hasRight { + _screen.SetContent(right-rightW-1, y, ' ', nil, style) + _screen.SetContent(right-rightW, y, w.borderStyle.right, nil, style) + } + } + // Top / bottom outer edge: overpaint the horizontal run and corners. + hw := runeWidth(w.borderStyle.top) + if paintTopEdge && shape.HasTop() { + y := w.top + for x := left; x <= right-hw; x += hw { + _screen.SetContent(x, y, w.borderStyle.top, nil, style) + } + if hasLeft { + _screen.SetContent(left, y, w.borderStyle.topLeft, nil, style) + } + if hasRight { + _screen.SetContent(right-runeWidth(w.borderStyle.topRight), y, w.borderStyle.topRight, nil, style) + } + } + if paintBottomEdge && shape.HasBottom() { + y := w.top + w.height - 1 + for x := left; x <= right-hw; x += hw { + _screen.SetContent(x, y, w.borderStyle.bottom, nil, style) + } + if hasLeft { + _screen.SetContent(left, y, w.borderStyle.bottomLeft, nil, style) + } + if hasRight { + _screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), y, w.borderStyle.bottomRight, nil, style) + } + } +} + func (w *TcellWindow) drawBorder(onlyHorizontal bool) { if w.height == 0 { return diff --git a/src/tui/tui.go b/src/tui/tui.go index c45e866f..5c06c06a 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -409,6 +409,14 @@ func (p ColorPair) WithUl(ul Color) ColorPair { return dup } +// WithBgColor replaces just the background color, leaving fg/ul/attr alone. +// (Named differently from WithBg, which expects a full ColorAttr and merges attrs.) +func (p ColorPair) WithBgColor(bg Color) ColorPair { + dup := p + dup.bg = bg + return dup +} + func (p ColorPair) Attr() Attr { return p.attr } @@ -595,11 +603,12 @@ const ( BorderBottom BorderLeft BorderRight + BorderInline ) func (s BorderShape) HasLeft() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left return false } return true @@ -607,7 +616,7 @@ func (s BorderShape) HasLeft() bool { func (s BorderShape) HasRight() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right return false } return true @@ -615,7 +624,7 @@ func (s BorderShape) HasRight() bool { func (s BorderShape) HasTop() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top return false } return true @@ -623,7 +632,7 @@ func (s BorderShape) HasTop() bool { func (s BorderShape) HasBottom() bool { switch s { - case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom + case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom return false } return true @@ -643,6 +652,8 @@ type BorderStyle struct { topRight rune bottomLeft rune bottomRight rune + leftMid rune + rightMid rune } type BorderCharacter int @@ -658,7 +669,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topLeft: ' ', topRight: ' ', bottomLeft: ' ', - bottomRight: ' '} + bottomRight: ' ', + leftMid: ' ', + rightMid: ' '} } if !unicode { return BorderStyle{ @@ -671,6 +684,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '+', bottomLeft: '+', bottomRight: '+', + leftMid: '+', + rightMid: '+', } } switch shape { @@ -685,6 +700,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '┐', bottomLeft: '└', bottomRight: '┘', + leftMid: '├', + rightMid: '┤', } case BorderBold: return BorderStyle{ @@ -697,6 +714,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '┓', bottomLeft: '┗', bottomRight: '┛', + leftMid: '┣', + rightMid: '┫', } case BorderBlock: // ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ @@ -712,6 +731,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '▜', bottomLeft: '▙', bottomRight: '▟', + leftMid: '▌', + rightMid: '▐', } case BorderThinBlock: @@ -728,6 +749,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '🭾', bottomLeft: '🭼', bottomRight: '🭿', + leftMid: '▏', + rightMid: '▕', } case BorderDouble: @@ -741,6 +764,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '╗', bottomLeft: '╚', bottomRight: '╝', + leftMid: '╠', + rightMid: '╣', } } return BorderStyle{ @@ -753,6 +778,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle { topRight: '╮', bottomLeft: '╰', bottomRight: '╯', + leftMid: '├', + rightMid: '┤', } } @@ -811,6 +838,18 @@ type Window interface { DrawBorder() DrawHBorder() + // DrawHSeparator draws an inline horizontal separator at `row` (relative to the + // window's top) using the color for `windowType`. When useBottom is true, the + // `bottom` horizontal char is used instead of `top`. For thinblock/block styles + // where the two characters differ, this keeps the thin line visually bonded to + // the list content that sits on the opposite side of the separator. + DrawHSeparator(row int, windowType WindowType, useBottom bool) + // PaintSectionFrame overpaints the border cells around the rows [topContent, + // bottomContent] (inclusive, relative to the window's top) with the color for + // `windowType`. When paintTopEdge / paintBottomEdge are true, the top / bottom + // edge rows of the frame (outer horizontal + corners) are also painted, letting + // an inline section claim the adjacent corner of the outer frame. + PaintSectionFrame(topContent, bottomContent int, windowType WindowType, paintTopEdge, paintBottomEdge bool) Refresh() FinishFill() @@ -1166,7 +1205,7 @@ func init() { } } -func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool) { +func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool, headerInline bool, footerInline bool) { if forceBlack { theme.Bg = ColorAttr{colBlack, AttrUndefined} } @@ -1300,11 +1339,22 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac } else { theme.HeaderBg = o(theme.Bg, theme.ListBg) } - theme.HeaderBorder = o(theme.Border, theme.HeaderBorder) + // Inline header/footer borders sit inside the list frame, so default their color + // to the list-border color when the user has not explicitly set it. The inline + // separator then matches the surrounding frame. + headerBorderFallback := theme.Border + if headerInline { + headerBorderFallback = theme.ListBorder + } + theme.HeaderBorder = o(headerBorderFallback, theme.HeaderBorder) theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel) theme.FooterBg = o(theme.Bg, theme.FooterBg) - theme.FooterBorder = o(theme.Border, theme.FooterBorder) + footerBorderFallback := theme.Border + if footerInline { + footerBorderFallback = theme.ListBorder + } + theme.FooterBorder = o(footerBorderFallback, theme.FooterBorder) theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel) if theme.Nomatch.IsUndefined() { diff --git a/test/test_layout.rb b/test/test_layout.rb index 2a521bf6..85a0e36b 100644 --- a/test/test_layout.rb +++ b/test/test_layout.rb @@ -1392,5 +1392,64 @@ class TestLayout < TestInteractive input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)", clicks: clicks) end + + # Inline header inside a rounded list border. + define_method(:"test_click_header_border_inline_#{slug}") do + opts = %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc' ) + verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS) + end + + # Inline header inside a horizontal list border (top+bottom only, no T-junctions). + define_method(:"test_click_header_border_inline_horizontal_list_#{slug}") do + opts = %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc' ) + verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS) + end + + # Inline header-lines inside a rounded list border. + define_method(:"test_click_header_lines_border_inline_#{slug}") do + clicks_hl = if layout == 'default' + [%w[Xaa 3], %w[Ybb 2], %w[Zcc 1]] + else + [%w[Xaa 1], %w[Ybb 2], %w[Zcc 3]] + end + opts = %(--layout=#{layout} --style full --header-lines 3 ) + verify_clicks(kind: :header, opts: opts, + input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)", + clicks: clicks_hl) + end + + # Inline footer inside a rounded list border. + define_method(:"test_click_footer_border_inline_#{slug}") do + opts = %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz' ) + verify_clicks(kind: :footer, opts: opts, input: 'seq 5', + clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]]) + end + end + + # An inline section requesting far more rows than the terminal can fit must not + # break the layout. The list frame must still render inside the pane with both + # corners visible and the prompt line present. + def test_inline_header_lines_oversized + tmux.send_keys %(seq 10000 | #{FZF} --style full --header-border inline --header-lines 9999), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + lines = tmux.capture + # Rounded (light) and sharp (tcell) default border glyphs. + top_corners = /[╭┌]/ + bottom_corners = /[╰└]/ + assert(lines.any? { |l| l.match?(top_corners) }, "list frame top missing: #{lines.inspect}") + assert(lines.any? { |l| l.match?(bottom_corners) }, "list frame bottom missing: #{lines.inspect}") + assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}") + tmux.send_keys 'Escape' + end + + # A non-inline section that consumes all available rows must still render without + # crashing when another section is inline but has no budget. The inline section's + # content is clipped to 0 but the layout proceeds. + def test_inline_footer_starved_by_non_inline_header + tmux.send_keys %(seq 10000 | #{FZF} --style full --footer-border inline --footer "$(seq 1000)" --header "$(seq 1000)"), :Enter + tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) } + lines = tmux.capture + assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}") + tmux.send_keys 'Escape' end end