mirror of
https://github.com/junegunn/fzf.git
synced 2026-04-26 17:30:32 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f70cc6738d | |||
| dacb87abca | |||
| abfa60b7d0 | |||
| 12199823ab | |||
| 2c459ffdff | |||
| f80ba22ab9 | |||
| 4cd97ba35b | |||
| 332382e5e7 | |||
| 987c37cb2d | |||
| 9deb7c5489 | |||
| 5352b88c5a |
+7
-7
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
|
|||||||
available color options.
|
available color options.
|
||||||
- The value of `--preview-window` option consists of 5 components delimited
|
- The value of `--preview-window` option consists of 5 components delimited
|
||||||
by `,`
|
by `,`
|
||||||
1. `up` — Position of the preview window
|
1. `up` -- Position of the preview window
|
||||||
1. `60%` — Size of the preview window
|
1. `60%` -- Size of the preview window
|
||||||
1. `border-bottom` — Preview window border only on the bottom side
|
1. `border-bottom` -- Preview window border only on the bottom side
|
||||||
1. `+{2}+3/3` — Scroll offset of the preview contents
|
1. `+{2}+3/3` -- Scroll offset of the preview contents
|
||||||
1. `~3` — Fixed header
|
1. `~3` -- Fixed header
|
||||||
- Let's break down the latter two. We want to display the bat output in the
|
- 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
|
preview window with a certain scroll offset so that the matching line is
|
||||||
positioned near the center of the preview window.
|
positioned near the center of the preview window.
|
||||||
- `+{2}` — The base offset is extracted from the second token
|
- `+{2}` -- The base offset is extracted from the second token
|
||||||
- `+3` — We add 3 lines to the base offset to compensate for the header
|
- `+3` -- We add 3 lines to the base offset to compensate for the header
|
||||||
part of `bat` output
|
part of `bat` output
|
||||||
- ```
|
- ```
|
||||||
───────┬──────────────────────────────────────────────────────────
|
───────┬──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
CHANGELOG
|
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
|
0.71.0
|
||||||
------
|
------
|
||||||
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
|
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
|
||||||
|
|||||||
@@ -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>
|
<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/>
|
||||||
<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/>
|
||||||
<br/>
|
<br/>
|
||||||
<a href="https://commitgoods.com/collections/fzf">commitgoods.com/collections/fzf</a>
|
<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
|
Highlights
|
||||||
----------
|
----------
|
||||||
|
|
||||||
- **Portable** — Distributed as a single binary for easy installation
|
- **Portable** -- Distributed as a single binary for easy installation
|
||||||
- **Fast** — Optimized to process millions of items instantly
|
- **Fast** -- Optimized to process millions of items instantly
|
||||||
- **Versatile** — Fully customizable through an event-action binding mechanism
|
- **Versatile** -- Fully customizable through an event-action binding mechanism
|
||||||
- **All-inclusive** — Comes with integrations for Bash, Zsh, Fish, Vim, and Neovim
|
- **All-inclusive** -- Comes with integrations for Bash, Zsh, Fish, Vim, and Neovim
|
||||||
|
|
||||||
Table of Contents
|
Table of Contents
|
||||||
-----------------
|
-----------------
|
||||||
@@ -440,7 +440,7 @@ or `py`.
|
|||||||
|
|
||||||
The user interface of fzf is fully customizable with a large number of
|
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
|
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
|
```sh
|
||||||
fzf --style full \
|
fzf --style full \
|
||||||
|
|||||||
+19
-2
@@ -1100,7 +1100,17 @@ Print header before the prompt line. When both normal header and header lines
|
|||||||
.TP
|
.TP
|
||||||
.BI "\-\-header\-border" [=STYLE]
|
.BI "\-\-header\-border" [=STYLE]
|
||||||
Draw border around the header section. \fBline\fR style draws a single
|
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
|
.TP
|
||||||
.BI "\-\-header\-label" [=LABEL]
|
.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
|
\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
|
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.
|
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
|
.SS FOOTER
|
||||||
|
|
||||||
@@ -1129,7 +1143,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe
|
|||||||
.TP
|
.TP
|
||||||
.BI "\-\-footer\-border" [=STYLE]
|
.BI "\-\-footer\-border" [=STYLE]
|
||||||
Draw border around the footer section. \fBline\fR style draws a single
|
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
|
.TP
|
||||||
.BI "\-\-footer\-label" [=LABEL]
|
.BI "\-\-footer\-label" [=LABEL]
|
||||||
|
|||||||
+5
-5
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
## What these functions do
|
## 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`.
|
**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`.
|
**last** occurrence of `b1` or `b2` in `s`, or `-1`.
|
||||||
|
|
||||||
They are used by the fuzzy matching algorithm (`algo.go`) to skip ahead
|
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:
|
The assembly is verified by three layers of testing:
|
||||||
|
|
||||||
1. **Table-driven tests** — known inputs with expected outputs.
|
1. **Table-driven tests** -- known inputs with expected outputs.
|
||||||
2. **Exhaustive tests** — all lengths 0–256, every match position, no-match
|
2. **Exhaustive tests** -- all lengths 0–256, every match position, no-match
|
||||||
cases, and both-bytes-present cases, compared against a simple loop
|
cases, and both-bytes-present cases, compared against a simple loop
|
||||||
reference.
|
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.
|
same loop reference.
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ loop:
|
|||||||
CBZ R6, loop
|
CBZ R6, loop
|
||||||
|
|
||||||
end:
|
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, V3.B16, V3.B16
|
||||||
VAND V5.B16, V4.B16, V4.B16
|
VAND V5.B16, V4.B16, V4.B16
|
||||||
VADDP V4.B16, V3.B16, V6.B16
|
VADDP V4.B16, V3.B16, V6.B16
|
||||||
|
|||||||
+1
-1
@@ -484,7 +484,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
|||||||
state.attr = state.attr | tui.Italic
|
state.attr = state.attr | tui.Italic
|
||||||
case 4:
|
case 4:
|
||||||
if sep == ':' {
|
if sep == ':' {
|
||||||
// SGR 4:N — underline style sub-parameter
|
// SGR 4:N - underline style sub-parameter
|
||||||
var subNum int
|
var subNum int
|
||||||
subNum, _, ansiCode = parseAnsiCode(ansiCode)
|
subNum, _, ansiCode = parseAnsiCode(ansiCode)
|
||||||
state.attr = state.attr &^ tui.UnderlineStyleMask
|
state.attr = state.attr &^ tui.UnderlineStyleMask
|
||||||
|
|||||||
+19
-3
@@ -178,10 +178,11 @@ Usage: fzf [options]
|
|||||||
--header-first Print header before the prompt line
|
--header-first Print header before the prompt line
|
||||||
--header-border[=STYLE] Draw border around the header section
|
--header-border[=STYLE] Draw border around the header section
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[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]
|
--header-lines-border[=STYLE]
|
||||||
Display header from --header-lines with a separate border.
|
Display header from --header-lines with a separate border.
|
||||||
Pass 'none' to still separate it but without a 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=LABEL Label to print on the header border
|
||||||
--header-label-pos=COL Position of the header label
|
--header-label-pos=COL Position of the header label
|
||||||
[POSITIVE_INTEGER: columns from left|
|
[POSITIVE_INTEGER: columns from left|
|
||||||
@@ -192,7 +193,7 @@ Usage: fzf [options]
|
|||||||
--footer=STR String to print as footer
|
--footer=STR String to print as footer
|
||||||
--footer-border[=STYLE] Draw border around the footer section
|
--footer-border[=STYLE] Draw border around the footer section
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[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=LABEL Label to print on the footer border
|
||||||
--footer-label-pos=COL Position of the footer label
|
--footer-label-pos=COL Position of the footer label
|
||||||
[POSITIVE_INTEGER: columns from left|
|
[POSITIVE_INTEGER: columns from left|
|
||||||
@@ -953,6 +954,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
|||||||
switch str {
|
switch str {
|
||||||
case "line":
|
case "line":
|
||||||
return tui.BorderLine, nil
|
return tui.BorderLine, nil
|
||||||
|
case "inline":
|
||||||
|
return tui.BorderInline, nil
|
||||||
case "rounded":
|
case "rounded":
|
||||||
return tui.BorderRounded, nil
|
return tui.BorderRounded, nil
|
||||||
case "sharp":
|
case "sharp":
|
||||||
@@ -983,7 +986,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
|||||||
if optional && str == "" {
|
if optional && str == "" {
|
||||||
return defaultBorderShape, nil
|
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) {
|
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)")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+291
-39
@@ -1167,8 +1167,15 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
|||||||
if baseTheme == nil {
|
if baseTheme == nil {
|
||||||
baseTheme = renderer.DefaultTheme()
|
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*
|
// 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
|
// Gutter character
|
||||||
var gutterChar, gutterRawChar string
|
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
|
// Determine header border shape
|
||||||
if t.headerBorderShape == tui.BorderLine {
|
if t.headerBorderShape == tui.BorderLine {
|
||||||
if t.layout == layoutReverse {
|
if t.layout == layoutReverse {
|
||||||
@@ -2237,6 +2260,98 @@ func (t *Terminal) determineHeaderLinesShape() (bool, tui.BorderShape) {
|
|||||||
return false, tui.BorderNone
|
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) {
|
func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
||||||
t.clearNumLinesCache()
|
t.clearNumLinesCache()
|
||||||
t.forcePreview = forcePreview
|
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
|
// Adjust position and size of the list window if header border is set
|
||||||
headerBorderHeight := 0
|
headerBorderHeight := 0
|
||||||
if hasHeaderWindow {
|
if hasHeaderWindow {
|
||||||
@@ -2360,6 +2519,9 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
if hasHeaderLinesWindow {
|
if hasHeaderLinesWindow {
|
||||||
headerWindowHeight -= t.headerLines
|
headerWindowHeight -= t.headerLines
|
||||||
}
|
}
|
||||||
|
if t.headerBorderShape == tui.BorderInline {
|
||||||
|
addInline(headerWindowHeight, inlineRoleHeader)
|
||||||
|
} else {
|
||||||
headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines)
|
headerBorderHeight = util.Constrain(borderLines(t.headerBorderShape)+headerWindowHeight, 0, availableLines)
|
||||||
if t.layout == layoutReverse {
|
if t.layout == layoutReverse {
|
||||||
shift += headerBorderHeight
|
shift += headerBorderHeight
|
||||||
@@ -2369,9 +2531,13 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
}
|
}
|
||||||
availableLines -= headerBorderHeight
|
availableLines -= headerBorderHeight
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
headerLinesHeight := 0
|
headerLinesHeight := 0
|
||||||
if hasHeaderLinesWindow {
|
if hasHeaderLinesWindow {
|
||||||
|
if headerLinesShape == tui.BorderInline {
|
||||||
|
addInline(t.headerLines, inlineRoleHeaderLines)
|
||||||
|
} else {
|
||||||
headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines)
|
headerLinesHeight = util.Constrain(borderLines(headerLinesShape)+t.headerLines, 0, availableLines)
|
||||||
if t.layout != layoutDefault {
|
if t.layout != layoutDefault {
|
||||||
shift += headerLinesHeight
|
shift += headerLinesHeight
|
||||||
@@ -2381,9 +2547,13 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
}
|
}
|
||||||
availableLines -= headerLinesHeight
|
availableLines -= headerLinesHeight
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
footerBorderHeight := 0
|
footerBorderHeight := 0
|
||||||
if hasFooterWindow {
|
if hasFooterWindow {
|
||||||
|
if t.footerBorderShape == tui.BorderInline {
|
||||||
|
addInline(len(t.footer), inlineRoleFooter)
|
||||||
|
} else {
|
||||||
// Footer lines should not take all available lines
|
// Footer lines should not take all available lines
|
||||||
footerBorderHeight = util.Constrain(borderLines(t.footerBorderShape)+len(t.footer), 0, availableLines)
|
footerBorderHeight = util.Constrain(borderLines(t.footerBorderShape)+len(t.footer), 0, availableLines)
|
||||||
shrink += footerBorderHeight
|
shrink += footerBorderHeight
|
||||||
@@ -2392,6 +2562,20 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
}
|
}
|
||||||
availableLines -= 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set up list border
|
// Set up list border
|
||||||
hasListBorder := t.listBorderShape.Visible()
|
hasListBorder := t.listBorderShape.Visible()
|
||||||
@@ -2501,12 +2685,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
if previewOpts.position == posUp {
|
if previewOpts.position == posUp {
|
||||||
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
|
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
|
||||||
t.window = t.tui.NewWindow(
|
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)
|
createPreviewWindow(marginInt[0], marginInt[3], width, pheight)
|
||||||
} else {
|
} else {
|
||||||
innerBorderFn(marginInt[0], marginInt[3], width, height-pheight)
|
innerBorderFn(marginInt[0], marginInt[3], width, height-pheight)
|
||||||
t.window = t.tui.NewWindow(
|
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)
|
createPreviewWindow(marginInt[0]+height-pheight, marginInt[3], width, pheight)
|
||||||
}
|
}
|
||||||
case posLeft, posRight:
|
case posLeft, posRight:
|
||||||
@@ -2545,7 +2729,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
m = 1
|
m = 1
|
||||||
}
|
}
|
||||||
t.window = t.tui.NewWindow(
|
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
|
// Clear characters on the margin
|
||||||
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1
|
// 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)
|
innerBorderFn(marginInt[0], marginInt[3], width-pwidth, height)
|
||||||
t.window = t.tui.NewWindow(
|
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
|
x := marginInt[3] + width - pwidth
|
||||||
createPreviewWindow(marginInt[0], x, pwidth, height)
|
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)
|
innerBorderFn(marginInt[0], marginInt[3], width, height)
|
||||||
t.window = t.tui.NewWindow(
|
t.window = t.tui.NewWindow(
|
||||||
innerMarginInt[0]+shift,
|
innerMarginInt[0]+shift+inlineTopLines,
|
||||||
innerMarginInt[3],
|
innerMarginInt[3],
|
||||||
innerWidth,
|
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 {
|
if len(t.scrollbar) == 0 {
|
||||||
@@ -2655,7 +2844,11 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
|
|
||||||
if hasInputWindow {
|
if hasInputWindow {
|
||||||
var btop int
|
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 {
|
switch t.layout {
|
||||||
case layoutDefault:
|
case layoutDefault:
|
||||||
btop = w.Top() + w.Height()
|
btop = w.Top() + w.Height()
|
||||||
@@ -2700,7 +2893,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up header border
|
// Set up header border
|
||||||
if hasHeaderWindow {
|
if hasHeaderWindow && t.headerBorderShape != tui.BorderInline {
|
||||||
var btop int
|
var btop int
|
||||||
if hasInputWindow && t.headerFirst {
|
if hasInputWindow && t.headerFirst {
|
||||||
if t.layout == layoutReverse {
|
if t.layout == layoutReverse {
|
||||||
@@ -2728,7 +2921,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up header lines border
|
// Set up header lines border
|
||||||
if hasHeaderLinesWindow {
|
if hasHeaderLinesWindow && headerLinesShape != tui.BorderInline {
|
||||||
var btop int
|
var btop int
|
||||||
// NOTE: We still have to handle --header-first here in case
|
// NOTE: We still have to handle --header-first here in case
|
||||||
// --header-lines-border is set. Can't we just use header window instead
|
// --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
|
// Set up footer
|
||||||
if hasFooterWindow {
|
if hasFooterWindow && t.footerBorderShape != tui.BorderInline {
|
||||||
var btop int
|
var btop int
|
||||||
if t.layout == layoutReverse {
|
if t.layout == layoutReverse {
|
||||||
btop = w.Top() + w.Height()
|
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)
|
t.footerWindow = createInnerWindow(t.footerBorder, t.footerBorderShape, tui.WindowFooter, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print border label
|
// When the list label lands on an edge owned by an inline section, swap its bg
|
||||||
t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, false)
|
// 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.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.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(), false)
|
||||||
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false)
|
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false)
|
||||||
@@ -2787,21 +2993,11 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
|
|||||||
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false)
|
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts labelOpts, length int, borderShape tui.BorderShape, redrawBorder bool) {
|
// printLabelAt positions and renders a label at the given row of `window`. Shared by
|
||||||
if window == nil {
|
// printLabel (which computes row from the border shape) and the inline-section label
|
||||||
return
|
// 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 {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
var col int
|
var col int
|
||||||
@@ -2812,12 +3008,24 @@ func (t *Terminal) printLabel(window tui.Window, render labelPrinter, opts label
|
|||||||
} else {
|
} else {
|
||||||
col = min(opts.column-1, window.Width()-length)
|
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 || 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()
|
||||||
|
}
|
||||||
row := 0
|
row := 0
|
||||||
if borderShape == tui.BorderBottom || opts.bottom {
|
if borderShape == tui.BorderBottom || opts.bottom {
|
||||||
row = window.Height() - 1
|
row = window.Height() - 1
|
||||||
}
|
}
|
||||||
window.Move(row, col)
|
t.printLabelAt(window, render, opts, length, row)
|
||||||
render(window, window.Width())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3185,8 +3393,19 @@ func (t *Terminal) resizeIfNeeded() bool {
|
|||||||
return true
|
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
|
// 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 {
|
len(t.footer) == 0 && t.footerWindow != nil {
|
||||||
t.printAll()
|
t.printAll()
|
||||||
return true
|
return true
|
||||||
@@ -3200,14 +3419,12 @@ func (t *Terminal) resizeIfNeeded() bool {
|
|||||||
if needHeaderLinesWindow {
|
if needHeaderLinesWindow {
|
||||||
primaryHeaderLines -= t.headerLines
|
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) ||
|
if (needHeaderWindow && t.headerWindow == nil) ||
|
||||||
(!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) ||
|
(!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()
|
t.printAll()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -3219,6 +3436,13 @@ func (t *Terminal) printHeader() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
t.withWindow(t.headerWindow, func() {
|
||||||
var headerItems []Item
|
var headerItems []Item
|
||||||
if !t.hasHeaderLinesWindow() {
|
if !t.hasHeaderLinesWindow() {
|
||||||
@@ -3226,7 +3450,9 @@ func (t *Terminal) printHeader() {
|
|||||||
}
|
}
|
||||||
t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems)
|
t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems)
|
||||||
})
|
})
|
||||||
if w, shape := t.determineHeaderLinesShape(); w {
|
}
|
||||||
|
if w, shape := t.determineHeaderLinesShape(); w &&
|
||||||
|
!(shape == tui.BorderInline && t.headerLinesWindow == nil) {
|
||||||
t.withWindow(t.headerLinesWindow, func() {
|
t.withWindow(t.headerLinesWindow, func() {
|
||||||
t.printHeaderImpl(t.headerLinesWindow, shape, nil, t.header)
|
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() {
|
if t.listBorderShape.HasLeft() {
|
||||||
indentSize += 1 + t.borderWidth
|
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
|
indentSize -= 1 + t.borderWidth
|
||||||
if indentSize < 0 {
|
if indentSize < 0 {
|
||||||
indentSize = 0
|
indentSize = 0
|
||||||
@@ -5952,11 +6181,28 @@ func (t *Terminal) Loop() error {
|
|||||||
case reqRedrawInputLabel:
|
case reqRedrawInputLabel:
|
||||||
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, true)
|
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, true)
|
||||||
case reqRedrawHeaderLabel:
|
case reqRedrawHeaderLabel:
|
||||||
|
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)
|
t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, true)
|
||||||
|
}
|
||||||
case reqRedrawFooterLabel:
|
case reqRedrawFooterLabel:
|
||||||
|
if t.footerBorderShape == tui.BorderInline {
|
||||||
|
t.printAll()
|
||||||
|
} else {
|
||||||
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true)
|
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, true)
|
||||||
|
}
|
||||||
case reqRedrawListLabel:
|
case reqRedrawListLabel:
|
||||||
|
// 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)
|
t.printLabel(t.wborder, t.listLabel, t.listLabelOpts, t.listLabelLen, t.listBorderShape, true)
|
||||||
|
}
|
||||||
case reqRedrawBorderLabel:
|
case reqRedrawBorderLabel:
|
||||||
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true)
|
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true)
|
||||||
case reqRedrawPreviewLabel:
|
case reqRedrawPreviewLabel:
|
||||||
@@ -6535,11 +6781,17 @@ func (t *Terminal) Loop() error {
|
|||||||
t.cx = len(t.input)
|
t.cx = len(t.input)
|
||||||
case actChangeHeader, actTransformHeader, actBgTransformHeader:
|
case actChangeHeader, actTransformHeader, actBgTransformHeader:
|
||||||
capture(false, func(header string) {
|
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) {
|
if t.changeHeader(header) {
|
||||||
|
// 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(reqList, reqPrompt, reqInfo)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
req(reqHeader)
|
req(reqHeader)
|
||||||
})
|
})
|
||||||
case actChangeFooter, actTransformFooter, actBgTransformFooter:
|
case actChangeFooter, actTransformFooter, actBgTransformFooter:
|
||||||
|
|||||||
@@ -721,7 +721,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
|||||||
t.Errorf("ANSI: %q", result)
|
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)
|
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
|
||||||
if len(result) != 1 || result[0] != "abcdefghij" {
|
if len(result) != 1 || result[0] != "abcdefghij" {
|
||||||
t.Errorf("Long word: %q", result)
|
t.Errorf("Long word: %q", result)
|
||||||
@@ -749,7 +749,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
|||||||
|
|
||||||
// Tab handling: tab expands to tabstop-aligned width
|
// Tab handling: tab expands to tabstop-aligned width
|
||||||
term.tabstop = 8
|
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
|
// maxWidth=15: "\thi" = 10 wide, "there" = 5 wide, total 16 > 15, wrap at space
|
||||||
result = term.wordWrapAnsiLine("\thi there", 15, 2)
|
result = term.wordWrapAnsiLine("\thi there", 15, 2)
|
||||||
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
|
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
|
||||||
|
|||||||
+118
-101
@@ -1129,127 +1129,144 @@ func (w *LightWindow) DrawHBorder() {
|
|||||||
w.drawBorder(true)
|
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) {
|
func (w *LightWindow) drawBorder(onlyHorizontal bool) {
|
||||||
if w.height == 0 {
|
if w.height == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch w.border.shape {
|
shape := w.border.shape
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
|
if shape == BorderNone {
|
||||||
w.drawBorderAround(onlyHorizontal)
|
|
||||||
case BorderHorizontal:
|
|
||||||
w.drawBorderHorizontal(true, true)
|
|
||||||
case BorderVertical:
|
|
||||||
if onlyHorizontal {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.drawBorderVertical(true, true)
|
color := BorderColor(w.windowType)
|
||||||
case BorderTop:
|
hasLeft := shape.HasLeft()
|
||||||
w.drawBorderHorizontal(true, false)
|
hasRight := shape.HasRight()
|
||||||
case BorderBottom:
|
|
||||||
w.drawBorderHorizontal(false, true)
|
|
||||||
case BorderLeft:
|
|
||||||
if onlyHorizontal {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.drawBorderVertical(true, false)
|
|
||||||
case BorderRight:
|
|
||||||
if onlyHorizontal {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.drawBorderVertical(false, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *LightWindow) drawBorderHorizontal(top, bottom bool) {
|
if shape.HasTop() {
|
||||||
color := ColBorder
|
var leftCap, rightCap rune
|
||||||
switch w.windowType {
|
if hasLeft {
|
||||||
case WindowList:
|
leftCap = w.border.topLeft
|
||||||
color = ColListBorder
|
|
||||||
case WindowInput:
|
|
||||||
color = ColInputBorder
|
|
||||||
case WindowHeader:
|
|
||||||
color = ColHeaderBorder
|
|
||||||
case WindowFooter:
|
|
||||||
color = ColFooterBorder
|
|
||||||
case WindowPreview:
|
|
||||||
color = ColPreviewBorder
|
|
||||||
}
|
}
|
||||||
hw := runeWidth(w.border.top)
|
if hasRight {
|
||||||
if top {
|
rightCap = w.border.topRight
|
||||||
w.Move(0, 0)
|
|
||||||
w.CPrint(color, repeat(w.border.top, w.width/hw))
|
|
||||||
}
|
}
|
||||||
|
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
|
||||||
if bottom {
|
|
||||||
w.Move(w.height-1, 0)
|
|
||||||
w.CPrint(color, repeat(w.border.bottom, w.width/hw))
|
|
||||||
}
|
}
|
||||||
}
|
if !onlyHorizontal && (hasLeft || hasRight) {
|
||||||
|
|
||||||
func (w *LightWindow) drawBorderVertical(left, right bool) {
|
|
||||||
vw := runeWidth(w.border.left)
|
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++ {
|
for y := 0; y < w.height; y++ {
|
||||||
if left {
|
// 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.Move(y, 0)
|
||||||
w.CPrint(color, string(w.border.left))
|
w.CPrint(color, string(w.border.left)+" ")
|
||||||
w.CPrint(color, " ") // Margin
|
|
||||||
}
|
}
|
||||||
if right {
|
if hasRight {
|
||||||
w.Move(y, w.width-vw-1)
|
w.Move(y, w.width-vw-1)
|
||||||
w.CPrint(color, " ") // Margin
|
w.CPrint(color, " "+string(w.border.right))
|
||||||
w.CPrint(color, string(w.border.right))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
if shape.HasBottom() {
|
||||||
tcw := runeWidth(w.border.topLeft) + runeWidth(w.border.topRight)
|
var leftCap, rightCap rune
|
||||||
bcw := runeWidth(w.border.bottomLeft) + runeWidth(w.border.bottomRight)
|
if hasLeft {
|
||||||
rem := (w.width - tcw) % hw
|
leftCap = w.border.bottomLeft
|
||||||
w.CPrint(color, string(w.border.topLeft)+repeat(w.border.top, (w.width-tcw)/hw)+repeat(' ', rem)+string(w.border.topRight))
|
|
||||||
if !onlyHorizontal {
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
if hasRight {
|
||||||
|
rightCap = w.border.bottomRight
|
||||||
|
}
|
||||||
|
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
|
||||||
}
|
}
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LightWindow) csi(code string) string {
|
func (w *LightWindow) csi(code string) string {
|
||||||
|
|||||||
+135
-54
@@ -1017,6 +1017,115 @@ func (w *TcellWindow) DrawHBorder() {
|
|||||||
w.drawBorder(true)
|
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) {
|
func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
||||||
if w.height == 0 {
|
if w.height == 0 {
|
||||||
return
|
return
|
||||||
@@ -1031,72 +1140,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
|||||||
top := w.top
|
top := w.top
|
||||||
bot := top + w.height
|
bot := top + w.height
|
||||||
|
|
||||||
var style tcell.Style
|
style := w.borderStyleFor(w.windowType)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
hw := runeWidth(w.borderStyle.top)
|
hasLeft := shape.HasLeft()
|
||||||
switch shape {
|
hasRight := shape.HasRight()
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
|
|
||||||
max := right - 2*hw
|
if shape.HasTop() {
|
||||||
if shape == BorderHorizontal || shape == BorderTop {
|
var leftCap, rightCap rune
|
||||||
max = right - hw
|
if hasLeft {
|
||||||
|
leftCap = w.borderStyle.topLeft
|
||||||
}
|
}
|
||||||
// tcell has an issue displaying two overlapping wide runes
|
if hasRight {
|
||||||
// e.g. SetContent( HH )
|
rightCap = w.borderStyle.topRight
|
||||||
// SetContent( TR )
|
|
||||||
// ==================
|
|
||||||
// ( HH ) => TR is ignored
|
|
||||||
for x := left; x <= max; x += hw {
|
|
||||||
_screen.SetContent(x, top, w.borderStyle.top, nil, style)
|
|
||||||
}
|
}
|
||||||
|
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
|
||||||
}
|
}
|
||||||
switch shape {
|
if shape.HasBottom() {
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
|
var leftCap, rightCap rune
|
||||||
max := right - 2*hw
|
if hasLeft {
|
||||||
if shape == BorderHorizontal || shape == BorderBottom {
|
leftCap = w.borderStyle.bottomLeft
|
||||||
max = right - hw
|
|
||||||
}
|
}
|
||||||
for x := left; x <= max; x += hw {
|
if hasRight {
|
||||||
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
|
rightCap = w.borderStyle.bottomRight
|
||||||
}
|
}
|
||||||
|
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
|
||||||
}
|
}
|
||||||
if !onlyHorizontal {
|
if !onlyHorizontal {
|
||||||
switch shape {
|
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
|
|
||||||
for y := top; y < bot; y++ {
|
|
||||||
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch shape {
|
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
|
|
||||||
vw := runeWidth(w.borderStyle.right)
|
vw := runeWidth(w.borderStyle.right)
|
||||||
for y := top; y < bot; y++ {
|
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)
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
|
_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
@@ -595,11 +595,12 @@ const (
|
|||||||
BorderBottom
|
BorderBottom
|
||||||
BorderLeft
|
BorderLeft
|
||||||
BorderRight
|
BorderRight
|
||||||
|
BorderInline
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s BorderShape) HasLeft() bool {
|
func (s BorderShape) HasLeft() bool {
|
||||||
switch s {
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -607,7 +608,7 @@ func (s BorderShape) HasLeft() bool {
|
|||||||
|
|
||||||
func (s BorderShape) HasRight() bool {
|
func (s BorderShape) HasRight() bool {
|
||||||
switch s {
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -615,7 +616,7 @@ func (s BorderShape) HasRight() bool {
|
|||||||
|
|
||||||
func (s BorderShape) HasTop() bool {
|
func (s BorderShape) HasTop() bool {
|
||||||
switch s {
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -623,7 +624,7 @@ func (s BorderShape) HasTop() bool {
|
|||||||
|
|
||||||
func (s BorderShape) HasBottom() bool {
|
func (s BorderShape) HasBottom() bool {
|
||||||
switch s {
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -643,6 +644,8 @@ type BorderStyle struct {
|
|||||||
topRight rune
|
topRight rune
|
||||||
bottomLeft rune
|
bottomLeft rune
|
||||||
bottomRight rune
|
bottomRight rune
|
||||||
|
leftMid rune
|
||||||
|
rightMid rune
|
||||||
}
|
}
|
||||||
|
|
||||||
type BorderCharacter int
|
type BorderCharacter int
|
||||||
@@ -658,7 +661,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topLeft: ' ',
|
topLeft: ' ',
|
||||||
topRight: ' ',
|
topRight: ' ',
|
||||||
bottomLeft: ' ',
|
bottomLeft: ' ',
|
||||||
bottomRight: ' '}
|
bottomRight: ' ',
|
||||||
|
leftMid: ' ',
|
||||||
|
rightMid: ' '}
|
||||||
}
|
}
|
||||||
if !unicode {
|
if !unicode {
|
||||||
return BorderStyle{
|
return BorderStyle{
|
||||||
@@ -671,6 +676,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '+',
|
topRight: '+',
|
||||||
bottomLeft: '+',
|
bottomLeft: '+',
|
||||||
bottomRight: '+',
|
bottomRight: '+',
|
||||||
|
leftMid: '+',
|
||||||
|
rightMid: '+',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch shape {
|
switch shape {
|
||||||
@@ -685,6 +692,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '┐',
|
topRight: '┐',
|
||||||
bottomLeft: '└',
|
bottomLeft: '└',
|
||||||
bottomRight: '┘',
|
bottomRight: '┘',
|
||||||
|
leftMid: '├',
|
||||||
|
rightMid: '┤',
|
||||||
}
|
}
|
||||||
case BorderBold:
|
case BorderBold:
|
||||||
return BorderStyle{
|
return BorderStyle{
|
||||||
@@ -697,6 +706,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '┓',
|
topRight: '┓',
|
||||||
bottomLeft: '┗',
|
bottomLeft: '┗',
|
||||||
bottomRight: '┛',
|
bottomRight: '┛',
|
||||||
|
leftMid: '┣',
|
||||||
|
rightMid: '┫',
|
||||||
}
|
}
|
||||||
case BorderBlock:
|
case BorderBlock:
|
||||||
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
|
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
|
||||||
@@ -712,6 +723,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '▜',
|
topRight: '▜',
|
||||||
bottomLeft: '▙',
|
bottomLeft: '▙',
|
||||||
bottomRight: '▟',
|
bottomRight: '▟',
|
||||||
|
leftMid: '▌',
|
||||||
|
rightMid: '▐',
|
||||||
}
|
}
|
||||||
|
|
||||||
case BorderThinBlock:
|
case BorderThinBlock:
|
||||||
@@ -728,6 +741,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '🭾',
|
topRight: '🭾',
|
||||||
bottomLeft: '🭼',
|
bottomLeft: '🭼',
|
||||||
bottomRight: '🭿',
|
bottomRight: '🭿',
|
||||||
|
leftMid: '▏',
|
||||||
|
rightMid: '▕',
|
||||||
}
|
}
|
||||||
|
|
||||||
case BorderDouble:
|
case BorderDouble:
|
||||||
@@ -741,6 +756,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '╗',
|
topRight: '╗',
|
||||||
bottomLeft: '╚',
|
bottomLeft: '╚',
|
||||||
bottomRight: '╝',
|
bottomRight: '╝',
|
||||||
|
leftMid: '╠',
|
||||||
|
rightMid: '╣',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return BorderStyle{
|
return BorderStyle{
|
||||||
@@ -753,6 +770,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '╮',
|
topRight: '╮',
|
||||||
bottomLeft: '╰',
|
bottomLeft: '╰',
|
||||||
bottomRight: '╯',
|
bottomRight: '╯',
|
||||||
|
leftMid: '├',
|
||||||
|
rightMid: '┤',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,6 +793,35 @@ const (
|
|||||||
WindowFooter
|
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 {
|
type Renderer interface {
|
||||||
DefaultTheme() *ColorTheme
|
DefaultTheme() *ColorTheme
|
||||||
Init() error
|
Init() error
|
||||||
@@ -811,6 +859,19 @@ type Window interface {
|
|||||||
|
|
||||||
DrawBorder()
|
DrawBorder()
|
||||||
DrawHBorder()
|
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()
|
Refresh()
|
||||||
FinishFill()
|
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 {
|
if forceBlack {
|
||||||
theme.Bg = ColorAttr{colBlack, AttrUndefined}
|
theme.Bg = ColorAttr{colBlack, AttrUndefined}
|
||||||
}
|
}
|
||||||
@@ -1300,11 +1361,22 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
|||||||
} else {
|
} else {
|
||||||
theme.HeaderBg = o(theme.Bg, theme.ListBg)
|
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.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel)
|
||||||
|
|
||||||
theme.FooterBg = o(theme.Bg, theme.FooterBg)
|
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)
|
theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel)
|
||||||
|
|
||||||
if theme.Nomatch.IsUndefined() {
|
if theme.Nomatch.IsUndefined() {
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ func TestWrapLine(t *testing.T) {
|
|||||||
t.Errorf("Basic wrap: %v", lines)
|
t.Errorf("Basic wrap: %v", lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exact fit — no wrapping needed
|
// Exact fit - no wrapping needed
|
||||||
lines = WrapLine("hello", 0, 5, 8, 2)
|
lines = WrapLine("hello", 0, 5, 8, 2)
|
||||||
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
|
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
|
||||||
t.Errorf("Exact fit: %v", lines)
|
t.Errorf("Exact fit: %v", lines)
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ class Tmux
|
|||||||
go(%W[send-keys -t #{win}] + args.map(&:to_s))
|
go(%W[send-keys -t #{win}] + args.map(&:to_s))
|
||||||
end
|
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)
|
def paste(str)
|
||||||
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
|
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
|
||||||
end
|
end
|
||||||
@@ -113,6 +130,71 @@ class Tmux
|
|||||||
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
|
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
|
||||||
end
|
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)
|
def until(refresh = false, timeout: DEFAULT_TIMEOUT)
|
||||||
lines = nil
|
lines = nil
|
||||||
begin
|
begin
|
||||||
|
|||||||
+9
-9
@@ -1672,7 +1672,7 @@ class TestCore < TestInteractive
|
|||||||
end
|
end
|
||||||
tmux.send_keys :BSpace, :BSpace, :BSpace
|
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.send_keys 'C-r'
|
||||||
tmux.until do |lines|
|
tmux.until do |lines|
|
||||||
assert_equal 1000, lines.match_count
|
assert_equal 1000, lines.match_count
|
||||||
@@ -1694,7 +1694,7 @@ class TestCore < TestInteractive
|
|||||||
tmux.send_keys :Up
|
tmux.send_keys :Up
|
||||||
tmux.until { |lines| assert_includes lines, '> 2 banana' }
|
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.send_keys 'C-r'
|
||||||
tmux.until do |lines|
|
tmux.until do |lines|
|
||||||
assert_equal 3, lines.match_count
|
assert_equal 3, lines.match_count
|
||||||
@@ -1709,7 +1709,7 @@ class TestCore < TestInteractive
|
|||||||
tmux.send_keys :Up
|
tmux.send_keys :Up
|
||||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
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)
|
# Cursor stays at the same position (second item)
|
||||||
tmux.send_keys 'C-r'
|
tmux.send_keys 'C-r'
|
||||||
tmux.until do |lines|
|
tmux.until do |lines|
|
||||||
@@ -1727,7 +1727,7 @@ class TestCore < TestInteractive
|
|||||||
assert_includes lines[-2], '+T'
|
assert_includes lines[-2], '+T'
|
||||||
end
|
end
|
||||||
|
|
||||||
# Trigger slow reload — should show +T* while blocked
|
# Trigger slow reload - should show +T* while blocked
|
||||||
tmux.send_keys 'C-r'
|
tmux.send_keys 'C-r'
|
||||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||||
|
|
||||||
@@ -1769,7 +1769,7 @@ class TestCore < TestInteractive
|
|||||||
assert_includes lines, '> 1'
|
assert_includes lines, '> 1'
|
||||||
end
|
end
|
||||||
|
|
||||||
# Trigger reload — blocked during initial sleep
|
# Trigger reload - blocked during initial sleep
|
||||||
tmux.send_keys 'C-r'
|
tmux.send_keys 'C-r'
|
||||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||||
# Match "1" arrives, unblocks before the remaining items load
|
# Match "1" arrives, unblocks before the remaining items load
|
||||||
@@ -1790,7 +1790,7 @@ class TestCore < TestInteractive
|
|||||||
assert_includes lines, '> 1'
|
assert_includes lines, '> 1'
|
||||||
end
|
end
|
||||||
|
|
||||||
# Trigger reload-sync — every observable state must be either:
|
# Trigger reload-sync - every observable state must be either:
|
||||||
# 1. +T* (still blocked), or
|
# 1. +T* (still blocked), or
|
||||||
# 2. final state (count=10, +T without *)
|
# 2. final state (count=10, +T without *)
|
||||||
# Any other combination (e.g. unblocked while count < 10) is a bug.
|
# Any other combination (e.g. unblocked while count < 10) is a bug.
|
||||||
@@ -1835,7 +1835,7 @@ class TestCore < TestInteractive
|
|||||||
tmux.send_keys :Up
|
tmux.send_keys :Up
|
||||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
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.send_keys 'C-r'
|
||||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||||
# After stream completes, unblocks with cursor at same position (second item)
|
# After stream completes, unblocks with cursor at same position (second item)
|
||||||
@@ -1857,7 +1857,7 @@ class TestCore < TestInteractive
|
|||||||
tmux.send_keys 'C-t'
|
tmux.send_keys 'C-t'
|
||||||
tmux.until { |lines| assert_includes lines[-2], '+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.send_keys 'C-r'
|
||||||
tmux.until do |lines|
|
tmux.until do |lines|
|
||||||
assert_equal 3, lines.match_count
|
assert_equal 3, lines.match_count
|
||||||
@@ -1876,7 +1876,7 @@ class TestCore < TestInteractive
|
|||||||
tmux.send_keys :Up, :Up, :Tab
|
tmux.send_keys :Up, :Up, :Tab
|
||||||
tmux.until { |lines| assert_includes lines[-2], '(2)' }
|
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.send_keys 'C-r'
|
||||||
tmux.until do |lines|
|
tmux.until do |lines|
|
||||||
assert_equal 3, lines.match_count
|
assert_equal 3, lines.match_count
|
||||||
|
|||||||
@@ -1298,4 +1298,325 @@ class TestLayout < TestInteractive
|
|||||||
tmux.send_keys :Enter
|
tmux.send_keys :Enter
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ class TestServer < TestInteractive
|
|||||||
assert_equal [0, 1], state[:current][:positions]
|
assert_equal [0, 1], state[:current][:positions]
|
||||||
assert_equal state[:current][:positions], state[:current][:positions].sort
|
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> ')
|
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 100, lines.item_count }
|
||||||
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }
|
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }
|
||||||
|
|||||||
Reference in New Issue
Block a user