From 7a6b130078e3c1e333ae6bf97490672f35d3808d Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 20 Sep 2026 07:49:52 -0400 Subject: [PATCH] editor: don't record a phantom trailing visual line at the window end The shaper flags the last visible glyph of a final line without a trailing newline (and the synthetic end-of-text glyph after a trailing newline); the layout capture recorded both as visual-line starts at len(str). Taps below the content mapped onto that phantom line (harmless: it returned the EOF byte), but CaretPoint resolved the caret through it and drew the caret on the phantom line - one line BELOW where the typed text lands. Result: tap the empty space below the last line, the cursor appears one line down, and the typed letter appears one line ABOVE the cursor. - render.go: only record a next-line start when a real line follows the break (a real newline cluster, or a soft wrap with content after it). - state.go: a tap below every recorded line now returns the window end explicitly (the old clamp-to-last-text-line fallback remains only for layouts with no line starts at all). Verified on the emulator for both 'test\nA' (caret now drawn at the end of the 'A' line, where the letter lands) and 'test\nA\n' (caret and letter on the trailing line); full test suite green. --- internal/editor/state.go | 12 ++++++++++-- internal/ui/render.go | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/editor/state.go b/internal/editor/state.go index 4557422..2391c6e 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -3290,8 +3290,16 @@ func textPosOnLineAtX(visualLine int, x float64) (int, bool) { if visualLine < len(layout.VisualLineStarts) { return base + layout.VisualLineStarts[visualLine], true } - // No line start recorded for this line (finger beyond the content): - // clamp to the last non-empty line. + // No line start recorded for this line (finger below the last line): + // the insertion point there is the end of the visible window — what + // the phantom trailing-line start used to provide (the shaper no + // longer records one at the window end; see the VisualLineStarts + // capture in drawWrappedText). A layout with NO line starts at all + // (degenerate/synthetic) has no window end to point at: clamp to + // the last non-empty line instead. + if len(layout.VisualLineStarts) > 0 { + return base + len(TheState.Editor.IMEWindowText), true + } for i := len(groups) - 1; i >= 0; i-- { if len(groups[i].indices) > 0 { visualLine = i diff --git a/internal/ui/render.go b/internal/ui/render.go index d849b85..ec05028 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -1276,7 +1276,25 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w flushLine() if g.Flags&text.FlagLineBreak != 0 { lineCount++ - layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset) + // Record the next visual line's start ONLY when a real line + // follows the break: a real "\n" cluster (zero width, one + // rune — including the final one of a newline-terminated + // file, whose trailing empty line is real) or a soft wrap + // with content after it. The shaper ALSO flags the last + // visible glyph of a final line without a trailing newline, + // and the synthetic end-of-text glyph after a trailing + // newline: both sit at len(str) with no line after them. + // Recording them creates a phantom visual line: taps below + // the content map onto it (harmless — it returns the EOF + // byte), but CaretPoint resolves the caret through it and + // draws the caret on that phantom line — one line BELOW + // where the typed text lands, so the letter appeared a line + // above the cursor. + isRealNewline := g.Advance == 0 && g.Runes > 0 + softWrap := g.Advance != 0 && byteOffset < len(str) + if isRealNewline || softWrap { + layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset) + } } } }