diff --git a/CHANGELOG.md b/CHANGELOG.md index db390b54..e2ed93a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index b75e180e..1ec99a07 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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. diff --git a/src/options.go b/src/options.go index 2ae004ed..ccb4e4b3 100644 --- a/src/options.go +++ b/src/options.go @@ -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 { diff --git a/src/terminal.go b/src/terminal.go index dd09b989..6758a816 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -331,6 +331,7 @@ type Terminal struct { scrollbar string previewScrollbar string ansi bool + freezeLeft int nthAttr tui.Attr nth []Range nthCurrent []Range @@ -1058,6 +1059,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, @@ -3533,6 +3535,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 @@ -3602,6 +3616,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 @@ -3675,69 +3693,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) } diff --git a/test/test_core.rb b/test/test_core.rb index 4628667b..4bdcd53b 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -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 }