diff options
Diffstat (limited to 'util/text')
-rw-r--r-- | util/text/left_padded.go | 20 | ||||
-rw-r--r-- | util/text/text.go | 160 | ||||
-rw-r--r-- | util/text/text_test.go | 191 |
3 files changed, 371 insertions, 0 deletions
diff --git a/util/text/left_padded.go b/util/text/left_padded.go new file mode 100644 index 00000000..a38dfba5 --- /dev/null +++ b/util/text/left_padded.go @@ -0,0 +1,20 @@ +package text + +import ( + "strings" + "unicode/utf8" +) + +// LeftPaddedString pads a string on the left by a specified amount and pads the string on the right to fill the maxLength +func LeftPaddedString(value string, maxValueLength, padAmount int) string { + valueLength := utf8.RuneCountInString(value) + if maxValueLength-padAmount >= valueLength { + return strings.Repeat(" ", padAmount) + value + strings.Repeat(" ", maxValueLength-valueLength-padAmount) + } else if maxValueLength-padAmount < valueLength { + tmp := strings.Trim(value[0:maxValueLength-padAmount-3], " ") + "..." + tmpLength := utf8.RuneCountInString(tmp) + return strings.Repeat(" ", padAmount) + tmp + strings.Repeat(" ", maxValueLength-tmpLength-padAmount) + } + + return value +} diff --git a/util/text/text.go b/util/text/text.go new file mode 100644 index 00000000..10b70b01 --- /dev/null +++ b/util/text/text.go @@ -0,0 +1,160 @@ +package text + +import ( + "bytes" + "strings" +) + +// Wrap a text for an exact line size +// Handle properly terminal color escape code +func Wrap(text string, lineWidth int) (string, int) { + return WrapLeftPadded(text, lineWidth, 0) +} + +// Wrap a text for an exact line size with a left padding +// Handle properly terminal color escape code +func WrapLeftPadded(text string, lineWidth int, leftPad int) (string, int) { + var textBuffer bytes.Buffer + var lineBuffer bytes.Buffer + nbLine := 1 + firstLine := true + pad := strings.Repeat(" ", leftPad) + + // tabs are formatted as 4 spaces + text = strings.Replace(text, "\t", " ", 4) + + for _, line := range strings.Split(text, "\n") { + spaceLeft := lineWidth - leftPad + + if !firstLine { + textBuffer.WriteString("\n") + nbLine++ + } + + firstWord := true + + for _, word := range strings.Split(line, " ") { + wordLength := wordLen(word) + + if !firstWord { + lineBuffer.WriteString(" ") + spaceLeft -= 1 + + if spaceLeft <= 0 { + textBuffer.WriteString(pad + strings.TrimRight(lineBuffer.String(), " ")) + textBuffer.WriteString("\n") + lineBuffer.Reset() + spaceLeft = lineWidth - leftPad + nbLine++ + firstLine = false + } + } + + // Word fit in the current line + if spaceLeft >= wordLength { + lineBuffer.WriteString(word) + spaceLeft -= wordLength + firstWord = false + } else { + // Break a word longer than a line + if wordLength > lineWidth { + for wordLength > 0 && len(word) > 0 { + l := minInt(spaceLeft, wordLength) + part, leftover := splitWord(word, l) + word = leftover + wordLength = wordLen(word) + + lineBuffer.WriteString(part) + textBuffer.WriteString(pad) + textBuffer.Write(lineBuffer.Bytes()) + lineBuffer.Reset() + + spaceLeft -= l + + if spaceLeft <= 0 { + textBuffer.WriteString("\n") + nbLine++ + spaceLeft = lineWidth - leftPad + } + } + } else { + // Normal break + textBuffer.WriteString(pad + strings.TrimRight(lineBuffer.String(), " ")) + textBuffer.WriteString("\n") + lineBuffer.Reset() + lineBuffer.WriteString(word) + firstWord = false + spaceLeft = lineWidth - wordLength + nbLine++ + } + } + } + + textBuffer.WriteString(pad + strings.TrimRight(lineBuffer.String(), " ")) + lineBuffer.Reset() + firstLine = false + } + + return textBuffer.String(), nbLine +} + +func wordLen(word string) int { + length := 0 + escape := false + + for _, char := range word { + if char == '\x1b' { + escape = true + } + + if !escape { + length++ + } + + if char == 'm' { + escape = false + } + } + + return length +} + +func splitWord(word string, length int) (string, string) { + result := "" + added := 0 + escape := false + + if length == 0 { + return "", word + } + + for _, char := range word { + if char == '\x1b' { + escape = true + } + + result += string(char) + + if !escape { + added++ + if added == length { + break + } + } + + if char == 'm' { + escape = false + } + } + + leftover := word[len(result):] + + return result, leftover +} + +func minInt(a, b int) int { + if a > b { + return b + } + return a +} diff --git a/util/text/text_test.go b/util/text/text_test.go new file mode 100644 index 00000000..96234464 --- /dev/null +++ b/util/text/text_test.go @@ -0,0 +1,191 @@ +package text + +import ( + "strings" + "testing" +) + +func TestWrap(t *testing.T) { + cases := []struct { + Input, Output string + Lim int + }{ + // A simple word passes through. + { + "foo", + "foo", + 4, + }, + // Word breaking + { + "foobarbaz", + "foob\narba\nz", + 4, + }, + // Lines are broken at whitespace. + { + "foo bar baz", + "foo\nbar\nbaz", + 4, + }, + // Word breaking + { + "foo bars bazzes", + "foo\nbars\nbazz\nes", + 4, + }, + // A word that would run beyond the width is wrapped. + { + "fo sop", + "fo\nsop", + 4, + }, + // A tab counts as 4 characters. + { + "foo\nb\t r\n baz", + "foo\nb\n r\n baz", + 4, + }, + // Trailing whitespace is removed after used for wrapping. + // Runs of whitespace on which a line is broken are removed. + { + "foo \nb ar ", + "foo\n\nb\nar\n", + 4, + }, + // An explicit line break at the end of the input is preserved. + { + "foo bar baz\n", + "foo\nbar\nbaz\n", + 4, + }, + // Explicit break are always preserved. + { + "\nfoo bar\n\n\nbaz\n", + "\nfoo\nbar\n\n\nbaz\n", + 4, + }, + // Ignore complete words with terminal color sequence + { + "foo \x1b[31mbar\x1b[0m baz", + "foo\n\x1b[31mbar\x1b[0m\nbaz", + 4, + }, + // Handle words with colors sequence inside the word + { + "foo b\x1b[31mbar\x1b[0mr baz", + "foo\nb\x1b[31mbar\n\x1b[0mr\nbaz", + 4, + }, + // Break words with colors sequence inside the word + { + "foo bb\x1b[31mbar\x1b[0mr baz", + "foo\nbb\x1b[31mba\nr\x1b[0mr\nbaz", + 4, + }, + // Complete example: + { + " This is a list: \n\n\t* foo\n\t* bar\n\n\n\t* baz \nBAM ", + " This\nis a\nlist:\n\n\n *\nfoo\n *\nbar\n\n\n *\nbaz\nBAM\n", + 6, + }, + } + + for i, tc := range cases { + actual, lines := Wrap(tc.Input, tc.Lim) + if actual != tc.Output { + t.Fatalf("Case %d Input:\n\n`%s`\n\nExpected Output:\n\n`%s`\n\nActual Output:\n\n`%s`", + i, tc.Input, tc.Output, actual) + } + + expected := len(strings.Split(tc.Output, "\n")) + if expected != lines { + t.Fatalf("Case %d Nb lines mismatch\nExpected:%d\nActual:%d", + i, expected, lines) + } + } +} + +func TestWordLen(t *testing.T) { + cases := []struct { + Input string + Length int + }{ + // A simple word + { + "foo", + 3, + }, + // A simple word with colors + { + "\x1b[31mbar\x1b[0m", + 3, + }, + // Handle prefix and suffix properly + { + "foo\x1b[31mfoobarHoy\x1b[0mbaaar", + 17, + }, + } + + for i, tc := range cases { + l := wordLen(tc.Input) + if l != tc.Length { + t.Fatalf("Case %d Input:\n\n`%s`\n\nExpected Output:\n\n`%d`\n\nActual Output:\n\n`%d`", + i, tc.Input, tc.Length, l) + } + } +} + +func TestSplitWord(t *testing.T) { + cases := []struct { + Input string + Length int + Result, Leftover string + }{ + // A simple word passes through. + { + "foo", + 4, + "foo", "", + }, + // Cut at the right place + { + "foobarHoy", + 4, + "foob", "arHoy", + }, + // A simple word passes through with colors + { + "\x1b[31mbar\x1b[0m", + 4, + "\x1b[31mbar\x1b[0m", "", + }, + // Cut at the right place with colors + { + "\x1b[31mfoobarHoy\x1b[0m", + 4, + "\x1b[31mfoob", "arHoy\x1b[0m", + }, + // Handle prefix and suffix properly + { + "foo\x1b[31mfoobarHoy\x1b[0mbaaar", + 4, + "foo\x1b[31mf", "oobarHoy\x1b[0mbaaar", + }, + // Cut properly with length = 0 + { + "foo", + 0, + "", "foo", + }, + } + + for i, tc := range cases { + result, leftover := splitWord(tc.Input, tc.Length) + if result != tc.Result || leftover != tc.Leftover { + t.Fatalf("Case %d Input:\n\n`%s`\n\nExpected Output:\n\n`%s` - `%s`\n\nActual Output:\n\n`%s` - `%s`", + i, tc.Input, tc.Result, tc.Leftover, result, leftover) + } + } +} |