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