From b9f2bf64ff36d018a80e327fc2e8e7511486d114 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 9 Nov 2025 19:41:58 +0900 Subject: [PATCH] Add --freeze-right=N option to keep the rightmost N fields visible --- CHANGELOG.md | 5 +++ man/man1/fzf.1 | 6 ++- src/options.go | 8 +++- src/terminal.go | 104 +++++++++++++++++++++++++++++++++------------- test/test_core.rb | 27 ++++++++++++ 5 files changed, 119 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ed93a8..d6a72df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ CHANGELOG 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 ------ diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 1ec99a07..331ef0c7 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -632,9 +632,13 @@ The given string will be repeated to draw a horizontal line on each gap .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 diff --git a/src/options.go b/src/options.go index ccb4e4b3..02eef8b9 100644 --- a/src/options.go +++ b/src/options.go @@ -105,6 +105,7 @@ Usage: fzf [options] --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) @@ -564,6 +565,7 @@ type Options struct { Normalize bool Nth []Range FreezeLeft int + FreezeRight int WithNth func(Delimiter) func([]Token, int32) string AcceptNth func(Delimiter) func([]Token, int32) string 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 { 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 { @@ -3344,7 +3350,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { 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") } diff --git a/src/terminal.go b/src/terminal.go index 6758a816..7b8cdc6e 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -332,6 +332,7 @@ type Terminal struct { previewScrollbar string ansi bool freezeLeft int + freezeRight int nthAttr tui.Attr nth []Range nthCurrent []Range @@ -1060,6 +1061,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor header0: opts.Header, ansi: opts.Ansi, freezeLeft: opts.FreezeLeft, + freezeRight: opts.FreezeRight, nthAttr: opts.Theme.Nth.Attr, nth: opts.Nth, nthCurrent: opts.Nth, @@ -3536,14 +3538,30 @@ 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() + 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 := 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) } } @@ -3616,9 +3634,13 @@ 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 + 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 { @@ -3626,10 +3648,10 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat } 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])) } } @@ -3693,26 +3715,39 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat wrapped = true } - frozen := line[:splitOffsetLocal] - rest := line[splitOffsetLocal:] + frozenLeft := line[:splitOffsetLeft] + middle := line[splitOffsetLeft:] + frozenRight := []rune{} + if splitOffsetRight >= splitOffsetLeft { + middle = line[splitOffsetLeft:splitOffsetRight] + frozenRight = line[splitOffsetRight:] + } displayWidthSum := 0 - for fidx, runes := range [][]rune{frozen, rest} { + todo := [3]func(){} + for fidx, runes := range [][]rune{frozenLeft, frozenRight, middle} { if len(runes) == 0 { continue } - if splitOffsetLocal > 0 && fidx == 1 { - for idx := range offsets { - offsets[idx].offset[0] -= int32(splitOffsetLocal) - offsets[idx].offset[1] -= int32(splitOffsetLocal) + 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 } - maxe -= splitOffsetLocal + 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 offsets { + for idx, offset := range offs { b, e := offset.offset[0], offset.offset[1] el := int32(len(ellipsis)) b += el - diff @@ -3721,12 +3756,12 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat if rightTrim { e = util.Min32(e, int32(maxWidth-ellipsisWidth)) } - offsets[idx].offset[0] = b - offsets[idx].offset[1] = util.Max32(b, e) + offs[idx].offset[0] = b + offs[idx].offset[1] = util.Max32(b, e) } } 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) transformOffsets(diff, false) runes = append(ellipsis, trimmed...) @@ -3753,9 +3788,9 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat 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)) + 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) @@ -3767,12 +3802,23 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat if hidden { color = color.WithFg(t.theme.Nomatch) } - t.printColoredString(t.window, runes, offsets, color) + todo[fidx] = func() { + t.printColoredString(t.window, runes, offs, color) + } } else { break } maxWidth -= displayWidth } + if todo[0] != nil { + todo[0]() + } + if todo[2] != nil { + todo[2]() + } + if todo[1] != nil { + todo[1]() + } if postTask != nil { postTask(actualLineNum, displayWidthSum, wasWrapped, forceRedraw, lbg) } else { diff --git a/test/test_core.rb b/test/test_core.rb index 4bdcd53b..ac02cbbc 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1201,6 +1201,33 @@ class TestCore < TestInteractive tmux.until { |lines| assert lines.any_include?('1␊2␊3␊4␊5␊') } 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 tmux.send_keys "echo foo | #{FZF} --bind 'backward-eof:reload(seq 100)'", :Enter tmux.until { |lines| lines.item_count == 1 && lines.match_count == 1 }