▍ humdrum codex / glint v1.0.2

feat: visual-row rendering, soft-wrap cursor and scroll in the editor

bfbe78cf2e76d5832f1f35360d1bf7d898f29e6f
humdrum <me@humdrum.me> · 2026-06-28 10:40

parent 18e4aba0

2 files changed

internal/editor/editor.go +90 −26
@@ -23,6 +23,7 @@ 	Scroll int
 	Dirty  bool
 	Width  int
 	Height int // visible text rows
+	goalCol int // remembered visual column for vertical moves
 	theme  theme.Theme
 }
 
@@ -48,6 +49,7 @@ 		e.Lines = []string{""}
 	}
 	e.Cursor = Position{}
 	e.Scroll = 0
+	e.goalCol = 0
 	e.Dirty = false
 }
 
@@ -77,6 +79,8 @@ 	rs = append(rs[:col], append([]rune{r}, rs[col:]...)...)
 	e.setLine(rs)
 	e.Cursor.Col = col + 1
 	e.Dirty = true
+	e.setGoal()
+	e.followCursor()
 }
 
 // InsertNewline splits the current line at the cursor.
@@ -90,6 +94,7 @@ 	e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...)
 	e.Cursor.Row++
 	e.Cursor.Col = 0
 	e.Dirty = true
+	e.setGoal()
 	e.followCursor()
 }
 
@@ -101,6 +106,8 @@ 		rs = append(rs[:e.Cursor.Col-1], rs[e.Cursor.Col:]...)
 		e.setLine(rs)
 		e.Cursor.Col--
 		e.Dirty = true
+		e.setGoal()
+		e.followCursor()
 		return
 	}
 	if e.Cursor.Row == 0 {
@@ -114,6 +121,7 @@ 	e.Lines = append(e.Lines[:e.Cursor.Row], e.Lines[e.Cursor.Row+1:]...)
 	e.Cursor.Row--
 	e.Cursor.Col = joinCol
 	e.Dirty = true
+	e.setGoal()
 	e.followCursor()
 }
 
@@ -124,6 +132,8 @@ 	if e.Cursor.Col < len(rs) {
 		rs = append(rs[:e.Cursor.Col], rs[e.Cursor.Col+1:]...)
 		e.setLine(rs)
 		e.Dirty = true
+		e.setGoal()
+		e.followCursor()
 		return
 	}
 	if e.Cursor.Row >= len(e.Lines)-1 {
@@ -132,6 +142,8 @@ 	}
 	e.Lines[e.Cursor.Row] = e.Lines[e.Cursor.Row] + e.Lines[e.Cursor.Row+1]
 	e.Lines = append(e.Lines[:e.Cursor.Row+1], e.Lines[e.Cursor.Row+2:]...)
 	e.Dirty = true
+	e.setGoal()
+	e.followCursor()
 }
 
 // MoveLeft moves one rune left, wrapping to the end of the previous line.
@@ -142,6 +154,7 @@ 	} else if e.Cursor.Row > 0 {
 		e.Cursor.Row--
 		e.Cursor.Col = len([]rune(e.Lines[e.Cursor.Row]))
 	}
+	e.setGoal()
 	e.followCursor()
 }
 
@@ -153,44 +166,93 @@ 	} else if e.Cursor.Row < len(e.Lines)-1 {
 		e.Cursor.Row++
 		e.Cursor.Col = 0
 	}
+	e.setGoal()
 	e.followCursor()
 }
 
-// MoveUp moves to the previous line, clamping the column.
+// MoveUp moves to the previous visual row, keeping the goal column.
 func (e *Editor) MoveUp() {
-	if e.Cursor.Row > 0 {
-		e.Cursor.Row--
-		e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+	rows := e.buildVisual()
+	ci := cursorVIndex(rows, e.Cursor)
+	if ci <= 0 {
+		return
 	}
+	// Initialize goal from current position if not yet set
+	if ci > 0 && e.goalCol == 0 {
+		e.goalCol = e.visualColOf(e.Cursor.Row, e.Cursor.Col)
+	}
+	e.applyGoal(rows[ci-1])
 	e.followCursor()
 }
 
-// MoveDown moves to the next line, clamping the column.
+// MoveDown moves to the next visual row, keeping the goal column.
 func (e *Editor) MoveDown() {
-	if e.Cursor.Row < len(e.Lines)-1 {
-		e.Cursor.Row++
-		e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+	rows := e.buildVisual()
+	ci := cursorVIndex(rows, e.Cursor)
+	if ci < 0 || ci+1 >= len(rows) {
+		return
+	}
+	// Initialize goal from current position if not yet set
+	if e.goalCol == 0 {
+		e.goalCol = e.visualColOf(e.Cursor.Row, e.Cursor.Col)
 	}
+	e.applyGoal(rows[ci+1])
 	e.followCursor()
 }
 
-// MoveHome moves to column 0; MoveEnd to end of line.
-func (e *Editor) MoveHome() { e.Cursor.Col = 0 }
-func (e *Editor) MoveEnd()  { e.Cursor.Col = len(e.curLine()) }
+// 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)
+}
 
-// followCursor scrolls the viewport so the cursor row stays visible.
+// MoveHome moves to column 0.
+func (e *Editor) MoveHome() {
+	e.Cursor.Col = 0
+	e.setGoal()
+	e.followCursor()
+}
+
+// MoveEnd moves to end of line.
+func (e *Editor) MoveEnd() {
+	e.Cursor.Col = len(e.curLine())
+	e.setGoal()
+	e.followCursor()
+}
+
+// followCursor scrolls the viewport so the cursor's visual row stays visible.
 func (e *Editor) followCursor() {
-	if e.Cursor.Row < e.Scroll {
-		e.Scroll = e.Cursor.Row
+	rows := e.buildVisual()
+	ci := cursorVIndex(rows, e.Cursor)
+	if ci < 0 {
+		e.Scroll = 0
+		return
+	}
+	if ci < e.Scroll {
+		e.Scroll = ci
 	}
-	if e.Cursor.Row >= e.Scroll+e.Height {
-		e.Scroll = e.Cursor.Row - e.Height + 1
+	if ci >= e.Scroll+e.Height {
+		e.Scroll = ci - e.Height + 1
 	}
 	if e.Scroll < 0 {
 		e.Scroll = 0
 	}
 }
 
+// setGoal records the cursor's current visual column as the goal for vertical moves.
+func (e *Editor) setGoal() { e.goalCol = e.visualColOf(e.Cursor.Row, e.Cursor.Col) }
+
+// visualColOf returns the column of (row, col) within its visual segment.
+func (e *Editor) visualColOf(row, col int) int {
+	segs := wrapLine(e.Lines[row], e.Width)
+	for i := len(segs) - 1; i >= 0; i-- {
+		if col >= segs[i].start {
+			return col - segs[i].start
+		}
+	}
+	return col
+}
+
 // HandleKey maps a key message to a buffer operation.
 func (e *Editor) HandleKey(k tea.KeyMsg) {
 	switch k.Type {
@@ -224,24 +286,26 @@ 		e.InsertRune('\t')
 	}
 }
 
-// View renders the visible rows, styled, with the cursor drawn on its row.
+// View renders the visible visual rows, styled, with a themed cursor cell on
+// the cursor's visual row. Output is e.Width columns wide; the app adds margins.
 func (e *Editor) View() string {
-	cursorStyle := lipgloss.NewStyle().Reverse(true)
-	all := ScanLines(e.Lines, e.theme)
+	cursorStyle := lipgloss.NewStyle().Foreground(e.theme.Background).Background(e.theme.Pointer)
+	rows := e.buildVisual()
+	ci := cursorVIndex(rows, e.Cursor)
 	var b strings.Builder
 	end := e.Scroll + e.Height
-	if end > len(e.Lines) {
-		end = len(e.Lines)
+	if end > len(rows) {
+		end = len(rows)
 	}
-	for row := e.Scroll; row < end; row++ {
-		if row == e.Cursor.Row {
-			b.WriteString(renderSpansCursor(all[row], e.Cursor.Col, cursorStyle))
+	for r := e.Scroll; r < end; r++ {
+		if r == ci {
+			b.WriteString(renderSpansCursor(rows[r].spans, e.Cursor.Col-rows[r].start, cursorStyle))
 		} else {
-			b.WriteString(renderSpans(all[row]))
+			b.WriteString(renderSpans(rows[r].spans))
 		}
 		b.WriteByte('\n')
 	}
-	for row := end; row < e.Scroll+e.Height; row++ {
+	for r := end; r < e.Scroll+e.Height; r++ {
 		b.WriteByte('\n')
 	}
 	return b.String()
internal/editor/editor_test.go +51 −0
@@ -218,3 +218,54 @@ 	if e.Cursor.Col != 5 {
 		t.Errorf("after MoveEnd: Cursor.Col = %d, want 5", e.Cursor.Col)
 	}
 }
+
+func TestVisualDownAndUpAcrossWrappedLine(t *testing.T) {
+	e := New()
+	e.Lines = []string{"aaaa bbbb cccc"} // wraps at width 9 into ["aaaa ","bbbb cccc"]
+	e.SetSize(9, 10)
+	e.Cursor = Position{Row: 0, Col: 0}
+	e.MoveDown() // to the second visual row, goal column 0
+	if e.Cursor != (Position{Row: 0, Col: 5}) {
+		t.Errorf("after MoveDown: %+v, want {0 5}", e.Cursor)
+	}
+	e.MoveUp() // back to the first visual row, goal column 0
+	if e.Cursor != (Position{Row: 0, Col: 0}) {
+		t.Errorf("after MoveUp: %+v, want {0 0}", e.Cursor)
+	}
+}
+
+func TestVisualDownKeepsGoalColumn(t *testing.T) {
+	e := New()
+	e.Lines = []string{"aaaa bbbb cccc"}
+	e.SetSize(9, 10)
+	e.Cursor = Position{Row: 0, Col: 2} // visual column 2 in segment 0
+	e.MoveDown()                        // segment 1 starts at col 5 → col 5+2 = 7
+	if e.Cursor != (Position{Row: 0, Col: 7}) {
+		t.Errorf("after MoveDown with goal: %+v, want {0 7}", e.Cursor)
+	}
+}
+
+func TestBytesRoundTripUnaffectedByWrap(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a very long single logical line that will wrap many times\nsecond"))
+	e.SetSize(10, 5)
+	if string(e.Bytes()) != "a very long single logical line that will wrap many times\nsecond" {
+		t.Errorf("wrap altered the buffer: %q", string(e.Bytes()))
+	}
+}
+
+func TestScrollIsVisualRowIndex(t *testing.T) {
+	e := New()
+	e.Lines = []string{"aaaa bbbb cccc dddd eeee ffff"} // one logical line, many visual rows at width 9
+	e.SetSize(9, 2)                                      // only 2 visible visual rows
+	e.Cursor = Position{Row: 0, Col: 0}
+	// Move down several visual rows; Scroll must follow so the cursor stays visible.
+	for i := 0; i < 3; i++ {
+		e.MoveDown()
+	}
+	rows := e.buildVisual()
+	ci := cursorVIndex(rows, e.Cursor)
+	if ci < e.Scroll || ci >= e.Scroll+e.Height {
+		t.Errorf("cursor visual row %d not in viewport [%d,%d)", ci, e.Scroll, e.Scroll+e.Height)
+	}
+}