m/fzf
1
0
mirror of https://github.com/junegunn/fzf.git synced 2025-11-15 06:43:47 -05:00

Add --freeze-right=N option to keep the rightmost N fields visible

This commit is contained in:
Junegunn Choi
2025-11-09 19:41:58 +09:00
parent 5e2d96d5e6
commit b7178f5a54
5 changed files with 119 additions and 31 deletions

View File

@@ -13,6 +13,11 @@ CHANGELOG
git grep --line-number --color=always -- '' | git grep --line-number --color=always -- '' |
fzf --ansi --delimiter : --freeze-left 1 --keep-right fzf --ansi --delimiter : --freeze-left 1 --keep-right
``` ```
- Also added `--freeze-right=N` option to keep the rightmost N columns visible.
```sh
fd | fzf --freeze-right 1 --delimiter /
fd | fzf --freeze-left 1 --freeze-right 1 --delimiter /
```
0.66.1 0.66.1
------ ------

View File

@@ -632,9 +632,13 @@ The given string will be repeated to draw a horizontal line on each gap
.BI "\-\-freeze\-left=" "N" .BI "\-\-freeze\-left=" "N"
Number of fields to freeze on the left. Number of fields to freeze on the left.
.TP .TP
.BI "\-\-freeze\-right=" "N"
Number of fields to freeze on the right.
.TP
.B "\-\-keep\-right" .B "\-\-keep\-right"
Keep the right end of the line visible when it's too long. Effective only when Keep the right end of the line visible when it's too long. Effective only when
the query string is empty. the query string is empty. Use \fB\-\-freeze\-right=1\fR instead if you want
the last field to be always visible even with a non-empty query.
.TP .TP
.BI "\-\-scroll\-off=" "LINES" .BI "\-\-scroll\-off=" "LINES"
Number of screen lines to keep above or below when scrolling to the top or to Number of screen lines to keep above or below when scrolling to the top or to

View File

@@ -105,6 +105,7 @@ Usage: fzf [options]
--gap-line[=STR] Draw horizontal line on each gap using the string --gap-line[=STR] Draw horizontal line on each gap using the string
(default: '┈' or '-') (default: '┈' or '-')
--freeze-left=N Number of fields to freeze on the left --freeze-left=N Number of fields to freeze on the left
--freeze-right=N Number of fields to freeze on the right
--keep-right Keep the right end of the line visible on overflow --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 --scroll-off=LINES Number of screen lines to keep above or below when
scrolling to the top or to the bottom (default: 0) scrolling to the top or to the bottom (default: 0)
@@ -564,6 +565,7 @@ type Options struct {
Normalize bool Normalize bool
Nth []Range Nth []Range
FreezeLeft int FreezeLeft int
FreezeRight int
WithNth func(Delimiter) func([]Token, int32) string WithNth func(Delimiter) func([]Token, int32) string
AcceptNth func(Delimiter) func([]Token, int32) string AcceptNth func(Delimiter) func([]Token, int32) string
Delimiter Delimiter Delimiter Delimiter
@@ -2701,6 +2703,10 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.FreezeLeft, err = nextInt("number of fields required"); err != nil { if opts.FreezeLeft, err = nextInt("number of fields required"); err != nil {
return err return err
} }
case "--freeze-right":
if opts.FreezeRight, err = nextInt("number of fields required"); err != nil {
return err
}
case "--with-nth": case "--with-nth":
str, err := nextString("nth expression required") str, err := nextString("nth expression required")
if err != nil { if err != nil {
@@ -3344,7 +3350,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return errors.New("empty jump labels") return errors.New("empty jump labels")
} }
if opts.FreezeLeft < 0 { if opts.FreezeLeft < 0 || opts.FreezeRight < 0 {
return errors.New("number of fields to freeze must be a non-negative integer") return errors.New("number of fields to freeze must be a non-negative integer")
} }

View File

@@ -332,6 +332,7 @@ type Terminal struct {
previewScrollbar string previewScrollbar string
ansi bool ansi bool
freezeLeft int freezeLeft int
freezeRight int
nthAttr tui.Attr nthAttr tui.Attr
nth []Range nth []Range
nthCurrent []Range nthCurrent []Range
@@ -1052,6 +1053,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
header0: opts.Header, header0: opts.Header,
ansi: opts.Ansi, ansi: opts.Ansi,
freezeLeft: opts.FreezeLeft, freezeLeft: opts.FreezeLeft,
freezeRight: opts.FreezeRight,
nthAttr: opts.Theme.Nth.Attr, nthAttr: opts.Theme.Nth.Attr,
nth: opts.Nth, nth: opts.Nth,
nthCurrent: opts.Nth, nthCurrent: opts.Nth,
@@ -3528,14 +3530,30 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
allOffsets := result.colorOffsets(charOffsets, nthOffsets, t.theme, colBase, colMatch, t.nthAttr, hidden) allOffsets := result.colorOffsets(charOffsets, nthOffsets, t.theme, colBase, colMatch, t.nthAttr, hidden)
// Determine split offset for horizontal scrolling with freeze // Determine split offset for horizontal scrolling with freeze
splitOffset := -1 splitOffset1 := -1
if t.hscroll && !t.wrap && t.freezeLeft > 0 { splitOffset2 := -1
tokens := Tokenize(item.text.ToString(), t.delimiter) if t.hscroll && !t.wrap {
if len(tokens) == 0 { var tokens []Token
splitOffset = 0 if t.freezeLeft > 0 || t.freezeRight > 0 {
} else { tokens = Tokenize(item.text.ToString(), t.delimiter)
}
// 0 1 2| 3| 4 5
// ----- ---
if t.freezeLeft > 0 {
if len(tokens) > 0 {
token := tokens[util.Min(t.freezeLeft, len(tokens))-1] token := tokens[util.Min(t.freezeLeft, len(tokens))-1]
splitOffset = int(token.prefixLength) + token.text.Length() - token.text.TrailingWhitespaces() splitOffset1 = int(token.prefixLength) + token.text.Length() - token.text.TrailingWhitespaces()
}
}
if t.freezeRight > 0 {
index := util.Max(t.freezeLeft-1, len(tokens)-t.freezeRight-1)
if index < 0 {
splitOffset2 = 0
} else if index >= t.freezeLeft {
token := tokens[index]
splitOffset2 = int(token.prefixLength) + token.text.Length()
}
splitOffset2 = util.Max(splitOffset2, splitOffset1)
} }
} }
@@ -3608,9 +3626,13 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
break break
} }
} }
splitOffsetLocal := 0 splitOffsetLeft := 0
if splitOffset >= 0 && splitOffset > from && splitOffset < from+len(line) { if splitOffset1 >= 0 && splitOffset1 > from && splitOffset1 < from+len(line) {
splitOffsetLocal = splitOffset - from splitOffsetLeft = splitOffset1 - from
}
splitOffsetRight := -1
if splitOffset2 >= 0 && splitOffset2 >= from && splitOffset2 < from+len(line) {
splitOffsetRight = splitOffset2 - from
} }
from += len(line) from += len(line)
if lineOffset < skipLines { if lineOffset < skipLines {
@@ -3618,10 +3640,10 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
} }
actualLineOffset := lineOffset - skipLines actualLineOffset := lineOffset - skipLines
var maxe int var maxEnd int
for _, offset := range offsets { for _, offset := range offsets {
if offset.match { if offset.match {
maxe = util.Max(maxe, int(offset.offset[1])) maxEnd = util.Max(maxEnd, int(offset.offset[1]))
} }
} }
@@ -3685,26 +3707,39 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
wrapped = true wrapped = true
} }
frozen := line[:splitOffsetLocal] frozenLeft := line[:splitOffsetLeft]
rest := line[splitOffsetLocal:] middle := line[splitOffsetLeft:]
frozenRight := []rune{}
if splitOffsetRight >= splitOffsetLeft {
middle = line[splitOffsetLeft:splitOffsetRight]
frozenRight = line[splitOffsetRight:]
}
displayWidthSum := 0 displayWidthSum := 0
for fidx, runes := range [][]rune{frozen, rest} { todo := [3]func(){}
for fidx, runes := range [][]rune{frozenLeft, frozenRight, middle} {
if len(runes) == 0 { if len(runes) == 0 {
continue continue
} }
if splitOffsetLocal > 0 && fidx == 1 { shift := 0
maxe := maxEnd
offs := make([]colorOffset, len(offsets))
for idx := range offsets { for idx := range offsets {
offsets[idx].offset[0] -= int32(splitOffsetLocal) offs[idx] = offsets[idx]
offsets[idx].offset[1] -= int32(splitOffsetLocal) if fidx == 1 && splitOffsetRight > 0 {
shift = splitOffsetRight
} else if fidx == 2 && splitOffsetLeft > 0 {
shift = splitOffsetLeft
} }
maxe -= splitOffsetLocal offs[idx].offset[0] -= int32(shift)
offs[idx].offset[1] -= int32(shift)
} }
maxe -= shift
displayWidth = t.displayWidthWithLimit(runes, 0, maxWidth) displayWidth = t.displayWidthWithLimit(runes, 0, maxWidth)
if !t.wrap && displayWidth > maxWidth { if !t.wrap && displayWidth > maxWidth {
ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2) ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2)
maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(runes)) maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(runes))
transformOffsets := func(diff int32, rightTrim bool) { transformOffsets := func(diff int32, rightTrim bool) {
for idx, offset := range offsets { for idx, offset := range offs {
b, e := offset.offset[0], offset.offset[1] b, e := offset.offset[0], offset.offset[1]
el := int32(len(ellipsis)) el := int32(len(ellipsis))
b += el - diff b += el - diff
@@ -3713,12 +3748,12 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
if rightTrim { if rightTrim {
e = util.Min32(e, int32(maxWidth-ellipsisWidth)) e = util.Min32(e, int32(maxWidth-ellipsisWidth))
} }
offsets[idx].offset[0] = b offs[idx].offset[0] = b
offsets[idx].offset[1] = util.Max32(b, e) offs[idx].offset[1] = util.Max32(b, e)
} }
} }
if t.hscroll { if t.hscroll {
if fidx > 0 && t.keepRight && pos == nil { if fidx == 1 || fidx == 2 && t.keepRight && pos == nil {
trimmed, diff := t.trimLeft(runes, maxWidth, ellipsisWidth) trimmed, diff := t.trimLeft(runes, maxWidth, ellipsisWidth)
transformOffsets(diff, false) transformOffsets(diff, false)
runes = append(ellipsis, trimmed...) runes = append(ellipsis, trimmed...)
@@ -3745,9 +3780,9 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth) runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth)
runes = append(runes, ellipsis...) runes = append(runes, ellipsis...)
for idx, offset := range offsets { for idx, offset := range offs {
offsets[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-len(ellipsis))) offs[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-len(ellipsis)))
offsets[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth)) offs[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth))
} }
} }
displayWidth = t.displayWidthWithLimit(runes, 0, displayWidth) displayWidth = t.displayWidthWithLimit(runes, 0, displayWidth)
@@ -3759,12 +3794,23 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
if hidden { if hidden {
color = color.WithFg(t.theme.Nomatch) color = color.WithFg(t.theme.Nomatch)
} }
t.printColoredString(t.window, runes, offsets, color) todo[fidx] = func() {
t.printColoredString(t.window, runes, offs, color)
}
} else { } else {
break break
} }
maxWidth -= displayWidth maxWidth -= displayWidth
} }
if todo[0] != nil {
todo[0]()
}
if todo[2] != nil {
todo[2]()
}
if todo[1] != nil {
todo[1]()
}
if postTask != nil { if postTask != nil {
postTask(actualLineNum, displayWidthSum, wasWrapped, forceRedraw, lbg) postTask(actualLineNum, displayWidthSum, wasWrapped, forceRedraw, lbg)
} else { } else {

View File

@@ -1201,6 +1201,33 @@ class TestCore < TestInteractive
tmux.until { |lines| assert lines.any_include?('1␊2␊3␊4␊5␊') } tmux.until { |lines| assert lines.any_include?('1␊2␊3␊4␊5␊') }
end end
def test_freeze_left_and_right
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX], :Enter
tmux.until { |lines| assert_match(/XX9998 9999 10000$/, lines[-3]) }
tmux.send_keys "'1000"
tmux.until { |lines| assert_match(/^> 1 2 3XX.*XX9998 9999 10000$/,lines[-3]) }
end
def test_freeze_right_exceed_range
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-right 100000 --ellipsis XX], :Enter
['', "'1000"].each do |query|
tmux.send_keys query
tmux.until { |lines| assert lines.any_include?("> #{query}".strip) }
tmux.until do |lines|
assert_match(/ 9998 9999 10000$/, lines[-3])
assert_equal(1, lines[-3].scan('XX').size)
end
end
end
def test_freeze_right_exceed_range_with_freeze_left
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 100000 --ellipsis XX], :Enter
tmux.until do |lines|
assert_match(/^> 1 2 3XX.*9998 9999 10000$/, lines[-3])
assert_equal(1, lines[-3].scan('XX').size)
end
end
def test_backward_eof def test_backward_eof
tmux.send_keys "echo foo | #{FZF} --bind 'backward-eof:reload(seq 100)'", :Enter tmux.send_keys "echo foo | #{FZF} --bind 'backward-eof:reload(seq 100)'", :Enter
tmux.until { |lines| lines.item_count == 1 && lines.match_count == 1 } tmux.until { |lines| lines.item_count == 1 && lines.match_count == 1 }