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