From e873128d93fee997408eaf307eb845d0641b3bec Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 18 Apr 2026 13:11:48 +0900 Subject: [PATCH] Add inline header/header-lines/footer borders inside the list frame Adds a new BorderShape, BorderInline, accepted as a value for --header-border, --header-lines-border, and --footer-border. When the surrounding --list-border has both top and bottom horizontals (rounded, sharp, bold, double, horizontal), the corresponding section is rendered inside the list frame separated from the list content by a horizontal line whose endpoints join the list border as T-junctions. Without a compatible list border, the shape falls back to BorderLine. Supports: - All three layouts (default, reverse, reverse-list). - Any combination of the three inline sections, producing stacked separators. - --header-label and --footer-label rendered on their separator row (and redrawn on reqRedrawHeaderLabel / reqRedrawFooterLabel). - Section-specific border colors on the separator line, with the T-junction characters painted in the list-border color so the outer frame stays visually continuous. Rejects the combinations that do not make sense: - --input-border=inline / --list-border=inline / --preview-border=inline - --header-first + (--header-border=inline | --header-lines-border=inline) - --header-border=inline with a non-inline --header-lines-border (inline has to propagate inward toward the list content). --- CHANGELOG.md | 19 ++++ man/man1/fzf.1 | 16 ++- src/options.go | 20 +++- src/terminal.go | 231 ++++++++++++++++++++++++++++++++++++++------ src/tui/light.go | 43 +++++++++ src/tui/tcell.go | 54 +++++++++++ src/tui/tui.go | 30 +++++- test/test_layout.rb | 32 ++++++ 8 files changed, 409 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9586991a..10a905a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ CHANGELOG ========= +0.72.0 +------ +- `--header-border`, `--header-lines-border`, and `--footer-border` now accept + a new `inline` style that embeds the section inside the list frame, + separated from the list content by a horizontal line whose endpoints join + the surrounding list border as T-junctions. + - Requires `--list-border` with a line-drawing shape (rounded / sharp / + bold / double / horizontal); falls back to `line` otherwise. + - Works in every layout and supports stacking, e.g. + `--header-border=inline --header-lines-border=inline --footer-border=inline` + produces up to three internal separators inside the list frame. + - `--header-label` and `--footer-label` render on their respective + separator row. + - The separator inherits `--color list-border` when the section's own + border color is not explicitly set. + - `--header-first` is not compatible with `--header-border=inline` or + `--header-lines-border=inline`; `--header-border=inline` requires + `--header-lines-border` to be `inline` or unset. + 0.71.0 ------ _Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_ diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index a77b7a66..79214743 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -1100,7 +1100,13 @@ Print header before the prompt line. When both normal header and header lines .TP .BI "\-\-header\-border" [=STYLE] Draw border around the header section. \fBline\fR style draws a single -separator line between the header window and the list section. +separator line between the header window and the list section. \fBinline\fR +style embeds the header inside the list border frame, joined to the list +section by a horizontal separator with T-junctions; it requires a +line-drawing \fB\-\-list\-border\fR (rounded / sharp / bold / double / +horizontal) and falls back to \fBline\fR otherwise. Not compatible with +\fB\-\-header\-first\fR, and when \fB\-\-header\-lines\fR is also set +\fB\-\-header\-lines\-border\fR must also be \fBinline\fR. .TP .BI "\-\-header\-label" [=LABEL] @@ -1116,6 +1122,9 @@ Display header from \fB--header\-lines\fR with a separate border. Pass \fBnone\fR to still separate the header lines but without a border. To combine two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws a single separator line between the header lines and the list section. +\fBinline\fR style embeds the header lines inside the list border frame +with a T-junction separator; it requires a line-drawing +\fB\-\-list\-border\fR and is not compatible with \fB\-\-header\-first\fR. .SS FOOTER @@ -1129,7 +1138,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe .TP .BI "\-\-footer\-border" [=STYLE] Draw border around the footer section. \fBline\fR style draws a single -separator line between the footer and the list section. +separator line between the footer and the list section. \fBinline\fR style +embeds the footer inside the list border frame with a T-junction separator; +it requires a line-drawing \fB\-\-list\-border\fR and falls back to +\fBline\fR otherwise. .TP .BI "\-\-footer\-label" [=LABEL] diff --git a/src/options.go b/src/options.go index 4a012a04..40fe3a94 100644 --- a/src/options.go +++ b/src/options.go @@ -953,6 +953,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) { switch str { case "line": return tui.BorderLine, nil + case "inline": + return tui.BorderInline, nil case "rounded": return tui.BorderRounded, nil case "sharp": @@ -983,7 +985,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) { if optional && str == "" { return defaultBorderShape, nil } - return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)") + return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|line|inline|none)") } func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) { @@ -3610,6 +3612,22 @@ func validateOptions(opts *Options) error { return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)") } + if opts.BorderShape == tui.BorderInline || + opts.ListBorderShape == tui.BorderInline || + opts.InputBorderShape == tui.BorderInline || + opts.Preview.border == tui.BorderInline { + return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border") + } + if opts.HeaderFirst && (opts.HeaderBorderShape == tui.BorderInline || opts.HeaderLinesShape == tui.BorderInline) { + return errors.New("--header-first is not compatible with --header-border=inline or --header-lines-border=inline") + } + if opts.HeaderBorderShape == tui.BorderInline && + opts.HeaderLinesShape != tui.BorderInline && + opts.HeaderLinesShape != tui.BorderUndefined && + opts.HeaderLinesShape != tui.BorderNone { + return errors.New("--header-border=inline requires --header-lines-border to be inline or unset") + } + return nil } diff --git a/src/terminal.go b/src/terminal.go index 723a7827..bc364cdf 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -456,6 +456,11 @@ type Terminal struct { proxyScript string numLinesCache map[int32]numLinesCacheValue raw bool + + // Separator rows (relative to wborder.Top()) for inline sections. -1 when not inline. + inlineHeaderSepRow int + inlineHeaderLinesSepRow int + inlineFooterSepRow int } type numLinesCacheValue struct { @@ -1227,6 +1232,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 +2374,36 @@ 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 + } + 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). + addInline := func(onTop bool, contentLines int, windowType tui.WindowType, role int, isInner bool) { + 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 +2411,69 @@ 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 } - availableLines -= footerBorderHeight + } + + // Compute total rows consumed inside wborder for inline sections (content + 1 separator per section). + inlineTopLines := 0 + for _, s := range inlineTop { + inlineTopLines += s.contentLines + 1 + } + inlineBottomLines := 0 + for _, s := range inlineBottom { + inlineBottomLines += s.contentLines + 1 } // Set up list border @@ -2501,12 +2584,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 +2628,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 +2660,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 +2698,66 @@ 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 and draw T-junction separators across wborder. + // Also record each separator's wborder-relative row so labels can be printed on it. + t.inlineHeaderSepRow = -1 + t.inlineHeaderLinesSepRow = -1 + t.inlineFooterSepRow = -1 + recordSep := func(role int, sepRow int) { + switch role { + case inlineRoleHeader: + t.inlineHeaderSepRow = sepRow + case inlineRoleHeaderLines: + t.inlineHeaderLinesSepRow = sepRow + case inlineRoleFooter: + t.inlineFooterSepRow = 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 _, s := range inlineTop { + win := t.tui.NewWindow(cursor, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true) + switch s.role { + case inlineRoleHeader: + t.headerWindow = win + case inlineRoleHeaderLines: + t.headerLinesWindow = win + case inlineRoleFooter: + t.footerWindow = win + } + cursor += s.contentLines + sepRow := cursor - t.wborder.Top() + t.wborder.DrawHSeparator(sepRow, s.windowType) + recordSep(s.role, sepRow) + cursor++ + } + cursor = t.window.Top() + t.window.Height() + inlineBottomLines - 1 + for _, s := range inlineBottom { + top := cursor - s.contentLines + 1 + win := t.tui.NewWindow(top, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true) + switch s.role { + case inlineRoleHeader: + t.headerWindow = win + case inlineRoleHeaderLines: + t.headerLinesWindow = win + case inlineRoleFooter: + t.footerWindow = win + } + sepRow := top - 1 - t.wborder.Top() + t.wborder.DrawHSeparator(sepRow, s.windowType) + recordSep(s.role, sepRow) + cursor = top - 2 + } } if len(t.scrollbar) == 0 { @@ -2700,7 +2839,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 +2867,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 +2900,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() @@ -2785,6 +2924,31 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) { t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false) t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, false) t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false) + // Labels for inline sections render on the separator row of wborder. + if t.wborder != nil { + if t.inlineHeaderSepRow >= 0 { + t.printInlineLabel(t.wborder, t.inlineHeaderSepRow, t.headerLabel, t.headerLabelOpts, t.headerLabelLen) + } + if t.inlineFooterSepRow >= 0 { + t.printInlineLabel(t.wborder, t.inlineFooterSepRow, t.footerLabel, t.footerLabelOpts, t.footerLabelLen) + } + } +} + +func (t *Terminal) printInlineLabel(window tui.Window, row int, render labelPrinter, opts labelOpts, length 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) { @@ -3277,7 +3441,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 @@ -5953,8 +6120,16 @@ func (t *Terminal) Loop() error { 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.wborder != nil && t.inlineHeaderSepRow >= 0 { + t.wborder.DrawHSeparator(t.inlineHeaderSepRow, tui.WindowHeader) + t.printInlineLabel(t.wborder, t.inlineHeaderSepRow, t.headerLabel, t.headerLabelOpts, t.headerLabelLen) + } case reqRedrawFooterLabel: t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true) + if t.wborder != nil && t.inlineFooterSepRow >= 0 { + t.wborder.DrawHSeparator(t.inlineFooterSepRow, tui.WindowFooter) + t.printInlineLabel(t.wborder, t.inlineFooterSepRow, t.footerLabel, t.footerLabelOpts, t.footerLabelLen) + } case reqRedrawListLabel: t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true) case reqRedrawBorderLabel: diff --git a/src/tui/light.go b/src/tui/light.go index 77f15b9a..a1e992bf 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -1122,6 +1122,49 @@ func (w *LightWindow) DrawHBorder() { w.drawBorder(true) } +func (w *LightWindow) DrawHSeparator(row int, windowType WindowType) { + 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 + } + // Section color for the horizontal; list-border color (w.windowType) for the T-junctions. + lineColor := colorFor(windowType) + junctionColor := colorFor(w.windowType) + hw := runeWidth(w.border.top) + 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(w.border.top, 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(w.border.top, inner)+repeat(' ', rem)) + w.CPrint(junctionColor, string(w.border.rightMid)) +} + 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..791fd952 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -1017,6 +1017,60 @@ func (w *TcellWindow) DrawHBorder() { w.drawBorder(true) } +func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType) { + 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() + } + // Section color for the horizontal; list-border color (w.windowType) for the T-junctions + // so the outer frame's verticals stay visually continuous. + lineStyle := styleFor(windowType) + junctionStyle := styleFor(w.windowType) + y := w.top + row + left := w.left + right := left + w.width + hw := runeWidth(w.borderStyle.top) + 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, w.borderStyle.top, 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, w.borderStyle.top, nil, lineStyle) + } + _screen.SetContent(left, y, w.borderStyle.leftMid, nil, junctionStyle) + _screen.SetContent(right-rightMidW, y, w.borderStyle.rightMid, nil, junctionStyle) +} + 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..dbe19a03 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: '┤', } } @@ -811,6 +830,7 @@ type Window interface { DrawBorder() DrawHBorder() + DrawHSeparator(row int, windowType WindowType) Refresh() FinishFill() diff --git a/test/test_layout.rb b/test/test_layout.rb index 2a521bf6..27287f80 100644 --- a/test/test_layout.rb +++ b/test/test_layout.rb @@ -1392,5 +1392,37 @@ 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 end