Skip to content
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

## [v2.0.1] - 2026-05-29
### Fixed
- Preserve ANSI resets when truncating styled tree rows.
- Fixes line style overlapping lines in multi-component TUIs.

## [v2.0.0] - 2026-04-22

See the [v2 update guide](./v2_updates.md) for upgrade notes and migration advice.
Expand Down
39 changes: 31 additions & 8 deletions renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ var sbPool = sync.Pool{New: func() any { return new(strings.Builder) }}
// ansiRegex matches ANSI escape sequences
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)

// ansiReset ends terminal line styling to prevent overlap
const ansiReset = "\x1b[0m"

// stripANSI removes ANSI escape sequences from a string
func stripANSI(s string) string {
return ansiRegex.ReplaceAllString(s, "")
Expand All @@ -39,29 +42,31 @@ func truncateLine(line string, maxWidth int) string {
return line
}

ansiParts := ansiRegex.FindAllStringIndex(line, -1)

// We need to truncate. Reserve 3 characters for ellipsis
if maxWidth <= 3 {
return "..."
}

targetWidth := maxWidth - 3

// Find ANSI sequences to preserve styling (work with byte indices)
ansiParts := ansiRegex.FindAllStringIndex(line, -1)

// Build the truncated string byte by byte
var result strings.Builder
currentWidth := 0
bytes := []byte(line)
ansiIdx := 0
styleOpen := false
i := 0

for i < len(bytes) {
// Check if we're at an ANSI sequence
if ansiIdx < len(ansiParts) && i == ansiParts[ansiIdx][0] {
// Add the ANSI sequence (doesn't affect width)
end := ansiParts[ansiIdx][1]
result.Write(bytes[i:end])
sequence := string(bytes[i:end])
styleOpen = updateStyleOpen(styleOpen, sequence)
result.WriteString(sequence)
i = end
ansiIdx++
continue
Expand All @@ -84,12 +89,30 @@ func truncateLine(line string, maxWidth int) string {
// Add ellipsis
result.WriteString("...")

// Add any remaining ANSI reset codes if they exist at the end of the original string
if strings.HasSuffix(line, "\x1b[0m") {
result.WriteString("\x1b[0m")
return closeTruncatedANSI(result.String(), styleOpen)
}

func closeTruncatedANSI(line string, styleOpen bool) string {
if !styleOpen || hasANSIResetSuffix(line) {
return line
}
return line + ansiReset
}

func updateStyleOpen(styleOpen bool, sequence string) bool {
if !strings.HasSuffix(sequence, "m") {
return styleOpen
}

return result.String()
params := strings.TrimSuffix(strings.TrimPrefix(sequence, "\x1b["), "m")
if params == "" || params == "0" {
return false
}
return true
}

func hasANSIResetSuffix(line string) bool {
return strings.HasSuffix(line, ansiReset) || strings.HasSuffix(line, "\x1b[m")
}

// renderNode implements the NodeRenderer interface. It asks the NodeProvider
Expand Down
88 changes: 88 additions & 0 deletions renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,70 @@ func TestBuildPrefix(t *testing.T) {
}
}

func TestTruncateLine(t *testing.T) {
tests := []struct {
name string
line string
maxWidth int
want string
}{
{
name: "unstyled",
line: "abcdef",
maxWidth: 5,
want: "ab...",
},
{
name: "styled_lipgloss_reset",
line: "\x1b[1mabcdefg\x1b[m",
maxWidth: 6,
want: "\x1b[1mabc...\x1b[0m",
},
{
name: "styled_full_reset",
line: "\x1b[31mabcdefg\x1b[0m",
maxWidth: 6,
want: "\x1b[31mabc...\x1b[0m",
},
{
name: "narrow_styled_line",
line: "\x1b[1mabcdef\x1b[m",
maxWidth: 3,
want: "...",
},
{
name: "non_style_ansi_sequence",
line: "\x1b[2Kabcdef",
maxWidth: 5,
want: "\x1b[2Kab...",
},
{
name: "style_reset_before_truncation",
line: "\x1b[1mabc\x1b[mdefg",
maxWidth: 6,
want: "\x1b[1mabc\x1b[m...",
},
{
name: "styled_line_that_already_fits",
line: "\x1b[1mabc\x1b[m",
maxWidth: 3,
want: "\x1b[1mabc\x1b[m",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := truncateLine(test.line, test.maxWidth)
if got != test.want {
t.Errorf("truncateLine(%q, %d) = %q, want %q", test.line, test.maxWidth, got, test.want)
}
if gotWidth := visualWidth(got); gotWidth > test.maxWidth {
t.Errorf("truncateLine(%q, %d) visual width = %d, want <= %d", test.line, test.maxWidth, gotWidth, test.maxWidth)
}
})
}
}

func TestRenderNode(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -176,6 +240,30 @@ func TestRenderNode(t *testing.T) {
}
}

func TestRenderNodeClosesTruncatedFocusedANSIStyle(t *testing.T) {
node := NewNode("focused", "focused", mockData{name: "focused"})
provider := &mockProvider{
iconFunc: func(n *Node[mockData]) string { return "" },
formatFunc: func(n *Node[mockData]) string { return "abcdefg" },
styleFunc: func(n *Node[mockData], focused bool) lipgloss.Style {
if focused {
return lipgloss.NewStyle().Bold(true)
}
return lipgloss.NewStyle()
},
}

got, err := renderNode(provider, node, "", true, 6)
if err != nil {
t.Fatalf("renderNode() error = %v", err)
}

want := "\x1b[1mabc...\x1b[0m"
if got != want {
t.Fatalf("renderNode() = %q, want %q", got, want)
}
}

// Create fresh nodes for each test case to avoid state sharing
func createTestTree() (*Node[mockData], *Node[mockData], *Node[mockData], *Node[mockData], *Node[mockData]) {
rootNode := NewNode("root", "root", mockData{name: "root"})
Expand Down
Loading