diff --git a/internal/editor/cursor_test.go b/internal/editor/cursor_test.go index 06dea1b..993fe68 100644 --- a/internal/editor/cursor_test.go +++ b/internal/editor/cursor_test.go @@ -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 diff --git a/internal/editor/state.go b/internal/editor/state.go index 60d82b6..74fe999 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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] } }