Repository: junegunn/fzf (language: go)

Issue:
Can you wrap lines so that they are fully displayed?
My most common usecase for fzf is search through my history, which often contains very long commands. When the terminal window is too narrow for the full command line, it is trimmed. Is it possible to wrap instead? ideally, I'd be able to specify the maximum number of lines that are used for the wrapping. This way I can make sure that extremely long items don't take all the screen, but also that I can see more of every item.

Thanks!



Discussion / hints:
Hi, thanks for the suggestion. It's currently not possible and unfortunately, implementing it is not at all trivial. See the previous (related) discussion here: https://github.com/junegunn/fzf/issues/154#issuecomment-84503814. I'm closing this not because this is a bad idea, but I can't see myself implementing it in a foreseeable future.

Thanks a lot for your quick response!

I'd use this too, if you or anyone else gets around to implementing it. But yeah, if it's complicated, maybe not worth the time currently.

It there any update on that ? Will it stay a "won't fix" ?

I would like to have beautiful input like 
```
echo " option1_title \n option1_text \00 option2_title \n  option2_text" | fzf --read0
```

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d2a81af38f..c1d6327e9fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,16 @@ CHANGELOG
 
 0.54.0
 ------
+- Implemented line wrap of long items
+    - `--wrap` option enables line wrap
+    - `--wrap-sign` customizes the sign for wrapped lines (default: `↳ `)
+    - `toggle-wrap` action toggles line wrap
+  ```sh
+  history | fzf --tac --wrap --bind 'ctrl-/:toggle-wrap'
+
+  # You can press CTRL-/ to toggle line wrap in CTRL-R binding
+  export FZF_CTRL_R_OPTS=$'--bind ctrl-/:toggle-wrap --wrap-sign "\t↳ "'
+  ```
 - Added `--info-command` option for customizing the info line
   ```sh
   # Prepend the current cursor position in yellow
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index cd18f74872e..147614386b4 100644
--- a/man/man1/fzf.1
+++ b/man/man1/fzf.1
@@ -198,6 +198,13 @@ the details.
 .B "\-\-cycle"
 Enable cyclic scroll
 .TP
+.B "\-\-wrap"
+Enable line wrap
+.TP
+.BI "\-\-wrap\-sign" "=INDICATOR"
+Indicator for wrapped lines. The default is '↳ ' or '> ' depending on
+\fB\-\-no\-unicode\fR.
+.TP
 .B "\-\-no\-multi\-line"
 Disable multi-line display of items when using \fB\-\-read0\fR
 .TP
@@ -1490,6 +1497,7 @@ 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
     \fBtoggle+up\fR                    \fIbtab    (shift\-tab)\fR
     \fBtrack\-current\fR                (track the current item; automatically disabled if focus changes)
     \fBtransform(...)\fR               (transform states using the output of an external command)
diff --git a/src/actiontype_string.go b/src/actiontype_string.go
index 03ec74c44a0..f5221f8abe7 100644
--- a/src/actiontype_string.go
+++ b/src/actiontype_string.go
@@ -58,73 +58,74 @@ func _() {
 	_ = x[actToggleTrack-47]
 	_ = x[actToggleTrackCurrent-48]
 	_ = x[actToggleHeader-49]
-	_ = x[actTrackCurrent-50]
-	_ = x[actUntrackCurrent-51]
-	_ = x[actDown-52]
-	_ = x[actUp-53]
-	_ = x[actPageUp-54]
-	_ = x[actPageDown-55]
-	_ = x[actPosition-56]
-	_ = x[actHalfPageUp-57]
-	_ = x[actHalfPageDown-58]
-	_ = x[actOffsetUp-59]
-	_ = x[actOffsetDown-60]
-	_ = x[actOffsetMiddle-61]
-	_ = x[actJump-62]
-	_ = x[actJumpAccept-63]
-	_ = x[actPrintQuery-64]
-	_ = x[actRefreshPreview-65]
-	_ = x[actReplaceQuery-66]
-	_ = x[actToggleSort-67]
-	_ = x[actShowPreview-68]
-	_ = x[actHidePreview-69]
-	_ = x[actTogglePreview-70]
-	_ = x[actTogglePreviewWrap-71]
-	_ = x[actTransform-72]
-	_ = x[actTransformBorderLabel-73]
-	_ = x[actTransformHeader-74]
-	_ = x[actTransformPreviewLabel-75]
-	_ = x[actTransformPrompt-76]
-	_ = x[actTransformQuery-77]
-	_ = x[actPreview-78]
-	_ = x[actChangePreview-79]
-	_ = x[actChangePreviewWindow-80]
-	_ = x[actPreviewTop-81]
-	_ = x[actPreviewBottom-82]
-	_ = x[actPreviewUp-83]
-	_ = x[actPreviewDown-84]
-	_ = x[actPreviewPageUp-85]
-	_ = x[actPreviewPageDown-86]
-	_ = x[actPreviewHalfPageUp-87]
-	_ = x[actPreviewHalfPageDown-88]
-	_ = x[actPrevHistory-89]
-	_ = x[actPrevSelected-90]
-	_ = x[actPrint-91]
-	_ = x[actPut-92]
-	_ = x[actNextHistory-93]
-	_ = x[actNextSelected-94]
-	_ = x[actExecute-95]
-	_ = x[actExecuteSilent-96]
-	_ = x[actExecuteMulti-97]
-	_ = x[actSigStop-98]
-	_ = x[actFirst-99]
-	_ = x[actLast-100]
-	_ = x[actReload-101]
-	_ = x[actReloadSync-102]
-	_ = x[actDisableSearch-103]
-	_ = x[actEnableSearch-104]
-	_ = x[actSelect-105]
-	_ = x[actDeselect-106]
-	_ = x[actUnbind-107]
-	_ = x[actRebind-108]
-	_ = x[actBecome-109]
-	_ = x[actShowHeader-110]
-	_ = x[actHideHeader-111]
+	_ = x[actToggleWrap-50]
+	_ = x[actTrackCurrent-51]
+	_ = x[actUntrackCurrent-52]
+	_ = x[actDown-53]
+	_ = x[actUp-54]
+	_ = x[actPageUp-55]
+	_ = x[actPageDown-56]
+	_ = x[actPosition-57]
+	_ = x[actHalfPageUp-58]
+	_ = x[actHalfPageDown-59]
+	_ = x[actOffsetUp-60]
+	_ = x[actOffsetDown-61]
+	_ = x[actOffsetMiddle-62]
+	_ = x[actJump-63]
+	_ = x[actJumpAccept-64]
+	_ = x[actPrintQuery-65]
+	_ = x[actRefreshPreview-66]
+	_ = x[actReplaceQuery-67]
+	_ = x[actToggleSort-68]
+	_ = x[actShowPreview-69]
+	_ = x[actHidePreview-70]
+	_ = x[actTogglePreview-71]
+	_ = x[actTogglePreviewWrap-72]
+	_ = x[actTransform-73]
+	_ = x[actTransformBorderLabel-74]
+	_ = x[actTransformHeader-75]
+	_ = x[actTransformPreviewLabel-76]
+	_ = x[actTransformPrompt-77]
+	_ = x[actTransformQuery-78]
+	_ = x[actPreview-79]
+	_ = x[actChangePreview-80]
+	_ = x[actChangePreviewWindow-81]
+	_ = x[actPreviewTop-82]
+	_ = x[actPreviewBottom-83]
+	_ = x[actPreviewUp-84]
+	_ = x[actPreviewDown-85]
+	_ = x[actPreviewPageUp-86]
+	_ = x[actPreviewPageDown-87]
+	_ = x[actPreviewHalfPageUp-88]
+	_ = x[actPreviewHalfPageDown-89]
+	_ = x[actPrevHistory-90]
+	_ = x[actPrevSelected-91]
+	_ = x[actPrint-92]
+	_ = x[actPut-93]
+	_ = x[actNextHistory-94]
+	_ = x[actNextSelected-95]
+	_ = x[actExecute-96]
+	_ = x[actExecuteSilent-97]
+	_ = x[actExecuteMulti-98]
+	_ = x[actSigStop-99]
+	_ = x[actFirst-100]
+	_ = x[actLast-101]
+	_ = x[actReload-102]
+	_ = x[actReloadSync-103]
+	_ = x[actDisableSearch-104]
+	_ = x[actEnableSearch-105]
+	_ = x[actSelect-106]
+	_ = x[actDeselect-107]
+	_ = x[actUnbind-108]
+	_ = x[actRebind-109]
+	_ = x[actBecome-110]
+	_ = x[actShowHeader-111]
+	_ = x[actHideHeader-112]
 }
 
-const _actionType_name = "actIgnoreactStartactClickactInvalidactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactCancelactChangeBorderLabelactChangeHeaderactChangeMultiactChangePreviewLabelactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactKillLineactKillWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactTrackCurrentactUntrackCurrentactDownactUpactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformHeaderactTransformPreviewLabelactTransformPromptactTransformQueryactPreviewactChangePreviewactChangePreviewWindowactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactBecomeactShowHeaderactHideHeader"
+const _actionType_name = "actIgnoreactStartactClickactInvalidactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactCancelactChangeBorderLabelactChangeHeaderactChangeMultiactChangePreviewLabelactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactKillLineactKillWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactTrackCurrentactUntrackCurrentactDownactUpactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformHeaderactTransformPreviewLabelactTransformPromptactTransformQueryactPreviewactChangePreviewactChangePreviewWindowactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactBecomeactShowHeaderactHideHeader"
 
-var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 42, 50, 68, 76, 85, 102, 123, 138, 159, 183, 198, 207, 227, 242, 256, 277, 292, 306, 320, 333, 350, 358, 371, 387, 399, 407, 421, 435, 446, 457, 475, 492, 499, 518, 530, 544, 553, 568, 580, 593, 604, 615, 627, 641, 662, 677, 692, 709, 716, 721, 730, 741, 752, 765, 780, 791, 804, 819, 826, 839, 852, 869, 884, 897, 911, 925, 941, 961, 973, 996, 1014, 1038, 1056, 1073, 1083, 1099, 1121, 1134, 1150, 1162, 1176, 1192, 1210, 1230, 1252, 1266, 1281, 1289, 1295, 1309, 1324, 1334, 1350, 1365, 1375, 1383, 1390, 1399, 1412, 1428, 1443, 1452, 1463, 1472, 1481, 1490, 1503, 1516}
+var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 42, 50, 68, 76, 85, 102, 123, 138, 159, 183, 198, 207, 227, 242, 256, 277, 292, 306, 320, 333, 350, 358, 371, 387, 399, 407, 421, 435, 446, 457, 475, 492, 499, 518, 530, 544, 553, 568, 580, 593, 604, 615, 627, 641, 662, 677, 690, 705, 722, 729, 734, 743, 754, 765, 778, 793, 804, 817, 832, 839, 852, 865, 882, 897, 910, 924, 938, 954, 974, 986, 1009, 1027, 1051, 1069, 1086, 1096, 1112, 1134, 1147, 1163, 1175, 1189, 1205, 1223, 1243, 1265, 1279, 1294, 1302, 1308, 1322, 1337, 1347, 1363, 1378, 1388, 1396, 1403, 1412, 1425, 1441, 1456, 1465, 1476, 1485, 1494, 1503, 1516, 1529}
 
 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 d9121c34f5a..63550712800 100644
--- a/src/options.go
+++ b/src/options.go
@@ -53,6 +53,8 @@ Usage: fzf [options]
     --no-mouse              Disable mouse
     --bind=KEYBINDS         Custom key bindings. Refer to the man page.
     --cycle                 Enable cyclic scroll
+    --wrap                  Enable line wrap
+    --wrap-sign=STR         Indicator for wrapped lines
     --no-multi-line         Disable multi-line display of items when using --read0
     --keep-right            Keep the right end of the line visible on overflow
     --scroll-off=LINES      Number of screen lines to keep above or below when
@@ -435,6 +437,8 @@ type Options struct {
 	MinHeight    int
 	Layout       layoutType
 	Cycle        bool
+	Wrap         bool
+	WrapSign     *string
 	MultiLine    bool
 	CursorLine   bool
 	KeepRight    bool
@@ -543,6 +547,7 @@ func defaultOptions() *Options {
 		MinHeight:    10,
 		Layout:       layoutDefault,
 		Cycle:        false,
+		Wrap:         false,
 		MultiLine:    true,
 		KeepRight:    false,
 		Hscroll:      true,
@@ -1366,6 +1371,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
 			appendAction(actToggleTrackCurrent)
 		case "toggle-header":
 			appendAction(actToggleHeader)
+		case "toggle-wrap":
+			appendAction(actToggleWrap)
 		case "show-header":
 			appendAction(actShowHeader)
 		case "hide-header":
@@ -2163,6 +2170,16 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
 			opts.CursorLine = false
 		case "--no-cycle":
 			opts.Cycle = false
+		case "--wrap":
+			opts.Wrap = true
+		case "--no-wrap":
+			opts.Wrap = false
+		case "--wrap-sign":
+			str, err := nextString(allArgs, &i, "wrap sign required")
+			if err != nil {
+				return err
+			}
+			opts.WrapSign = &str
 		case "--multi-line":
 			opts.MultiLine = true
 		case "--no-multi-line":
@@ -2513,6 +2530,8 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
 				if err := parseLabelPosition(&opts.PreviewLabel, value); err != nil {
 					return err
 				}
+			} else if match, value := optString(arg, "--wrap-sign="); match {
+				opts.WrapSign = &value
 			} else if match, value := optString(arg, "--prompt="); match {
 				opts.Prompt = value
 			} else if match, value := optString(arg, "--pointer="); match {
diff --git a/src/terminal.go b/src/terminal.go
index 337f57000e7..d94e54ec93f 100644
--- a/src/terminal.go
+++ b/src/terminal.go
@@ -155,6 +155,7 @@ type eachLine struct {
 
 type itemLine struct {
 	firstLine int
+	numLines  int
 	cy        int
 	current   bool
 	selected  bool
@@ -215,6 +216,9 @@ type Terminal struct {
 	infoCommand        string
 	infoStyle          infoStyle
 	infoPrefix         string
+	wrap               bool
+	wrapSign           string
+	wrapSignWidth      int
 	separator          labelPrinter
 	separatorLen       int
 	spinner            []string
@@ -446,6 +450,7 @@ const (
 	actToggleTrack
 	actToggleTrackCurrent
 	actToggleHeader
+	actToggleWrap
 	actTrackCurrent
 	actUntrackCurrent
 	actDown
@@ -787,6 +792,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
 		input:              input,
 		multi:              opts.Multi,
 		multiLine:          opts.ReadZero && opts.MultiLine,
+		wrap:               opts.Wrap,
 		sort:               opts.Sort > 0,
 		toggleSort:         opts.ToggleSort,
 		track:              opts.Track,
@@ -876,8 +882,15 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
 		t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
 	}
 	if t.unicode {
+		t.wrapSign = "↳ "
 		t.borderWidth = uniseg.StringWidth("│")
+	} else {
+		t.wrapSign = "> "
+	}
+	if opts.WrapSign != nil {
+		t.wrapSign = *opts.WrapSign
 	}
+	t.wrapSign, t.wrapSignWidth = t.processTabs([]rune(t.wrapSign), 0)
 	if opts.Scrollbar == nil {
 		if t.unicode && t.borderWidth == 1 {
 			t.scrollbar = "│"
@@ -1067,8 +1080,11 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
 	}
 	output := func() {
 		line := t.promptLine()
+		wrap := t.wrap
+		t.wrap = false
 		t.printHighlighted(
 			Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, line, line, true, nil, nil)
+		t.wrap = wrap
 	}
 	_, promptLen := t.processTabs([]rune(trimmed), 0)
 
@@ -1103,10 +1119,37 @@ func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
 	return barLength, barStart
 }
 
+func (t *Terminal) wrapCols() int {
+	if !t.wrap {
+		return 0 // No wrap
+	}
+	return util.Max(t.window.Width()-(t.pointerLen+t.markerLen+1), 1)
+}
+
+func (t *Terminal) numItemLines(item *Item, atMost int) (int, bool) {
+	if !t.wrap && !t.multiLine {
+		return 1, false
+	}
+	if !t.wrap && t.multiLine {
+		return item.text.NumLines(atMost)
+	}
+	lines, overflow := item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop)
+	return len(lines), overflow
+}
+
+func (t *Terminal) itemLines(item *Item, atMost int) ([][]rune, bool) {
+	if !t.wrap && !t.multiLine {
+		text := make([]rune, item.text.Length())
+		copy(text, item.text.ToRunes())
+		return [][]rune{text}, false
+	}
+	return item.text.Lines(t.multiLine, atMost, t.wrapCols(), t.wrapSignWidth, t.tabstop)
+}
+
 // Estimate the average number of lines per item. Instead of going through all
 // items, we only check a few items around the current cursor position.
 func (t *Terminal) avgNumLines() int {
-	if !t.multiLine {
+	if !t.wrap && !t.multiLine {
 		return 1
 	}
 
@@ -1116,8 +1159,8 @@ func (t *Terminal) avgNumLines() int {
 	total := t.merger.Length()
 	offset := util.Max(0, util.Min(t.offset, total-maxItems-1))
 	for idx := 0; idx < maxItems && idx+offset < total; idx++ {
-		item := t.merger.Get(idx + offset)
-		lines, _ := item.item.text.NumLines(maxItems)
+		result := t.merger.Get(idx + offset)
+		lines, _ := t.numItemLines(result.item, maxItems)
 		numLines += lines
 		count++
 	}
@@ -1964,6 +2007,9 @@ func (t *Terminal) printHeader() {
 	case layoutDefault, layoutReverseList:
 		needReverse = true
 	}
+	// Wrapping is not supported for header
+	wrap := t.wrap
+	t.wrap = false
 	for idx, lineStr := range append(append([]string{}, t.header0...), t.header...) {
 		line := idx
 		if needReverse && idx < len(t.header0) {
@@ -1988,6 +2034,7 @@ func (t *Terminal) printHeader() {
 			tui.ColHeader, tui.ColHeader, false, false, line, line, true,
 			func(markerClass) { t.window.Print("  ") }, nil)
 	}
+	t.wrap = wrap
 }
 
 func (t *Terminal) printList() {
@@ -2015,7 +2062,7 @@ func (t *Terminal) printList() {
 			// If the screen is not filled with the list in non-multi-line mode,
 			// scrollbar is not visible at all. But in multi-line mode, we may need
 			// to redraw the scrollbar character at the end.
-			if t.multiLine {
+			if t.multiLine || t.wrap {
 				t.prevLines[line].hasBar = t.printBar(line, true, barRange)
 			}
 		}
@@ -2048,7 +2095,8 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu
 	}
 
 	// Avoid unnecessary redraw
-	newLine := itemLine{firstLine: line, cy: index + t.offset, current: current, selected: selected, label: label,
+	numLines, _ := t.numItemLines(item, maxLine-line+1)
+	newLine := itemLine{firstLine: line, numLines: numLines, cy: index + t.offset, current: current, selected: selected, label: label,
 		result: result, queryLen: len(t.input), width: 0, hasBar: line >= barRange[0] && line < barRange[1]}
 	prevLine := t.prevLines[line]
 	forceRedraw := prevLine.other || prevLine.firstLine != newLine.firstLine
@@ -2057,37 +2105,46 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu
 	}
 
 	if !forceRedraw &&
+		prevLine.numLines == newLine.numLines &&
 		prevLine.current == newLine.current &&
 		prevLine.selected == newLine.selected &&
 		prevLine.label == newLine.label &&
 		prevLine.queryLen == newLine.queryLen &&
 		prevLine.result == newLine.result {
 		t.prevLines[line].hasBar = printBar(line, false)
-		if !t.multiLine {
+		if !t.multiLine && !t.wrap {
 			return line
 		}
-		lines, _ := item.text.NumLines(maxLine - line + 1)
-		return line + lines - 1
+		return line + numLines - 1
 	}
 
 	maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
-	postTask := func(lineNum int, width int) {
+	postTask := func(lineNum int, width int, wrapped bool) {
 		if (current || selected) && t.highlightLine {
 			color := tui.ColSelected
 			if current {
 				color = tui.ColCurrent
 			}
 			fillSpaces := maxWidth - width
+			if wrapped {
+				fillSpaces -= t.wrapSignWidth
+			}
 			if fillSpaces > 0 {
 				t.window.CPrint(color, strings.Repeat(" ", fillSpaces))
 			}
 			newLine.width = maxWidth
 		} else {
 			fillSpaces := t.prevLines[lineNum].width - width
+			if wrapped {
+				fillSpaces -= t.wrapSignWidth
+			}
 			if fillSpaces > 0 {
 				t.window.Print(strings.Repeat(" ", fillSpaces))
 			}
 			newLine.width = width
+			if wrapped {
+				newLine.width += t.wrapSignWidth
+			}
 		}
 		// When width is 0, line is completely cleared. We need to redraw scrollbar
 		newLine.hasBar = printBar(lineNum, forceRedraw || width == 0)
@@ -2185,7 +2242,7 @@ func (t *Terminal) overflow(runes []rune, max int) bool {
 	return t.displayWidthWithLimit(runes, 0, max) > max
 }
 
-func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMatch tui.ColorPair, current bool, match bool, lineNum int, maxLineNum int, forceRedraw bool, preTask func(markerClass), postTask func(int, int)) int {
+func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMatch tui.ColorPair, current bool, match bool, lineNum int, maxLineNum int, forceRedraw bool, preTask func(markerClass), postTask func(int, int, bool)) int {
 	var displayWidth int
 	item := result.item
 	matchOffsets := []Offset{}
@@ -2204,57 +2261,63 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
 	}
 	allOffsets := result.colorOffsets(charOffsets, t.theme, colBase, colMatch, current)
 
-	from := 0
-	text := make([]rune, item.text.Length())
-	copy(text, item.text.ToRunes())
+	maxLines := 1
+	if t.multiLine || t.wrap {
+		maxLines = maxLineNum - lineNum + 1
+	}
+	lines, overflow := t.itemLines(item, maxLines)
+	numItemLines := len(lines)
 
 	finalLineNum := lineNum
-	numItemLines := 1
-	cutoff := 0
-	overflow := false
 	topCutoff := false
-	if t.multiLine {
-		maxLines := maxLineNum - lineNum + 1
-		numItemLines, overflow = item.text.NumLines(maxLines)
+	wrapped := false
+	if t.multiLine || t.wrap {
 		// Cut off the upper lines in the 'default' layout
 		if t.layout == layoutDefault && !current && maxLines == numItemLines && overflow {
-			actualLines, _ := item.text.NumLines(math.MaxInt32)
-			cutoff = actualLines - maxLines
+			lines, _ = t.itemLines(item, math.MaxInt)
+
+			// To see if the first visible line is wrapped, we need to check the last cut-off line
+			prevLine := lines[len(lines)-maxLines-1]
+			if len(prevLine) == 0 || prevLine[len(prevLine)-1] != '\n' {
+				wrapped = true
+			}
+
+			lines = lines[len(lines)-maxLines:]
 			topCutoff = true
 		}
 	}
-	for lineOffset := 0; from <= len(text) && (lineNum <= maxLineNum || maxLineNum == 0); lineOffset++ {
+	from := 0
+	for lineOffset := 0; lineOffset < len(lines) && (lineNum <= maxLineNum || maxLineNum == 0); lineOffset++ {
+		line := lines[lineOffset]
 		finalLineNum = lineNum
+		offsets := []colorOffset{}
+		for _, offset := range allOffsets {
+			if offset.offset[0] >= int32(from+len(line)) {
+				allOffsets = allOffsets[len(offsets):]
+				break
+			}
 
-		line := text[from:]
-		if t.multiLine {
-			for idx, r := range text[from:] {
-				if r == '\n' {
-					line = line[:idx]
-					break
-				}
+			if offset.offset[0] < int32(from) {
+				continue
 			}
-		}
 
-		offsets := []colorOffset{}
-		for _, offset := range allOffsets {
-			if offset.offset[0] >= int32(from) && offset.offset[1] <= int32(from+len(line)) {
+			if offset.offset[1] < int32(from+len(line)) {
 				offset.offset[0] -= int32(from)
 				offset.offset[1] -= int32(from)
 				offsets = append(offsets, offset)
 			} else {
-				allOffsets = allOffsets[len(offsets):]
-				break
-			}
-		}
+				dupe := offset
+				dupe.offset[0] = int32(from + len(line))
 
-		from += len(line) + 1
+				offset.offset[0] -= int32(from)
+				offset.offset[1] = int32(from + len(line))
+				offsets = append(offsets, offset)
 
-		if cutoff > 0 {
-			cutoff--
-			lineOffset--
-			continue
+				allOffsets = append([]colorOffset{dupe}, allOffsets[len(offsets):]...)
+				break
+			}
 		}
+		from += len(line)
 
 		var maxe int
 		for _, offset := range offsets {
@@ -2301,10 +2364,24 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
 		}
 
 		maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
-		ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2)
-		maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(line))
+		wasWrapped := false
+		if wrapped {
+			maxWidth -= t.wrapSignWidth
+			t.window.CPrint(colBase.WithAttr(tui.Dim), t.wrapSign)
+			wrapped = false
+			wasWrapped = true
+		}
+
+		if len(line) > 0 && line[len(line)-1] == '\n' {
+			line = line[:len(line)-1]
+		} else {
+			wrapped = true
+		}
+
 		displayWidth = t.displayWidthWithLimit(line, 0, maxWidth)
-		if displayWidth > maxWidth {
+		if !t.wrap && displayWidth > maxWidth {
+			ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2)
+			maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(line))
 			transformOffsets := func(diff int32, rightTrim bool) {
 				for idx, offset := range offsets {
 					b, e := offset.offset[0], offset.offset[1]
@@ -2357,7 +2434,7 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
 
 		t.printColoredString(t.window, line, offsets, colBase)
 		if postTask != nil {
-			postTask(actualLineNum, displayWidth)
+			postTask(actualLineNum, displayWidth, wasWrapped)
 		} else {
 			t.markOtherLine(actualLineNum)
 		}
@@ -4369,6 +4446,9 @@ func (t *Terminal) Loop() error {
 			case actToggleHeader:
 				t.headerVisible = !t.headerVisible
 				req(reqList, reqInfo, reqPrompt, reqHeader)
+			case actToggleWrap:
+				t.wrap = !t.wrap
+				req(reqList, reqHeader)
 			case actTrackCurrent:
 				if t.track == trackDisabled {
 					t.track = trackCurrent
@@ -4751,12 +4831,12 @@ func (t *Terminal) constrain() {
 	for tries := 0; tries < maxLines; tries++ {
 		numItems := maxLines
 		// How many items can be fit on screen including the current item?
-		if t.multiLine && t.merger.Length() > 0 {
+		if (t.multiLine || t.wrap) && t.merger.Length() > 0 {
 			numItemsFound := 0
 			linesSum := 0
 
 			add := func(i int) bool {
-				lines, _ := t.merger.Get(i).item.text.NumLines(numItems - linesSum)
+				lines, _ := t.numItemLines(t.merger.Get(i).item, numItems-linesSum)
 				linesSum += lines
 				if linesSum >= numItems {
 					if numItemsFound == 0 {
@@ -4800,14 +4880,14 @@ func (t *Terminal) constrain() {
 					prevOffset := newOffset
 					numItems := t.merger.Length()
 					itemLines := 1
-					if t.multiLine && t.cy < numItems {
-						itemLines, _ = t.merger.Get(t.cy).item.text.NumLines(maxLines)
+					if (t.multiLine || t.wrap) && t.cy < numItems {
+						itemLines, _ = t.numItemLines(t.merger.Get(t.cy).item, maxLines)
 					}
 					linesBefore := t.cy - newOffset
-					if t.multiLine {
+					if t.multiLine || t.wrap {
 						linesBefore = 0
 						for i := newOffset; i < t.cy && i < numItems; i++ {
-							lines, _ := t.merger.Get(i).item.text.NumLines(maxLines - linesBefore - itemLines)
+							lines, _ := t.numItemLines(t.merger.Get(i).item, maxLines-linesBefore-itemLines)
 							linesBefore += lines
 						}
 					}
diff --git a/src/util/chars.go b/src/util/chars.go
index 82773f409f6..a0ea943635c 100644
--- a/src/util/chars.go
+++ b/src/util/chars.go
@@ -226,3 +226,85 @@ func (chars *Chars) Prepend(prefix string) {
 		chars.slice = append([]byte(prefix), chars.slice...)
 	}
 }
+
+func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) {
+	text := make([]rune, chars.Length())
+	copy(text, chars.ToRunes())
+
+	lines := [][]rune{}
+	overflow := false
+	if !multiLine {
+		lines = append(lines, text)
+	} else {
+		from := 0
+		for off := 0; off < len(text); off++ {
+			if text[off] == '\n' {
+				lines = append(lines, text[from:off+1]) // Include '\n'
+				from = off + 1
+				if len(lines) >= maxLines {
+					break
+				}
+			}
+		}
+
+		var lastLine []rune
+		if from < len(text) {
+			lastLine = text[from:]
+		}
+
+		overflow = false
+		if len(lines) >= maxLines {
+			overflow = true
+		} else {
+			lines = append(lines, lastLine)
+		}
+	}
+
+	// If wrapping is disabled, we're done
+	if wrapCols == 0 {
+		return lines, overflow
+	}
+
+	wrapped := [][]rune{}
+	for _, line := range lines {
+		// Remove trailing '\n' and remember if it was there
+		newline := len(line) > 0 && line[len(line)-1] == '\n'
+		if newline {
+			line = line[:len(line)-1]
+		}
+
+		for {
+			cols := wrapCols
+			if len(wrapped) > 0 {
+				cols -= wrapSignWidth
+			}
+			_, overflowIdx := RunesWidth(line, 0, tabstop, cols)
+			if overflowIdx >= 0 {
+				// Might be a wide character
+				if overflowIdx == 0 {
+					overflowIdx = 1
+				}
+				if len(wrapped) >= maxLines {
+					return wrapped, true
+				}
+				wrapped = append(wrapped, line[:overflowIdx])
+				line = line[overflowIdx:]
+				continue
+			}
+
+			// Restore trailing '\n'
+			if newline {
+				line = append(line, '\n')
+			}
+
+			if len(wrapped) >= maxLines {
+				return wrapped, true
+			}
+
+			wrapped = append(wrapped, line)
+			break
+		}
+	}
+
+	return wrapped, false
+}


Test patch:
diff --git a/src/util/chars_test.go b/src/util/chars_test.go
index b7983f30be2..0d3e4f37781 100644
--- a/src/util/chars_test.go
+++ b/src/util/chars_test.go
@@ -1,6 +1,9 @@
 package util
 
-import "testing"
+import (
+	"fmt"
+	"testing"
+)
 
 func TestToCharsAscii(t *testing.T) {
 	chars := ToChars([]byte("foobar"))
@@ -44,3 +47,37 @@ func TestTrimLength(t *testing.T) {
 	check("  h   o  ", 5)
 	check("         ", 0)
 }
+
+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)
+		fmt.Println(lines, overflow)
+		if len(lines) != expectedNumLines || overflow != expectedOverflow {
+			t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow)
+		}
+	}
+
+	// No wrap
+	check(true, 1, 0, 0, 8, 1, true)
+	check(true, 2, 0, 0, 8, 2, true)
+	check(true, 3, 0, 0, 8, 3, false)
+
+	// Wrap (2)
+	check(true, 4, 2, 0, 8, 4, true)
+	check(true, 5, 2, 0, 8, 5, true)
+	check(true, 6, 2, 0, 8, 6, true)
+	check(true, 7, 2, 0, 8, 7, true)
+	check(true, 8, 2, 0, 8, 8, true)
+	check(true, 9, 2, 0, 8, 9, false)
+	check(true, 9, 2, 0, 1, 8, false) // Smaller tab size
+
+	// With wrap sign (3 + 1)
+	check(true, 100, 3, 1, 1, 8, false)
+
+	// With wrap sign (3 + 2)
+	check(true, 100, 3, 2, 1, 12, false)
+
+	// With wrap sign (3 + 2) and no multi-line
+	check(false, 100, 3, 2, 1, 13, false)
+}