260218.1 · b6411beaa1ea2170fcdbb8b57bcb9522c189029f
Implement word wrapping in the preview window
Example:
fzf --preview 'bat --style=plain --color=always {}' \
--preview-window wrap-word \
--bind space:toggle-preview-wrap-word
Close https://github.com/junegunn/fzf/discussions/3383
reading declarations
@@ -3,6 +3,14 @@ CHANGELOG 0.68.0 ------+- Implemented word wrapping in the preview window+ - Added `wrap-word` flag for `--preview-window` to enable word-level wrapping+ - Added `toggle-preview-wrap-word` action+ ```sh+ fzf --preview 'bat --style=plain --color=always {}' \+ --preview-window wrap-word \+ --bind space:toggle-preview-wrap-word+ ``` - Added support for underline style variants in `--color`: `underline-double`, `underline-curly`, `underline-dotted`, `underline-dashed` ```sh@@ -22,17 +30,17 @@ CHANGELOG - Bug fixes - Fixed symlinks to directories being returned as files (#4676) (@skk64) - Fixed SIGHUP signal handling (#4668) (@LangLangBart)- - Fixed preview process not killed on exit- - Fixed coloring of items with zero-width characters- - Fixed `track-current` unset after a combined movement action+ - Fixed preview process not killed on exit (#4667)+ - Fixed coloring of items with zero-width characters (#4620)+ - Fixed `track-current` unset after a combined movement action (#4649) - Fixed `--accept-nth` being ignored in filter mode (#4636) (@charemma) - Fixed display width calculation with `maxWidth` (#4596) (@LangLangBart)- - Fixed clearing of the rest of the current line on start- - Fixed `x-api-key` header not required for GET requests+ - Fixed clearing of the rest of the current line on start (#4652)+ - Fixed `x-api-key` header not required for GET requests (#4627) - Fixed key reading not cancelled when `execute` triggered via a server request (#4653) - Fixed rebind of readline command `redraw-current-line` (#4635) (@jameslazo) - Fixed `fzf-tmux` `TERM` quoting and added `mktemp` usage (#4664) (@Goofygiraffe06)- - Do not allow very long queries in `FuzzyMatchV2`+ - Do not allow very long queries in `FuzzyMatchV2` (#4608) 0.67.0 ------
@@ -926,7 +926,7 @@ default value 0 (or \fBcenter\fR) will put the label at the center of the border line. .TP-.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"+.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,wrap\-word][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]" .RS .B POSITION: (default: right)@@ -944,7 +944,8 @@ default until \fBtoggle\-preview\fR action is triggered. execute the command in the background. * Long lines are truncated by default. Line wrap can be enabled with-\fBwrap\fR flag.+\fBwrap\fR flag. \fBwrap\-word\fR flag enables word-level wrapping, which+breaks lines at word boundaries instead of mid-word. * Preview window will automatically scroll to the bottom when \fBfollow\fR flag is set, similarly to how \fBtail \-f\fR works.@@ -1961,6 +1962,7 @@ A key or an event can be bound to one or more of the following actions. \fBtoggle\-multi\-line\fR \fBtoggle\-preview\fR \fBtoggle\-preview\-wrap\fR+ \fBtoggle\-preview\-wrap\-word\fR \fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items) \fBtoggle\-search\fR (toggle search functionality) \fBtoggle\-sort\fR
@@ -107,82 +107,83 @@ func _() { _ = x[actHidePreview-96] _ = x[actTogglePreview-97] _ = x[actTogglePreviewWrap-98]- _ = x[actTransform-99]- _ = x[actTransformBorderLabel-100]- _ = x[actTransformGhost-101]- _ = x[actTransformHeader-102]- _ = x[actTransformFooter-103]- _ = x[actTransformHeaderLabel-104]- _ = x[actTransformFooterLabel-105]- _ = x[actTransformInputLabel-106]- _ = x[actTransformListLabel-107]- _ = x[actTransformNth-108]- _ = x[actTransformPointer-109]- _ = x[actTransformPreviewLabel-110]- _ = x[actTransformPrompt-111]- _ = x[actTransformQuery-112]- _ = x[actTransformSearch-113]- _ = x[actTrigger-114]- _ = x[actBgTransform-115]- _ = x[actBgTransformBorderLabel-116]- _ = x[actBgTransformGhost-117]- _ = x[actBgTransformHeader-118]- _ = x[actBgTransformFooter-119]- _ = x[actBgTransformHeaderLabel-120]- _ = x[actBgTransformFooterLabel-121]- _ = x[actBgTransformInputLabel-122]- _ = x[actBgTransformListLabel-123]- _ = x[actBgTransformNth-124]- _ = x[actBgTransformPointer-125]- _ = x[actBgTransformPreviewLabel-126]- _ = x[actBgTransformPrompt-127]- _ = x[actBgTransformQuery-128]- _ = x[actBgTransformSearch-129]- _ = x[actBgCancel-130]- _ = x[actSearch-131]- _ = x[actPreview-132]- _ = x[actPreviewTop-133]- _ = x[actPreviewBottom-134]- _ = x[actPreviewUp-135]- _ = x[actPreviewDown-136]- _ = x[actPreviewPageUp-137]- _ = x[actPreviewPageDown-138]- _ = x[actPreviewHalfPageUp-139]- _ = x[actPreviewHalfPageDown-140]- _ = x[actPrevHistory-141]- _ = x[actPrevSelected-142]- _ = x[actPrint-143]- _ = x[actPut-144]- _ = x[actNextHistory-145]- _ = x[actNextSelected-146]- _ = x[actExecute-147]- _ = x[actExecuteSilent-148]- _ = x[actExecuteMulti-149]- _ = x[actSigStop-150]- _ = x[actBest-151]- _ = x[actFirst-152]- _ = x[actLast-153]- _ = x[actReload-154]- _ = x[actReloadSync-155]- _ = x[actDisableSearch-156]- _ = x[actEnableSearch-157]- _ = x[actSelect-158]- _ = x[actDeselect-159]- _ = x[actUnbind-160]- _ = x[actRebind-161]- _ = x[actToggleBind-162]- _ = x[actBecome-163]- _ = x[actShowHeader-164]- _ = x[actHideHeader-165]- _ = x[actBell-166]- _ = x[actExclude-167]- _ = x[actExcludeMulti-168]- _ = x[actAsync-169]+ _ = x[actTogglePreviewWrapWord-99]+ _ = x[actTransform-100]+ _ = x[actTransformBorderLabel-101]+ _ = x[actTransformGhost-102]+ _ = x[actTransformHeader-103]+ _ = x[actTransformFooter-104]+ _ = x[actTransformHeaderLabel-105]+ _ = x[actTransformFooterLabel-106]+ _ = x[actTransformInputLabel-107]+ _ = x[actTransformListLabel-108]+ _ = x[actTransformNth-109]+ _ = x[actTransformPointer-110]+ _ = x[actTransformPreviewLabel-111]+ _ = x[actTransformPrompt-112]+ _ = x[actTransformQuery-113]+ _ = x[actTransformSearch-114]+ _ = x[actTrigger-115]+ _ = x[actBgTransform-116]+ _ = x[actBgTransformBorderLabel-117]+ _ = x[actBgTransformGhost-118]+ _ = x[actBgTransformHeader-119]+ _ = x[actBgTransformFooter-120]+ _ = x[actBgTransformHeaderLabel-121]+ _ = x[actBgTransformFooterLabel-122]+ _ = x[actBgTransformInputLabel-123]+ _ = x[actBgTransformListLabel-124]+ _ = x[actBgTransformNth-125]+ _ = x[actBgTransformPointer-126]+ _ = x[actBgTransformPreviewLabel-127]+ _ = x[actBgTransformPrompt-128]+ _ = x[actBgTransformQuery-129]+ _ = x[actBgTransformSearch-130]+ _ = x[actBgCancel-131]+ _ = x[actSearch-132]+ _ = x[actPreview-133]+ _ = x[actPreviewTop-134]+ _ = x[actPreviewBottom-135]+ _ = x[actPreviewUp-136]+ _ = x[actPreviewDown-137]+ _ = x[actPreviewPageUp-138]+ _ = x[actPreviewPageDown-139]+ _ = x[actPreviewHalfPageUp-140]+ _ = x[actPreviewHalfPageDown-141]+ _ = x[actPrevHistory-142]+ _ = x[actPrevSelected-143]+ _ = x[actPrint-144]+ _ = x[actPut-145]+ _ = x[actNextHistory-146]+ _ = x[actNextSelected-147]+ _ = x[actExecute-148]+ _ = x[actExecuteSilent-149]+ _ = x[actExecuteMulti-150]+ _ = x[actSigStop-151]+ _ = x[actBest-152]+ _ = x[actFirst-153]+ _ = x[actLast-154]+ _ = x[actReload-155]+ _ = x[actReloadSync-156]+ _ = x[actDisableSearch-157]+ _ = x[actEnableSearch-158]+ _ = x[actSelect-159]+ _ = x[actDeselect-160]+ _ = x[actUnbind-161]+ _ = x[actRebind-162]+ _ = x[actToggleBind-163]+ _ = x[actBecome-164]+ _ = x[actShowHeader-165]+ _ = x[actHideHeader-166]+ _ = x[actBell-167]+ _ = x[actExclude-168]+ _ = x[actExcludeMulti-169]+ _ = x[actAsync-170] } -const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"+const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync" -var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 331, 351, 371, 390, 408, 422, 434, 450, 466, 487, 509, 524, 538, 552, 565, 582, 590, 603, 619, 631, 639, 653, 667, 684, 695, 706, 720, 738, 755, 762, 781, 803, 815, 829, 838, 853, 865, 878, 889, 900, 912, 926, 947, 962, 975, 993, 1009, 1021, 1033, 1046, 1061, 1075, 1087, 1099, 1116, 1123, 1135, 1140, 1150, 1159, 1170, 1181, 1194, 1209, 1220, 1233, 1248, 1255, 1268, 1281, 1298, 1313, 1326, 1340, 1354, 1370, 1390, 1402, 1425, 1442, 1460, 1478, 1501, 1524, 1546, 1567, 1582, 1601, 1625, 1643, 1660, 1678, 1688, 1702, 1727, 1746, 1766, 1786, 1811, 1836, 1860, 1883, 1900, 1921, 1947, 1967, 1986, 2006, 2017, 2026, 2036, 2049, 2065, 2077, 2091, 2107, 2125, 2145, 2167, 2181, 2196, 2204, 2210, 2224, 2239, 2249, 2265, 2280, 2290, 2297, 2305, 2312, 2321, 2334, 2350, 2365, 2374, 2385, 2394, 2403, 2416, 2425, 2438, 2451, 2458, 2468, 2483, 2491}+var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 331, 351, 371, 390, 408, 422, 434, 450, 466, 487, 509, 524, 538, 552, 565, 582, 590, 603, 619, 631, 639, 653, 667, 684, 695, 706, 720, 738, 755, 762, 781, 803, 815, 829, 838, 853, 865, 878, 889, 900, 912, 926, 947, 962, 975, 993, 1009, 1021, 1033, 1046, 1061, 1075, 1087, 1099, 1116, 1123, 1135, 1140, 1150, 1159, 1170, 1181, 1194, 1209, 1220, 1233, 1248, 1255, 1268, 1281, 1298, 1313, 1326, 1340, 1354, 1370, 1390, 1414, 1426, 1449, 1466, 1484, 1502, 1525, 1548, 1570, 1591, 1606, 1625, 1649, 1667, 1684, 1702, 1712, 1726, 1751, 1770, 1790, 1810, 1835, 1860, 1884, 1907, 1924, 1945, 1971, 1991, 2010, 2030, 2041, 2050, 2060, 2073, 2089, 2101, 2115, 2131, 2149, 2169, 2191, 2205, 2220, 2228, 2234, 2248, 2263, 2273, 2289, 2304, 2314, 2321, 2329, 2336, 2345, 2358, 2374, 2389, 2398, 2409, 2418, 2427, 2440, 2449, 2462, 2475, 2482, 2492, 2507, 2515} func (i actionType) String() string { if i < 0 || i >= actionType(len(_actionType_index)-1) {
@@ -157,7 +157,7 @@ Usage: fzf [options] --preview=COMMAND Command to preview highlighted line ({}) --preview-window=OPT Preview window layout (default: right:50%) [up|down|left|right][,SIZE[%]]- [,[no]wrap][,[no]cycle][,[no]follow][,[no]info]+ [,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info] [,[no]hidden][,border-STYLE] [,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES] [,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]@@ -367,6 +367,7 @@ type previewOpts struct { scroll string hidden bool wrap bool+ wrapWord bool cycle bool follow bool info bool@@ -543,7 +544,7 @@ func (o *previewOpts) compare(active *previewOpts, b *previewOpts) previewOptsCo return previewOptsDifferentLayout } - if a.wrap == b.wrap && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {+ if a.wrap == b.wrap && a.wrapWord == b.wrapWord && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll { return previewOptsSame } @@ -691,7 +692,13 @@ func filterNonEmpty(input []string) []string { } func defaultPreviewOpts(command string) previewOpts {- return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, true, defaultBorderShape, 0, 0, nil}+ return previewOpts{+ command: command,+ position: posRight,+ size: sizeSpec{50, true},+ info: true,+ border: defaultBorderShape,+ } } func defaultOptions() *Options {@@ -1863,6 +1870,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA appendAction(actTogglePreview) case "toggle-preview-wrap": appendAction(actTogglePreviewWrap)+ case "toggle-preview-wrap-word":+ appendAction(actTogglePreviewWrapWord) case "toggle-sort": appendAction(actToggleSort) case "offset-up":@@ -2274,8 +2283,13 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error { opts.hidden = false case "wrap": opts.wrap = true+ opts.wrapWord = false+ case "wrap-word":+ opts.wrap = true+ opts.wrapWord = true case "nowrap": opts.wrap = false+ opts.wrapWord = false case "cycle": opts.cycle = true case "nocycle":
@@ -448,6 +448,20 @@ func TestPreviewOpts(t *testing.T) { opts.Preview.size.size == 70) { t.Error(opts.Preview) }++ // wrap-word tests+ opts = optsFor("--preview-window=wrap-word")+ if !(opts.Preview.wrap == true && opts.Preview.wrapWord == true) {+ t.Errorf("wrap-word: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)+ }+ opts = optsFor("--preview-window=wrap-word,nowrap")+ if !(opts.Preview.wrap == false && opts.Preview.wrapWord == false) {+ t.Errorf("wrap-word,nowrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)+ }+ opts = optsFor("--preview-window=wrap-word,wrap")+ if !(opts.Preview.wrap == true && opts.Preview.wrapWord == false) {+ t.Errorf("wrap-word,wrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)+ } } func TestAdditiveExpect(t *testing.T) {
@@ -617,6 +617,7 @@ const ( actHidePreview actTogglePreview actTogglePreviewWrap+ actTogglePreviewWrapWord actTransform actTransformBorderLabel@@ -941,7 +942,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor } if fullscreen { if tui.HasFullscreenRenderer() {- renderer = tui.NewFullscreenRenderer(opts.Theme, opts.Black, opts.Mouse)+ renderer = tui.NewFullscreenRenderer(opts.Theme, opts.Black, opts.Mouse, opts.Tabstop) } else { renderer, err = tui.NewLightRenderer(opts.TtyDefault, ttyin, opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, opts.ClearOnExit, true, func(h int) int { return h })@@ -4089,6 +4090,75 @@ func extractPassThroughs(line string) ([]string, string) { return passThroughs, transformed } +func (t *Terminal) wordWrapAnsiLine(line string, maxWidth int, wrapSignWidth int) []string {+ if maxWidth <= 0 {+ return []string{line}+ }++ var result []string+ lineStart := 0+ width := 0+ lastSpaceStart := -1+ lastSpaceEnd := -1+ widthBeforeLastSpace := 0+ lastSpaceWidth := 0+ max := maxWidth+ pos := 0++ for pos < len(line) {+ // Find next ANSI escape sequence+ start, end := nextAnsiEscapeSequence(line[pos:])++ // Determine the end of printable text before the next escape+ var printableEnd int+ if start < 0 {+ printableEnd = len(line)+ } else {+ printableEnd = pos + start+ }++ // Process printable characters using grapheme clusters+ gr := uniseg.NewGraphemes(line[pos:printableEnd])+ for gr.Next() {+ gStart, gEnd := gr.Positions()+ w := gr.Width()+ str := gr.Str()++ if str == "\t" {+ w = t.tabstop - (width % t.tabstop)+ }++ if str == " " || str == "\t" {+ lastSpaceStart = pos + gStart+ lastSpaceEnd = pos + gEnd+ widthBeforeLastSpace = width+ lastSpaceWidth = w+ }++ width += w++ if width > max && lastSpaceEnd > lineStart {+ result = append(result, line[lineStart:lastSpaceStart])+ lineStart = lastSpaceEnd+ width -= widthBeforeLastSpace + lastSpaceWidth+ lastSpaceStart = -1+ lastSpaceEnd = -1+ widthBeforeLastSpace = 0+ max = maxWidth - wrapSignWidth+ }+ }+ pos = printableEnd++ // Skip the ANSI escape sequence+ if start >= 0 {+ pos += end - start+ }+ }++ result = append(result, line[lineStart:])+ return result+}+ func (t *Terminal) renderPreviewText(height int, lines []string, lineNo int, unchanged bool) { maxWidth := t.pwindow.Width() var ansi *ansiState@@ -4182,48 +4252,77 @@ Loop: continue } + // Pre-split line into sub-lines for word wrapping+ var subLines []string+ if t.activePreviewOpts.wrapWord {+ subLines = t.wordWrapAnsiLine(line, maxWidth, t.wrapSignWidth)+ } else {+ subLines = []string{line}+ }+ var fillRet tui.FillReturn- prefixWidth := 0- var url *url- _, _, ansi = extractColor(line, ansi, func(str string, ansi *ansiState) bool {- trimmed := []rune(str)- isTrimmed := false- if !t.activePreviewOpts.wrap {- trimmed, isTrimmed = t.trimRight(trimmed, maxWidth-t.pwindow.X())- }- if url == nil && ansi != nil && ansi.url != nil {- url = ansi.url- t.pwindow.LinkBegin(url.uri, url.params)- }- if url != nil && (ansi == nil || ansi.url == nil) {- url = nil- t.pwindow.LinkEnd()+ wrap := t.activePreviewOpts.wrap+ for subIdx, subLine := range subLines {+ // Render wrap sign for continuation sub-lines+ if subIdx > 0 {+ if fillRet == tui.FillContinue {+ fillRet = t.pwindow.Fill("\n")+ if fillRet == tui.FillSuspend {+ t.previewed.filled = true+ break Loop+ }+ }+ t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, tui.Dim, t.wrapSign) }- if ansi != nil {- lbg = ansi.lbg- } else {- lbg = -1- }- str, width := t.processTabs(trimmed, prefixWidth)- if width > prefixWidth {- prefixWidth = width- colored := ansi != nil && ansi.colored()- if t.theme.Colored && colored {- fillRet = t.pwindow.CFill(ansi.fg, ansi.bg, ansi.ul, ansi.attr, str)++ prefixWidth := t.pwindow.X()+ var url *url+ _, _, ansi = extractColor(subLine, ansi, func(str string, ansi *ansiState) bool {+ trimmed := []rune(str)+ isTrimmed := false+ if !wrap {+ trimmed, isTrimmed = t.trimRight(trimmed, maxWidth-t.pwindow.X())+ }+ if url == nil && ansi != nil && ansi.url != nil {+ url = ansi.url+ t.pwindow.LinkBegin(url.uri, url.params)+ }+ if url != nil && (ansi == nil || ansi.url == nil) {+ url = nil+ t.pwindow.LinkEnd()+ }+ if ansi != nil {+ lbg = ansi.lbg } else {- attr := tui.AttrRegular- if colored {- attr = ansi.attr+ lbg = -1+ }+ str, width := t.processTabs(trimmed, prefixWidth)+ if width > prefixWidth {+ prefixWidth = width+ colored := ansi != nil && ansi.colored()+ if t.theme.Colored && colored {+ fillRet = t.pwindow.CFill(ansi.fg, ansi.bg, ansi.ul, ansi.attr, str)+ } else {+ attr := tui.AttrRegular+ if colored {+ attr = ansi.attr+ }+ fillRet = t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, attr, str) }- fillRet = t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), -1, attr, str) }+ return !isTrimmed &&+ (fillRet == tui.FillContinue || wrap && fillRet == tui.FillNextLine)+ })+ if url != nil {+ t.pwindow.LinkEnd()+ }++ if fillRet == tui.FillSuspend {+ t.previewed.filled = true+ break Loop }- return !isTrimmed &&- (fillRet == tui.FillContinue || t.activePreviewOpts.wrap && fillRet == tui.FillNextLine)- })- if url != nil {- t.pwindow.LinkEnd() }+ t.previewer.scrollable = t.previewer.scrollable || t.pwindow.Y() == height-1 && t.pwindow.X() == t.pwindow.Width() if fillRet == tui.FillNextLine { continue@@ -5972,9 +6071,17 @@ func (t *Terminal) Loop() error { t.cancelPreview() } }- case actTogglePreviewWrap:+ case actTogglePreviewWrap, actTogglePreviewWrapWord: if t.hasPreviewWindow() {- t.activePreviewOpts.wrap = !t.activePreviewOpts.wrap+ if a.t == actTogglePreviewWrapWord {+ t.activePreviewOpts.wrapWord = !t.activePreviewOpts.wrapWord+ t.activePreviewOpts.wrap = t.activePreviewOpts.wrapWord+ } else {+ t.activePreviewOpts.wrap = !t.activePreviewOpts.wrap+ if !t.activePreviewOpts.wrap {+ t.activePreviewOpts.wrapWord = false+ }+ } // Reset preview version so that full redraw occurs t.previewed.version = 0 req(reqPreviewRefresh)
@@ -699,3 +699,72 @@ func readFile(path string) ([]byte, error) { } } }++func TestWordWrapAnsiLine(t *testing.T) {+ term := &Terminal{}++ // Simple wrapping+ result := term.wordWrapAnsiLine("hello world", 7, 2)+ if len(result) != 2 || result[0] != "hello" || result[1] != "world" {+ t.Errorf("Simple: %q", result)+ }++ // No wrapping needed+ result = term.wordWrapAnsiLine("hello", 10, 2)+ if len(result) != 1 || result[0] != "hello" {+ t.Errorf("No wrap: %q", result)+ }++ // ANSI codes preserved across split+ result = term.wordWrapAnsiLine("\x1b[31mhello \x1b[32mworld", 8, 2)+ if len(result) != 2 || result[0] != "\x1b[31mhello" || result[1] != "\x1b[32mworld" {+ t.Errorf("ANSI: %q", result)+ }++ // Long word (no space) — no break, let character wrapping handle it+ result = term.wordWrapAnsiLine("abcdefghij", 5, 2)+ if len(result) != 1 || result[0] != "abcdefghij" {+ t.Errorf("Long word: %q", result)+ }++ // Multiple words with continuation wrapSignWidth+ result = term.wordWrapAnsiLine("aa bb cc dd", 5, 2)+ // max=5 for first line, max=3 for continuations (5-2)+ // "aa bb" (5 wide), split at second space -> "aa bb" | "cc" | "dd"+ if len(result) != 3 || result[0] != "aa bb" || result[1] != "cc" || result[2] != "dd" {+ t.Errorf("Multiple words: %q", result)+ }++ // Empty string+ result = term.wordWrapAnsiLine("", 10, 2)+ if len(result) != 1 || result[0] != "" {+ t.Errorf("Empty: %q", result)+ }++ // OSC 8 hyperlink preserved+ result = term.wordWrapAnsiLine("\x1b]8;;http://example.com\x1b\\click here\x1b]8;;\x1b\\", 8, 2)+ if len(result) != 2 {+ t.Errorf("Hyperlink split count: %d, %q", len(result), result)+ }++ // Tab handling: tab expands to tabstop-aligned width+ term.tabstop = 8+ // "\thi there" — tab at column 0 expands to 8, total "hi" starts at 8+ // maxWidth=15: "\thi" = 10 wide, "there" = 5 wide, total 16 > 15, wrap at space+ result = term.wordWrapAnsiLine("\thi there", 15, 2)+ if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {+ t.Errorf("Tab: %q", result)+ }++ // Tab as word boundary: "hello"(5) + tab(3→col8) + "world"(5) = 13 total+ // maxWidth=13: fits without wrapping+ result = term.wordWrapAnsiLine("hello\tworld", 13, 2)+ if len(result) != 1 || result[0] != "hello\tworld" {+ t.Errorf("Tab no wrap: %q", result)+ }+ // maxWidth=12: 13 > 12, wraps at tab+ result = term.wordWrapAnsiLine("hello\tworld", 12, 2)+ if len(result) != 2 || result[0] != "hello" || result[1] != "world" {+ t.Errorf("Tab wrap: %q", result)+ }+}
@@ -13,7 +13,6 @@ import ( "unicode/utf8" "github.com/junegunn/fzf/src/util"- "github.com/rivo/uniseg" "golang.org/x/term" )@@ -1419,52 +1418,13 @@ func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { w.stderrInternal(cleanse(text), false, code) } -type wrappedLine struct {- text string- displayWidth int-}--func wrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []wrappedLine {- lines := []wrappedLine{}- width := 0- line := ""- gr := uniseg.NewGraphemes(input)- max := initialMax- for gr.Next() {- rs := gr.Runes()- str := string(rs)- var w int- if len(rs) == 1 && rs[0] == '\t' {- w = tabstop - (prefixLength+width)%tabstop- str = repeat(' ', w)- } else if rs[0] == '\r' {- w++- } else {- w = uniseg.StringWidth(str)- }- width += w-- if prefixLength+width <= max {- line += str- } else {- lines = append(lines, wrappedLine{string(line), width - w})- line = str- prefixLength = 0- width = w- max = initialMax - wrapSignWidth- }- }- lines = append(lines, wrappedLine{string(line), width})- return lines-}- func (w *LightWindow) fill(str string, resetCode string) FillReturn { allLines := strings.Split(str, "\n") for i, line := range allLines {- lines := wrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)+ 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+ w.stderrInternal(wl.Text, false, resetCode)+ w.posx += wl.DisplayWidth // Wrap line if j < len(lines)-1 || i < len(allLines)-1 {
@@ -5,6 +5,7 @@ package tui import ( "os" "regexp"+ "strings" "time" "github.com/gdamore/tcell/v2"@@ -53,6 +54,7 @@ type TcellWindow struct { showCursor bool wrapSign string wrapSignWidth int+ tabstop int } func (w *TcellWindow) Top() int {@@ -757,7 +759,8 @@ func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, height: height, normal: normal, borderStyle: borderStyle,- showCursor: r.showCursor}+ showCursor: r.showCursor,+ tabstop: r.tabstop} w.Erase() return w }@@ -894,10 +897,8 @@ func (w *TcellWindow) CPrint(pair ColorPair, text string) { w.printString(text, pair) } -func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {- lx := 0+func (w *TcellWindow) pairStyle(pair ColorPair) tcell.Style { a := pair.Attr()- var style tcell.Style if w.color { style = pair.style()@@ -919,61 +920,61 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn { } else { style = style.Underline(false) }- style = w.withUrl(style)+ return w.withUrl(style)+} +func (w *TcellWindow) renderGraphemes(text string, style tcell.Style) { gr := uniseg.NewGraphemes(text)-Loop: for gr.Next() { st := style rs := gr.Runes()- if len(rs) == 1 {- r := rs[0]- switch r {- case '\r':- st = style.Dim(true)- rs[0] = '␍'- case '\n':- w.lastY++- w.lastX = 0- lx = 0- continue Loop- }- }-- // word wrap:- xPos := w.left + w.lastX + lx- if xPos >= w.left+w.width {- w.lastY++- if w.lastY >= w.height {- return FillSuspend- }- w.lastX = 0- lx = 0- xPos = w.left- sign := w.wrapSign- if w.wrapSignWidth > w.width {- runes, _ := util.Truncate(sign, w.width)- sign = string(runes)- }- wgr := uniseg.NewGraphemes(sign)- for wgr.Next() {- rs := wgr.Runes()- _screen.SetContent(w.left+lx, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))- lx += uniseg.StringWidth(string(rs))- }- xPos = w.left + lx+ if len(rs) == 1 && rs[0] == '\r' {+ st = style.Dim(true)+ rs[0] = '␍' } + xPos := w.left + w.lastX yPos := w.top + w.lastY- if yPos >= (w.top + w.height) {- return FillSuspend+ if xPos < (w.left+w.width) && yPos < (w.top+w.height) {+ _screen.SetContent(xPos, yPos, rs[0], rs[1:], st) }+ w.lastX += util.StringWidth(string(rs))+ }+} - _screen.SetContent(xPos, yPos, rs[0], rs[1:], st)- lx += util.StringWidth(string(rs))+func (w *TcellWindow) renderWrapSign(style tcell.Style) {+ sign := w.wrapSign+ if w.wrapSignWidth > w.width {+ runes, _ := util.Truncate(sign, w.width)+ sign = string(runes) }- w.lastX += lx- if w.lastX == w.width {+ gr := uniseg.NewGraphemes(sign)+ for gr.Next() {+ rs := gr.Runes()+ _screen.SetContent(w.left+w.lastX, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))+ w.lastX += uniseg.StringWidth(string(rs))+ }+}++func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {+ style := w.pairStyle(pair)++ for i, segment := range strings.Split(text, "\n") {+ for j, wl := range WrapLine(segment, w.lastX, w.width, w.tabstop, w.wrapSignWidth) {+ if i > 0 || j > 0 {+ w.lastY+++ if w.lastY >= w.height {+ return FillSuspend+ }+ w.lastX = 0+ if j > 0 {+ w.renderWrapSign(style)+ }+ }+ w.renderGraphemes(wl.Text, style)+ }+ }+ if w.lastX >= w.width { w.lastY++ w.lastX = 0 return FillNextLine
@@ -253,7 +253,7 @@ func TestGetCharEventKey(t *testing.T) { {giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled }- r := NewFullscreenRenderer(&ColorTheme{}, false, false)+ r := NewFullscreenRenderer(&ColorTheme{}, false, false, 8) r.Init() // run and evaluate the tests@@ -265,22 +265,22 @@ func TestGetCharEventKey(t *testing.T) { t.Logf("giveEvent = %T{key: %v, ch: %q (%[3]v), mod: %#04b}\n", giveEvent, giveEvent.Key(), giveEvent.Rune(), giveEvent.Modifiers()) // process the event in fzf and evaluate the test- gotEvent := r.GetChar()+ gotEvent := r.GetChar(true) // skip Resize events, those are sometimes put in the buffer outside of this test if initialResizeAsInvalid && gotEvent.Type == Invalid { t.Logf("Resize as Invalid swallowed") initialResizeAsInvalid = false- gotEvent = r.GetChar()+ gotEvent = r.GetChar(true) } if gotEvent.Type == Resize { t.Logf("Resize swallowed")- gotEvent = r.GetChar()+ gotEvent = r.GetChar(true) } t.Logf("wantEvent = %T{Type: %v, Char: %q (%[3]v)}\n", test.wantKey, test.wantKey.Type, test.wantKey.Char) t.Logf("gotEvent = %T{Type: %v, Char: %q (%[3]v)}\n", gotEvent, gotEvent.Type, gotEvent.Char) - assert(t, "r.GetChar().Type", gotEvent.Type, test.wantKey.Type)- assert(t, "r.GetChar().Char", gotEvent.Char, test.wantKey.Char)+ assert(t, "r.GetChar(true).Type", gotEvent.Type, test.wantKey.Type)+ assert(t, "r.GetChar(true).Char", gotEvent.Char, test.wantKey.Char) } r.Close()
@@ -2,6 +2,7 @@ package tui import ( "strconv"+ "strings" "time" "github.com/junegunn/fzf/src/util"@@ -829,16 +830,18 @@ type FullscreenRenderer struct { theme *ColorTheme mouse bool forceBlack bool+ tabstop int prevDownTime time.Time clicks [][2]int showCursor bool } -func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool) Renderer {+func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int) Renderer { r := &FullscreenRenderer{ theme: theme, mouse: mouse, forceBlack: forceBlack,+ tabstop: tabstop, prevDownTime: time.Unix(0, 0), clicks: [][2]int{}, showCursor: true}@@ -1360,3 +1363,45 @@ func initPalette(theme *ColorTheme) { func runeWidth(r rune) int { return uniseg.StringWidth(string(r)) }++// WrappedLine represents a single visual line after character-level wrapping.+type WrappedLine struct {+ Text string+ DisplayWidth int+}++// WrapLine splits a single line (no embedded \n) into visual lines+// that fit within initialMax columns. Character-level wrapping only.+func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []WrappedLine {+ lines := []WrappedLine{}+ width := 0+ line := ""+ gr := uniseg.NewGraphemes(input)+ max := initialMax+ for gr.Next() {+ rs := gr.Runes()+ str := string(rs)+ var w int+ if len(rs) == 1 && rs[0] == '\t' {+ w = tabstop - (prefixLength+width)%tabstop+ str = strings.Repeat(" ", w)+ } else if rs[0] == '\r' {+ w+++ } else {+ w = uniseg.StringWidth(str)+ }+ width += w++ if prefixLength+width <= max {+ line += str+ } else {+ lines = append(lines, WrappedLine{string(line), width - w})+ line = str+ prefixLength = 0+ width = w+ max = initialMax - wrapSignWidth+ }+ }+ lines = append(lines, WrappedLine{string(line), width})+ return lines+}
@@ -2,6 +2,46 @@ package tui import "testing" +func TestWrapLine(t *testing.T) {+ // Basic wrapping+ lines := WrapLine("hello world", 0, 7, 8, 2)+ if len(lines) != 2 || lines[0].Text != "hello w" || lines[1].Text != "orld" {+ t.Errorf("Basic wrap: %v", lines)+ }++ // Exact fit — no wrapping needed+ lines = WrapLine("hello", 0, 5, 8, 2)+ if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {+ t.Errorf("Exact fit: %v", lines)+ }++ // With prefix length+ lines = WrapLine("hello", 3, 5, 8, 2)+ if len(lines) != 2 || lines[0].Text != "he" || lines[1].Text != "llo" {+ t.Errorf("Prefix length: %v", lines)+ }++ // Empty string+ lines = WrapLine("", 0, 10, 8, 2)+ if len(lines) != 1 || lines[0].Text != "" || lines[0].DisplayWidth != 0 {+ t.Errorf("Empty string: %v", lines)+ }++ // Continuation lines account for wrapSignWidth+ lines = WrapLine("abcdefghij", 0, 5, 8, 2)+ // First line: "abcde" (5 chars fit in width 5)+ // Continuation max: 5-2=3, so "fgh" then "ij"+ if len(lines) != 3 || lines[0].Text != "abcde" || lines[1].Text != "fgh" || lines[2].Text != "ij" {+ t.Errorf("Continuation: %v", lines)+ }++ // Tab expansion+ lines = WrapLine("\there", 0, 10, 4, 2)+ if len(lines) != 1 || lines[0].DisplayWidth != 8 {+ t.Errorf("Tab: %v", lines)+ }+}+ func TestHexToColor(t *testing.T) { assert := func(expr string, r, g, b int) { color := HexToColor(expr)