diff --git a/CHANGELOG.md b/CHANGELOG.md index 3868a97..916f3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/renderer.go b/renderer.go index 7232b38..8590ed1 100644 --- a/renderer.go +++ b/renderer.go @@ -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, "") @@ -39,6 +42,8 @@ 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 "..." @@ -46,14 +51,12 @@ func truncateLine(line string, maxWidth int) string { 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) { @@ -61,7 +64,9 @@ func truncateLine(line string, maxWidth int) string { 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 @@ -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 diff --git a/renderer_test.go b/renderer_test.go index a3e4773..9b24327 100644 --- a/renderer_test.go +++ b/renderer_test.go @@ -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 @@ -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"})