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/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/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/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 } 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$" {