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