m/fzf
1
0
mirror of https://github.com/junegunn/fzf.git synced 2025-11-09 03:43:49 -05:00

Compare commits

...

1 Commits

Author SHA1 Message Date
Junegunn Choi
5e2d96d5e6 Add --freeze-left=N option to keep the leftmost N fields visible 2025-11-09 17:24:45 +09:00
5 changed files with 129 additions and 55 deletions

View File

@@ -1,6 +1,19 @@
CHANGELOG
=========
0.67.0
------
- Added `--freeze-left=N` option to keep the leftmost N columns visible.
```sh
# Keeps the file name column fixed and always visible
git grep --line-number --color=always -- '' |
fzf --ansi --delimiter : --freeze-left 1
# Used with --keep-right
git grep --line-number --color=always -- '' |
fzf --ansi --delimiter : --freeze-left 1 --keep-right
```
0.66.1
------
- Bug fixes

View File

@@ -629,6 +629,9 @@ Render empty lines between each item
The given string will be repeated to draw a horizontal line on each gap
(default: '┈' or '\-' depending on \fB\-\-no\-unicode\fR).
.TP
.BI "\-\-freeze\-left=" "N"
Number of fields to freeze on the left.
.TP
.B "\-\-keep\-right"
Keep the right end of the line visible when it's too long. Effective only when
the query string is empty.

View File

@@ -104,6 +104,7 @@ Usage: fzf [options]
--gap[=N] Render empty lines between each item
--gap-line[=STR] Draw horizontal line on each gap using the string
(default: '┈' or '-')
--freeze-left=N Number of fields to freeze on the left
--keep-right Keep the right end of the line visible on overflow
--scroll-off=LINES Number of screen lines to keep above or below when
scrolling to the top or to the bottom (default: 0)
@@ -562,6 +563,7 @@ type Options struct {
Case Case
Normalize bool
Nth []Range
FreezeLeft int
WithNth func(Delimiter) func([]Token, int32) string
AcceptNth func(Delimiter) func([]Token, int32) string
Delimiter Delimiter
@@ -2695,6 +2697,10 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.Nth, err = splitNth(str); err != nil {
return err
}
case "--freeze-left":
if opts.FreezeLeft, err = nextInt("number of fields required"); err != nil {
return err
}
case "--with-nth":
str, err := nextString("nth expression required")
if err != nil {
@@ -3338,6 +3344,10 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return errors.New("empty jump labels")
}
if opts.FreezeLeft < 0 {
return errors.New("number of fields to freeze must be a non-negative integer")
}
if validateJumpLabels {
for _, r := range opts.JumpLabels {
if r < 32 || r > 126 {

View File

@@ -331,6 +331,7 @@ type Terminal struct {
scrollbar string
previewScrollbar string
ansi bool
freezeLeft int
nthAttr tui.Attr
nth []Range
nthCurrent []Range
@@ -1050,6 +1051,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
footer: opts.Footer,
header0: opts.Header,
ansi: opts.Ansi,
freezeLeft: opts.FreezeLeft,
nthAttr: opts.Theme.Nth.Attr,
nth: opts.Nth,
nthCurrent: opts.Nth,
@@ -3525,6 +3527,18 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
}
allOffsets := result.colorOffsets(charOffsets, nthOffsets, t.theme, colBase, colMatch, t.nthAttr, hidden)
// Determine split offset for horizontal scrolling with freeze
splitOffset := -1
if t.hscroll && !t.wrap && t.freezeLeft > 0 {
tokens := Tokenize(item.text.ToString(), t.delimiter)
if len(tokens) == 0 {
splitOffset = 0
} else {
token := tokens[util.Min(t.freezeLeft, len(tokens))-1]
splitOffset = int(token.prefixLength) + token.text.Length() - token.text.TrailingWhitespaces()
}
}
maxLines := 1
if t.canSpanMultiLines() {
maxLines = maxLineNum - lineNum + 1
@@ -3594,6 +3608,10 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
break
}
}
splitOffsetLocal := 0
if splitOffset >= 0 && splitOffset > from && splitOffset < from+len(line) {
splitOffsetLocal = splitOffset - from
}
from += len(line)
if lineOffset < skipLines {
continue
@@ -3667,69 +3685,88 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
wrapped = true
}
displayWidth = t.displayWidthWithLimit(line, 0, maxWidth)
if !t.wrap && displayWidth > maxWidth {
ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2)
maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(line))
transformOffsets := func(diff int32, rightTrim bool) {
for idx, offset := range offsets {
b, e := offset.offset[0], offset.offset[1]
el := int32(len(ellipsis))
b += el - diff
e += el - diff
b = util.Max32(b, el)
if rightTrim {
e = util.Min32(e, int32(maxWidth-ellipsisWidth))
}
offsets[idx].offset[0] = b
offsets[idx].offset[1] = util.Max32(b, e)
}
frozen := line[:splitOffsetLocal]
rest := line[splitOffsetLocal:]
displayWidthSum := 0
for fidx, runes := range [][]rune{frozen, rest} {
if len(runes) == 0 {
continue
}
if t.hscroll {
if t.keepRight && pos == nil {
trimmed, diff := t.trimLeft(line, maxWidth, ellipsisWidth)
transformOffsets(diff, false)
line = append(ellipsis, trimmed...)
} else if !t.overflow(line[:maxe], maxWidth-ellipsisWidth) {
// Stri..
line, _ = t.trimRight(line, maxWidth-ellipsisWidth)
line = append(line, ellipsis...)
if splitOffsetLocal > 0 && fidx == 1 {
for idx := range offsets {
offsets[idx].offset[0] -= int32(splitOffsetLocal)
offsets[idx].offset[1] -= int32(splitOffsetLocal)
}
maxe -= splitOffsetLocal
}
displayWidth = t.displayWidthWithLimit(runes, 0, maxWidth)
if !t.wrap && displayWidth > maxWidth {
ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2)
maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(runes))
transformOffsets := func(diff int32, rightTrim bool) {
for idx, offset := range offsets {
b, e := offset.offset[0], offset.offset[1]
el := int32(len(ellipsis))
b += el - diff
e += el - diff
b = util.Max32(b, el)
if rightTrim {
e = util.Min32(e, int32(maxWidth-ellipsisWidth))
}
offsets[idx].offset[0] = b
offsets[idx].offset[1] = util.Max32(b, e)
}
}
if t.hscroll {
if fidx > 0 && t.keepRight && pos == nil {
trimmed, diff := t.trimLeft(runes, maxWidth, ellipsisWidth)
transformOffsets(diff, false)
runes = append(ellipsis, trimmed...)
} else if fidx == 0 || !t.overflow(runes[:maxe], maxWidth-ellipsisWidth) {
// Stri..
runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth)
runes = append(runes, ellipsis...)
} else {
// Stri..
rightTrim := false
if t.overflow(runes[maxe:], ellipsisWidth) {
runes = append(runes[:maxe], ellipsis...)
rightTrim = true
}
// ..ri..
var diff int32
runes, diff = t.trimLeft(runes, maxWidth, ellipsisWidth)
// Transform offsets
transformOffsets(diff, rightTrim)
runes = append(ellipsis, runes...)
}
} else {
// Stri..
rightTrim := false
if t.overflow(line[maxe:], ellipsisWidth) {
line = append(line[:maxe], ellipsis...)
rightTrim = true
runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth)
runes = append(runes, ellipsis...)
for idx, offset := range offsets {
offsets[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-len(ellipsis)))
offsets[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth))
}
// ..ri..
var diff int32
line, diff = t.trimLeft(line, maxWidth, ellipsisWidth)
// Transform offsets
transformOffsets(diff, rightTrim)
line = append(ellipsis, line...)
}
displayWidth = t.displayWidthWithLimit(runes, 0, displayWidth)
}
displayWidthSum += displayWidth
if maxWidth > 0 {
color := colBase
if hidden {
color = color.WithFg(t.theme.Nomatch)
}
t.printColoredString(t.window, runes, offsets, color)
} else {
line, _ = t.trimRight(line, maxWidth-ellipsisWidth)
line = append(line, ellipsis...)
for idx, offset := range offsets {
offsets[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-len(ellipsis)))
offsets[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth))
}
break
}
displayWidth = t.displayWidthWithLimit(line, 0, displayWidth)
}
if maxWidth > 0 {
color := colBase
if hidden {
color = color.WithFg(t.theme.Nomatch)
}
t.printColoredString(t.window, line, offsets, color)
maxWidth -= displayWidth
}
if postTask != nil {
postTask(actualLineNum, displayWidth, wasWrapped, forceRedraw, lbg)
postTask(actualLineNum, displayWidthSum, wasWrapped, forceRedraw, lbg)
} else {
t.markOtherLine(actualLineNum)
}

View File

@@ -1190,6 +1190,17 @@ class TestCore < TestInteractive
tmux.until { |lines| assert lines.any_include?('9999␊10000') }
end
def test_freeze_left_keep_right
tmux.send_keys %[seq 10000 | #{FZF} --read0 --delimiter "\n" --freeze-left 3 --keep-right --ellipsis XX --no-multi-line --bind space:toggle-multi-line], :Enter
tmux.until { |lines| assert_match(/^> 1␊2␊3XX.*10000␊$/, lines[-3]) }
tmux.send_keys '5'
tmux.until { |lines| assert_match(/^> 1␊2␊3␊4␊5␊.*XX$/, lines[-3]) }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('> 1') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('1␊2␊3␊4␊5␊') }
end
def test_backward_eof
tmux.send_keys "echo foo | #{FZF} --bind 'backward-eof:reload(seq 100)'", :Enter
tmux.until { |lines| lines.item_count == 1 && lines.match_count == 1 }