diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b31f89..68b26aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ CHANGELOG - Added fish completion support (#4605) (@lalvarezt) - zsh: Handle multi-line history selection (#4595) (@LangLangBart) - Bug fixes + - Fixed `--preview-window follow` not working correctly with wrapping (#3243, #4258) - Fixed symlinks to directories being returned as files (#4676) (@skk64) - Fixed SIGHUP signal handling (#4668) (@LangLangBart) - Fixed preview process not killed on exit (#4667) diff --git a/src/terminal.go b/src/terminal.go index 39f7a7ac..bf93b76a 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -4093,6 +4093,80 @@ func extractPassThroughs(line string) ([]string, string) { return passThroughs, transformed } +// followOffset computes the correct content-line offset for follow mode, +// accounting for line wrapping in the preview window. +func (t *Terminal) followOffset() int { + lines := t.previewer.lines + headerLines := t.activePreviewOpts.headerLines + height := t.pwindow.Height() - headerLines + if height <= 0 || len(lines) <= headerLines { + return headerLines + } + + body := lines[headerLines:] + if !t.activePreviewOpts.wrap { + return max(t.previewer.offset, headerLines+len(body)-height) + } + + maxWidth := t.pwindow.Width() + visualLines := 0 + for i := len(body) - 1; i >= 0; i-- { + h := t.previewLineHeight(body[i], maxWidth) + if visualLines+h > height { + return headerLines + i + 1 + } + visualLines += h + } + return headerLines +} + +// previewLineHeight estimates the number of visual lines a preview content line +// occupies when wrapping is enabled. +func (t *Terminal) previewLineHeight(line string, maxWidth int) int { + if maxWidth <= 0 { + return 1 + } + + // For word-wrap mode, count the sub-lines produced by word wrapping. + // Each sub-line may still char-wrap if it contains a word longer than the width. + if t.activePreviewOpts.wrapWord { + subLines := t.wordWrapAnsiLine(line, maxWidth, t.wrapSignWidth) + total := 0 + for i, sub := range subLines { + prefixWidth := 0 + cols := maxWidth + if i > 0 { + prefixWidth = t.wrapSignWidth + cols -= t.wrapSignWidth + } + w := t.ansiLineWidth(sub, prefixWidth) + if cols <= 0 { + cols = 1 + } + total += max(1, (w+cols-1)/cols) + } + return total + } + + // For char-wrap, compute visible width and divide by available width. + w := t.ansiLineWidth(line, 0) + if w <= maxWidth { + return 1 + } + remaining := w - maxWidth + contWidth := max(1, maxWidth-t.wrapSignWidth) + return 1 + (remaining+contWidth-1)/contWidth +} + +// ansiLineWidth computes the display width of a string, skipping ANSI escape sequences. +// prefixWidth is the visual offset where the content starts (e.g. wrap sign width for +// continuation lines), used for correct tab stop alignment. +func (t *Terminal) ansiLineWidth(line string, prefixWidth int) int { + trimmed, _, _ := extractColor(line, nil, nil) + _, width := t.processTabs([]rune(trimmed), prefixWidth) + return width - prefixWidth +} + func (t *Terminal) wordWrapAnsiLine(line string, maxWidth int, wrapSignWidth int) []string { if maxWidth <= 0 { return []string{line} @@ -5704,7 +5778,7 @@ func (t *Terminal) Loop() error { t.previewer.lines = result.lines t.previewer.spinner = result.spinner if t.hasPreviewWindow() && t.previewer.following.Enabled() { - t.previewer.offset = max(t.previewer.offset, len(t.previewer.lines)-(t.pwindow.Height()-t.activePreviewOpts.headerLines)) + t.previewer.offset = t.followOffset() } else if result.offset >= 0 { t.previewer.offset = util.Constrain(result.offset, t.activePreviewOpts.headerLines, len(t.previewer.lines)-1) } diff --git a/src/tui/light.go b/src/tui/light.go index 55507009..77f15b9a 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -1423,8 +1423,10 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn { for i, line := range allLines { lines := WrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth) for j, wl := range lines { - w.stderrInternal(wl.Text, false, resetCode) - w.posx += wl.DisplayWidth + if w.posx < w.width { + w.stderrInternal(wl.Text, false, resetCode) + w.posx += wl.DisplayWidth + } // Wrap line if j < len(lines)-1 || i < len(allLines)-1 { diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 01e75d14..720187fa 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -971,7 +971,9 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn { w.renderWrapSign(style) } } - w.renderGraphemes(wl.Text, style) + if w.lastX < w.width { + w.renderGraphemes(wl.Text, style) + } } } if w.lastX >= w.width { diff --git a/src/tui/tui.go b/src/tui/tui.go index a24251fa..4f47b992 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -1377,7 +1377,8 @@ func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapS width := 0 line := "" gr := uniseg.NewGraphemes(input) - max := initialMax + maxWidth := initialMax + contMax := max(1, initialMax-wrapSignWidth) for gr.Next() { rs := gr.Runes() str := string(rs) @@ -1392,14 +1393,14 @@ func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapS } width += w - if prefixLength+width <= max { + if prefixLength+width <= maxWidth { line += str } else { lines = append(lines, WrappedLine{string(line), width - w}) line = str prefixLength = 0 width = w - max = initialMax - wrapSignWidth + maxWidth = contMax } } lines = append(lines, WrappedLine{string(line), width}) diff --git a/test/test_preview.rb b/test/test_preview.rb index d14d9f68..1bcb0767 100644 --- a/test/test_preview.rb +++ b/test/test_preview.rb @@ -383,6 +383,16 @@ class TestPreview < TestInteractive end end + def test_preview_follow_wrap + tmux.send_keys "seq 1 | #{FZF} --preview 'seq 1000' --preview-window right,2,follow,wrap", :Enter + tmux.until { |lines| assert_equal 1, lines.match_count } + tmux.until do |lines| + assert_includes lines[-4], '│ 10 │' + assert_includes lines[-3], '│ ↳ │' + assert_includes lines[-2], '│ ↳ │' + end + end + def test_close tmux.send_keys "seq 100 | #{FZF} --preview 'echo foo' --bind ctrl-c:close", :Enter tmux.until { |lines| assert_equal 100, lines.match_count } @@ -541,7 +551,7 @@ class TestPreview < TestInteractive tmux.send_keys "seq 10 | #{FZF} --preview-border rounded --preview-window '~5,2,+0,<100000(~0,+100,wrap,noinfo)' --preview 'seq 1000'", :Enter tmux.until { |lines| assert_equal 10, lines.match_count } tmux.until do |lines| - assert_equal ['╭────╮', '│ 10 │', '│ ↳ 0│', '│ 10 │', '│ ↳ 1│'], lines.take(5).map(&:strip) + assert_equal ['╭────╮', '│ 10 │', '│ ↳ │', '│ 10 │', '│ ↳ │'], lines.take(5).map(&:strip) end end