Skip to content

Commit

Permalink
text: refactor wrapping text into WrapHard/WrapSoft
Browse files Browse the repository at this point in the history
  • Loading branch information
jedib0t committed Oct 9, 2018
1 parent 996191e commit 46de814
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 85 deletions.
2 changes: 1 addition & 1 deletion table/render.go
Expand Up @@ -256,7 +256,7 @@ func (t *Table) renderRow(out *strings.Builder, rowNum int, row rowStr, hint ren
colMaxLines := 0
rowWrapped := make(rowStr, len(row))
for colIdx, colStr := range row {
rowWrapped[colIdx] = text.WrapText(colStr, t.maxColumnLengths[colIdx])
rowWrapped[colIdx] = text.WrapHard(colStr, t.maxColumnLengths[colIdx])
colNumLines := strings.Count(rowWrapped[colIdx], "\n") + 1
if colNumLines > colMaxLines {
colMaxLines = colNumLines
Expand Down
63 changes: 0 additions & 63 deletions text/string.go
Expand Up @@ -170,66 +170,3 @@ func TrimTextWithoutEscapeSeq(s string, n int) string {
}
return out.String()
}

// WrapText wraps a string to the given length using a newline. For ex.:
// WrapText("Ghost", 1) == "G\nh\no\ns\nt"
// WrapText("Ghost", 2) == "Gh\nos\nt"
// WrapText("Ghost", 3) == "Gho\nst"
// WrapText("Ghost", 4) == "Ghos\nt"
// WrapText("Ghost", 5) == "Ghost"
// WrapText("Ghost", 6) == "Ghost"
// WrapText("Jon\nSnow", 2) == "Jo\nn\nSn\now"
// WrapText("Jon\nSnow\n", 2) == "Jo\nn\nSn\now\n"
func WrapText(s string, n int) string {
if n <= 0 {
return ""
}

var out strings.Builder
sLen := utf8.RuneCountInString(s)
out.Grow(sLen + (sLen / n))
lineIdx, isEscSeq, lastEscSeq := 0, false, ""
for idx, c := range s {
if c == EscapeStartRune {
isEscSeq = true
lastEscSeq = ""
}
if isEscSeq {
lastEscSeq += string(c)
}

wrapRune(sLen, n, idx, c, &lineIdx, isEscSeq, lastEscSeq, &out)

if isEscSeq && c == EscapeStopRune {
isEscSeq = false
}
if lastEscSeq == EscapeReset {
lastEscSeq = ""
}
}
if lastEscSeq != "" && lastEscSeq != EscapeReset {
out.WriteString(EscapeReset)
}
return out.String()
}

func wrapRune(sLen int, wrapLen int, idx int, c int32, lineIdx *int, isEscSeq bool, lastEscSeq string, out *strings.Builder) {
if !isEscSeq && *lineIdx == wrapLen && c != '\n' {
if lastEscSeq != "" {
out.WriteString(EscapeReset)
}
out.WriteRune('\n')
out.WriteString(lastEscSeq)
*lineIdx = 0
}
if c == '\n' && lastEscSeq != "" {
out.WriteString(EscapeReset)
}
out.WriteRune(c)
if c == '\n' {
out.WriteString(lastEscSeq)
*lineIdx = 0
} else if !isEscSeq && idx < sLen {
*lineIdx++
}
}
21 changes: 0 additions & 21 deletions text/string_test.go
Expand Up @@ -59,24 +59,3 @@ func TestTrimTextWithoutEscapeSeq(t *testing.T) {
assert.Equal(t, "\x1b[33mGho\x1b[0m", TrimTextWithoutEscapeSeq("\x1b[33mGhost\x1b[0m", 3))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", TrimTextWithoutEscapeSeq("\x1b[33mGhost\x1b[0m", 6))
}

func TestWrapText(t *testing.T) {
assert.Equal(t, "", WrapText("Ghost", 0))
assert.Equal(t, "G\nh\no\ns\nt", WrapText("Ghost", 1))
assert.Equal(t, "Gh\nos\nt", WrapText("Ghost", 2))
assert.Equal(t, "Gho\nst", WrapText("Ghost", 3))
assert.Equal(t, "Ghos\nt", WrapText("Ghost", 4))
assert.Equal(t, "Ghost", WrapText("Ghost", 5))
assert.Equal(t, "Ghost", WrapText("Ghost", 6))
assert.Equal(t, "Jo\nn\nSn\now", WrapText("Jon\nSnow", 2))
assert.Equal(t, "Jo\nn\nSn\now\n", WrapText("Jon\nSnow\n", 2))
assert.Equal(t, "Jon\nSno\nw\n", WrapText("Jon\nSnow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapText("\x1b[33mJon\x1b[0m\nSnow", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw\n", WrapText("\x1b[33mJon\x1b[0m\nSnow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m", WrapText("\x1b[33mJon Snow\x1b[0m", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n\x1b[0m", 3))

complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+"
assert.Equal(t, complexIn, WrapText(complexIn, 27))
}
223 changes: 223 additions & 0 deletions text/wrap.go
@@ -0,0 +1,223 @@
package text

import (
"strings"
"unicode/utf8"
)

// WrapHard wraps a string to the given length using a newline. Handles strings
// with ANSI escape sequences (such as text color) without breaking the text
// formatting. Breaks all words that go beyond the line boundary. For ex.:
// Wrap("Ghost", 1) == "G\nh\no\ns\nt"
// Wrap("Ghost", 2) == "Gh\nos\nt"
// Wrap("Ghost", 3) == "Gho\nst"
// Wrap("Ghost", 4) == "Ghos\nt"
// Wrap("Ghost", 5) == "Ghost"
// Wrap("Ghost", 6) == "Ghost"
// Wrap("Jon\nSnow", 2) == "Jo\nn\nSn\now"
// Wrap("Jon\nSnow\n", 2) == "Jo\nn\nSn\now\n"
// Wrap("Jon is a Snow", 5) == "Jon i\ns a S\nnow"
// Wrap("\x1b[33mJon\x1b[0m\nSnow", 3) == "\x1b[33mJon\x1b[0m\nSno\nw"
// Wrap("\x1b[33mJon Snow\x1b[0m", 3) == "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m"
func WrapHard(str string, wrapLen int) string {
if wrapLen <= 0 {
return ""
}

str = strings.Replace(str, "\t", " ", -1)
sLen := utf8.RuneCountInString(str)
if sLen <= wrapLen {
return str
}

out := &strings.Builder{}
out.Grow(sLen + (sLen / wrapLen))
lineLen, inEscSeq, lastSeenEscSeq := 0, false, ""
for _, char := range str {
if char == EscapeStartRune {
inEscSeq = true
lastSeenEscSeq = ""
}
if inEscSeq {
lastSeenEscSeq += string(char)
}

appendChar(char, wrapLen, &lineLen, inEscSeq, lastSeenEscSeq, out)

if inEscSeq && char == EscapeStopRune {
inEscSeq = false
}
if lastSeenEscSeq == EscapeReset {
lastSeenEscSeq = ""
}
}
terminateOutput(lastSeenEscSeq, out)

return out.String()
}

// WrapSoft wraps a string to the given length using a newline. Handles strings
// with ANSI escape sequences (such as text color) without breaking the text
// formatting. Tries to move words that go beyond the line boundary to the next
// line. For ex.:
// Wrap("Ghost", 1) == "G\nh\no\ns\nt"
// Wrap("Ghost", 2) == "Gh\nos\nt"
// Wrap("Ghost", 3) == "Gho\nst"
// Wrap("Ghost", 4) == "Ghos\nt"
// Wrap("Ghost", 5) == "Ghost"
// Wrap("Ghost", 6) == "Ghost"
// Wrap("Jon\nSnow", 2) == "Jo\nn\nSn\now"
// Wrap("Jon\nSnow\n", 2) == "Jo\nn\nSn\now\n"
// Wrap("Jon is a Snow", 5) == "Jon \nis a \nSnow"
// Wrap("\x1b[33mJon\x1b[0m\nSnow", 3) == "\x1b[33mJon\x1b[0m\nSno\nw"
// Wrap("\x1b[33mJon Snow\x1b[0m", 3) == "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m"
func WrapSoft(str string, wrapLen int) string {
if wrapLen <= 0 {
return ""
}
sLen := utf8.RuneCountInString(str)
if sLen <= wrapLen {
return str
}

out := &strings.Builder{}
out.Grow(sLen + (sLen / wrapLen))
lineLen, lastSeenEscSeq := 0, ""
words := strings.Fields(str)
for wordIdx, word := range words {
escSeq := extractOpenEscapeSeq(word)
if escSeq != "" {
lastSeenEscSeq = escSeq
}
spacing, spacingLen := "", 0
if lineLen > 0 {
spacing, spacingLen = " ", 1
}

wordLen := RuneCountWithoutEscapeSeq(word)
if lineLen+spacingLen+wordLen <= wrapLen {
// word fits within the line
out.WriteString(spacing)
out.WriteString(word)
lineLen += spacingLen + wordLen
} else {
// word doesn't fit within the line
if lineLen > 0 {
// something is already on the line; terminate it
terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out)
}
if wordLen <= wrapLen {
// word fits within a single line
out.WriteString(word)
lineLen = wordLen
} else {
// word doesn't fit within a single line; hard-wrap
appendWord(word, &lineLen, lastSeenEscSeq, wrapLen, out)
}
}

// end of line; but more words incoming
if lineLen == wrapLen && wordIdx < len(words)-1 {
terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out)
}
}
terminateOutput(lastSeenEscSeq, out)

return out.String()
}

// WrapText is an alias to WrapHard for backward-compatibility. This should be
// removed in the next Major release.
func WrapText(str string, wrapLen int) string {
return WrapHard(str, wrapLen)
}

func appendChar(char rune, wrapLen int, lineLen *int, inEscSeq bool, lastSeenEscSeq string, out *strings.Builder) {
// handle reaching the end of the line as dictated by wrapLen or by finding
// a newline character
if (*lineLen == wrapLen && !inEscSeq && char != '\n') || (char == '\n') {
if lastSeenEscSeq != "" {
// terminate escape sequence and the line; and restart the escape
// sequence in the next line
out.WriteString(EscapeReset)
out.WriteRune('\n')
out.WriteString(lastSeenEscSeq)
} else {
// just start a new line
out.WriteRune('\n')
}
// reset line index to 0th character
*lineLen = 0
}

// if the rune is not a new line, output it
if char != '\n' {
out.WriteRune(char)

// increment the line index if not in the middle of an escape sequence
if !inEscSeq {
*lineLen++
}
}
}

func appendWord(word string, lineIdx *int, lastSeenEscSeq string, wrapLen int, out *strings.Builder) {
inEscSeq := false
for _, char := range word {
if char == EscapeStartRune {
inEscSeq = true
lastSeenEscSeq = ""
}
if inEscSeq {
lastSeenEscSeq += string(char)
}

appendChar(char, wrapLen, lineIdx, inEscSeq, lastSeenEscSeq, out)

if inEscSeq && char == EscapeStopRune {
inEscSeq = false
}
if lastSeenEscSeq == EscapeReset {
lastSeenEscSeq = ""
}
}
}

func extractOpenEscapeSeq(str string) string {
escapeSeq, inEscSeq := "", false
for _, char := range str {
if char == EscapeStartRune {
inEscSeq = true
escapeSeq = ""
}
if inEscSeq {
escapeSeq += string(char)
}
if char == EscapeStopRune {
inEscSeq = false
}
}
if escapeSeq == EscapeReset {
escapeSeq = ""
}
return escapeSeq
}

func terminateLine(wrapLen int, lineLen *int, lastSeenEscSeq string, out *strings.Builder) {
if *lineLen < wrapLen {
out.WriteString(strings.Repeat(" ", wrapLen-*lineLen))
}
// something is already on the line; terminate it
if lastSeenEscSeq != "" {
out.WriteString(EscapeReset)
}
out.WriteRune('\n')
out.WriteString(lastSeenEscSeq)
*lineLen = 0
}

func terminateOutput(lastSeenEscSeq string, out *strings.Builder) {
if lastSeenEscSeq != "" && lastSeenEscSeq != EscapeReset && !strings.HasSuffix(out.String(), EscapeReset) {
out.WriteString(EscapeReset)
}
}

0 comments on commit 46de814

Please sign in to comment.