Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(term): ansi: move Wrap to Hardwrap and introduce Wrap #57

Merged
merged 2 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 152 additions & 3 deletions exp/term/ansi/wrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import (
"github.com/rivo/uniseg"
)

// Wrap wraps a string or a block of text to a given line length, breaking word
// boundaries. This will preserve ANSI escape codes and will account for
// Hardwrap wraps a string or a block of text to a given line length, breaking
// word boundaries. This will preserve ANSI escape codes and will account for
// wide-characters in the string.
// When preserveSpace is true, spaces at the beginning of a line will be
// preserved.
func Wrap(s string, limit int, preserveSpace bool) string {
func Hardwrap(s string, limit int, preserveSpace bool) string {
if limit < 1 {
return s
}
Expand Down Expand Up @@ -167,6 +167,7 @@ func Wordwrap(s string, limit int, breakpoints string) string {
addSpace()
addWord()
buf.Write(cluster)
curWidth++
} else {
word.Write(cluster)
wordLen += width
Expand Down Expand Up @@ -202,6 +203,7 @@ func Wordwrap(s string, limit int, breakpoints string) string {
addSpace()
addWord()
buf.WriteByte(b[i])
curWidth++
default:
word.WriteByte(b[i])
wordLen++
Expand All @@ -227,6 +229,153 @@ func Wordwrap(s string, limit int, breakpoints string) string {
return buf.String()
}

// Wrap wraps a string or a block of text to a given line length, breaking word
// boundaries if necessary. This will preserve ANSI escape codes and will
// account for wide-characters in the string. The breakpoints string is a list
// of characters that are considered breakpoints for word wrapping. A hyphen
// (-) is always considered a breakpoint.
func Wrap(s string, limit int, breakpoints string) string {
if limit < 1 {
return s
}

// Add a hyphen to the breakpoints
breakpoints += "-"

var (
cluster []byte
buf bytes.Buffer
word bytes.Buffer
space bytes.Buffer
curWidth int
wordLen int
gstate = -1
pstate = parser.GroundState // initial state
b = []byte(s)
)

addSpace := func() {
curWidth += space.Len()
buf.Write(space.Bytes())
space.Reset()
}

addWord := func() {
if word.Len() == 0 {
return
}
addSpace()
curWidth += wordLen
buf.Write(word.Bytes())
word.Reset()
wordLen = 0
}

addNewline := func() {
buf.WriteByte('\n')
curWidth = 0
space.Reset()
}

i := 0
for i < len(b) {
state, action := parser.Table.Transition(pstate, b[i])

switch action {
case parser.PrintAction:
if utf8ByteLen(b[i]) > 1 {
var width int
cluster, _, width, gstate = uniseg.FirstGraphemeCluster(b[i:], gstate)
i += len(cluster)

r, _ := utf8.DecodeRune(cluster)
if r != utf8.RuneError && unicode.IsSpace(r) {
addWord()
space.WriteRune(r)
} else if bytes.ContainsAny(cluster, breakpoints) {
addSpace()
addWord()
buf.Write(cluster)
curWidth++
} else {
if wordLen+width > limit {
addWord()
addNewline()
}
word.Write(cluster)
wordLen += width
if curWidth+space.Len()+wordLen > limit &&
wordLen < limit {
addNewline()
} else if curWidth+wordLen >= limit {
addWord()
if i < len(b)-1 {
addNewline()
}
}
}

pstate = parser.GroundState
continue
}
fallthrough
case parser.ExecuteAction:
r := rune(b[i])
switch {
case r == '\n':
if wordLen == 0 {
if curWidth+space.Len() > limit {
curWidth = 0
} else {
buf.Write(space.Bytes())
}
space.Reset()
}

addWord()
addNewline()
case unicode.IsSpace(r):
addWord()
space.WriteByte(b[i])
case runeContainsAny(r, breakpoints):
addSpace()
addWord()
buf.WriteByte(b[i])
curWidth++
default:
if wordLen+1 > limit {
addWord()
addNewline()
}
word.WriteByte(b[i])
wordLen++
if curWidth+space.Len()+wordLen > limit &&
wordLen < limit {
addNewline()
} else if curWidth+wordLen >= limit {
addWord()
if i < len(b)-1 {
addNewline()
}
}
}

default:
word.WriteByte(b[i])
}

// We manage the UTF8 state separately manually above.
if pstate != parser.Utf8State {
pstate = state
}
i++
}

addWord()

return buf.String()
}

func runeContainsAny(r rune, s string) bool {
for _, c := range s {
if c == r {
Expand Down
98 changes: 80 additions & 18 deletions exp/term/ansi/wrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ var cases = []struct {
func TestWrap(t *testing.T) {
for i, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if got := ansi.Wrap(tt.input, tt.limit, tt.preserveSpace); got != tt.expected {
if got := ansi.Hardwrap(tt.input, tt.limit, tt.preserveSpace); got != tt.expected {
t.Errorf("case %d, expected %q, got %q", i+1, tt.expected, got)
}
})
Expand Down Expand Up @@ -84,27 +84,89 @@ func TestWordwrap(t *testing.T) {
}

func TestWrapWordwrap(t *testing.T) {
t.Skip("WIP")
input := "the quick brown foxxxxxxxxxxxxxxxx jumped over the lazy dog."
limit := 16
output := ansi.Wordwrap(input, limit, "")
t.Logf("output: %q", output)
output = ansi.Wrap(output, limit, false)
if output != "the quick brown\nfoxxxxxxxxxxxxx\nxxxx jumped over\nthe lazy dog." {
output := ansi.Wrap(input, limit, "")
if output != "the quick brown\nfoxxxxxxxxxxxxxx\nxx jumped over\nthe lazy dog." {
t.Errorf("expected %q, got %q", "the quick brown\nfoxxxxxxxxxxxxxx\nxx jumped over\nthe lazy dog.", output)
}
}

const _ = `
the quick brown
foxxxxxxxxxxxxxxxx
jumped over the
lazy dog.
`
var smartWrapCases = []struct {
name string
input string
expected string
width int
}{
{
name: "simple",
input: "I really \x1B[38;2;249;38;114mlove\x1B[0m Go!",
expected: "I really\n\x1B[38;2;249;38;114mlove\x1B[0m Go!",
width: 8,
},
{
name: "passthrough",
input: "hello world",
expected: "hello world",
width: 11,
},
{
name: "asian",
input: "こんにち",
expected: "こんに\nち",
width: 7,
},
{
name: "emoji",
input: "😃👰🏻‍♀️🫧",
expected: "😃\n👰🏻‍♀️\n🫧",
width: 2,
},
{
name: "long style",
input: "\x1B[38;2;249;38;114ma really long string\x1B[0m",
expected: "\x1B[38;2;249;38;114ma really\nlong\nstring\x1B[0m",
width: 10,
},
{
name: "longer",
input: "the quick brown foxxxxxxxxxxxxxxxx jumped over the lazy dog.",
expected: "the quick brown\nfoxxxxxxxxxxxxxx\nxx jumped over\nthe lazy dog.",
width: 16,
},
{
name: "longer asian",
input: "猴 猴 猴猴 猴猴猴猴猴猴猴猴猴 猴猴猴 猴猴 猴’ 猴猴 猴.",
expected: "猴 猴 猴猴\n猴猴猴猴猴猴猴猴\n猴 猴猴猴 猴猴\n猴’ 猴猴 猴.",
width: 16,
},
{
name: "long input",
input: "Rotated keys for a-good-offensive-cheat-code-incorporated/animal-like-law-on-the-rocks.",
expected: "Rotated keys for a-good-offensive-cheat-code-incorporated/animal-like-law-on\n-the-rocks.",
width: 76,
},
{
name: "long input2",
input: "Rotated keys for a-good-offensive-cheat-code-incorporated/crypto-line-operating-system.",
expected: "Rotated keys for a-good-offensive-cheat-code-incorporated/crypto-line-operat\ning-system.",
width: 76,
},
{
name: "paragraph with styles",
input: "Lorem ipsum dolor \x1b[1msit\x1b[m amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \x1b[31mUt enim\x1b[m ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea \x1b[38;5;200mcommodo consequat\x1b[m. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \x1b[1;2;33mExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\x1b[m",
expected: "Lorem ipsum dolor \x1b[1msit\x1b[m amet,\nconsectetur adipiscing elit,\nsed do eiusmod tempor\nincididunt ut labore et dolore\nmagna aliqua. \x1b[31mUt enim\x1b[m ad minim\nveniam, quis nostrud\nexercitation ullamco laboris\nnisi ut aliquip ex ea \x1b[38;5;200mcommodo\nconsequat\x1b[m. Duis aute irure\ndolor in reprehenderit in\nvoluptate velit esse cillum\ndolore eu fugiat nulla\npariatur. \x1b[1;2;33mExcepteur sint\noccaecat cupidatat non\nproident, sunt in culpa qui\nofficia deserunt mollit anim\nid est laborum.\x1b[m",
width: 30,
},
}

const _ = `
the quick brown
foxxxxxxxxxxxxxx
xx jumped over t
he lazy dog.
`
func TestSmartWrap(t *testing.T) {
for i, tc := range smartWrapCases {
t.Run(tc.name, func(t *testing.T) {
output := ansi.Wrap(tc.input, tc.width, "")
if output != tc.expected {
t.Errorf("case %d, expected %q, got %q", i+1, tc.expected, output)
}
})
}
}
Loading