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