Add --wrap option and 'toggle-wrap' action (#3887)

* `--wrap`
* `--wrap-sign`
* `toggle-wrap`

Close #3619
Close #2236
Close #577
Close #461
This commit is contained in:
Junegunn Choi 2024-06-25 17:08:47 +09:00 committed by GitHub
parent 724f8a1d45
commit 70bf8bc35d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 355 additions and 118 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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