Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 53 additions & 8 deletions pkg/ui/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,46 @@ func trimRightSpaces(s string) string {
return result.String()
}

// trimLeftSpaces removes only leading spaces from an ANSI-coded string while
// preserving all ANSI escape sequences on the remaining content.
// This is useful for removing Glamour's paragraph indent while preserving styled content.
func trimLeftSpaces(s string) string {
stripped := ansi.Strip(s)
trimmed := strings.TrimLeft(stripped, " ")

if trimmed == stripped {
return s // No leading spaces to remove.
}
if trimmed == "" {
return "" // All spaces.
}

// Calculate how many leading spaces to skip.
leadingSpaces := len(stripped) - len(trimmed)

// Walk through original string, skipping ANSI codes and counting spaces.
spacesSkipped := 0
i := 0

// Skip leading ANSI codes and spaces until we've skipped the required amount.
skipLoop:
for i < len(s) && spacesSkipped < leadingSpaces {
switch {
case isANSIStart(s, i):
// Skip ANSI sequence (don't output it since it's styling skipped content).
i = skipANSISequence(s, i)
case s[i] == ' ':
spacesSkipped++
i++
default:
break skipLoop // Non-space content found.
}
}

// Return remaining content (including any ANSI codes).
return s[i:]
}

// isWhitespace checks if byte b is a space or tab.
func isWhitespace(b byte) bool {
return b == ' ' || b == '\t'
Expand Down Expand Up @@ -680,13 +720,15 @@ func (f *formatter) toastMarkdown(icon string, style *lipgloss.Style, text strin

if len(lines) == 1 {
// For single line: trim leading spaces from Glamour's paragraph indent
// since the icon+space already provides visual separation
line := strings.TrimLeft(lines[0], space)
// since the icon+space already provides visual separation.
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
line := trimLeftSpaces(lines[0])
return fmt.Sprintf(iconMessageFormat, styledIcon, line), nil
}

// Multi-line: trim leading spaces from first line (goes next to icon)
lines[0] = strings.TrimLeft(lines[0], space)
// Multi-line: trim leading spaces from first line (goes next to icon).
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
lines[0] = trimLeftSpaces(lines[0])

// Multi-line: first line with icon, rest indented to align under first line's text
result := fmt.Sprintf(iconMessageFormat, styledIcon, lines[0])
Expand All @@ -697,8 +739,9 @@ func (f *formatter) toastMarkdown(icon string, style *lipgloss.Style, text strin
indent := strings.Repeat(space, iconWidth+1) // +1 for the space in "%s %s" format

for i := 1; i < len(lines); i++ {
// Glamour already added 2-space paragraph indent, replace with our calculated indent
line := strings.TrimLeft(lines[i], space) // Remove Glamour's indent
// Glamour already added 2-space paragraph indent, replace with our calculated indent.
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
line := trimLeftSpaces(lines[i])
result += newline + indent + line
}

Expand Down Expand Up @@ -834,11 +877,13 @@ func (f *formatter) renderInlineMarkdownWithBase(text string, baseStyle *lipglos
}

// For single line, trim leading spaces.
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
if len(lines) == 1 {
rendered = strings.TrimLeft(lines[0], space)
rendered = trimLeftSpaces(lines[0])
} else {
// Multi-line: trim first line and rejoin.
lines[0] = strings.TrimLeft(lines[0], space)
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
lines[0] = trimLeftSpaces(lines[0])
rendered = strings.Join(lines, newline)
}

Expand Down
157 changes: 157 additions & 0 deletions pkg/ui/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1710,3 +1710,160 @@ func TestTrimRight(t *testing.T) {
})
}
}

//nolint:dupl // Test structure intentionally mirrors TestTrimRight for consistency.
func TestTrimLeftSpaces(t *testing.T) {
tests := []struct {
name string
input string
expected string
desc string
}{
{
name: "plain text no leading spaces",
input: "hello world",
expected: "hello world",
desc: "Baseline: plain text without leading spaces should be unchanged",
},
{
name: "plain text with leading spaces",
input: " hello world",
expected: "hello world",
desc: "Plain text with leading spaces should be trimmed",
},
{
name: "ANSI colored text no leading spaces",
input: "\x1b[38;2;247;250;252mhello world\x1b[0m",
expected: "\x1b[38;2;247;250;252mhello world\x1b[0m",
desc: "ANSI colored text without leading spaces should preserve all codes",
},
{
name: "ANSI colored text with plain leading spaces",
input: " \x1b[38;2;247;250;252mhello world\x1b[0m",
expected: "\x1b[38;2;247;250;252mhello world\x1b[0m",
desc: "ANSI colored text with plain leading spaces should trim spaces",
},
{
name: "ANSI codes before leading spaces (Glamour pattern)",
input: "\x1b[38;2;247;250;252m\x1b[0m\x1b[38;2;247;250;252m\x1b[0m \x1b[38;2;247;250;252mhello world\x1b[0m",
expected: "\x1b[38;2;247;250;252mhello world\x1b[0m",
desc: "ANSI codes before leading spaces (Glamour pattern) should be trimmed",
},
{
name: "ANSI wrapped leading spaces",
input: "\x1b[38;2;247;250;252m \x1b[0m\x1b[38;2;247;250;252mhello world\x1b[0m",
expected: "\x1b[0m\x1b[38;2;247;250;252mhello world\x1b[0m",
desc: "ANSI-wrapped leading spaces should be trimmed (reset code preserved)",
},
{
name: "mixed ANSI codes and spaces at start",
input: "\x1b[0m\x1b[38;2;247;250;252m\x1b[0m \x1b[38;2;247;250;252m• Item one\x1b[0m",
expected: "\x1b[38;2;247;250;252m• Item one\x1b[0m",
desc: "Mixed ANSI codes and spaces at start should be trimmed correctly",
},
{
name: "Unicode characters with leading spaces",
input: " ℹ hello → world",
expected: "ℹ hello → world",
desc: "Unicode characters with leading spaces should be trimmed correctly",
},
{
name: "Unicode with ANSI and leading spaces",
input: "\x1b[38;2;247;250;252m \x1b[0m\x1b[38;2;247;250;252mℹ hello → world\x1b[0m",
expected: "\x1b[0m\x1b[38;2;247;250;252mℹ hello → world\x1b[0m",
desc: "Unicode with ANSI codes and leading spaces should trim correctly (reset code preserved)",
},
{
name: "empty string",
input: "",
expected: "",
desc: "Empty string should remain empty",
},
{
name: "only spaces",
input: " ",
expected: "",
desc: "String with only spaces should become empty",
},
{
name: "only ANSI wrapped spaces",
input: "\x1b[38;2;247;250;252m \x1b[0m",
expected: "",
desc: "String with only ANSI-wrapped spaces should become empty",
},
{
name: "preserves trailing spaces",
input: " hello world ",
expected: "hello world ",
desc: "Trailing spaces should be preserved, only leading removed",
},
{
name: "preserves ANSI on trailing spaces",
input: "\x1b[38;2;247;250;252m \x1b[0m\x1b[38;2;247;250;252mhello world\x1b[0m\x1b[38;2;247;250;252m \x1b[0m",
expected: "\x1b[0m\x1b[38;2;247;250;252mhello world\x1b[0m\x1b[38;2;247;250;252m \x1b[0m",
desc: "ANSI codes on trailing spaces should be preserved (leading reset code after trim)",
},
{
name: "real Glamour output with bullet",
input: "\x1b[38;2;247;250;252m\x1b[0m\x1b[38;2;247;250;252m\x1b[0m \x1b[38;2;247;250;252m• \x1b[0m\x1b[38;2;247;250;252mItem one\x1b[0m",
expected: "\x1b[38;2;247;250;252m• \x1b[0m\x1b[38;2;247;250;252mItem one\x1b[0m",
desc: "Real Glamour bullet list output should have leading spaces trimmed",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trimLeftSpaces(tt.input)

// Compare results.
if result != tt.expected {
t.Errorf("\nTest: %s\nDescription: %s\n\nInput:\n Raw: %q\n Hex: % X\n Visual: %s\n\nExpected:\n Raw: %q\n Hex: % X\n Visual: %s\n\nGot:\n Raw: %q\n Hex: % X\n Visual: %s",
tt.name,
tt.desc,
tt.input,
[]byte(tt.input),
tt.input,
tt.expected,
[]byte(tt.expected),
tt.expected,
result,
[]byte(result),
result,
)
}

// Additional verification: check visual width.
strippedInput := ansi.Strip(tt.input)
strippedExpected := ansi.Strip(tt.expected)
strippedResult := ansi.Strip(result)

expectedWidth := ansi.StringWidth(strings.TrimLeft(strippedInput, " "))
resultWidth := ansi.StringWidth(strippedResult)

if resultWidth != expectedWidth {
t.Errorf("\nVisual width mismatch:\n Expected trimmed width: %d (from %q)\n Got width: %d (from %q)",
expectedWidth,
strings.TrimLeft(strippedInput, " "),
resultWidth,
strippedResult,
)
}

// Verify no leading whitespace in result.
if strippedResult != strings.TrimLeft(strippedResult, " ") {
t.Errorf("\nResult still has leading whitespace:\n Stripped result: %q\n After TrimLeft: %q",
strippedResult,
strings.TrimLeft(strippedResult, " "),
)
}

// Verify expected also matches this property.
if strippedExpected != strings.TrimLeft(strippedExpected, " ") {
t.Errorf("\nTest case error - expected value has leading whitespace:\n Stripped expected: %q\n After TrimLeft: %q",
strippedExpected,
strings.TrimLeft(strippedExpected, " "),
)
}
})
}
}
Loading