From ebb1fc97bb2bbe0cc28529a1e0bf3119a129ed66 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 22 Oct 2026 08:28:22 -0500 Subject: [PATCH 1/2] Fix(vim): a second $ on a wrapped line reaches the end of the line With soft wrap on and vim.wrapped_line_motions = "display" (the default), a bare `$` stops at the end of the row the cursor is on, as the desktop editor does (#536 there). That part is deliberate. But pressing `$` again on that row found the same row and stayed put, so the true end of a long line was out of reach without `A` or switching to logical motions. Reported on Discord by Scotty, who expected nvim's `$`, or at least a second press that gets there. Now a bare `$` that starts on the last character of a row that is not the line's last goes on to the end of the logical line. The first press still ends the visible row; `g$` stays the row motion and never runs past it, and operators keep the row too, so `d$` still deletes only what the user can see. A counted `$` and the "logical" setting behave as before. Engine tests cover the first, second and third press, `g$`, `d$` and `v$$`. The README's wrapped-line sentence and the manual's motion list say how to reach the end. Live in zn tui on a 168-column line: `$` landed on 3:88, `$` again on 3:168, and `g$` on 3:88. --- README.md | 3 +- internal/tui/helpdata.go | 1 + internal/vim/display_motion_test.go | 45 +++++++++++++++++++++++++++++ internal/vim/motions.go | 16 +++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 06d59a7..356fb4e 100644 --- a/README.md +++ b/README.md @@ -550,7 +550,8 @@ Enter after a code fence creates its closing fence. `:table` adds/removes rows or columns and sets alignment; `:formatting` opens formatting actions. These edits participate in undo. `:format` still formats the whole note. `vim.wrapped_line_motions = "display"` makes `$`, `I` and `A` use the -visible wrapped row; `"logical"` uses the source line. +visible wrapped row (press `$` again at the end of a row to reach the end of +the line); `"logical"` uses the source line. `Space l e` opens the note in `$VISUAL` or `$EDITOR` at the cursor line and reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index 87630de..b62da95 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -72,6 +72,7 @@ func helpLines(a *App, filter string) []string { "Ctrl+D Ctrl+U Ctrl+F Ctrl+B Ctrl+E Ctrl+Y zt zz zb\tScrolling", "Ctrl+O Ctrl+I\tJump list, across notes too", "gj gk\tMove by display row when a line wraps", + "$ $\tOn a wrapped line, $ ends the visible row and a second $ reaches the line end", "", "## Editor: operators and text objects", "d c y > < gu gU g~ = J gq\tOperators; double them for a line (dd, cc, yy, >>)", diff --git a/internal/vim/display_motion_test.go b/internal/vim/display_motion_test.go index 07b2bcd..7b0f8b1 100644 --- a/internal/vim/display_motion_test.go +++ b/internal/vim/display_motion_test.go @@ -44,3 +44,48 @@ func TestDisplayRowBoundariesRespectCountsAndLogicalMode(t *testing.T) { t.Fatal(ed.Cursor()) } } + +func TestSecondDollarOnAWrappedRowReachesTheLineEnd(t *testing.T) { + opts := DefaultOptions() + opts.WrappedLineMotions = true + rows := Hooks{DisplayRowBounds: func(p Pos) (int, int) { + if p.Col < 5 { + return 0, 5 + } + if p.Col < 10 { + return 5, 10 + } + return 10, 14 + }} + ed := New("abcdefghijklmn", opts, rows) + ed.HandleKey(R('$')) + if ed.Cursor().Col != 4 { + t.Fatalf("first $ should stop at the row end: %v", ed.Cursor()) + } + ed.HandleKey(R('$')) + if ed.Cursor().Col != 13 { + t.Fatalf("second $ should reach the line end: %v", ed.Cursor()) + } + ed.HandleKey(R('$')) + if ed.Cursor().Col != 13 { + t.Fatalf("$ at the line end should stay: %v", ed.Cursor()) + } + // g$ is the row motion, so it never runs on past the row. + ed.SetCursor(Pos{0, 4}) + ed.Feed("g$") + if ed.Cursor().Col != 4 { + t.Fatalf("g$ at the row end left the row: %v", ed.Cursor()) + } + // Operators keep the visible row, even from its last character. + ed.SetCursor(Pos{0, 4}) + ed.Feed("d$") + if ed.Text() != "abcdfghijklmn" { + t.Fatalf("d$ at the row end: %q", ed.Text()) + } + // Visual mode extends to the line end the same way. + ed = New("abcdefghijklmn", opts, rows) + ed.Feed("v$$") + if ed.Cursor().Col != 13 || ed.Mode() != ModeVisual { + t.Fatalf("v$$: %v %v", ed.Cursor(), ed.Mode()) + } +} diff --git a/internal/vim/motions.go b/internal/vim/motions.go index d443d05..5bf860d 100644 --- a/internal/vim/motions.go +++ b/internal/vim/motions.go @@ -98,6 +98,20 @@ func parseMotion(keys []Key) (motion, parseStatus) { return motion{}, parseInvalid } +// atWrappedRowEnd reports a cursor already on the last character of a +// soft-wrapped row that is not the line's last. A bare `$` stops at the end +// of the visible row, so pressing it again there goes on to the end of the +// line, where Vim's `$` lands; otherwise the second press did nothing and +// the true end was out of reach. Operators keep the row (`d$` deletes what +// the user can see). +func (e *Editor) atWrappedRowEnd(from Pos, forOperator bool) bool { + if forOperator { + return false + } + start, end := e.hooks.DisplayRowBounds(from) + return end < e.buf.LineLen(from.Line) && from.Col >= max(start, end-1) +} + // applyMotion computes where a motion lands from `from`. forOperator marks // operator-pending use, where a few motions behave differently (cw, e). func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOperator bool) (motionResult, bool) { @@ -105,7 +119,7 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp line := e.buf.Line(from.Line) lineCount := e.buf.LineCount() res := motionResult{pos: from} - if e.hooks.DisplayRowBounds != nil && ((m.kind == "$" && n == 1 && e.opts.WrappedLineMotions) || m.kind == "g$" || m.kind == "g0" || m.kind == "g^") { + if e.hooks.DisplayRowBounds != nil && ((m.kind == "$" && n == 1 && e.opts.WrappedLineMotions && !e.atWrappedRowEnd(from, forOperator)) || m.kind == "g$" || m.kind == "g0" || m.kind == "g^") { start, end := e.hooks.DisplayRowBounds(from) res.pos.Col = start if m.kind == "$" || m.kind == "g$" { From 8ada21866235762a8f88f4895d29109bd33871c8 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 22 Oct 2026 08:28:26 -0500 Subject: [PATCH 2/2] Fix(editor): typing ``` makes a code fence instead of four backticks With auto pairs on, the editor gave every backtick a closing partner. The first backtick paired, the second stepped over its partner, and the third paired again, so ``` became ```` with the cursor before the fourth. Enter then split that line into ``` and a stray backtick, and the fence snippet that closes a code block on Enter never got its chance. Reported on Discord by Scotty. A backtick typed right after another backtick now grows the run instead of opening a pair, so ``` (and ```go) stays as typed, and Enter adds the closing fence below with the cursor inside the block, as in the desktop editor. A single backtick still pairs, so `ls` types as before. The desktop never pairs backticks; the terminal keeps its inline pairing and only stops pairing inside a run. Tests: engine cases for ```, ```go and an inline span, and a TUI test that types ```go and Enter through the App's key path. Both failed before this change (```go` and ````). Live in zn tui: ```go then Enter opened the block with ``` below it, and `ls` still paired. --- internal/tui/authoring_test.go | 16 ++++++++++++++++ internal/vim/editor_test.go | 3 +++ internal/vim/insert.go | 7 ++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/tui/authoring_test.go b/internal/tui/authoring_test.go index 7557820..61ba9ad 100644 --- a/internal/tui/authoring_test.go +++ b/internal/tui/authoring_test.go @@ -77,3 +77,19 @@ func TestHelpersRespectLongCodeFences(t *testing.T) { t.Fatal("closing fence left helper disabled") } } + +func TestTypedBacktickFenceClosesOnEnter(t *testing.T) { + a, buf := newTagTestApp(t, vimPrefs(), "# Draft") + feed(a, vim.R('o')) + typeKeys(a, "```go") + if got := buf.ed.Line(1); got != "```go" { + t.Fatalf("typed fence %q", got) + } + feed(a, vim.KeyEnter) + if got := buf.ed.Text(); got != "# Draft\n```go\n\n```" { + t.Fatalf("Enter after the fence: %q", got) + } + if got := buf.ed.Cursor(); got != (vim.Pos{Line: 2, Col: 0}) { + t.Fatalf("cursor should sit inside the block: %v", got) + } +} diff --git a/internal/vim/editor_test.go b/internal/vim/editor_test.go index 05571f0..4cc5b4e 100644 --- a/internal/vim/editor_test.go +++ b/internal/vim/editor_test.go @@ -199,6 +199,9 @@ func TestInsertMode(t *testing.T) { {"autopair skip", "", Pos{0, 0}, "i(a)", "(a)", Pos{0, 2}, ModeNormal, nil}, {"autopair backspace", "", Pos{0, 0}, "i(", "", Pos{0, 0}, ModeNormal, nil}, {"no autopair before word", "x", Pos{0, 0}, "i(", "(x", Pos{0, 0}, ModeNormal, nil}, + {"autopair backtick span", "", Pos{0, 0}, "i`a`", "`a`", Pos{0, 2}, ModeNormal, nil}, + {"backtick fence stays three", "", Pos{0, 0}, "i```", "```", Pos{0, 2}, ModeNormal, nil}, + {"backtick fence with language", "", Pos{0, 0}, "i```go", "```go", Pos{0, 4}, ModeNormal, nil}, {"insert escape jk", "", Pos{0, 0}, "iajk", "a", Pos{0, 0}, ModeNormal, func(o *Options) { o.InsertEscape = "jk" }}, {"insert escape j alone", "", Pos{0, 0}, "iaj", "aj", Pos{0, 1}, ModeNormal, func(o *Options) { o.InsertEscape = "jk" }}, {"text replacement", "", Pos{0, 0}, "ia->b", "a→b", Pos{0, 2}, ModeNormal, func(o *Options) { o.TextReplacementsEnabled = true }}, diff --git a/internal/vim/insert.go b/internal/vim/insert.go index 55ac99f..c442b46 100644 --- a/internal/vim/insert.go +++ b/internal/vim/insert.go @@ -234,9 +234,14 @@ func (e *Editor) insertPrintable(r rune) { e.cursor.Col++ return } + // A backtick after a backtick grows a run (a ``` fence, a `` span) + // instead of opening a pair: pairing every one turned ``` into ```` with + // the cursor before the fourth, so Enter split the fence instead of + // closing it. + afterBacktick := e.cursor.Col > 0 && line[e.cursor.Col-1] == '`' e.insertRaw(string(r)) if closer, ok := autoPairOpeners[r]; ok && e.opts.AutoPairs && (next == 0 || isBlank(next) || next == ')' || next == ']' || next == '}') { - if r != '`' || next == 0 || isBlank(next) { + if r != '`' || ((next == 0 || isBlank(next)) && !afterBacktick) { e.buf.InsertText(e.cursor, string(closer)) e.ins.pairedInsert = e.cursor }