diff --git a/exp/term/ansi/wrap.go b/exp/term/ansi/wrap.go index 67c06c63..36dcbe4c 100644 --- a/exp/term/ansi/wrap.go +++ b/exp/term/ansi/wrap.go @@ -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 } @@ -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 @@ -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++ @@ -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 { diff --git a/exp/term/ansi/wrap_test.go b/exp/term/ansi/wrap_test.go index a3c5f8be..21eea25d 100644 --- a/exp/term/ansi/wrap_test.go +++ b/exp/term/ansi/wrap_test.go @@ -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) } }) @@ -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) + } + }) + } +}