@ 261102.1

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

CHANGELOG.md

@@ -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 ------

man/man1/fzf.1

@@ -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

src/actiontype_string.go

@@ -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) {

src/options.go

@@ -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":

src/options_test.go

@@ -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) {

src/terminal.go

@@ -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)

src/terminal_test.go

@@ -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)+	}+}

src/tui/light.go

@@ -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 {

src/tui/tcell.go

@@ -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

src/tui/tcell_test.go

@@ -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()

src/tui/tui.go

@@ -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+}

src/tui/tui_test.go

@@ -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)