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)