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

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)
+	}
+}