▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0

fix(grid): measure ACTIVITY cell width in display cells so emoji labels don't overflow/misalign

7261dd8b9592032aad1aa96e501aac7d1433b91a
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 14:14

parent fd600a23

3 files changed

go.mod +1 −1
@@ -8,6 +8,7 @@ 	github.com/charmbracelet/bubbletea v1.3.10
 	github.com/charmbracelet/huh v1.0.0
 	github.com/charmbracelet/lipgloss v1.1.0
 	github.com/charmbracelet/x/exp/teatest v0.0.0-20260705004817-2cc9a8fe1146
+	github.com/mattn/go-runewidth v0.0.19
 	github.com/spf13/cobra v1.10.2
 )
 
@@ -31,7 +32,6 @@ 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
-	github.com/mattn/go-runewidth v0.0.19 // indirect
 	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
internal/tui/grid/view.go +14 −6
@@ -7,6 +7,7 @@ 	"strings"
 	"time"
 
 	"github.com/charmbracelet/lipgloss"
+	"github.com/mattn/go-runewidth"
 
 	"ticktock/internal/store"
 )
@@ -85,22 +86,29 @@ func minuteLabel(min int) string {
 	return fmt.Sprintf("%02d:%02d", (min/60)%24, min%60)
 }
 
+// trunc shortens s so its terminal display width is at most w, appending "…"
+// when it truncates. Width is measured in display cells (via go-runewidth), not
+// runes, so double-width glyphs like emoji in calendar labels are counted as 2
+// — otherwise a label with an emoji overflows its column and the terminal wraps
+// it, shoving the dividers and the LOGGED lane out of alignment.
 func trunc(s string, w int) string {
-	r := []rune(s)
-	if len(r) <= w {
+	if runewidth.StringWidth(s) <= w {
 		return s
 	}
 	if w <= 1 {
-		return string(r[:w])
+		return runewidth.Truncate(s, w, "")
 	}
-	return string(r[:w-1]) + "…"
+	return runewidth.Truncate(s, w, "…")
 }
 
 // gridRow renders one line with fixed 1-col gutters on both sides of the
 // dividers so separators align and the cursor marker can sit next to whichever
 // column is active: [gutterL] time(5) │ activity(actWidth) │[gutterR] logged.
 func gridRow(gutterL, timeStr, actCell, gutterR, logCell string) string {
-	return fmt.Sprintf("%s %-5s │ %-*s │%s %s", gutterL, timeStr, actWidth, actCell, gutterR, logCell)
+	// actCell is always pre-sized to actWidth display cells by renderActCell /
+	// the gap/band builders, so print it verbatim — a %-*s here would re-pad by
+	// rune count and re-overflow any cell containing double-width glyphs.
+	return fmt.Sprintf("%s %-5s │ %s │%s %s", gutterL, timeStr, actCell, gutterR, logCell)
 }
 
 // ruleLine draws the header underline with ┼ crossings at the │ columns.
@@ -234,7 +242,7 @@ // padCell pads s with spaces to exactly w runes. The activity cell must be
 // padded BEFORE styling: gridRow's %-*s pads by byte length, which ANSI
 // escapes would defeat, misaligning the column divider.
 func padCell(s string, w int) string {
-	if n := len([]rune(s)); n < w {
+	if n := runewidth.StringWidth(s); n < w {
 		return s + strings.Repeat(" ", w-n)
 	}
 	return s
internal/tui/grid/view_test.go +59 −0
@@ -0,0 +1,59 @@
+package grid
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/lipgloss"
+	"github.com/mattn/go-runewidth"
+)
+
+// A calendar label carries a double-width emoji (🧠 = 2 cells). trunc and
+// padCell must reason in display cells, not runes, or the cell overflows its
+// column and the terminal wraps it — the alignment bug this guards against.
+
+func TestTruncMeasuresDisplayWidthNotRunes(t *testing.T) {
+	// "🧠 Focus time" is 12 runes but 13 display cells (emoji counts as 2).
+	s := "🧠 Focus time"
+	if got := runewidth.StringWidth(s); got != 13 {
+		t.Fatalf("precondition: want display width 13, got %d", got)
+	}
+	// Truncating to 10 cells must yield a result that fits in 10 cells.
+	out := trunc(s, 10)
+	if w := runewidth.StringWidth(out); w > 10 {
+		t.Fatalf("trunc(%q,10) display width = %d, want <= 10 (got %q)", s, w, out)
+	}
+	// A string already within width is returned untouched.
+	if out := trunc(s, 20); out != s {
+		t.Fatalf("trunc of in-width string changed it: %q", out)
+	}
+}
+
+func TestPadCellPadsToDisplayWidth(t *testing.T) {
+	// Emoji cell: 3 runes / 4 display cells; padding to actWidth must land on
+	// exactly actWidth *cells*, not actWidth runes (which would over-pad and
+	// push the divider right).
+	out := padCell("🧠 x", actWidth)
+	if w := runewidth.StringWidth(out); w != actWidth {
+		t.Fatalf("padCell display width = %d, want %d (got %q)", w, actWidth, out)
+	}
+}
+
+func TestRenderActCellCalendarKeepsColumnWidth(t *testing.T) {
+	// The rendered lane cell (before ANSI styling widths are irrelevant to the
+	// terminal) must occupy exactly actWidth display cells regardless of emoji.
+	for _, tc := range []struct {
+		name string
+		cell Cell
+	}{
+		{"calendar+emoji", Cell{Kind: CellLabel, Act: ActCalendar, Text: "▪ 🧠 Focus time"}},
+		{"plain active", Cell{Kind: CellLabel, Act: ActActive, Text: "organize"}},
+		{"idle", Cell{Kind: CellLabel, Act: ActIdle, Text: "(idle)"}},
+		{"continuation", Cell{Kind: CellCont, Act: ActCalendar}},
+		{"long title truncates", Cell{Kind: CellLabel, Act: ActActive, Text: "Investigate story structure and the whole backlog pile"}},
+	} {
+		out := renderActCell(tc.cell)
+		if w := lipgloss.Width(out); w != actWidth {
+			t.Errorf("%s: rendered width = %d, want %d (got %q)", tc.name, w, actWidth, out)
+		}
+	}
+}