▍ humdrum codex / glint v1.0.2
license AGPL-3.0

perf: scan visual model once per vertical keystroke (TASK-004)

8e2d5c2bc33caf9f7efd3a15b3c52b98a6ed9733
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 17:49

parent 62acab90

perf: scan visual model once per vertical keystroke (TASK-004)

MoveUp/MoveDown/MoveToVisual called buildVisual() then followCursor()
rebuilt it again — two O(document) scans+rewraps per vertical keystroke.
Extract followCursorWith(rows) so the caller's single scan is reused for
scroll-follow. buildCount instrumentation + 3 perf tests assert at most one
buildVisual per move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj

4 files changed

- → Editor-rebuilds-full-visual-model-twice-per-keystroke-Odocument.md +17 −3
@@ -1,10 +1,10 @@
 ---
 id: TASK-004
 title: Editor rebuilds full visual model twice per keystroke (O(document))
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-28 18:38'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 00:49'
 labels:
   - perf
   - release-1
@@ -21,5 +21,19 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 at most one buildVisual per keystroke
+- [x] #1 at most one buildVisual per keystroke
 <!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. RED: add buildCount instrumentation + tests asserting MoveUp/MoveDown/MoveToVisual each call buildVisual at most once per keystroke.
+2. GREEN: extract followCursorWith(rows []vrow); have followCursor() = followCursorWith(buildVisual()). Thread the single rows slice from MoveUp/MoveDown/MoveToVisual into followCursorWith instead of re-scanning.
+3. Verify suite + vet green; check AC #1.
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Extracted followCursorWith(rows); MoveUp/MoveDown/MoveToVisual now scan once and reuse the rows for scroll-follow (was 2 scans/vertical keystroke). buildCount instrumentation + 3 perf tests assert <=1 buildVisual per move. Suite + vet green.
+<!-- SECTION:NOTES:END -->
internal/editor/editor.go +11 −5
@@ -37,6 +37,8 @@ 	findActive int     // index of the active match in find, -1 = none
 	findQuery  string  // current find query
 
 	codeFile string // filename for the chroma lexer; "" = prose/markdown scanner (TASK-018)
+
+	buildCount int // count of buildVisual scans; perf guard for tests (TASK-004)
 }
 
 // SetLanguage selects the scanner from the file's extension: markdown/text/no
@@ -134,7 +136,7 @@ 	}
 	e.Cursor.Row = vr.logRow
 	e.Cursor.Col = vr.start + col
 	e.setGoal()
-	e.followCursor()
+	e.followCursorWith(rows)
 }
 
 // ScrollBy moves the viewport by delta visual rows (negative = up) without
@@ -447,7 +449,7 @@ 	if ci <= 0 {
 		return
 	}
 	e.applyGoal(rows, ci-1)
-	e.followCursor()
+	e.followCursorWith(rows)
 }
 
 // MoveDown moves to the next visual row, keeping the goal column.
@@ -458,7 +460,7 @@ 	if ci < 0 || ci+1 >= len(rows) {
 		return
 	}
 	e.applyGoal(rows, ci+1)
-	e.followCursor()
+	e.followCursorWith(rows)
 }
 
 // applyGoal places the cursor at the goal column on the visual row at idx. For a
@@ -504,8 +506,12 @@ 	e.followCursor()
 }
 
 // followCursor scrolls the viewport so the cursor's visual row stays visible.
-func (e *Editor) followCursor() {
-	rows := e.buildVisual()
+func (e *Editor) followCursor() { e.followCursorWith(e.buildVisual()) }
+
+// followCursorWith is followCursor against an already-built visual model, so a
+// caller that just scanned (MoveUp/MoveDown/MoveToVisual) reuses those rows
+// instead of triggering a second O(document) scan (TASK-004).
+func (e *Editor) followCursorWith(rows []vrow) {
 	ci := cursorVIndex(rows, e.Cursor)
 	if ci < 0 {
 		e.Scroll = 0
internal/editor/perf_test.go +42 −0
@@ -0,0 +1,42 @@
+package editor
+
+import "testing"
+
+// TASK-004: vertical motion must not rescan + rewrap the whole document twice.
+// buildVisual is O(document); one keystroke should trigger at most one scan.
+
+func setupDoc(t *testing.T) *Editor {
+	t.Helper()
+	e := New()
+	e.SetContent([]byte("line one\nline two\nline three\nline four"))
+	e.SetSize(80, 24)
+	return e
+}
+
+func TestMoveDownBuildsVisualOnce(t *testing.T) {
+	e := setupDoc(t)
+	e.buildCount = 0
+	e.MoveDown()
+	if e.buildCount > 1 {
+		t.Errorf("MoveDown built visual %d times, want <= 1", e.buildCount)
+	}
+}
+
+func TestMoveUpBuildsVisualOnce(t *testing.T) {
+	e := setupDoc(t)
+	e.MoveDocEnd()
+	e.buildCount = 0
+	e.MoveUp()
+	if e.buildCount > 1 {
+		t.Errorf("MoveUp built visual %d times, want <= 1", e.buildCount)
+	}
+}
+
+func TestMoveToVisualBuildsVisualOnce(t *testing.T) {
+	e := setupDoc(t)
+	e.buildCount = 0
+	e.MoveToVisual(2, 1)
+	if e.buildCount > 1 {
+		t.Errorf("MoveToVisual built visual %d times, want <= 1", e.buildCount)
+	}
+}
internal/editor/wrap.go +1 −0
@@ -79,6 +79,7 @@ // buildVisual wraps every logical line into visual rows, slicing each line's
 // styled spans to the segment ranges. Every span is given the theme background
 // so glyphs sit on the theme's paper rather than the terminal default.
 func (e *Editor) buildVisual() []vrow {
+	e.buildCount++
 	var all [][]Span
 	if e.codeFile != "" {
 		all = ScanCode(e.Lines, e.codeFile, e.theme)