fix: current-slot underline re-escaped nested ANSI codes in grid
0ec3bf42bfb7373a43d95562e57dd09ca0b61e46
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 19:28
parent 69eb4bae
3 files changed
go.mod +1 −1
@@ -4,6 +4,7 @@ go 1.26.4
require (
github.com/charmbracelet/lipgloss v1.1.0
+ github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/term v0.45.0
@@ -21,7 +22,6 @@ github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
internal/render/grid.go +10 −7
@@ -21,25 +21,28 @@ colorOf := make(map[string]string, len(d.Types))
for _, t := range d.Types {
colorOf[t.ID] = t.Color
}
- dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
var b strings.Builder
for row := 0; row < 6; row++ {
start := row * perRow
b.WriteString(fmt.Sprintf("%-6s", day.SlotLabel(start, d.BlockSize)))
for i := start; i < start+perRow; i++ {
- var cell string
+ style := lipgloss.NewStyle()
+ var glyph string
if id, ok := d.Blocks[i]; ok {
- cell = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOf[id])).Render("●")
+ style = style.Foreground(lipgloss.Color(colorOf[id]))
+ glyph = "●"
} else if id, ok := d.PlanBlocks[i]; ok {
- cell = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOf[id])).Render("○")
+ style = style.Foreground(lipgloss.Color(colorOf[id]))
+ glyph = "○"
} else {
- cell = dim.Render("·")
+ style = style.Foreground(lipgloss.Color("240"))
+ glyph = "·"
}
if i == currentIndex {
- cell = lipgloss.NewStyle().Underline(true).Render(cell)
+ style = style.Underline(true)
}
- b.WriteString(cell + " ")
+ b.WriteString(style.Render(glyph) + " ")
}
b.WriteString("\n")
}
internal/render/grid_test.go +30 −0
@@ -5,6 +5,9 @@ "fmt"
"strings"
"testing"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/muesli/termenv"
+
"github.com/humdrum-tiv/dots-cli/internal/api"
"github.com/humdrum-tiv/dots-cli/internal/day"
)
@@ -40,3 +43,30 @@ }
})
}
}
+
+// TestGridCurrentSlotNoAnsiLeak guards against nested lipgloss.Render calls
+// mangling inner ANSI escape sequences (the current-slot underline used to
+// re-render an already-styled glyph, corrupting its color code).
+func TestGridCurrentSlotNoAnsiLeak(t *testing.T) {
+ orig := lipgloss.ColorProfile()
+ lipgloss.SetColorProfile(termenv.TrueColor)
+ defer lipgloss.SetColorProfile(orig)
+
+ d := &api.DayData{
+ BlockSize: 60,
+ Types: []day.ActivityType{
+ {ID: "t1", Name: "archer", Color: "#008cb3"},
+ },
+ Blocks: map[int]string{0: "t1"},
+ }
+ out := Grid(d, 0)
+
+ needle := "[38;2"
+ for i := 0; i+len(needle) <= len(out); i++ {
+ if out[i:i+len(needle)] == needle {
+ if i == 0 || out[i-1] != '\x1b' {
+ t.Fatalf("found unescaped %q at byte %d (not preceded by ESC) in output:\n%q", needle, i, out)
+ }
+ }
+ }
+}