Compare commits

...

12 Commits

Author SHA1 Message Date
Junegunn Choi f70cc6738d Redraw when change-header changes line count
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
The inline header slot's row budget depends on header content length,
but resizeIfNeeded() tolerates a shorter-than-wanted inline window, so
the stale slot stays. Drive a redraw on length change to re-run the
layout.
2026-04-19 22:40:33 +09:00
Junegunn Choi dacb87abca Let inline sections take precedence over --header-first
--header-first previously was rejected with --header-border=inline or
--header-lines-border=inline. Now, inline placement wins: an inline
section stays inside the list frame, and --header-first only affects
non-inline sections (mainly the main --header).
2026-04-19 22:39:47 +09:00
Junegunn Choi abfa60b7d0 Set inline separator caps per side 2026-04-19 20:47:38 +09:00
Junegunn Choi 12199823ab Update man page: clarify inline list-border shape requirements 2026-04-19 20:47:31 +09:00
Junegunn Choi 2c459ffdff Update CHANGELOG: clarify inline list-border shape requirements 2026-04-19 20:47:25 +09:00
Junegunn Choi f80ba22ab9 Fix misleading comment on printHeader nil-window guard
The guard fires when hasHeaderWindow() returned false at resize time,
not when addInline had no budget (placeInlineStack always leaves a
non-nil 0-height placeholder).
2026-04-19 20:47:17 +09:00
Junegunn Choi 4cd97ba35b Fix inline header/footer border color when falling back to line
InitTheme was called before the runtime coerced BorderInline to
BorderLine, so HeaderBorder / FooterBorder inherited from ListBorder
even when the effective shape was 'line'. Mirror the coercion so
color inheritance matches the rendered shape.
2026-04-19 20:47:09 +09:00
Junegunn Choi 332382e5e7 Add --{header,header-lines,footer}-border=inline
New BorderShape that embeds the section inside the --list-border
frame, joined to the list content by a horizontal separator with
T-junctions where the list shape has side borders. Requires a list
border with both top and bottom segments; falls back to 'line'
otherwise. Stacks when multiple sections are inline.

Sections inherit --color list-border by default and are colored as a
uniform block via their own --color *-border and *-bg.

Incompatible with --header-first. --header-border=inline requires
--header-lines-border to be inline or unset.
2026-04-19 20:47:00 +09:00
Junegunn Choi 987c37cb2d Add a test case for click-header with --header and --header-lines combined
The two sections swap order between layouts, and header-lines are
reversed under layout=default, so expected LINE values differ per
layout.
2026-04-19 20:46:49 +09:00
Junegunn Choi 9deb7c5489 Add test cases for click-header and click-footer
Injects SGR 1006 mouse events via tmux send-keys -l to exercise
FZF_CLICK_HEADER_* / FZF_CLICK_FOOTER_* across all three layouts,
with and without a header border.
2026-04-19 20:46:41 +09:00
Junegunn Choi 5352b88c5a Clean up non-ascii characters 2026-04-19 20:28:28 +09:00
Tymoteusz Makowski 1ad7c617b1 fix: add no-modifiers fallbacks
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Close #4776
2026-04-19 14:38:36 +09:00
18 changed files with 1160 additions and 279 deletions
+7 -7
View File
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
available color options.
- The value of `--preview-window` option consists of 5 components delimited
by `,`
1. `up` Position of the preview window
1. `60%` Size of the preview window
1. `border-bottom` Preview window border only on the bottom side
1. `+{2}+3/3` Scroll offset of the preview contents
1. `~3` Fixed header
1. `up` -- Position of the preview window
1. `60%` -- Size of the preview window
1. `border-bottom` -- Preview window border only on the bottom side
1. `+{2}+3/3` -- Scroll offset of the preview contents
1. `~3` -- Fixed header
- Let's break down the latter two. We want to display the bat output in the
preview window with a certain scroll offset so that the matching line is
positioned near the center of the preview window.
- `+{2}` The base offset is extracted from the second token
- `+3` We add 3 lines to the base offset to compensate for the header
- `+{2}` -- The base offset is extracted from the second token
- `+3` -- We add 3 lines to the base offset to compensate for the header
part of `bat` output
- ```
───────┬──────────────────────────────────────────────────────────
+16
View File
@@ -1,6 +1,22 @@
CHANGELOG
=========
0.72.0
------
- `--header-border`, `--header-lines-border`, and `--footer-border` now accept a new `inline` style that embeds the section inside the list frame, separated from the list content by a horizontal line. When the list border has side segments, the separator joins them as T-junctions.
- Requires a `--list-border` shape that has both top and bottom segments (`rounded`, `sharp`, `bold`, `double`, `block`, `thinblock`, or `horizontal`); falls back to `line` otherwise. `horizontal` has no side borders, so the separator is drawn without T-junction endpoints.
- Sections stack. Example combining all three:
```sh
ps -ef | fzf --reverse --style full:double \
--header 'Select a process' --header-lines 1 \
--bind 'load:transform-footer:echo $FZF_TOTAL_COUNT processes' \
--header-border=inline --header-lines-border=inline \
--footer-border=inline
```
- `--header-label` and `--footer-label` render on their respective separator row.
- The separator inherits `--color list-border` when the section's own border color is not explicitly set.
- `--header-first` is not compatible with `--header-border=inline` or `--header-lines-border=inline`; `--header-border=inline` requires `--header-lines-border` to be `inline` or unset.
0.71.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
+6 -6
View File
@@ -15,7 +15,7 @@
<a href="https://commitgoods.com/collections/fzf"><img src="https://junegunn.github.io/fzf/images/fzf-mugs.jpg" width="80%" alt="fzf merch"></a>
<br/>
<br/>
Show your love for fzf T-shirts, mugs, and stickers now available!
Show your love for fzf -- T-shirts, mugs, and stickers now available!
<br/>
<br/>
<a href="https://commitgoods.com/collections/fzf">commitgoods.com/collections/fzf</a>
@@ -37,10 +37,10 @@ characters and still get the results you want.
Highlights
----------
- **Portable** Distributed as a single binary for easy installation
- **Fast** Optimized to process millions of items instantly
- **Versatile** Fully customizable through an event-action binding mechanism
- **All-inclusive** Comes with integrations for Bash, Zsh, Fish, Vim, and Neovim
- **Portable** -- Distributed as a single binary for easy installation
- **Fast** -- Optimized to process millions of items instantly
- **Versatile** -- Fully customizable through an event-action binding mechanism
- **All-inclusive** -- Comes with integrations for Bash, Zsh, Fish, Vim, and Neovim
Table of Contents
-----------------
@@ -440,7 +440,7 @@ or `py`.
The user interface of fzf is fully customizable with a large number of
configuration options. For a quick setup, you can start with one of the style
presets `default`, `full`, or `minimal` using the `--style` option.
presets -- `default`, `full`, or `minimal` -- using the `--style` option.
```sh
fzf --style full \
+19 -2
View File
@@ -1100,7 +1100,17 @@ 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; it requires a \fB\-\-list\-border\fR
shape that has both top and bottom segments (rounded / sharp / bold /
double / block / thinblock / horizontal) and falls back to \fBline\fR
otherwise. When the list border also has side segments, the separator
joins them with T-junctions; \fBhorizontal\fR has no side borders, so the
separator is drawn without T-junction endpoints. Takes precedence over
\fB\-\-header\-first\fR (the section stays inside the list frame), 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 +1126,10 @@ Display header from \fB--header\-lines\fR with a separate border. Pass
\fBnone\fR to still separate the header lines but without a border. To combine
two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws
a single separator line between the header lines and the list section.
\fBinline\fR style embeds the header lines inside the list border frame
with a horizontal separator; it requires a \fB\-\-list\-border\fR shape
that has both top and bottom segments, falls back to \fBline\fR
otherwise.
.SS FOOTER
@@ -1129,7 +1143,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 horizontal separator;
it requires a \fB\-\-list\-border\fR shape that has both top and bottom
segments and falls back to \fBline\fR otherwise.
.TP
.BI "\-\-footer\-label" [=LABEL]
+5 -5
View File
@@ -2,10 +2,10 @@
## What these functions do
`indexByteTwo(s []byte, b1, b2 byte) int` returns the index of the
`indexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
**first** occurrence of `b1` or `b2` in `s`, or `-1`.
`lastIndexByteTwo(s []byte, b1, b2 byte) int` returns the index of the
`lastIndexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
**last** occurrence of `b1` or `b2` in `s`, or `-1`.
They are used by the fuzzy matching algorithm (`algo.go`) to skip ahead
@@ -91,9 +91,9 @@ implementations (`2xIndexByte` using `bytes.IndexByte`, and a simple `loop`).
The assembly is verified by three layers of testing:
1. **Table-driven tests** known inputs with expected outputs.
2. **Exhaustive tests** all lengths 0256, every match position, no-match
1. **Table-driven tests** -- known inputs with expected outputs.
2. **Exhaustive tests** -- all lengths 0256, every match position, no-match
cases, and both-bytes-present cases, compared against a simple loop
reference.
3. **Fuzz tests** randomized inputs via `testing.F`, compared against the
3. **Fuzz tests** -- randomized inputs via `testing.F`, compared against the
same loop reference.
+1 -1
View File
@@ -78,7 +78,7 @@ loop:
CBZ R6, loop
end:
// Found something or out of data build full syndrome
// Found something or out of data, build full syndrome
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
+1 -1
View File
@@ -484,7 +484,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
state.attr = state.attr | tui.Italic
case 4:
if sep == ':' {
// SGR 4:N underline style sub-parameter
// SGR 4:N - underline style sub-parameter
var subNum int
subNum, _, ansiCode = parseAnsiCode(ansiCode)
state.attr = state.attr &^ tui.UnderlineStyleMask
+19 -3
View File
@@ -178,10 +178,11 @@ Usage: fzf [options]
--header-first Print header before the prompt line
--header-border[=STYLE] Draw border around the header section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded)
top|bottom|left|right|line|inline|none] (default: rounded)
--header-lines-border[=STYLE]
Display header from --header-lines with a separate border.
Pass 'none' to still separate it but without a border.
Pass 'inline' to embed it inside the list frame.
--header-label=LABEL Label to print on the header border
--header-label-pos=COL Position of the header label
[POSITIVE_INTEGER: columns from left|
@@ -192,7 +193,7 @@ Usage: fzf [options]
--footer=STR String to print as footer
--footer-border[=STYLE] Draw border around the footer section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: line)
top|bottom|left|right|line|inline|none] (default: line)
--footer-label=LABEL Label to print on the footer border
--footer-label-pos=COL Position of the footer label
[POSITIVE_INTEGER: columns from left|
@@ -953,6 +954,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
switch str {
case "line":
return tui.BorderLine, nil
case "inline":
return tui.BorderInline, nil
case "rounded":
return tui.BorderRounded, nil
case "sharp":
@@ -983,7 +986,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
if optional && str == "" {
return defaultBorderShape, nil
}
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)")
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|line|inline|none)")
}
func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) {
@@ -3610,6 +3613,19 @@ 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.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
}
+322 -70
View File
@@ -1167,8 +1167,15 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
if baseTheme == nil {
baseTheme = renderer.DefaultTheme()
}
// If the list border can't host an inline separator, the runtime later coerces
// BorderInline to BorderLine. Mirror that here so theme color inheritance matches
// the final rendering rather than the user's requested shape.
inlineListSupported := opts.ListBorderShape.HasTop() && opts.ListBorderShape.HasBottom()
headerInline := inlineListSupported && (opts.HeaderBorderShape == tui.BorderInline || opts.HeaderLinesShape == tui.BorderInline)
footerInline := inlineListSupported && opts.FooterBorderShape == tui.BorderInline
hasHeader := opts.HeaderBorderShape.Visible() || opts.HeaderLinesShape.Visible()
// This should be called before accessing tui.Color*
tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible())
tui.InitTheme(opts.Theme, baseTheme, opts.Bold, opts.Black, opts.InputBorderShape.Visible(), hasHeader, headerInline, footerInline)
// Gutter character
var gutterChar, gutterRawChar string
@@ -1227,6 +1234,22 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
}
}
// Inline borders are embedded between the list's top and bottom horizontals.
// Shapes missing either one (none/phantom/line/single-sided) fall back to a plain
// horizontal separator (same as BorderLine).
inlineSupported := t.listBorderShape.HasTop() && t.listBorderShape.HasBottom()
if !inlineSupported {
if t.headerBorderShape == tui.BorderInline {
t.headerBorderShape = tui.BorderLine
}
if t.headerLinesShape == tui.BorderInline {
t.headerLinesShape = tui.BorderLine
}
if t.footerBorderShape == tui.BorderInline {
t.footerBorderShape = tui.BorderLine
}
}
// Determine header border shape
if t.headerBorderShape == tui.BorderLine {
if t.layout == layoutReverse {
@@ -2237,6 +2260,98 @@ func (t *Terminal) determineHeaderLinesShape() (bool, tui.BorderShape) {
return false, tui.BorderNone
}
// Inline sections live inside wborder rather than consuming shift/shrink/availableLines.
type inlineRole int
const (
inlineRoleHeader inlineRole = iota
inlineRoleHeaderLines
inlineRoleFooter
)
type inlineSlot struct {
role inlineRole
windowType tui.WindowType
contentLines int // 0 when out of budget: a ghost placeholder is created so
// t.headerWindow / t.footerWindow stay non-nil but no frame is painted.
label labelPrinter
labelOpts labelOpts
labelLen int
}
// inlineMetaFor returns (onTop: top stack vs bottom; windowType; isInner:
// adjacent to list content) for the given inline role, derived from the layout.
// Header is outer when paired with header-lines; header-lines is always inner;
// footer is inner except in reverseList where it sits outside header-lines.
func (t *Terminal) inlineMetaFor(role inlineRole) (onTop bool, windowType tui.WindowType, isInner bool) {
switch role {
case inlineRoleHeader:
return t.layout == layoutReverse, tui.WindowHeader, false
case inlineRoleHeaderLines:
return t.layout != layoutDefault, tui.WindowHeader, true
case inlineRoleFooter:
return t.layout != layoutReverse, tui.WindowFooter, t.layout != layoutReverseList
}
return false, tui.WindowBase, false
}
func (t *Terminal) placeInlineSection(win tui.Window, role inlineRole) {
switch role {
case inlineRoleHeader:
t.headerWindow = win
case inlineRoleHeaderLines:
t.headerLinesWindow = win
case inlineRoleFooter:
t.footerWindow = win
}
}
// placeInlineStack walks `slots` in outer-to-inner order. Only the outermost
// slot (index 0) claims the adjacent wborder edge; later slots paint side-only
// frames so the T-junction separator lands between sections.
func (t *Terminal) placeInlineStack(slots []inlineSlot, startRow int, onTop bool) {
firstEdge := tui.SectionEdgeTop
if !onTop {
firstEdge = tui.SectionEdgeBottom
}
noBorder := tui.MakeBorderStyle(tui.BorderNone, t.unicode)
subLeft := t.window.Left()
subWidth := t.window.Width()
cursor := startRow
for i, s := range slots {
var windowTop, sepRow int
if onTop {
windowTop = cursor
sepRow = cursor + s.contentLines - t.wborder.Top()
} else {
windowTop = cursor - s.contentLines + 1
sepRow = windowTop - 1 - t.wborder.Top()
}
// 0-height placeholder keeps t.headerWindow / t.footerWindow non-nil.
win := t.tui.NewWindow(windowTop, subLeft, subWidth, s.contentLines, s.windowType, noBorder, true)
t.placeInlineSection(win, s.role)
if s.contentLines == 0 {
continue
}
secTop := windowTop - t.wborder.Top()
secBottom := secTop + s.contentLines - 1
edge := tui.SectionEdgeNone
if i == 0 {
edge = firstEdge
}
t.wborder.PaintSectionFrame(secTop, secBottom, s.windowType, edge)
// useBottom=onTop so the separator always hugs the list (inner side).
// Matters for thinblock/block where top != bottom char.
t.wborder.DrawHSeparator(sepRow, s.windowType, onTop)
t.printLabelAt(t.wborder, s.label, s.labelOpts, s.labelLen, sepRow)
if onTop {
cursor += s.contentLines + 1
} else {
cursor -= s.contentLines + 1
}
}
}
func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.clearNumLinesCache()
t.forcePreview = forcePreview
@@ -2353,6 +2468,50 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
}
// Slices are ordered outer-to-inner: index 0 touches wborder's edge.
var inlineTop []inlineSlot
var inlineBottom []inlineSlot
// Caps contentLines against remaining wborder space. Oversized requests (e.g. a
// huge --header-lines) become 0-height placeholders rather than pushing the list
// window to negative height.
addInline := func(contentLines int, role inlineRole) {
onTop, windowType, isInner := t.inlineMetaFor(role)
used := 0
for _, s := range inlineTop {
if s.contentLines > 0 {
used += s.contentLines + 1
}
}
for _, s := range inlineBottom {
if s.contentLines > 0 {
used += s.contentLines + 1
}
}
remaining := availableLines - borderLines(t.listBorderShape) - used - 1
if remaining < 2 {
contentLines = 0
} else {
contentLines = util.Constrain(contentLines, 1, remaining-1)
}
slot := inlineSlot{role: role, windowType: windowType, contentLines: contentLines}
switch role {
case inlineRoleHeader:
slot.label, slot.labelOpts, slot.labelLen = t.headerLabel, t.headerLabelOpts, t.headerLabelLen
case inlineRoleFooter:
slot.label, slot.labelOpts, slot.labelLen = t.footerLabel, t.footerLabelOpts, t.footerLabelLen
}
target := &inlineTop
if !onTop {
target = &inlineBottom
}
if isInner {
*target = append(*target, slot)
} else {
*target = append([]inlineSlot{slot}, *target...)
}
}
// Adjust position and size of the list window if header border is set
headerBorderHeight := 0
if hasHeaderWindow {
@@ -2360,37 +2519,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 +2685,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 +2729,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 +2761,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 +2799,15 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
innerBorderFn(marginInt[0], marginInt[3], width, height)
t.window = t.tui.NewWindow(
innerMarginInt[0]+shift,
innerMarginInt[0]+shift+inlineTopLines,
innerMarginInt[3],
innerWidth,
innerHeight-shrink, tui.WindowList, noBorder, true)
innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
}
if len(inlineTop)+len(inlineBottom) > 0 && t.wborder != nil {
t.placeInlineStack(inlineTop, t.window.Top()-inlineTopLines, true)
t.placeInlineStack(inlineBottom, t.window.Top()+t.window.Height()+inlineBottomLines-1, false)
}
if len(t.scrollbar) == 0 {
@@ -2655,7 +2844,11 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if hasInputWindow {
var btop int
if (hasHeaderWindow || hasHeaderLinesWindow) && t.headerFirst {
// Inline sections live inside the list frame, so they don't participate
// in --header-first repositioning; only non-inline sections do.
hasNonInlineHeader := hasHeaderWindow && t.headerBorderShape != tui.BorderInline
hasNonInlineHeaderLines := hasHeaderLinesWindow && headerLinesShape != tui.BorderInline
if (hasNonInlineHeader || hasNonInlineHeaderLines) && t.headerFirst {
switch t.layout {
case layoutDefault:
btop = w.Top() + w.Height()
@@ -2700,7 +2893,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 +2921,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 +2954,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 +2971,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 +2993,39 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false)
}
// printLabelAt positions and renders a label at the given row of `window`. Shared by
// printLabel (which computes row from the border shape) and the inline-section label
// code (which uses an explicit separator row).
func (t *Terminal) printLabelAt(window tui.Window, render labelPrinter, opts labelOpts, length int, row int) {
if window == nil || render == nil || window.Height() == 0 {
return
}
var col int
if opts.column == 0 {
col = max(0, (window.Width()-length)/2)
} else if opts.column < 0 {
col = max(0, window.Width()+opts.column+1-length)
} else {
col = min(opts.column-1, window.Width()-length)
}
window.Move(row, col)
render(window, window.Width())
}
func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts labelOpts, length int, borderShape tui.BorderShape, redrawBorder bool) {
if window == nil {
if window == nil || window.Height() == 0 {
return
}
if window.Height() == 0 {
return
}
switch borderShape {
case tui.BorderHorizontal, tui.BorderTop, tui.BorderBottom, tui.BorderRounded, tui.BorderSharp, tui.BorderBold, tui.BorderBlock, tui.BorderThinBlock, tui.BorderDouble:
if redrawBorder {
window.DrawHBorder()
}
if render == nil {
return
}
var col int
if opts.column == 0 {
col = max(0, (window.Width()-length)/2)
} else if opts.column < 0 {
col = max(0, window.Width()+opts.column+1-length)
} else {
col = min(opts.column-1, window.Width()-length)
}
row := 0
if borderShape == tui.BorderBottom || opts.bottom {
row = window.Height() - 1
}
window.Move(row, col)
render(window, window.Width())
t.printLabelAt(window, render, opts, length, row)
}
}
@@ -3185,8 +3393,19 @@ func (t *Terminal) resizeIfNeeded() bool {
return true
}
// Inline sections are budget-capped inside wborder, so window.Height() may be
// smaller than the requested content length. Treat "capped" (height < want) as
// a no-op to avoid triggering a full redraw on every info/header/footer event
// when the user has requested more content than fits.
mismatch := func(shape tui.BorderShape, height, want int) bool {
if shape == tui.BorderInline && height < want {
return false
}
return height != want
}
// Check footer window
if len(t.footer) > 0 && (t.footerWindow == nil || t.footerWindow.Height() != len(t.footer)) ||
if len(t.footer) > 0 && (t.footerWindow == nil || mismatch(t.footerBorderShape, t.footerWindow.Height(), len(t.footer))) ||
len(t.footer) == 0 && t.footerWindow != nil {
t.printAll()
return true
@@ -3200,14 +3419,12 @@ func (t *Terminal) resizeIfNeeded() bool {
if needHeaderLinesWindow {
primaryHeaderLines -= t.headerLines
}
// FIXME: Full redraw is triggered if there are too many lines in the header
// so that the header window cannot display all of them.
if (needHeaderWindow && t.headerWindow == nil) ||
(!needHeaderWindow && t.headerWindow != nil) ||
(needHeaderWindow && t.headerWindow != nil && primaryHeaderLines != t.headerWindow.Height()) ||
(needHeaderWindow && t.headerWindow != nil && mismatch(t.headerBorderShape, t.headerWindow.Height(), primaryHeaderLines)) ||
(needHeaderLinesWindow && t.headerLinesWindow == nil) ||
(!needHeaderLinesWindow && t.headerLinesWindow != nil) ||
(needHeaderLinesWindow && t.headerLinesWindow != nil && t.headerLines != t.headerLinesWindow.Height()) {
(needHeaderLinesWindow && t.headerLinesWindow != nil && mismatch(t.headerLinesShape, t.headerLinesWindow.Height(), t.headerLines)) {
t.printAll()
return true
}
@@ -3219,14 +3436,23 @@ 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 {
// headerWindow is nil when hasHeaderWindow() returned false at resize time,
// e.g. --header-border=inline combined with empty header content. Don't
// delegate to printHeaderImpl because its nil-window branch folds the header
// into the list window, which isn't valid for inline. 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 +3503,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 +6181,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:
@@ -6535,10 +6781,16 @@ func (t *Terminal) Loop() error {
t.cx = len(t.input)
case actChangeHeader, actTransformHeader, actBgTransformHeader:
capture(false, func(header string) {
// When a dedicated header window is not used, we may need to
// update other elements as well.
if t.changeHeader(header) {
req(reqList, reqPrompt, reqInfo)
// resizeIfNeeded() tolerates a shorter-than-wanted inline
// window, so a length change can leave the inline slot
// stale. Force a redraw to re-run the layout. Non-inline
// shapes are handled by resizeIfNeeded.
if t.headerBorderShape == tui.BorderInline {
req(reqRedraw)
} else {
req(reqList, reqPrompt, reqInfo)
}
}
req(reqHeader)
})
+2 -2
View File
@@ -721,7 +721,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
t.Errorf("ANSI: %q", result)
}
// Long word (no space) no break, let character wrapping handle it
// Long word (no space) - no break, let character wrapping handle it
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
if len(result) != 1 || result[0] != "abcdefghij" {
t.Errorf("Long word: %q", result)
@@ -749,7 +749,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
// Tab handling: tab expands to tabstop-aligned width
term.tabstop = 8
// "\thi there" tab at column 0 expands to 8, total "hi" starts at 8
// "\thi there" - tab at column 0 expands to 8, total "hi" starts at 8
// maxWidth=15: "\thi" = 10 wide, "there" = 5 wide, total 16 > 15, wrap at space
result = term.wordWrapAnsiLine("\thi there", 15, 2)
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
+133 -109
View File
@@ -689,6 +689,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
switch r.buffer[4] {
case '1', '2', '3', '4', '5', '6', '7', '8', '9':
// Kitty iTerm2 WezTerm
// ARROW "\e[1;1D"
// SHIFT-ARROW "\e[1;2D"
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
// CTRL-SHIFT-ARROW "\e[1;6D" N/A
@@ -743,6 +744,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftUp, 0, nil}
}
return Event{Up, 0, nil}
case 'B':
if ctrlAltShift {
return Event{CtrlAltShiftDown, 0, nil}
@@ -765,6 +767,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftDown, 0, nil}
}
return Event{Down, 0, nil}
case 'C':
if ctrlAltShift {
return Event{CtrlAltShiftRight, 0, nil}
@@ -787,6 +790,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if alt {
return Event{AltRight, 0, nil}
}
return Event{Right, 0, nil}
case 'D':
if ctrlAltShift {
return Event{CtrlAltShiftLeft, 0, nil}
@@ -809,6 +813,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftLeft, 0, nil}
}
return Event{Left, 0, nil}
case 'H':
if ctrlAltShift {
return Event{CtrlAltShiftHome, 0, nil}
@@ -831,6 +836,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftHome, 0, nil}
}
return Event{Home, 0, nil}
case 'F':
if ctrlAltShift {
return Event{CtrlAltShiftEnd, 0, nil}
@@ -853,6 +859,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftEnd, 0, nil}
}
return Event{End, 0, nil}
}
} // r.buffer[4]
} // r.buffer[3]
@@ -1122,127 +1129,144 @@ 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() {
leftCap = w.border.leftMid
}
if shape.HasRight() {
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 {
+135 -54
View File
@@ -1017,6 +1017,115 @@ 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() {
leftCap = w.borderStyle.leftMid
}
if shape.HasRight() {
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 +1140,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
top := w.top
bot := top + w.height
var style tcell.Style
if w.color {
switch w.windowType {
case WindowBase:
style = ColBorder.style()
case WindowList:
style = ColListBorder.style()
case WindowHeader:
style = ColHeaderBorder.style()
case WindowFooter:
style = ColFooterBorder.style()
case WindowInput:
style = ColInputBorder.style()
case WindowPreview:
style = ColPreviewBorder.style()
}
} else {
style = w.normal.style()
}
style := w.borderStyleFor(w.windowType)
hw := runeWidth(w.borderStyle.top)
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderTop {
max = right - hw
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
if shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.topLeft
}
// tcell has an issue displaying two overlapping wide runes
// e.g. SetContent( HH )
// SetContent( TR )
// ==================
// ( HH ) => TR is ignored
for x := left; x <= max; x += hw {
_screen.SetContent(x, top, w.borderStyle.top, nil, style)
if hasRight {
rightCap = w.borderStyle.topRight
}
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderBottom {
max = right - hw
if shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.bottomLeft
}
for x := left; x <= max; x += hw {
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
if hasRight {
rightCap = w.borderStyle.bottomRight
}
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
}
if !onlyHorizontal {
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
for y := top; y < bot; y++ {
vw := runeWidth(w.borderStyle.right)
for y := top; y < bot; y++ {
// Corner rows are already painted by drawHLine above / below.
if (y == top && shape.HasTop()) || (y == bot-1 && shape.HasBottom()) {
continue
}
if hasLeft {
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
}
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
vw := runeWidth(w.borderStyle.right)
for y := top; y < bot; y++ {
if hasRight {
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
}
}
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
_screen.SetContent(left, top, w.borderStyle.topLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.topRight), top, w.borderStyle.topRight, nil, style)
_screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), bot-1, w.borderStyle.bottomRight, nil, style)
}
}
+80 -8
View File
@@ -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() {
+1 -1
View File
@@ -9,7 +9,7 @@ func TestWrapLine(t *testing.T) {
t.Errorf("Basic wrap: %v", lines)
}
// Exact fit no wrapping needed
// Exact fit - no wrapping needed
lines = WrapLine("hello", 0, 5, 8, 2)
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
t.Errorf("Exact fit: %v", lines)
+82
View File
@@ -105,6 +105,23 @@ class Tmux
go(%W[send-keys -t #{win}] + args.map(&:to_s))
end
# Simulate a mouse click at the given 1-based column and row using the SGR mouse protocol
# (xterm mouse mode 1006, which fzf enables). The escape sequence is injected as literal
# keystrokes via tmux, and fzf parses it like a real terminal mouse event.
#
# tmux's own mouse handling intercepts these sequences when `set -g mouse on`, so we toggle
# mouse off for the duration of the click and restore the previous state afterwards.
def click(col, row, button: 0)
prev = go(%w[show-options -gv mouse]).first
go(%w[set-option -g mouse off])
begin
seq = "\e[<#{button};#{col};#{row}M\e[<#{button};#{col};#{row}m"
go(%W[send-keys -t #{win} -l #{seq}])
ensure
go(%W[set-option -g mouse #{prev}]) if prev && !prev.empty?
end
end
def paste(str)
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
end
@@ -113,6 +130,71 @@ class Tmux
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
end
# Raw pane capture with ANSI escape sequences preserved.
def capture_ansi
go(%W[capture-pane -p -J -e -t #{win}])
end
# 3-bit ANSI bg code (40..47) -> color name used in --color options.
BG_NAMES = %w[black red green yellow blue magenta cyan white].freeze
# Parse `tmux capture-pane -e` output into per-row bg ranges. Each row is an
# array of [col_start, col_end, bg] tuples where bg is one of:
# 'default'
# 'red' / 'green' / 'blue' / ... (3-bit names)
# 'bright-red' / ... (bright variants)
# '256:<n>' (256-color fallback)
# ANSI state persists across rows, matching real terminal behavior.
def bg_ranges
raw = go(%W[capture-pane -p -J -e -t #{win}])
bg = 'default'
raw.map do |row|
cells = []
i = 0
len = row.length
while i < len
c = row[i]
if c == "\e" && row[i + 1] == '['
j = i + 2
j += 1 while j < len && row[j] != 'm'
parts = row[i + 2...j].split(';')
k = 0
while k < parts.length
p = parts[k].to_i
case p
when 0, 49 then bg = 'default'
when 40..47 then bg = BG_NAMES[p - 40]
when 100..107 then bg = "bright-#{BG_NAMES[p - 100]}"
when 48
if parts[k + 1] == '5'
bg = "256:#{parts[k + 2]}"
k += 2
elsif parts[k + 1] == '2'
bg = "rgb:#{parts[k + 2]}:#{parts[k + 3]}:#{parts[k + 4]}"
k += 4
end
end
k += 1
end
i = j + 1
else
cells << bg
i += 1
end
end
ranges = []
start = 0
cells.each_with_index do |b, idx|
if idx.positive? && b != cells[idx - 1]
ranges << [start, idx - 1, cells[idx - 1]]
start = idx
end
end
ranges << [start, cells.length - 1, cells.last] unless cells.empty?
ranges
end
end
def until(refresh = false, timeout: DEFAULT_TIMEOUT)
lines = nil
begin
+9 -9
View File
@@ -1672,7 +1672,7 @@ class TestCore < TestInteractive
end
tmux.send_keys :BSpace, :BSpace, :BSpace
# Reload with shuffled order cursor should track "555"
# Reload with shuffled order - cursor should track "555"
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 1000, lines.match_count
@@ -1694,7 +1694,7 @@ class TestCore < TestInteractive
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> 2 banana' }
# Reload the second field changes, but first field "2" stays
# Reload - the second field changes, but first field "2" stays
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
@@ -1709,7 +1709,7 @@ class TestCore < TestInteractive
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' }
# Reload with completely different items no match for "beta"
# Reload with completely different items - no match for "beta"
# Cursor stays at the same position (second item)
tmux.send_keys 'C-r'
tmux.until do |lines|
@@ -1727,7 +1727,7 @@ class TestCore < TestInteractive
assert_includes lines[-2], '+T'
end
# Trigger slow reload should show +T* while blocked
# Trigger slow reload - should show +T* while blocked
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
@@ -1769,7 +1769,7 @@ class TestCore < TestInteractive
assert_includes lines, '> 1'
end
# Trigger reload blocked during initial sleep
# Trigger reload - blocked during initial sleep
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
# Match "1" arrives, unblocks before the remaining items load
@@ -1790,7 +1790,7 @@ class TestCore < TestInteractive
assert_includes lines, '> 1'
end
# Trigger reload-sync every observable state must be either:
# Trigger reload-sync - every observable state must be either:
# 1. +T* (still blocked), or
# 2. final state (count=10, +T without *)
# Any other combination (e.g. unblocked while count < 10) is a bug.
@@ -1835,7 +1835,7 @@ class TestCore < TestInteractive
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' }
# Reload with completely different items no match for "beta"
# Reload with completely different items - no match for "beta"
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
# After stream completes, unblocks with cursor at same position (second item)
@@ -1857,7 +1857,7 @@ class TestCore < TestInteractive
tmux.send_keys 'C-t'
tmux.until { |lines| assert_includes lines[-2], '+t' }
# Reload should track by field "2"
# Reload - should track by field "2"
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
@@ -1876,7 +1876,7 @@ class TestCore < TestInteractive
tmux.send_keys :Up, :Up, :Tab
tmux.until { |lines| assert_includes lines[-2], '(2)' }
# Reload selections should be preserved by id-nth key
# Reload - selections should be preserved by id-nth key
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
+321
View File
@@ -1298,4 +1298,325 @@ class TestLayout < TestInteractive
tmux.send_keys :Enter
end
end
# Locate a word in the currently captured screen and click its first character.
# tmux rows/columns are 1-based; capture indices are 0-based.
def click_word(word)
tmux.capture.each_with_index do |line, idx|
col = line.index(word)
return tmux.click(col + 1, idx + 1) if col
end
flunk("word #{word.inspect} not found on screen")
end
# Launch fzf with a click-{header,footer} binding that echoes FZF_CLICK_* into the prompt,
# then click each word in `clicks` and assert the resulting L/W values.
# `clicks` is an array of [word_to_click, expected_line].
def verify_clicks(kind:, opts:, input:, clicks:)
var = kind.to_s.upcase # HEADER or FOOTER
binding = "click-#{kind}:transform-prompt:" \
"echo \"L=$FZF_CLICK_#{var}_LINE W=$FZF_CLICK_#{var}_WORD> \""
# --multi makes the info line end in " (0)" so the wait regex is unambiguous.
tmux.send_keys %(#{input} | #{FZF} #{opts} --multi --bind '#{binding}'), :Enter
# Wait for fzf to fully render before inspecting the screen, otherwise the echoed
# command line can shadow click targets.
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+ \(0\)}) }
clicks.each do |word, line|
click_word(word)
tmux.until { |lines| assert lines.any_include?("L=#{line} W=#{word}>") }
end
tmux.send_keys 'Escape'
end
# Header lines (--header-lines) are rendered in reverse display order only under
# layout=default; in layout=reverse and layout=reverse-list they keep the input order.
# FZF_CLICK_HEADER_LINE reflects the visual row, so the expected value flips.
HEADER_CLICKS = [%w[Aaa 1], %w[Bbb 2], %w[Ccc 3]].freeze
%w[default reverse reverse-list].each do |layout|
slug = layout.tr('-', '_')
# Plain --header with no border around the header section.
define_method(:"test_click_header_plain_#{slug}") do
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --header $'Aaa\\nBbb\\nCcc'),
input: 'seq 5',
clicks: HEADER_CLICKS)
end
# --header with a framing border (--style full gives --header-border=rounded by default).
define_method(:"test_click_header_border_rounded_#{slug}") do
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc'),
input: 'seq 5',
clicks: HEADER_CLICKS)
end
# --header-lines consumed from stdin, with its own framing border.
define_method(:"test_click_header_lines_border_rounded_#{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
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --style full --header-lines 3),
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
clicks: clicks_hl)
end
# --footer with a framing border.
define_method(:"test_click_footer_border_rounded_#{slug}") do
verify_clicks(kind: :footer,
opts: %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz'),
input: 'seq 5',
clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]])
end
# --header and --header-lines combined. Click-header numbering concatenates the two
# sections, but the order depends on the layout:
# layoutReverse: custom header (1..N), then header-lines (N+1..N+M)
# layoutDefault: header-lines (1..M, reversed visually), then custom header (M+1..M+N)
# layoutReverseList: header-lines (1..M), then custom header (M+1..M+N)
define_method(:"test_click_header_combined_#{slug}") do
clicks = case layout
when 'reverse'
[%w[Aaa 1], %w[Bbb 2], %w[Ccc 3], %w[Xaa 4], %w[Ybb 5], %w[Zcc 6]]
when 'default'
[%w[Aaa 4], %w[Bbb 5], %w[Ccc 6], %w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
else # reverse-list
[%w[Aaa 4], %w[Bbb 5], %w[Ccc 6], %w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
end
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --header $'Aaa\\nBbb\\nCcc' --header-lines 3),
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
clicks: clicks)
end
# Inline header inside a rounded list border.
define_method(:"test_click_header_border_inline_#{slug}") do
opts = %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc' --header-border=inline)
verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS)
end
# Inline header inside a horizontal list border (top+bottom only, no T-junctions).
define_method(:"test_click_header_border_inline_horizontal_list_#{slug}") do
opts = %(--layout=#{layout} --style full --list-border=horizontal --header $'Aaa\\nBbb\\nCcc' --header-border=inline)
verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS)
end
# Inline header-lines inside a rounded list border.
define_method(:"test_click_header_lines_border_inline_#{slug}") do
clicks_hl = if layout == 'default'
[%w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
else
[%w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
end
opts = %(--layout=#{layout} --style full --header-lines 3 --header-lines-border=inline)
verify_clicks(kind: :header, opts: opts,
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
clicks: clicks_hl)
end
# Inline footer inside a rounded list border.
define_method(:"test_click_footer_border_inline_#{slug}") do
opts = %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz' --footer-border=inline)
verify_clicks(kind: :footer, opts: opts, input: 'seq 5',
clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]])
end
end
# An inline section requesting far more rows than the terminal can fit must not
# break the layout. The list frame must still render inside the pane with both
# corners visible and the prompt line present.
def test_inline_header_lines_oversized
tmux.send_keys %(seq 10000 | #{FZF} --style full --header-border inline --header-lines 9999), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
lines = tmux.capture
# Rounded (light) and sharp (tcell) default border glyphs.
top_corners = /[╭┌]/
bottom_corners = /[╰└]/
assert(lines.any? { |l| l.match?(top_corners) }, "list frame top missing: #{lines.inspect}")
assert(lines.any? { |l| l.match?(bottom_corners) }, "list frame bottom missing: #{lines.inspect}")
assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}")
tmux.send_keys 'Escape'
end
# A non-inline section that consumes all available rows must still render without
# crashing when another section is inline but has no budget. The inline section's
# content is clipped to 0 but the layout proceeds.
def test_inline_footer_starved_by_non_inline_header
tmux.send_keys %(seq 10000 | #{FZF} --style full --footer-border inline --footer "$(seq 1000)" --header "$(seq 1000)"), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
lines = tmux.capture
assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}")
tmux.send_keys 'Escape'
end
# Without a line-drawing --list-border, --header-border=inline must silently
# fall back to the `line` style (documented behavior).
def test_inline_falls_back_without_list_border
tmux.send_keys %(seq 5 | #{FZF} --list-border=none --header HEADER --header-border=inline), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
lines = tmux.capture
assert(lines.any? { |l| l.include?('HEADER') }, "header missing: #{lines.inspect}")
# Neither list frame corners (rounded/sharp) nor T-junction runes appear,
# since we've fallen back to a plain line separator.
assert(lines.none? { |l| l.match?(/[╭╮╰╯┌┐└┘├┤]/) }, "unexpected frame glyphs: #{lines.inspect}")
tmux.send_keys 'Escape'
end
# Regression: when --header-border=inline falls back to `line` because the
# list border can't host an inline separator, the header-border color must
# inherit from `border`, not `list-border`. The effective shape is `line`,
# so color inheritance must match what `line` rendering would use.
def test_inline_fallback_does_not_inherit_list_border_color
# Marker attribute (bold) on list-border. If HeaderBorder wrongly inherits
# from ListBorder, the header separator characters will carry the bold
# attribute. --info=hidden and --no-separator strip other separator lines
# so the only row of `─` chars is the header separator.
tmux.send_keys %(seq 5 | #{FZF} --list-border=none --header HEADER --header-border=inline --info=hidden --no-separator --color=bg:-1,list-border:red:bold), :Enter
sep_row = nil
tmux.until do |_|
sep_row = tmux.capture_ansi.find do |row|
stripped = row.gsub(/\e\[[\d;]*m/, '').rstrip
stripped.match?(/\A─+\z/)
end
!sep_row.nil?
end
# Bold (1) or red fg (31) on the header separator means it inherited from
# list-border even though the effective shape is `line` (non-inline).
refute_match(/\e\[(?:[\d;]*;)?(?:1|31)(?:;[\d;]*)?m─/, sep_row,
"header separator inherited list-border attr: #{sep_row.inspect}")
tmux.send_keys 'Escape'
end
# Inline takes precedence over --header-first: the main header stays
# inside the list frame instead of moving below the input.
def test_inline_header_border_overrides_header_first
tmux.send_keys %(seq 5 | #{FZF} --style full --header foo --header-first --header-border inline), :Enter
tmux.until do |lines|
foo_idx = lines.index { |l| l.match?(/\A│\s+foo\s+│\z/) }
input_idx = lines.index { |l| l.match?(/\A│\s+>\s+\d+\/\d+\s+│\z/) }
foo_idx && input_idx && foo_idx < input_idx
end
end
# With both sections present, --header-first still moves the main --header
# below the input while --header-lines-border=inline keeps header-lines
# inside the list frame.
def test_inline_header_lines_with_header_first_and_main_header
tmux.send_keys %(seq 5 | #{FZF} --style full --header foo --header-lines 1 --header-first --header-lines-border inline), :Enter
tmux.until do |lines|
one_idx = lines.index { |l| l.match?(/\A│\s+1\s+│\z/) }
foo_idx = lines.index { |l| l.match?(/\A│\s+foo\s+│\z/) }
input_idx = lines.index { |l| l.match?(/\A│\s+>\s+\d+\/\d+\s+│\z/) }
one_idx && foo_idx && input_idx && one_idx < input_idx && input_idx < foo_idx
end
end
# With no main --header, --header-first previously repositioned
# header-lines. Inline now takes precedence: header-lines stays inside
# the list frame.
def test_inline_header_lines_with_header_first_no_main_header
tmux.send_keys %(seq 5 | #{FZF} --style full --header-lines 1 --header-first --header-lines-border inline), :Enter
tmux.until do |lines|
one_idx = lines.index { |l| l.match?(/\A│\s+1\s+│\z/) }
input_idx = lines.index { |l| l.match?(/\A│\s+>\s+\d+\/\d+\s+│\z/) }
one_idx && input_idx && one_idx < input_idx
end
end
# Regression: with --header-border=inline and --header-lines but no
# --header, the inline slot was sized for header-lines only. After
# change-header added a main header line, resizeIfNeeded tolerated the
# too-small slot, so the header-lines line got displaced and disappeared.
def test_inline_change_header_grows_slot
tmux.send_keys %(seq 5 | #{FZF} --style full --header-lines 1 --header-border inline --bind space:change-header:tada), :Enter
tmux.until { |lines| lines.any_include?(/\A│\s+1\s+│\z/) }
tmux.send_keys ' '
tmux.until do |lines|
lines.any_include?(/\A│\s+1\s+│\z/) && lines.any_include?(/\A│\s+tada\s+│\z/)
end
end
# Invalid inline combinations must be rejected at startup.
def test_inline_rejected_on_unsupported_options
[
['--border=inline', 'inline border is only supported'],
['--list-border=inline', 'inline border is only supported'],
['--input-border=inline', 'inline border is only supported'],
['--preview-window=border-inline --preview :', 'invalid preview window option: border-inline'],
['--header-border=inline --header-lines-border=sharp --header-lines=1',
'--header-border=inline requires --header-lines-border to be inline or unset']
].each do |args, expected|
output = `#{FZF} #{args} < /dev/null 2>&1`
refute_equal 0, $CHILD_STATUS.exitstatus, "expected non-zero exit for: #{args}"
assert_includes output, expected, "wrong error for: #{args}"
end
end
private
# Count rows whose entire width is a single `color` range.
def count_full_rows(ranges_by_row, color)
ranges_by_row.count { |r| r.length == 1 && r[0][2] == color }
end
# Wait until `tmux.bg_ranges` has at least `count` fully-`color` rows; return them.
def wait_for_full_rows(color, count)
ranges = nil
tmux.until do |_|
ranges = tmux.bg_ranges
count_full_rows(ranges, color) >= count
end
ranges
end
public
# Inline header's entire section (outer edge + content-row verticals + separator)
# carries the header-bg color; list rows below carry list-bg.
def test_inline_header_bg_color
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
# 3 fully-red rows: top edge, header content, separator.
ranges = wait_for_full_rows('red', 3)
assert_equal_org(3, count_full_rows(ranges, 'red'))
# List rows below (>=5) are fully green.
assert_operator count_full_rows(ranges, 'green'), :>=, 5
tmux.send_keys 'Escape'
end
# Regression: when --header-lines-border=inline is the only inline section
# (no --header-border), the section must still use header-bg, not list-bg.
def test_inline_header_lines_bg_without_main_header
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header-lines 2 --header-lines-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
# Top edge + 2 content rows + separator = 4 fully-red rows.
ranges = wait_for_full_rows('red', 4)
assert_equal_org(4, count_full_rows(ranges, 'red'))
tmux.send_keys 'Escape'
end
# Inline footer's entire section carries footer-bg; list rows above carry list-bg.
def test_inline_footer_bg_color
tmux.send_keys %(seq 5 | #{FZF} --list-border --footer FOOTER --footer-border=inline --color=bg:-1,footer-border:white,list-border:white,footer-bg:blue,list-bg:green), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
ranges = wait_for_full_rows('blue', 3)
assert_equal_org(3, count_full_rows(ranges, 'blue'))
tmux.send_keys 'Escape'
end
# The list-label's bg is swapped to match the adjacent inline section so it reads as
# part of the section frame rather than a list-colored island on a section-colored edge.
def test_list_label_bg_on_inline_section_edge
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --list-label=LL --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green,list-label:yellow:bold), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
# The label sits on the header-owned top edge, so the entire row must be a
# single red run (no green breaks where the label cells are).
ranges = wait_for_full_rows('red', 3)
assert_operator count_full_rows(ranges, 'red'), :>=, 3
tmux.send_keys 'Escape'
end
end
+1 -1
View File
@@ -40,7 +40,7 @@ class TestServer < TestInteractive
assert_equal [0, 1], state[:current][:positions]
assert_equal state[:current][:positions], state[:current][:positions].sort
# No match no current item
# No match - no current item
Net::HTTP.post(fn.call, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ')
tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }