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

feat(grid): vertical viewport — scroll rows to follow cursor, keep header/footer pinned on short windows

9708b3ce371ee81b26c5109264034a83fb6e9257
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 14:23

parent d81129c1

3 files changed

internal/tui/grid/model.go +44 −0
@@ -36,6 +36,7 @@ 	events   []activity.Event
 	activity []Cell
 
 	cursor int
+	top    int // first visible row (vertical scroll offset)
 	col    Column
 	anchor *int // non-nil while a selection is in progress
 
@@ -118,6 +119,46 @@ 	}
 	if m.cursor < 0 {
 		m.cursor = 0
 	}
+	m.clampScroll()
+}
+
+// chromeRows is the number of non-grid lines View always draws: header + blank
+// + column header + rule (4 top) and blank + legend + help (3 bottom).
+const chromeRows = 7
+
+// visibleRows is how many grid rows fit in the current terminal height. When the
+// height is unknown (before the first WindowSizeMsg) it returns all rows so the
+// view degrades to the pre-viewport behavior.
+func (m Model) visibleRows() int {
+	if m.height <= 0 {
+		return m.grid.Rows
+	}
+	v := m.height - chromeRows
+	if v < 1 {
+		v = 1
+	}
+	if v > m.grid.Rows {
+		v = m.grid.Rows
+	}
+	return v
+}
+
+// clampScroll keeps the scroll offset (m.top) valid and the cursor within the
+// visible window, scrolling just enough to reveal the cursor at either edge.
+func (m *Model) clampScroll() {
+	vis := m.visibleRows()
+	if m.cursor < m.top {
+		m.top = m.cursor
+	}
+	if m.cursor >= m.top+vis {
+		m.top = m.cursor - vis + 1
+	}
+	if max := m.grid.Rows - vis; m.top > max {
+		m.top = max
+	}
+	if m.top < 0 {
+		m.top = 0
+	}
 }
 
 // selectionRange returns the inclusive band [lo,hi] and whether a selection is
@@ -174,6 +215,7 @@ 		m.rebuild()
 		return m, nil
 	case tea.WindowSizeMsg:
 		m.width, m.height = msg.Width, msg.Height
+		m.clampScroll()
 		return m, nil
 	case tea.KeyMsg:
 		return m.handleKey(msg)
@@ -196,11 +238,13 @@ 	case "up":
 		if m.cursor > 0 {
 			m.cursor--
 		}
+		m.clampScroll()
 		return m, nil
 	case "down":
 		if m.cursor < m.grid.Rows-1 {
 			m.cursor++
 		}
+		m.clampScroll()
 		return m, nil
 	case "esc":
 		m.anchor = nil
internal/tui/grid/model_test.go +31 −0
@@ -188,3 +188,34 @@ 	if len(em.segs) != 1 || em.segs[0].App != "Trace" {
 		t.Fatalf("load should fetch the day's segments, got %+v", em.segs)
 	}
 }
+
+func TestVerticalViewportScrollsAndKeepsHeader(t *testing.T) {
+	m := loaded(&fakeStore{})
+	// Short window: height 12 → visibleRows = 12 - chromeRows(7) = 5.
+	nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	top := nm.(Model).View()
+	if !strings.Contains(top, "ACTIVITY") {
+		t.Fatalf("header must always render:\n%s", top)
+	}
+	if !strings.Contains(top, "07:00") {
+		t.Fatalf("first slot should be visible at top:\n%s", top)
+	}
+	if strings.Contains(top, "20:30") {
+		t.Fatalf("last slot should be scrolled out of a short window:\n%s", top)
+	}
+	// Drive the cursor to the bottom; the window must follow it.
+	cur := nm
+	for i := 0; i < 40; i++ {
+		cur, _ = cur.(Model).Update(special(tea.KeyDown))
+	}
+	bot := cur.(Model).View()
+	if !strings.Contains(bot, "ACTIVITY") {
+		t.Fatalf("header must stay visible after scrolling:\n%s", bot)
+	}
+	if !strings.Contains(bot, "20:30") {
+		t.Fatalf("bottom slot should be visible after scrolling down:\n%s", bot)
+	}
+	if strings.Contains(bot, "07:00") {
+		t.Fatalf("top slot should be scrolled off when at the bottom:\n%s", bot)
+	}
+}
internal/tui/grid/view.go +6 −1
@@ -159,7 +159,12 @@ 	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++ {
+	start := m.top
+	end := m.top + m.visibleRows()
+	if end > m.grid.Rows {
+		end = m.grid.Rows
+	}
+	for row := start; row < end; row++ {
 		onCursor := row == m.cursor
 
 		// ACTIVITY column: painted lane (calendar/active/idle) over the gap