Skip to content

Commit

Permalink
Add unconditional wrapping (#30)
Browse files Browse the repository at this point in the history
  • Loading branch information
erikgeiser authored Nov 26, 2020
1 parent d06e047 commit eee88dd
Show file tree
Hide file tree
Showing 6 changed files with 317 additions and 4 deletions.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,58 @@ f.Breakpoints = []rune{':', ','}
f.Newline = []rune{'\r'}
```

## Unconditional Wrapping

The `wrap` package lets you unconditionally wrap strings or entire blocks of text.

```go
import "github.com/muesli/reflow/wrap"

s := wrap.String("Hello World!", 7)
fmt.Println(s)
```

Result:
```
Hello W
orld!
```

The unconditional wrapping Writer is compatible with the `io.Writer` interfaces:

```go
f := wrap.NewWriter(limit)
f.Write(b)

fmt.Println(f.String())
```

Customize word-wrapping behavior:

```go
f := wrap.NewWriter(limit)
f.Newline = []rune{'\r'}
f.KeepNewlines = false
f.reserveSpace = true
f.TabWidth = 2
```

**Tip:** This wrapping method can be used in conjunction with word-wrapping when word-wrapping is preferred but a line limit has to be enforced:

```go
wrapped := wrap.String(wordwrap.String("Just an example", 5), 5)
fmt.Println(wrapped)
```

Result:
```
Just
an
examp
le
```


### ANSI Example

```go
Expand Down
7 changes: 7 additions & 0 deletions ansi/ansi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ansi

const Marker = '\x1B'

func IsTerminator(c rune) bool {
return (c >= 0x40 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a)
}
4 changes: 2 additions & 2 deletions ansi/buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ func PrintableRuneWidth(s string) int {
var ansi bool

for _, c := range s {
if c == '\x1B' {
if c == Marker {
// ANSI escape sequence
ansi = true
} else if ansi {
if (c >= 0x40 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) {
if IsTerminator(c) {
// ANSI sequence terminated
ansi = false
}
Expand Down
4 changes: 2 additions & 2 deletions ansi/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ type Writer struct {
// Write is used to write content to the ANSI buffer.
func (w *Writer) Write(b []byte) (int, error) {
for _, c := range string(b) {
if c == '\x1B' {
if c == Marker {
// ANSI escape sequence
w.ansi = true
w.seqchanged = true
_, _ = w.ansiseq.WriteRune(c)
} else if w.ansi {
_, _ = w.ansiseq.WriteRune(c)
if (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) {
if IsTerminator(c) {
// ANSI sequence terminated
w.ansi = false

Expand Down
123 changes: 123 additions & 0 deletions wrap/wrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package wrap

import (
"bytes"
"strings"
"unicode"

"github.com/mattn/go-runewidth"
"github.com/muesli/reflow/ansi"
)

var (
defaultNewline = []rune{'\n'}
defaultTabWidth = 4
)

type Wrap struct {
Limit int
Newline []rune
KeepNewlines bool
PreserveSpace bool
TabWidth int

buf *bytes.Buffer
lineLen int
ansi bool
}

// NewWriter returns a new instance of a wrapping writer, initialized with
// default settings.
func NewWriter(limit int) *Wrap {
return &Wrap{
Limit: limit,
Newline: defaultNewline,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: defaultTabWidth,

buf: &bytes.Buffer{},
}
}

// Bytes is shorthand for declaring a new default Wrap instance,
// used to immediately wrap a byte slice.
func Bytes(b []byte, limit int) []byte {
f := NewWriter(limit)
_, _ = f.Write(b)

return f.buf.Bytes()
}

func (w *Wrap) addNewLine() {
_, _ = w.buf.WriteRune('\n')
w.lineLen = 0
}

// String is shorthand for declaring a new default Wrap instance,
// used to immediately wrap a string.
func String(s string, limit int) string {
return string(Bytes([]byte(s), limit))
}

func (w *Wrap) Write(b []byte) (int, error) {
s := strings.Replace(string(b), "\t", strings.Repeat(" ", w.TabWidth), -1)
if !w.KeepNewlines {
s = strings.Replace(s, "\n", "", -1)
}

width := ansi.PrintableRuneWidth(s)

if w.Limit <= 0 || w.lineLen+width <= w.Limit {
w.lineLen += width
return w.buf.Write(b)
}

for _, c := range s {
if c == ansi.Marker {
w.ansi = true
} else if w.ansi {
if ansi.IsTerminator(c) {
w.ansi = false
}
} else if inGroup(w.Newline, c) {
w.addNewLine()
continue
} else {
width := runewidth.RuneWidth(c)

if w.lineLen+width > w.Limit {
w.addNewLine()
}

if !w.PreserveSpace && w.lineLen == 0 && unicode.IsSpace(c) {
continue
}

w.lineLen += width
}

_, _ = w.buf.WriteRune(c)
}

return len(b), nil
}

// Bytes returns the wrapped result as a byte slice.
func (w *Wrap) Bytes() []byte {
return w.buf.Bytes()
}

// String returns the wrapped result as a string.
func (w *Wrap) String() string {
return w.buf.String()
}

func inGroup(a []rune, c rune) bool {
for _, v := range a {
if v == c {
return true
}
}
return false
}
131 changes: 131 additions & 0 deletions wrap/wrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package wrap

import (
"testing"
)

func TestWrap(t *testing.T) {
tt := []struct {
Input string
Expected string
Limit int
KeepNewlines bool
PreserveSpace bool
TabWidth int
}{
// No-op, should pass through, including trailing whitespace:
{
Input: "foobar\n ",
Expected: "foobar\n ",
Limit: 0,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: 0,
},
// Nothing to wrap here, should pass through:
{
Input: "foo",
Expected: "foo",
Limit: 4,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: 0,
},
// In contrast to wordwrap we break a long word to obey the given limit
{
Input: "foobarfoo",
Expected: "foob\narfo\no",
Limit: 4,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: 0,
},
// Newlines in the input are respected if desired
{
Input: "f\no\nobar",
Expected: "f\no\noba\nr",
Limit: 3,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: 0,
},
// Newlines in the input can be ignored if desired
{
Input: "f\no\nobar",
Expected: "foo\nbar",
Limit: 3,
KeepNewlines: false,
PreserveSpace: false,
TabWidth: 0,
},
// Leading whitespaces can be preserved if desired
{
Input: "foo bar",
Expected: "foo\n ba\nr",
Limit: 3,
KeepNewlines: true,
PreserveSpace: true,
TabWidth: 0,
},
// Tabs are broken up according to the configured TabWidth
{
Input: "foo\tbar",
Expected: "foo \n ba\nr",
Limit: 4,
KeepNewlines: true,
PreserveSpace: true,
TabWidth: 3,
},
// Remaining width of wrapped tab is ignored when space is not preserved
{
Input: "foo\tbar",
Expected: "foo \nbar",
Limit: 4,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: 3,
},
// ANSI sequence codes don't affect length calculation:
{
Input: "\x1B[38;2;249;38;114mfoo\x1B[0m\x1B[38;2;248;248;242m \x1B[0m\x1B[38;2;230;219;116mbar\x1B[0m",
Expected: "\x1B[38;2;249;38;114mfoo\x1B[0m\x1B[38;2;248;248;242m \x1B[0m\x1B[38;2;230;219;116mbar\x1B[0m",
Limit: 7,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: 0,
},
// ANSI control codes don't get wrapped:
{
Input: "\x1B[38;2;249;38;114m(\x1B[0m\x1B[38;2;248;248;242mjust another test\x1B[38;2;249;38;114m)\x1B[0m",
Expected: "\x1B[38;2;249;38;114m(\x1B[0m\x1B[38;2;248;248;242mju\nst \nano\nthe\nr t\nest\x1B[38;2;249;38;114m\n)\x1B[0m",
Limit: 3,
KeepNewlines: true,
PreserveSpace: false,
TabWidth: 0,
},
}

for i, tc := range tt {
f := NewWriter(tc.Limit)
f.KeepNewlines = tc.KeepNewlines
f.PreserveSpace = tc.PreserveSpace
f.TabWidth = tc.TabWidth

_, err := f.Write([]byte(tc.Input))
if err != nil {
t.Error(err)
}

if f.String() != tc.Expected {
t.Errorf("Test %d, expected:\n\n`%s`\n\nActual Output:\n\n`%s`", i, tc.Expected, f.String())
}
}
}

func TestWrapString(t *testing.T) {
actual := String("foo bar", 3)
expected := "foo\nbar"
if actual != expected {
t.Errorf("expected:\n\n`%s`\n\nActual Output:\n\n`%s`", expected, actual)
}
}

0 comments on commit eee88dd

Please sign in to comment.