fix: vertical move no longer skips a row at a wrap boundary (TASK-003)
497744e2e96941a04bddbb3529c0c3928a912732
humdrum <me@humdrum.me> · 2026-06-28 18:18
parent cfcd5282
fix: vertical move no longer skips a row at a wrap boundary (TASK-003) applyGoal placed the cursor at start+runes when the goal column met or exceeded a wrapped segment's width; that column is the next segment's first cell, so the cursor jumped onto the following visual row. Clamp non-last visual segments to runes-1 so the cursor stays on the target row. Regression test added.
2 files changed
internal/editor/editor.go +14 −6
@@ -202,7 +202,7 @@ ci := cursorVIndex(rows, e.Cursor)
if ci <= 0 {
return
}
- e.applyGoal(rows[ci-1])
+ e.applyGoal(rows, ci-1)
e.followCursor()
}
@@ -213,14 +213,22 @@ ci := cursorVIndex(rows, e.Cursor)
if ci < 0 || ci+1 >= len(rows) {
return
}
- e.applyGoal(rows[ci+1])
+ e.applyGoal(rows, ci+1)
e.followCursor()
}
-// applyGoal places the cursor at the goal column on the given visual row.
-func (e *Editor) applyGoal(target vrow) {
- e.Cursor.Row = target.logRow
- e.Cursor.Col = target.start + min(e.goalCol, target.runes)
+// applyGoal places the cursor at the goal column on the visual row at idx. For a
+// non-last visual segment of a wrapped line, the column is clamped to runes-1:
+// start+runes is the next segment's first column, so landing there would skip
+// the cursor onto the following visual row.
+func (e *Editor) applyGoal(rows []vrow, idx int) {
+ t := rows[idx]
+ maxCol := t.runes
+ if idx+1 < len(rows) && rows[idx+1].logRow == t.logRow && maxCol > 0 {
+ maxCol-- // non-last segment of this logical line
+ }
+ e.Cursor.Row = t.logRow
+ e.Cursor.Col = t.start + min(e.goalCol, maxCol)
}
// MoveHome moves to column 0.
internal/editor/editor_test.go +18 −0
@@ -324,3 +324,21 @@ if e.Lines[0] != "abc" || e.Dirty {
t.Errorf("kill at col 0 should be a noop; line=%q dirty=%v", e.Lines[0], e.Dirty)
}
}
+
+func TestVisualMoveDownDoesNotSkipWrapBoundary(t *testing.T) {
+ e := New()
+ e.Lines = []string{"zzzzz", "aaaa bbbb"} // line1 wraps at width 5 into ["aaaa ","bbbb"]
+ e.SetSize(5, 10)
+ e.Cursor = Position{Row: 0, Col: 5} // end of line0
+ e.setGoal() // goalCol = 5
+ e.MoveDown()
+ rows := e.buildVisual()
+ // rows: [0]=line0, [1]=line1 "aaaa ", [2]=line1 "bbbb".
+ // MoveDown must land on visual row 1 (first segment of line1), not skip to 2.
+ if ci := cursorVIndex(rows, e.Cursor); ci != 1 {
+ t.Errorf("MoveDown landed on visual row %d, want 1 (did not stay on the wrap-boundary row)", ci)
+ }
+ if e.Cursor.Row != 1 {
+ t.Errorf("Cursor.Row = %d, want 1", e.Cursor.Row)
+ }
+}