util: add a text wrapping function

Michael Muré created

Change summary

termui/error_popup.go |  26 ----------
util/text.go          | 100 +++++++++++++++++++++++++++++++++++++++++++++
util/text_test.go     |  89 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 191 insertions(+), 24 deletions(-)

Detailed changes

termui/error_popup.go 🔗

@@ -2,8 +2,8 @@ package termui
 
 import (
 	"fmt"
+	"github.com/MichaelMure/git-bug/util"
 	"github.com/jroimartin/gocui"
-	"strings"
 )
 
 const errorPopupView = "errorPopupView"
@@ -37,7 +37,7 @@ func (ep *errorPopup) layout(g *gocui.Gui) error {
 	maxX, maxY := g.Size()
 
 	width := minInt(30, maxX)
-	wrapped, nblines := word_wrap(ep.message, width-2)
+	wrapped, nblines := util.WordWrap(ep.message, width-2)
 	height := minInt(nblines+2, maxY)
 	x0 := (maxX - width) / 2
 	y0 := (maxY - height) / 2
@@ -69,25 +69,3 @@ func (ep *errorPopup) close(g *gocui.Gui, v *gocui.View) error {
 func (ep *errorPopup) activate(message string) {
 	ep.message = message
 }
-
-func word_wrap(text string, lineWidth int) (string, int) {
-	words := strings.Fields(strings.TrimSpace(text))
-	if len(words) == 0 {
-		return text, 1
-	}
-	lines := 1
-	wrapped := words[0]
-	spaceLeft := lineWidth - len(wrapped)
-	for _, word := range words[1:] {
-		if len(word)+1 > spaceLeft {
-			wrapped += "\n" + word
-			spaceLeft = lineWidth - len(word)
-			lines++
-		} else {
-			wrapped += " " + word
-			spaceLeft -= 1 + len(word)
-		}
-	}
-
-	return wrapped, lines
-}

util/text.go 🔗

@@ -0,0 +1,100 @@
+package util
+
+import (
+	"bytes"
+	"strings"
+)
+
+func WordWrap(text string, lineWidth int) (string, int) {
+	words := strings.Fields(strings.TrimSpace(text))
+	if len(words) == 0 {
+		return "", 1
+	}
+	lines := 1
+	wrapped := words[0]
+	spaceLeft := lineWidth - len(wrapped)
+	for _, word := range words[1:] {
+		if len(word)+1 > spaceLeft {
+			wrapped += "\n" + word
+			spaceLeft = lineWidth - len(word)
+			lines++
+		} else {
+			wrapped += " " + word
+			spaceLeft -= 1 + len(word)
+		}
+	}
+
+	return wrapped, lines
+}
+
+func TextWrap(text string, lineWidth int) (string, int) {
+	var textBuffer bytes.Buffer
+	var lineBuffer bytes.Buffer
+	nbLine := 1
+	firstLine := true
+
+	// tabs are formatted as 4 spaces
+	text = strings.Replace(text, "\t", "    ", 4)
+
+	for _, line := range strings.Split(text, "\n") {
+		spaceLeft := lineWidth
+
+		if !firstLine {
+			textBuffer.WriteString("\n")
+			nbLine++
+		}
+
+		firstWord := true
+
+		for _, word := range strings.Split(line, " ") {
+			if spaceLeft > len(word) {
+				if !firstWord {
+					lineBuffer.WriteString(" ")
+					spaceLeft -= 1
+				}
+				lineBuffer.WriteString(word)
+				spaceLeft -= len(word)
+				firstWord = false
+			} else {
+				if len(word) > lineWidth {
+					for len(word) > 0 {
+						l := minInt(spaceLeft, len(word))
+						part := word[:l]
+						word = word[l:]
+
+						lineBuffer.WriteString(part)
+						textBuffer.Write(lineBuffer.Bytes())
+						lineBuffer.Reset()
+
+						if len(word) > 0 {
+							textBuffer.WriteString("\n")
+							nbLine++
+						}
+
+						spaceLeft = lineWidth
+					}
+				} else {
+					textBuffer.WriteString(strings.TrimRight(lineBuffer.String(), " "))
+					textBuffer.WriteString("\n")
+					lineBuffer.Reset()
+					lineBuffer.WriteString(word)
+					firstWord = false
+					spaceLeft = lineWidth - len(word)
+					nbLine++
+				}
+			}
+		}
+		textBuffer.WriteString(strings.TrimRight(lineBuffer.String(), " "))
+		lineBuffer.Reset()
+		firstLine = false
+	}
+
+	return textBuffer.String(), nbLine
+}
+
+func minInt(a, b int) int {
+	if a > b {
+		return b
+	}
+	return a
+}

util/text_test.go 🔗

@@ -0,0 +1,89 @@
+package util
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestTextWrap(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,
+		},
+		// 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    *\nfoo\n    *\nbar\n\n\n    *\nbaz\nBAM\n",
+			6,
+		},
+	}
+
+	for i, tc := range cases {
+		actual, lines := TextWrap(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("Nb lines mismatch\nExpected:%d\nActual:%d",
+				expected, lines)
+		}
+	}
+}