package ansi import ( "bytes" "unicode" "unicode/utf8" "github.com/charmbracelet/x/ansi/parser" "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" ) // nbsp is a non-breaking space const nbsp = 0xA0 // 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. // This treats the text as a sequence of graphemes. func Hardwrap(s string, limit int, preserveSpace bool) string { return hardwrap(GraphemeWidth, s, limit, preserveSpace) } // HardwrapWc 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. // This treats the text as a sequence of wide characters and runes. func HardwrapWc(s string, limit int, preserveSpace bool) string { return hardwrap(WcWidth, s, limit, preserveSpace) } func hardwrap(m Method, s string, limit int, preserveSpace bool) string { if limit < 1 { return s } var ( cluster []byte buf bytes.Buffer curWidth int forceNewline bool pstate = parser.GroundState // initial state b = []byte(s) ) addNewline := func() { buf.WriteByte('\n') curWidth = 0 } i := 0 for i < len(b) { state, action := parser.Table.Transition(pstate, b[i]) if state == parser.Utf8State { var width int cluster, _, width, _ = uniseg.FirstGraphemeCluster(b[i:], -1) if m == WcWidth { width = runewidth.StringWidth(string(cluster)) } i += len(cluster) if curWidth+width > limit { addNewline() } if !preserveSpace && curWidth == 0 && len(cluster) <= 4 { // Skip spaces at the beginning of a line if r, _ := utf8.DecodeRune(cluster); r != utf8.RuneError && unicode.IsSpace(r) { pstate = parser.GroundState continue } } buf.Write(cluster) curWidth += width pstate = parser.GroundState continue } switch action { case parser.PrintAction, parser.ExecuteAction: if b[i] == '\n' { addNewline() forceNewline = false break } if curWidth+1 > limit { addNewline() forceNewline = true } // Skip spaces at the beginning of a line if curWidth == 0 { if !preserveSpace && forceNewline && unicode.IsSpace(rune(b[i])) { break } forceNewline = false } buf.WriteByte(b[i]) if action == parser.PrintAction { curWidth++ } default: buf.WriteByte(b[i]) } // We manage the UTF8 state separately manually above. if pstate != parser.Utf8State { pstate = state } i++ } return buf.String() } // Wordwrap wraps a string or a block of text to a given line length, not // breaking word boundaries. 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. // // Note: breakpoints must be a string of 1-cell wide rune characters. // // This treats the text as a sequence of graphemes. func Wordwrap(s string, limit int, breakpoints string) string { return wordwrap(GraphemeWidth, s, limit, breakpoints) } // WordwrapWc wraps a string or a block of text to a given line length, not // breaking word boundaries. 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. // // Note: breakpoints must be a string of 1-cell wide rune characters. // // This treats the text as a sequence of wide characters and runes. func WordwrapWc(s string, limit int, breakpoints string) string { return wordwrap(WcWidth, s, limit, breakpoints) } func wordwrap(m Method, s string, limit int, breakpoints string) string { if limit < 1 { return s } var ( cluster []byte buf bytes.Buffer word bytes.Buffer space bytes.Buffer curWidth int wordLen int 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]) if state == parser.Utf8State { var width int cluster, _, width, _ = uniseg.FirstGraphemeCluster(b[i:], -1) if m == WcWidth { width = runewidth.StringWidth(string(cluster)) } i += len(cluster) r, _ := utf8.DecodeRune(cluster) if r != utf8.RuneError && unicode.IsSpace(r) && r != nbsp { addWord() space.WriteRune(r) } else if bytes.ContainsAny(cluster, breakpoints) { addSpace() addWord() buf.Write(cluster) curWidth++ } else { word.Write(cluster) wordLen += width if curWidth+space.Len()+wordLen > limit && wordLen < limit { addNewline() } } pstate = parser.GroundState continue } switch action { case parser.PrintAction, 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 r == '-': fallthrough case runeContainsAny(r, breakpoints): addSpace() addWord() buf.WriteByte(b[i]) curWidth++ default: word.WriteByte(b[i]) wordLen++ if curWidth+space.Len()+wordLen > limit && wordLen < limit { 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() } // 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. // // Note: breakpoints must be a string of 1-cell wide rune characters. // // This treats the text as a sequence of graphemes. func Wrap(s string, limit int, breakpoints string) string { return wrap(GraphemeWidth, s, limit, breakpoints) } // WrapWc 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. // // Note: breakpoints must be a string of 1-cell wide rune characters. // // This treats the text as a sequence of wide characters and runes. func WrapWc(s string, limit int, breakpoints string) string { return wrap(WcWidth, s, limit, breakpoints) } func wrap(m Method, s string, limit int, breakpoints string) string { if limit < 1 { return s } var ( cluster []byte buf bytes.Buffer word bytes.Buffer space bytes.Buffer curWidth int // written width of the line wordLen int // word buffer len without ANSI escape codes 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]) if state == parser.Utf8State { var width int cluster, _, width, _ = uniseg.FirstGraphemeCluster(b[i:], -1) if m == WcWidth { width = runewidth.StringWidth(string(cluster)) } i += len(cluster) r, _ := utf8.DecodeRune(cluster) switch { case r != utf8.RuneError && unicode.IsSpace(r) && r != nbsp: // nbsp is a non-breaking space addWord() space.WriteRune(r) case bytes.ContainsAny(cluster, breakpoints): addSpace() if curWidth+wordLen+width > limit { word.Write(cluster) wordLen += width } else { addWord() buf.Write(cluster) curWidth += width } default: if wordLen+width > limit { // Hardwrap the word if it's too long addWord() } word.Write(cluster) wordLen += width if curWidth+wordLen+space.Len() > limit { addNewline() } } pstate = parser.GroundState continue } switch action { case parser.PrintAction, parser.ExecuteAction: switch r := rune(b[i]); { case r == '\n': if wordLen == 0 { if curWidth+space.Len() > limit { curWidth = 0 } else { // preserve whitespaces buf.Write(space.Bytes()) } space.Reset() } addWord() addNewline() case unicode.IsSpace(r): addWord() space.WriteRune(r) case r == '-': fallthrough case runeContainsAny(r, breakpoints): addSpace() if curWidth+wordLen >= limit { // We can't fit the breakpoint in the current line, treat // it as part of the word. word.WriteRune(r) wordLen++ } else { addWord() buf.WriteRune(r) curWidth++ } default: if curWidth == limit { addNewline() } word.WriteRune(r) wordLen++ if wordLen == limit { // Hardwrap the word if it's too long addWord() } if curWidth+wordLen+space.Len() > limit { addNewline() } } default: word.WriteByte(b[i]) } // We manage the UTF8 state separately manually above. if pstate != parser.Utf8State { pstate = state } i++ } if wordLen == 0 { if curWidth+space.Len() > limit { curWidth = 0 } else { // preserve whitespaces buf.Write(space.Bytes()) } space.Reset() } addWord() return buf.String() } func runeContainsAny(r rune, s string) bool { for _, c := range s { if c == r { return true } } return false }