From 1be3f85a475d7390a16334af788e77c7eb489494 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Sun, 19 Apr 2026 19:50:19 +0200 Subject: [PATCH] fix(tui): cursor after multi-line paste lands in wrong column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrapLine()'s internal newLine() toggled the firstLine flag BEFORE checking it, so the very first wrap continuation flushed to the output WITHOUT the cont indent. Second and later continuations were fine. Visible as: 0: '▌ this is a very long first line that' 1: 'will wrap around terminal boundaries' <- no indent 2: ' still wrapping further past this point' <- indented Downstream, locateCursor() in the editor assumed continuation rows always start with cont and stripped its width when counting runes. When the first continuation didn't actually have it, the stripping was a no-op but the leadW was still added, so the reported visual column for the cursor drifted by cont-width (2 cells) to the right. Effect for the user: after drag-dropping a multi-line payload (or pasting any text where the first paragraph wraps), the terminal cursor rendered mid-text instead of at the end of the pasted content. Typing still appended at the correct logical position, so keystrokes landed in the right place in the buffer, it was purely visual drift. Fix: in newLine(), always write cont to cur after flushing (and after setting firstLine = false). That makes the second row, and every subsequent wrap continuation, carry the indent consistently. Added three regression tests: - wrapLine directly: every row >= 1 has cont prefix - editor multi-line paste: cursor lands at logical end with correct visual (row, col) - editor long-paste-with-wrap: wrap continuations all indented AND cursor still lands at correct column --- internal/tui/editor.go | 13 ++++-- internal/tui/wrap_test.go | 85 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 internal/tui/wrap_test.go diff --git a/internal/tui/editor.go b/internal/tui/editor.go index 2bcfd2b..6d1f6b0 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -754,11 +754,16 @@ func wrapLine(s string, width int, cont string) []string { out = append(out, cur.String()) cur.Reset() curW = 0 - if !firstLine { - cur.WriteString(cont) - curW = contW - } + // After flushing, everything we append next is a CONTINUATION + // row, which must start with the cont indent so the editor's + // visual cursor alignment stays consistent. The old code only + // wrote cont when !firstLine BEFORE toggling firstLine, which + // meant the very first wrap never got its indent. That caused + // the terminal cursor to land in the wrong column after a + // multi-line paste. firstLine = false + cur.WriteString(cont) + curW = contW } for i := 0; i < len(tokens); i++ { diff --git a/internal/tui/wrap_test.go b/internal/tui/wrap_test.go new file mode 100644 index 0000000..62efb44 --- /dev/null +++ b/internal/tui/wrap_test.go @@ -0,0 +1,85 @@ +package tui + +import ( + "strings" + "testing" +) + +// TestWrapLineFirstContinuationHasIndent is a regression test for a +// bug where wrapLine()'s internal newLine() toggled the firstLine +// flag and THEN checked it, so the very first wrap continuation +// flushed without the cont indent. Any subsequent continuation +// (second wrap onwards) got the indent. That was visible as a +// misaligned second row and caused the editor's cursor to land in +// the wrong column after a multi-line paste (locateCursor assumes +// continuations carry cont, so when they didn't, the cursor drifted +// one-indent-worth to the right). +func TestWrapLineFirstContinuationHasIndent(t *testing.T) { + s := "prefix this is a long line that will wrap around at forty cells" + out := wrapLine(s, 40, " ") + + if len(out) < 2 { + t.Fatalf("want at least 2 wrapped rows, got %d: %v", len(out), out) + } + // Row 0 is the first line (no indent; it's the lead). + // Every row from index 1 onward is a continuation and MUST start + // with the cont prefix. + for i := 1; i < len(out); i++ { + if !strings.HasPrefix(out[i], " ") { + t.Errorf("row %d missing cont indent: %q", i, out[i]) + } + } +} + +// TestEditorCursorAfterMultilinePaste is the downstream test: the +// rendered editor cursor must land at the logical end of the paste, +// with its visual column equal to leadW + runewidth(last-line). +func TestEditorCursorAfterMultilinePaste(t *testing.T) { + e := NewEditor("▌ ") + e.HandleKey(Key{Kind: KeyPaste, Paste: "aaa\nbbb\nccccc"}) + + // Logical end: last line "ccccc", cursor past its 5 runes. + if e.CursorR != 2 || e.CursorC != 5 { + t.Fatalf("logical cursor: want (2, 5), got (%d, %d)", e.CursorR, e.CursorC) + } + + lines, row, col := e.Render(80) + if len(lines) != 3 { + t.Fatalf("want 3 rendered rows, got %d: %v", len(lines), lines) + } + // Row 0 "▌ aaa", row 1 " bbb", row 2 " ccccc". + // Cursor lives at row 2; column = 2 (cont indent) + 5 = 7. + if row != 2 { + t.Errorf("visual row: want 2, got %d", row) + } + if col != 7 { + t.Errorf("visual col: want 7, got %d", col) + } +} + +// TestEditorCursorAfterLongPasteWithWrap verifies the cursor lands +// correctly when a pasted line is long enough to wrap at the given +// render width. This is the scenario that was broken: before the +// fix, the first wrap continuation missed its cont indent, so the +// terminal cursor drifted when typed after pasting a wrapped path. +func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) { + e := NewEditor("▌ ") + e.HandleKey(Key{Kind: KeyPaste, Paste: "this is a very long line that should wrap\nshort"}) + + lines, row, col := e.Render(30) + + // Every continuation row (anything after row 0) must be + // cont-indented so locateCursor's rune-counting stays honest. + for i := 1; i < len(lines); i++ { + if !strings.HasPrefix(lines[i], " ") { + t.Errorf("continuation row %d missing indent: %q", i, lines[i]) + } + } + // Cursor should be at the end of "short" on the last rendered row. + if row != len(lines)-1 { + t.Errorf("visual row: want %d (last), got %d", len(lines)-1, row) + } + if col != 2+5 { // cont indent + len("short") + t.Errorf("visual col: want 7, got %d", col) + } +}