▍ humdrum codex / dots v1.0.0

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)
+			}
+		}
+	}
+}