From 70bf8bc35dfb31eb1963c92fa72e38261fa0056a Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Tue, 25 Jun 2024 17:08:47 +0900 Subject: [PATCH] Add --wrap option and 'toggle-wrap' action (#3887) * `--wrap` * `--wrap-sign` * `toggle-wrap` Close #3619 Close #2236 Close #577 Close #461 --- CHANGELOG.md | 10 +++ man/man1/fzf.1 | 8 ++ src/actiontype_string.go | 129 +++++++++++++-------------- src/options.go | 19 ++++ src/terminal.go | 186 ++++++++++++++++++++++++++++----------- src/util/chars.go | 82 +++++++++++++++++ src/util/chars_test.go | 39 +++++++- 7 files changed, 355 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2a81a..c1d6327 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 cd18f74..1476143 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 03ec74c..f5221f8 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 d9121c3..6355071 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 337f570..d94e54e 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 - - line := text[from:] - if t.multiLine { - for idx, r := range text[from:] { - if r == '\n' { - line = line[:idx] - break - } - } - } - offsets := []colorOffset{} for _, offset := range allOffsets { - if offset.offset[0] >= int32(from) && offset.offset[1] <= int32(from+len(line)) { + if offset.offset[0] >= int32(from+len(line)) { + allOffsets = allOffsets[len(offsets):] + break + } + + if offset.offset[0] < int32(from) { + continue + } + + 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):] + dupe := offset + dupe.offset[0] = int32(from + len(line)) + + offset.offset[0] -= int32(from) + offset.offset[1] = int32(from + len(line)) + offsets = append(offsets, offset) + + allOffsets = append([]colorOffset{dupe}, allOffsets[len(offsets):]...) break } } - - from += len(line) + 1 - - if cutoff > 0 { - cutoff-- - lineOffset-- - continue - } + 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 82773f4..a0ea943 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 +} diff --git a/src/util/chars_test.go b/src/util/chars_test.go index b7983f3..0d3e4f3 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) +}