From 69e9abdab4e7aea98437f40dbaeab56c7431be18 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Wed, 18 Feb 2026 15:20:56 +0900 Subject: [PATCH] Implement word wrapping in the list section --- CHANGELOG.md | 8 ++ man/man1/fzf.1 | 10 +- src/actiontype_string.go | 213 ++++++++++++++++++++------------------- src/options.go | 28 ++++- src/terminal.go | 23 +++-- src/util/chars.go | 15 ++- src/util/chars_test.go | 50 ++++++++- 7 files changed, 228 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2be0443..66b31f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ CHANGELOG 0.68.0 ------ +- Implemented word wrapping in the list section + - Added `--wrap=word` (or `--wrap-word`) option and `toggle-wrap-word` action + for word-level line wrapping in the list section + - Changed default binding of `ctrl-/` and `alt-/` from `toggle-wrap` to + `toggle-wrap-word` + ```sh + fzf --wrap=word + ``` - 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 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index b044645b..2ebd7c98 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -593,8 +593,11 @@ Highlight the whole current line .B "\-\-cycle" Enable cyclic scroll .TP -.B "\-\-wrap" -Enable line wrap +.BI "\-\-wrap" "[=MODE]" +Enable line wrap. \fIMODE\fR can be \fBchar\fR (default) or \fBword\fR. +\fBword\fR mode wraps lines at word boundaries (spaces and tabs) instead of +at arbitrary character positions. \fB\-\-wrap\-word\fR is a synonym for +\fB\-\-wrap=word\fR. .TP .BI "\-\-wrap\-sign" "=INDICATOR" Indicator for wrapped lines. The default is '↳ ' or '> ' depending on @@ -1968,7 +1971,8 @@ A key or an event can be bound to one or more of the following actions. \fBtoggle\-sort\fR \fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR)) \fBtoggle\-track\-current\fR (toggle tracking of the current item) - \fBtoggle\-wrap\fR \fIctrl\-/\fR \fIalt\-/\fR + \fBtoggle\-wrap\fR + \fBtoggle\-wrap\-word\fR \fIctrl\-/\fR \fIalt\-/\fR \fBtoggle+down\fR \fIctrl\-i (tab)\fR \fBtoggle+up\fR \fIbtab (shift\-tab)\fR \fBtrack\-current\fR (track the current item; automatically disabled if focus changes) diff --git a/src/actiontype_string.go b/src/actiontype_string.go index d6d73e5a..f85f73bc 100644 --- a/src/actiontype_string.go +++ b/src/actiontype_string.go @@ -75,115 +75,116 @@ func _() { _ = x[actToggleTrackCurrent-64] _ = x[actToggleHeader-65] _ = x[actToggleWrap-66] - _ = x[actToggleMultiLine-67] - _ = x[actToggleHscroll-68] - _ = x[actToggleRaw-69] - _ = x[actEnableRaw-70] - _ = x[actDisableRaw-71] - _ = x[actTrackCurrent-72] - _ = x[actToggleInput-73] - _ = x[actHideInput-74] - _ = x[actShowInput-75] - _ = x[actUntrackCurrent-76] - _ = x[actDown-77] - _ = x[actDownMatch-78] - _ = x[actUp-79] - _ = x[actUpMatch-80] - _ = x[actPageUp-81] - _ = x[actPageDown-82] - _ = x[actPosition-83] - _ = x[actHalfPageUp-84] - _ = x[actHalfPageDown-85] - _ = x[actOffsetUp-86] - _ = x[actOffsetDown-87] - _ = x[actOffsetMiddle-88] - _ = x[actJump-89] - _ = x[actJumpAccept-90] - _ = x[actPrintQuery-91] - _ = x[actRefreshPreview-92] - _ = x[actReplaceQuery-93] - _ = x[actToggleSort-94] - _ = x[actShowPreview-95] - _ = x[actHidePreview-96] - _ = x[actTogglePreview-97] - _ = x[actTogglePreviewWrap-98] - _ = 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] + _ = x[actToggleWrapWord-67] + _ = x[actToggleMultiLine-68] + _ = x[actToggleHscroll-69] + _ = x[actToggleRaw-70] + _ = x[actEnableRaw-71] + _ = x[actDisableRaw-72] + _ = x[actTrackCurrent-73] + _ = x[actToggleInput-74] + _ = x[actHideInput-75] + _ = x[actShowInput-76] + _ = x[actUntrackCurrent-77] + _ = x[actDown-78] + _ = x[actDownMatch-79] + _ = x[actUp-80] + _ = x[actUpMatch-81] + _ = x[actPageUp-82] + _ = x[actPageDown-83] + _ = x[actPosition-84] + _ = x[actHalfPageUp-85] + _ = x[actHalfPageDown-86] + _ = x[actOffsetUp-87] + _ = x[actOffsetDown-88] + _ = x[actOffsetMiddle-89] + _ = x[actJump-90] + _ = x[actJumpAccept-91] + _ = x[actPrintQuery-92] + _ = x[actRefreshPreview-93] + _ = x[actReplaceQuery-94] + _ = x[actToggleSort-95] + _ = x[actShowPreview-96] + _ = x[actHidePreview-97] + _ = x[actTogglePreview-98] + _ = x[actTogglePreviewWrap-99] + _ = x[actTogglePreviewWrapWord-100] + _ = x[actTransform-101] + _ = x[actTransformBorderLabel-102] + _ = x[actTransformGhost-103] + _ = x[actTransformHeader-104] + _ = x[actTransformFooter-105] + _ = x[actTransformHeaderLabel-106] + _ = x[actTransformFooterLabel-107] + _ = x[actTransformInputLabel-108] + _ = x[actTransformListLabel-109] + _ = x[actTransformNth-110] + _ = x[actTransformPointer-111] + _ = x[actTransformPreviewLabel-112] + _ = x[actTransformPrompt-113] + _ = x[actTransformQuery-114] + _ = x[actTransformSearch-115] + _ = x[actTrigger-116] + _ = x[actBgTransform-117] + _ = x[actBgTransformBorderLabel-118] + _ = x[actBgTransformGhost-119] + _ = x[actBgTransformHeader-120] + _ = x[actBgTransformFooter-121] + _ = x[actBgTransformHeaderLabel-122] + _ = x[actBgTransformFooterLabel-123] + _ = x[actBgTransformInputLabel-124] + _ = x[actBgTransformListLabel-125] + _ = x[actBgTransformNth-126] + _ = x[actBgTransformPointer-127] + _ = x[actBgTransformPreviewLabel-128] + _ = x[actBgTransformPrompt-129] + _ = x[actBgTransformQuery-130] + _ = x[actBgTransformSearch-131] + _ = x[actBgCancel-132] + _ = x[actSearch-133] + _ = x[actPreview-134] + _ = x[actPreviewTop-135] + _ = x[actPreviewBottom-136] + _ = x[actPreviewUp-137] + _ = x[actPreviewDown-138] + _ = x[actPreviewPageUp-139] + _ = x[actPreviewPageDown-140] + _ = x[actPreviewHalfPageUp-141] + _ = x[actPreviewHalfPageDown-142] + _ = x[actPrevHistory-143] + _ = x[actPrevSelected-144] + _ = x[actPrint-145] + _ = x[actPut-146] + _ = x[actNextHistory-147] + _ = x[actNextSelected-148] + _ = x[actExecute-149] + _ = x[actExecuteSilent-150] + _ = x[actExecuteMulti-151] + _ = x[actSigStop-152] + _ = x[actBest-153] + _ = x[actFirst-154] + _ = x[actLast-155] + _ = x[actReload-156] + _ = x[actReloadSync-157] + _ = x[actDisableSearch-158] + _ = x[actEnableSearch-159] + _ = x[actSelect-160] + _ = x[actDeselect-161] + _ = x[actUnbind-162] + _ = x[actRebind-163] + _ = x[actToggleBind-164] + _ = x[actBecome-165] + _ = x[actShowHeader-166] + _ = x[actHideHeader-167] + _ = x[actBell-168] + _ = x[actExclude-169] + _ = x[actExcludeMulti-170] + _ = x[actAsync-171] } -const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync" +const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync" -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} +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, 992, 1010, 1026, 1038, 1050, 1063, 1078, 1092, 1104, 1116, 1133, 1140, 1152, 1157, 1167, 1176, 1187, 1198, 1211, 1226, 1237, 1250, 1265, 1272, 1285, 1298, 1315, 1330, 1343, 1357, 1371, 1387, 1407, 1431, 1443, 1466, 1483, 1501, 1519, 1542, 1565, 1587, 1608, 1623, 1642, 1666, 1684, 1701, 1719, 1729, 1743, 1768, 1787, 1807, 1827, 1852, 1877, 1901, 1924, 1941, 1962, 1988, 2008, 2027, 2047, 2058, 2067, 2077, 2090, 2106, 2118, 2132, 2148, 2166, 2186, 2208, 2222, 2237, 2245, 2251, 2265, 2280, 2290, 2306, 2321, 2331, 2338, 2346, 2353, 2362, 2375, 2391, 2406, 2415, 2426, 2435, 2444, 2457, 2466, 2479, 2492, 2499, 2509, 2524, 2532} func (i actionType) String() string { if i < 0 || i >= actionType(len(_actionType_index)-1) { diff --git a/src/options.go b/src/options.go index fd9b11b4..f8066020 100644 --- a/src/options.go +++ b/src/options.go @@ -95,7 +95,7 @@ Usage: fzf [options] -m, --multi[=MAX] Enable multi-select with tab/shift-tab --highlight-line Highlight the whole current line --cycle Enable cyclic scroll - --wrap Enable line wrap + --wrap[=MODE] Enable line wrap (char|word, default: char) --wrap-sign=STR Indicator for wrapped lines --no-multi-line Disable multi-line display of items when using --read0 --raw Enable raw mode (show non-matching items) @@ -606,6 +606,7 @@ type Options struct { Layout layoutType Cycle bool Wrap bool + WrapWord bool WrapSign *string MultiLine bool CursorLine bool @@ -740,6 +741,7 @@ func defaultOptions() *Options { Layout: layoutDefault, Cycle: false, Wrap: false, + WrapWord: false, MultiLine: true, KeepRight: false, Hscroll: true, @@ -1804,6 +1806,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA appendAction(actToggleHeader) case "toggle-wrap": appendAction(actToggleWrap) + case "toggle-wrap-word": + appendAction(actToggleWrapWord) case "toggle-multi-line": appendAction(actToggleMultiLine) case "toggle-hscroll": @@ -2853,9 +2857,29 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { case "--no-cycle": opts.Cycle = false case "--wrap": - opts.Wrap = true + given, str := optionalNextString() + if given { + switch str { + case "char": + opts.Wrap = true + opts.WrapWord = false + case "word": + opts.Wrap = true + opts.WrapWord = true + default: + return errors.New("invalid wrap mode: " + str + " (expected: char or word)") + } + } else { + opts.Wrap = true + } case "--no-wrap": opts.Wrap = false + opts.WrapWord = false + case "--wrap-word": + opts.Wrap = true + opts.WrapWord = true + case "--no-wrap-word": + opts.WrapWord = false case "--wrap-sign": str, err := nextString("wrap sign required") if err != nil { diff --git a/src/terminal.go b/src/terminal.go index 9028b5bf..39f7a7ac 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -250,6 +250,7 @@ type Terminal struct { infoStyle infoStyle infoPrefix string wrap bool + wrapWord bool wrapSign string wrapSignWidth int ghost string @@ -585,6 +586,7 @@ const ( actToggleTrackCurrent actToggleHeader actToggleWrap + actToggleWrapWord actToggleMultiLine actToggleHscroll actToggleRaw @@ -830,8 +832,8 @@ func defaultKeymap() map[tui.Event][]*action { if !util.IsWindows() { add(tui.CtrlZ, actSigStop) } - add(tui.CtrlSlash, actToggleWrap) - addEvent(tui.AltKey('/'), actToggleWrap) + add(tui.CtrlSlash, actToggleWrapWord) + addEvent(tui.AltKey('/'), actToggleWrapWord) addEvent(tui.AltKey('b'), actBackwardWord) add(tui.ShiftLeft, actBackwardWord) @@ -1014,6 +1016,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor multi: opts.Multi, multiLine: opts.ReadZero && opts.MultiLine, wrap: opts.Wrap, + wrapWord: opts.WrapWord, sort: opts.Sort > 0, toggleSort: opts.ToggleSort, track: opts.Track, @@ -1635,7 +1638,7 @@ func (t *Terminal) numItemLines(item *Item, atMost int) (int, bool) { numLines, overflow = item.text.NumLines(atMost) } else { var lines [][]rune - lines, overflow = item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop) + lines, overflow = item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop, t.wrapWord) numLines = len(lines) } numLines += t.gap @@ -1651,7 +1654,7 @@ func (t *Terminal) itemLines(item *Item, atMost int) ([][]rune, bool) { copy(text, item.text.ToRunes()) return [][]rune{text}, false } - return item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop) + return item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop, t.wrapWord) } // Estimate the average number of lines per item. Instead of going through all @@ -6721,8 +6724,16 @@ func (t *Terminal) Loop() error { case actToggleHeader: t.headerVisible = !t.headerVisible req(reqList, reqInfo, reqPrompt, reqHeader) - case actToggleWrap: - t.wrap = !t.wrap + case actToggleWrap, actToggleWrapWord: + if a.t == actToggleWrapWord { + t.wrapWord = !t.wrapWord + t.wrap = t.wrapWord + } else { + t.wrap = !t.wrap + if !t.wrap { + t.wrapWord = false + } + } t.clearNumLinesCache() req(reqList, reqHeader) case actToggleMultiLine: diff --git a/src/util/chars.go b/src/util/chars.go index 1701c812..ee46afcf 100644 --- a/src/util/chars.go +++ b/src/util/chars.go @@ -249,7 +249,7 @@ func (chars *Chars) Prepend(prefix string) { } } -func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) { +func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) { text := make([]rune, chars.Length()) copy(text, chars.ToRunes()) @@ -307,6 +307,19 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi if overflowIdx == 0 { overflowIdx = 1 } + if wrapWord { + // Find last space/tab at or before overflowIdx + breakIdx := -1 + for k := overflowIdx; k > 0; k-- { + if line[k-1] == ' ' || line[k-1] == '\t' { + breakIdx = k + break + } + } + if breakIdx > 0 { + overflowIdx = breakIdx + } + } if len(wrapped) >= maxLines { return wrapped, true } diff --git a/src/util/chars_test.go b/src/util/chars_test.go index c3d6c994..f27ae45d 100644 --- a/src/util/chars_test.go +++ b/src/util/chars_test.go @@ -51,7 +51,7 @@ func TestTrimLength(t *testing.T) { func TestCharsLines(t *testing.T) { chars := ToChars([]byte("abcdef\n가나다\n\tdef")) check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) { - lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop) + lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop, false) fmt.Println(lines, overflow) if len(lines) != expectedNumLines || overflow != expectedOverflow { t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow) @@ -81,3 +81,51 @@ func TestCharsLines(t *testing.T) { // With wrap sign (3 + 2) and no multi-line check(false, 100, 3, 2, 1, 13, false) } + +func TestCharsLinesWrapWord(t *testing.T) { + // "hello world foo bar" with width 12 should break at word boundaries + chars := ToChars([]byte("hello world foo bar")) + lines, overflow := chars.Lines(false, 100, 12, 0, 8, true) + // "hello world " (12) | "foo bar" (7) + if len(lines) != 2 || overflow { + t.Errorf("Expected 2 lines, got %d (overflow: %v): %v", len(lines), overflow, lines) + } + if string(lines[0]) != "hello world " { + t.Errorf("Expected first line 'hello world ', got %q", string(lines[0])) + } + if string(lines[1]) != "foo bar" { + t.Errorf("Expected second line 'foo bar', got %q", string(lines[1])) + } + + // No word boundary: a single long word falls back to character wrap + chars2 := ToChars([]byte("abcdefghijklmnop")) + lines2, _ := chars2.Lines(false, 100, 10, 0, 8, true) + if len(lines2) != 2 { + t.Errorf("Expected 2 lines for long word, got %d: %v", len(lines2), lines2) + } + if string(lines2[0]) != "abcdefghij" { + t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0])) + } + + // Tab as word boundary + chars3 := ToChars([]byte("hello\tworld")) + lines3, _ := chars3.Lines(false, 100, 7, 0, 8, true) + // "hello\t" should break at tab (width of tab at pos 5 with tabstop 8 = 3, total width = 8 > 7) + // Actually RunesWidth: 'h'=1,'e'=1,'l'=1,'l'=1,'o'=1,'\t'=3 = 8 > 7, overflowIdx=5 + // Then word-wrap scans back and finds no space/tab before idx 5 (tab IS at idx 5 but we check line[k-1]) + // Wait - let me think: overflowIdx=5, we check k=5 -> line[4]='o', k=4 -> line[3]='l'... no space/tab found + // Falls back to character wrap: "hello" | "\tworld" + if len(lines3) < 2 { + t.Errorf("Expected at least 2 lines for tab test, got %d: %v", len(lines3), lines3) + } + + // wrapWord=false still character-wraps + chars4 := ToChars([]byte("hello world")) + lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false) + if len(lines4) != 2 { + t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4) + } + if string(lines4[0]) != "hello wo" { + t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0])) + } +}