editor: add window base offset to tap/cursor positioning

The GlyphLayout is shaped from the visible window alone, so its
ByteOffsets are relative to the window start (IMEWindowStartByte). But
SetCursorFromPoint, HandleHome, HandleEnd and HandleVerticalCursorMove
treated them as absolute file offsets. On a large file scrolled deep, the
cursor was set to a window-relative offset (near the file start), so the
next EditorLayout computed visibleCursorPos = CursorPosition - start as a
small/negative value and the IME selection snapped to the window top
(regardless of where the user tapped).

Add glyphBase() and convert at the four cursor boundaries: subtract the
base before searching the window-relative offsets, add it back before
storing the absolute CursorPosition. For small files (window == whole
file) the base is 0 and the change is a no-op.

Add TestTapToPosition_WindowBaseOffset, a regression test that sets a
non-zero IMEWindowStartByte and asserts the cursor lands at base + window
byte (fails on the pre-fix window-relative assignment).

Verified on-device: with a 6 MB file scrolled to ~line 1200, a tap now
places the cursor on the tapped line (typed text lands ~16 lines below the
window top), where before it always landed on the window top.
This commit is contained in:
Greg Pomerantz 2026-08-16 20:59:06 -04:00
parent e11d705196
commit 3bc3ed3530
2 changed files with 77 additions and 19 deletions

View File

@ -111,6 +111,49 @@ func TestTapLocalY_WindowRelative(t *testing.T) {
}
}
// TestTapToPosition_WindowBaseOffset is a regression test for the
// window-relative GlyphLayout bug: the GlyphLayout is shaped from the visible
// window alone, so its ByteOffsets are relative to the window start
// (IMEWindowStartByte). SetCursorFromPoint must add that base to produce an
// absolute file offset. Before the fix, a tap on a scrolled large file set the
// cursor to a window-relative offset (near the file start), so the IME
// selection snapped to the window top on every tap.
func TestTapToPosition_WindowBaseOffset(t *testing.T) {
lh := float64(EditorLineHeight())
const lines = 40
gl := ui.GlyphLayout{LineHeight: EditorLineHeight()}
for i := 0; i < lines; i++ {
for _, x := range []ui.Dp{10, 20} {
gl.ByteOffsets = append(gl.ByteOffsets, i*2) // window-relative
gl.X = append(gl.X, x)
gl.Y = append(gl.Y, ui.Dp(float64(i+1)*lh))
gl.Advance = append(gl.Advance, 10)
}
}
TheState = NewState()
TheState.Editor.GlyphLayout = gl
TheState.Editor.IMEWindowStartByte = 47618 // window starts deep in the file
TheState.ScrollOffset = 16500
// Tap ~3 lines below the top of the window.
regionTop := ui.Dp(114)
ptY := regionTop + ui.Dp(3*lh)
localY := tapLocalY(ptY, regionTop, TheState.ScrollOffset)
SetCursorFromPoint(15, localY)
// Window line 3 is window-relative byte 6, so the absolute cursor must be
// base (47618) + 6 = 47624. Before the fix it would be 6 (window-relative),
// which is the file start, not the tapped line.
want := 47618 + 6
if TheState.Editor.CursorPosition < want-2 || TheState.Editor.CursorPosition > want+2 {
t.Errorf("cursor = %d, want %d (base 47618 + window byte 6); window-relative offset bug?", TheState.Editor.CursorPosition, want)
}
// Regression: the buggy cursor would be ~6 (window-relative, near file start).
if TheState.Editor.CursorPosition < 100 {
t.Errorf("cursor = %d, window-relative offset leaked (should be absolute ~%d)", TheState.Editor.CursorPosition, want)
}
}
// TestTapToPosition_LargeFileScrolled is the end-to-end regression: with a
// window-relative GlyphLayout and a deep scroll, a tap a few lines below the top
// of the window must land on that window line, NOT be clamped to the last window

View File

@ -341,7 +341,6 @@ func HandleCursorMove(delta int) {
if newPos > maxPos {
newPos = maxPos
}
log.Printf("HandleCursorMove: old=%d, new=%d (max=%d)", TheState.Editor.CursorPosition, newPos, maxPos)
TheState.Editor.CursorPosition = newPos
}
@ -352,11 +351,9 @@ func HandleKeyDown(data any) {
if TheState.Editor.TooLarge {
return
}
log.Printf("HandleKeyDown: received data of type %T: %v", data, data)
switch v := data.(type) {
case key.EditEvent:
// Text input from IME / keyboard.
log.Printf("HandleKeyDown: EditEvent text=%q range=%+v", v.Text, v.Range)
if v.Text == "\b" {
// Backspace character (legacy / hardware): delete one char before
// the cursor.
@ -370,7 +367,6 @@ func HandleKeyDown(data any) {
HandleReplaceRange(v.Range.Start, v.Range.End, v.Text)
}
case key.Name:
log.Printf("HandleKeyDown: name=%q", v)
switch v {
case key.NameLeftArrow:
HandleCursorMove(-1)
@ -398,6 +394,15 @@ func HandleKeyDown(data any) {
}
}
// glyphBase returns the absolute file byte offset at the start of the current
// visible window. The GlyphLayout is shaped from the visible window alone, so
// every one of its ByteOffsets is relative to this base; adding it yields an
// absolute file offset. For a whole-file window (small files, or scroll 0) the
// base is 0 and this is a no-op.
func glyphBase() int {
return TheState.Editor.IMEWindowStartByte
}
// HandleHome moves the cursor to the start of the current visual line.
func HandleHome() {
layout := TheState.Editor.GlyphLayout
@ -405,8 +410,12 @@ func HandleHome() {
return
}
pos := TheState.Editor.CursorPosition
// Find current glyph index.
base := glyphBase()
pos := TheState.Editor.CursorPosition - base
if pos < 0 {
pos = 0
}
// Find current glyph index (offsets are window-relative).
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
@ -424,7 +433,7 @@ func HandleHome() {
break
}
}
TheState.Editor.CursorPosition = layout.ByteOffsets[targetIdx]
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
}
// HandleEnd moves the cursor to the end of the current visual line.
@ -434,8 +443,12 @@ func HandleEnd() {
return
}
pos := TheState.Editor.CursorPosition
// Find current glyph index.
base := glyphBase()
pos := TheState.Editor.CursorPosition - base
if pos < 0 {
pos = 0
}
// Find current glyph index (offsets are window-relative).
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
@ -456,7 +469,8 @@ func HandleEnd() {
// Position after the last character of the line.
// If it's a newline, it's the newline itself.
start := layout.ByteOffsets[targetIdx]
// start is window-relative; convert to an absolute file offset.
start := base + layout.ByteOffsets[targetIdx]
buf := TheState.Editor.ChunkedBuffer
var fileContent string
if buf != nil {
@ -503,8 +517,12 @@ func HandleVerticalCursorMove(up bool) {
return
}
pos := TheState.Editor.CursorPosition
// Find current glyph index.
base := glyphBase()
pos := TheState.Editor.CursorPosition - base
if pos < 0 {
pos = 0
}
// Find current glyph index (offsets are window-relative).
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
@ -578,7 +596,7 @@ func HandleVerticalCursorMove(up bool) {
}
}
TheState.Editor.CursorPosition = layout.ByteOffsets[targetIdx]
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
}
// HandleDelete removes the character after the cursor.
@ -617,7 +635,6 @@ func HandleInsert(s string) {
// HandleBackspace removes the character before the cursor.
func HandleBackspace() {
log.Printf("HandleBackspace: CursorPosition=%d", TheState.Editor.CursorPosition)
pos := TheState.Editor.CursorPosition
if pos == 0 {
return
@ -749,9 +766,6 @@ func markDirty() {
// EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// Debug: ensure we are actually running this
// fmt.Printf("DEBUG: EditorLayout called\n")
margin := ui.Dp(10)
// --- Top bar: filename on row 1, icons on row 2 ---
@ -1007,6 +1021,7 @@ func SetCursorFromPoint(x, y float64) {
return
}
base := glyphBase()
lineHeight := float64(EditorLineHeight())
// 1. Identify the intended line index based on y
@ -1091,7 +1106,7 @@ func SetCursorFromPoint(x, y float64) {
// 4. Check if tap is to the right of the last character
if rightmostIdx != -1 && x > rightmostX {
// Position at the end of the line content, before any trailing newline.
start := layout.ByteOffsets[rightmostIdx]
start := base + layout.ByteOffsets[rightmostIdx]
buf := TheState.Editor.ChunkedBuffer
var fileContent string
if buf != nil {
@ -1132,6 +1147,6 @@ func SetCursorFromPoint(x, y float64) {
}
}
if bestIdx != -1 {
TheState.Editor.CursorPosition = layout.ByteOffsets[bestIdx]
TheState.Editor.CursorPosition = base + layout.ByteOffsets[bestIdx]
}
}