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

Compare commits

..

2 Commits

Author SHA1 Message Date
Junegunn Choi
b2773d5bc3 Add --freeze-right=N option to keep the rightmost N fields visible 2025-11-09 19:41:58 +09:00
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 201 additions and 66 deletions

View File

@@ -1,6 +1,24 @@
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
```
- 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
------
- Bug fixes

View File

@@ -629,9 +629,16 @@ 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
.BI "\-\-freeze\-right=" "N"
Number of fields to freeze on the right.
.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.
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
.BI "\-\-scroll\-off=" "LINES"
Number of screen lines to keep above or below when scrolling to the top or to

View File

@@ -104,6 +104,8 @@ 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
--freeze-right=N Number of fields to freeze on the right
--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 +564,8 @@ type Options struct {
Case Case
Normalize bool
Nth []Range
FreezeLeft int
FreezeRight int
WithNth func(Delimiter) func([]Token, int32) string
AcceptNth func(Delimiter) func([]Token, int32) string
Delimiter Delimiter
@@ -2695,6 +2699,14 @@ 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 "--freeze-right":
if opts.FreezeRight, err = nextInt("number of fields required"); err != nil {
return err
}
case "--with-nth":
str, err := nextString("nth expression required")
if err != nil {
@@ -3338,6 +3350,10 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return errors.New("empty jump labels")
}
if opts.FreezeLeft < 0 || opts.FreezeRight < 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,8 @@ type Terminal struct {
scrollbar string
previewScrollbar string
ansi bool
freezeLeft int
freezeRight int
nthAttr tui.Attr
nth []Range
nthCurrent []Range
@@ -496,14 +498,6 @@ const (
reqFatal
)
func isTerminalEvent(et util.EventType) bool {
switch et {
case reqClose, reqPrintQuery, reqBecome, reqQuit, reqFatal:
return true
}
return false
}
type action struct {
t actionType
a string
@@ -1058,6 +1052,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
footer: opts.Footer,
header0: opts.Header,
ansi: opts.Ansi,
freezeLeft: opts.FreezeLeft,
freezeRight: opts.FreezeRight,
nthAttr: opts.Theme.Nth.Attr,
nth: opts.Nth,
nthCurrent: opts.Nth,
@@ -3533,6 +3529,33 @@ 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
splitOffset1 := -1
splitOffset2 := -1
if t.hscroll && !t.wrap {
var tokens []Token
if t.freezeLeft > 0 || t.freezeRight > 0 {
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]
splitOffset1 = int(token.prefixLength) + token.text.Length() - token.text.TrailingWhitespaces()
}
}
if t.freezeRight > 0 {
index := len(tokens) - t.freezeRight - 1
if index == t.freezeLeft-1 {
splitOffset2 = 0
} else if index >= t.freezeLeft {
token := tokens[index]
splitOffset2 = int(token.prefixLength) + token.text.Length()
}
}
}
maxLines := 1
if t.canSpanMultiLines() {
maxLines = maxLineNum - lineNum + 1
@@ -3602,16 +3625,24 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
break
}
}
splitOffsetLeft := 0
if splitOffset1 >= 0 && splitOffset1 > from && splitOffset1 < from+len(line) {
splitOffsetLeft = splitOffset1 - from
}
splitOffsetRight := -1
if splitOffset2 >= 0 && splitOffset2 >= from && splitOffset2 < from+len(line) {
splitOffsetRight = splitOffset2 - from
}
from += len(line)
if lineOffset < skipLines {
continue
}
actualLineOffset := lineOffset - skipLines
var maxe int
var maxEnd int
for _, offset := range offsets {
if offset.match {
maxe = util.Max(maxe, int(offset.offset[1]))
maxEnd = util.Max(maxEnd, int(offset.offset[1]))
}
}
@@ -3675,69 +3706,112 @@ 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)
}
frozenLeft := line[:splitOffsetLeft]
middle := line[splitOffsetLeft:]
frozenRight := []rune{}
if splitOffsetRight >= splitOffsetLeft {
middle = line[splitOffsetLeft:splitOffsetRight]
frozenRight = line[splitOffsetRight:]
}
displayWidthSum := 0
todo := [3]func(){}
for fidx, runes := range [][]rune{frozenLeft, frozenRight, middle} {
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...)
} else {
// Stri..
rightTrim := false
if t.overflow(line[maxe:], ellipsisWidth) {
line = append(line[:maxe], ellipsis...)
rightTrim = true
shift := 0
maxe := maxEnd
offs := make([]colorOffset, len(offsets))
for idx := range offsets {
offs[idx] = offsets[idx]
if fidx == 1 && splitOffsetRight > 0 {
shift = splitOffsetRight
} else if fidx == 2 && splitOffsetLeft > 0 {
shift = splitOffsetLeft
}
offs[idx].offset[0] -= int32(shift)
offs[idx].offset[1] -= int32(shift)
}
maxe -= shift
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 offs {
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))
}
offs[idx].offset[0] = b
offs[idx].offset[1] = util.Max32(b, e)
}
// ..ri..
var diff int32
line, diff = t.trimLeft(line, maxWidth, ellipsisWidth)
}
if t.hscroll {
if fidx == 1 || fidx == 2 && 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)
line = append(ellipsis, line...)
// Transform offsets
transformOffsets(diff, rightTrim)
runes = append(ellipsis, runes...)
}
} else {
runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth)
runes = append(runes, ellipsis...)
for idx, offset := range offs {
offs[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-len(ellipsis)))
offs[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth))
}
}
displayWidth = t.displayWidthWithLimit(runes, 0, displayWidth)
}
displayWidthSum += displayWidth
if maxWidth > 0 {
color := colBase
if hidden {
color = color.WithFg(t.theme.Nomatch)
}
todo[fidx] = func() {
t.printColoredString(t.window, runes, offs, 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)
maxWidth -= displayWidth
}
if maxWidth > 0 {
color := colBase
if hidden {
color = color.WithFg(t.theme.Nomatch)
}
t.printColoredString(t.window, line, offsets, color)
if todo[0] != nil {
todo[0]()
}
if todo[2] != nil {
todo[2]()
}
if todo[1] != nil {
todo[1]()
}
if postTask != nil {
postTask(actualLineNum, displayWidth, wasWrapped, forceRedraw, lbg)
postTask(actualLineNum, displayWidthSum, wasWrapped, forceRedraw, lbg)
} else {
t.markOtherLine(actualLineNum)
}
@@ -5536,7 +5610,7 @@ func (t *Terminal) Loop() error {
req := func(evts ...util.EventType) {
for _, event := range evts {
events = append(events, event)
if isTerminalEvent(event) {
if event == reqClose || event == reqQuit {
looping = false
}
}

View File

@@ -1190,6 +1190,26 @@ 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_freeze_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]) }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('> 1') }
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 }