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

feat(grid): responsive ACTIVITY width (fills terminal, floor 22) + fix header cell padding

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

parent 7261dd8b

2 files changed

internal/tui/grid/view.go +32 −12
@@ -78,9 +78,26 @@ 	return gHeader.Render("<< " + label + " >>")
 }
 
 const (
-	actWidth    = 22
+	actWidthMin = 22 // floor before a WindowSizeMsg arrives / on narrow terminals
 	loggedWidth = 40
+
+	// gridRow overhead around the ACTIVITY cell: gutterL+sp+time(5)+sp + │+sp
+	// before the cell, then sp+│+gutterR after it = 14 fixed columns.
+	rowFixed = 14
+	// columns kept for the LOGGED lane when sizing ACTIVITY responsively.
+	loggedReserve = 24
 )
+
+// activityWidth sizes the ACTIVITY column to fill the terminal, leaving room for
+// the TIME prefix, dividers, and a LOGGED reserve — floored at actWidthMin so it
+// stays readable before the first WindowSizeMsg (m.width == 0) or when narrow.
+func (m Model) activityWidth() int {
+	aw := m.width - rowFixed - loggedReserve
+	if aw < actWidthMin {
+		return actWidthMin
+	}
+	return aw
+}
 
 func minuteLabel(min int) string {
 	return fmt.Sprintf("%02d:%02d", (min/60)%24, min%60)
@@ -111,12 +128,13 @@ 	// 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.
-func ruleLine() string {
+// ruleLine draws the header underline with ┼ crossings at the │ columns, sized
+// to the current ACTIVITY width so the crossings line up with the data dividers.
+func ruleLine(aw int) string {
 	// gridRow prefix before first │: gutter(1)+sp(1)+time(5)+sp(1) = 8 chars.
 	return gDim.Render(
 		strings.Repeat("─", 8) + "┼" +
-			strings.Repeat("─", actWidth+2) + "┼" +
+			strings.Repeat("─", aw+2) + "┼" +
 			strings.Repeat("─", 14))
 }
 
@@ -133,10 +151,12 @@ 		hint := gDim.Render("ctrl+s save · esc cancel")
 		return head + "\n\n" + m.form.View() + "\n" + hint
 	}
 
+	aw := m.activityWidth()
+
 	var b strings.Builder
 	b.WriteString(gHeaderLine(m.date) + "\n\n")
-	b.WriteString(gridRow(" ", "TIME", "ACTIVITY", " ", "LOGGED → tock") + "\n")
-	b.WriteString(ruleLine() + "\n")
+	b.WriteString(gridRow(" ", "TIME", padCell("ACTIVITY", aw), " ", "LOGGED → tock") + "\n")
+	b.WriteString(ruleLine(aw) + "\n")
 
 	lo, hi, selecting := m.selectionRange()
 	for row := 0; row < m.grid.Rows; row++ {
@@ -144,12 +164,12 @@ 		onCursor := row == m.cursor
 
 		// ACTIVITY column: painted lane (calendar/active/idle) over the gap
 		// lane; the selection band still overrides while selecting.
-		act := strings.Repeat("░", actWidth)
+		act := strings.Repeat("░", aw)
 		if row < len(m.activity) && m.activity[row].Kind != CellEmpty {
-			act = renderActCell(m.activity[row])
+			act = renderActCell(m.activity[row], aw)
 		}
 		if selecting && row >= lo && row <= hi {
-			act = gBand.Render(strings.Repeat("▓", actWidth))
+			act = gBand.Render(strings.Repeat("▓", aw))
 		}
 
 		// LOGGED column
@@ -251,12 +271,12 @@
 // renderActCell paints one ACTIVITY-lane cell: label text or continuation
 // glyph, colored by flavor — calendar accent, idle dimmed, active plain;
 // continuation glyphs render faint.
-func renderActCell(c Cell) string {
-	text := trunc(c.Text, actWidth)
+func renderActCell(c Cell, aw int) string {
+	text := trunc(c.Text, aw)
 	if c.Kind == CellCont {
 		text = "│"
 	}
-	text = padCell(text, actWidth)
+	text = padCell(text, aw)
 	style := lipgloss.NewStyle()
 	switch c.Act {
 	case ActCalendar:
internal/tui/grid/view_test.go +13 −11
@@ -29,18 +29,20 @@ 	}
 }
 
 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)
+	// Emoji cell: 3 runes / 4 display cells; padding to a width must land on
+	// exactly that many *cells*, not runes (which would over-pad and push the
+	// divider right).
+	const w = 22
+	out := padCell("🧠 x", w)
+	if got := runewidth.StringWidth(out); got != w {
+		t.Fatalf("padCell display width = %d, want %d (got %q)", got, w, 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.
+	// The rendered lane cell must occupy exactly the requested display width
+	// regardless of emoji or over-long titles.
+	const aw = 22
 	for _, tc := range []struct {
 		name string
 		cell Cell
@@ -51,9 +53,9 @@ 		{"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)
+		out := renderActCell(tc.cell, aw)
+		if w := lipgloss.Width(out); w != aw {
+			t.Errorf("%s: rendered width = %d, want %d (got %q)", tc.name, w, aw, out)
 		}
 	}
 }