diff --git a/embedded_ansi_test.go b/embedded_ansi_test.go new file mode 100644 index 00000000..2e75bdfc --- /dev/null +++ b/embedded_ansi_test.go @@ -0,0 +1,34 @@ +package lipgloss + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// TestSpaceStylerPreservesEmbeddedANSI verifies that when a space styler is +// active (underline or strikethrough), Render does not mangle ANSI escape +// sequences already present in the input, such as a previously-rendered string. +// See https://github.com/charmbracelet/lipgloss/issues/233. +func TestSpaceStylerPreservesEmbeddedANSI(t *testing.T) { + for _, tc := range []struct { + name string + style Style + }{ + {"strikethrough", NewStyle().Strikethrough(true)}, + {"underline", NewStyle().Underline(true)}, + } { + t.Run(tc.name, func(t *testing.T) { + inner := NewStyle().Bold(true).Render("hi") // "\x1b[1mhi\x1b[m" + got := tc.style.Render(inner) + + if w := ansi.StringWidth(got); w != 2 { + t.Errorf("visible width = %d, want 2 (embedded ANSI leaked as text): %q", w, got) + } + if !strings.Contains(got, "\x1b[1m") { + t.Errorf("embedded bold sequence was mangled, not preserved verbatim: %q", got) + } + }) + } +} diff --git a/style.go b/style.go index 3cd65952..0c646eea 100644 --- a/style.go +++ b/style.go @@ -426,13 +426,28 @@ func (s Style) Render(strs ...string) string { b.WriteRune('\n') } if useSpaceStyler { - // Look for spaces and apply a different styler - for _, r := range line { - if unicode.IsSpace(r) { - b.WriteString(teSpace.Styled(string(r))) - continue + // Look for spaces and apply a different styler, while passing + // through any ANSI escape sequences already present in the line + // so they are not mangled (e.g. when rendering an + // already-styled string). + // See https://github.com/charmbracelet/lipgloss/issues/233. + var state byte + for len(line) > 0 { + seq, width, n, newState := ansi.DecodeSequence(line, state, nil) + if n == 0 { + break } - b.WriteString(te.Styled(string(r))) + switch { + case width == 0: + // Control or escape sequence: emit verbatim, unstyled. + b.WriteString(seq) + case isSpaceSequence(seq): + b.WriteString(teSpace.Styled(seq)) + default: + b.WriteString(te.Styled(seq)) + } + line = line[n:] + state = newState } } else { b.WriteString(te.Styled(line)) @@ -525,6 +540,17 @@ func (s Style) Render(strs ...string) string { return str } +// isSpaceSequence reports whether the given grapheme consists solely of +// whitespace runes. +func isSpaceSequence(s string) bool { + for _, r := range s { + if !unicode.IsSpace(r) { + return false + } + } + return s != "" +} + func (s Style) maybeConvertTabs(str string) string { tw := tabWidthDefault if s.isSet(tabWidthKey) {