Skip to content

feat: integrate native Neovim multicursor - #16

Open
lettertwo wants to merge 11 commits into
mainfrom
feat/native-multicursor
Open

feat: integrate native Neovim multicursor#16
lettertwo wants to merge 11 commits into
mainfrom
feat/native-multicursor

Conversation

@lettertwo

@lettertwo lettertwo commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Background

Neovim merged built-in multiple cursors (neovim/neovim#41587, :h multicursor, 0.13+). occurrence.nvim already documents a wiki integration with the third-party multicursor.nvim plugin. This PR ships a first-class native equivalent: mark occurrences, then hand them to Neovim as real cursors.

What this adds

  • cursors action (Q in occurrence mode): convert all marks, or in visual mode the marks in the selection, to native cursors. With no marks yet, it marks the word under the cursor first, like next.
  • cursors_start / cursors_end (I / A): motion-scoped operators that place a cursor on the first or last character of each mark within a motion, e.g. Iip, Aip. After Aip, a appends after every occurrence.
  • change_cursors: delete the marks in a motion, place cursors at the insertion points, and enter insert. On 0.13+ this becomes the default c, in occurrence mode (cip) and operator-pending mode (coip). The prompt-based change stays available with operators = { c = "change" }.
  • follow_cursors option (default true): cursors start in follow-mode (:h q=) so motions replay at every cursor. Toggle at runtime with native q=.

These new bindings are gated on the presence of the new cursor API, so compataibility with nvim < 0.13 is maintained.

@lettertwo
lettertwo force-pushed the feat/native-multicursor branch 4 times, most recently from 52aefd0 to 0789903 Compare September 3, 2026 22:09
Neovim merged built-in multiple cursors (neovim/neovim#41587, `:h
multicursor`, 0.13+). This makes it possible to hand occurrence marks
off to that feature.

New occurrence-mode action `cursors` (`Q`) converts all marks, or the
marks in a visual selection, to native cursors. Motion-scoped operators
`cursors_start` (`I`) and `cursors_end` (`A`) place cursors on the first
or last character of marks within a motion, and `change_cursors` deletes
the marks and enters insert. On 0.13+, `change_cursors` becomes the
default `c` (in occurrence mode and via `coip`); the prompt-based change
is still reachable with `operators = { c = "change" }`.

Cursors start in follow-mode by default (`follow_cursors`), so motions
replay at every cursor. The whole surface is gated on
`vim.api.nvim_mcursor`, so the default keys are never bound on Neovim
without the feature.
The new `cursors` action tested `mode():match("[vV]")`, which misses
blockwise Visual ("\22"), so `<C-v>` then `Q` took the non-visual branch
and converted every mark in the buffer. The three older sites carried a
literal 0x16 byte inside the character class, invisible in most editors,
which is how the new copy lost it. One `in_visual_mode()` helper now
serves all four.
The `cursors` action consumed the selection but never left Visual mode,
so the scheduled `Cursor.move(primary)` extended the selection instead of
placing the primary cursor, and the next keystroke acted on a selection
the user never made. Exit to Normal mode once the scope is captured, the
same way `Occurrence:of_selection` and the operator path do.
`opts.primary` is documented as an index into `locations`, but the list
was deduped and clamped first and then indexed with the caller's original
index. A duplicate ahead of the primary shifted it onto the wrong location,
or past the end, where `Cursor.move(nil)` threw and no cursors were made.
Resolve the index while building the deduped list instead.
`done(edited, edited)` handed the edited list to `on_done` as `result`,
where only `false` means cancelled, and left `text_edited` to fall back
to its default. It worked by accident. Pass `nil` explicitly and fix the
docstring to match the real `on_done` signature.
`change_cursors` scheduled the cursor handoff and `nvim_input("i")` as
two independent closures. If the buffer changed before they ran, or
`mcursor.add` raised, the handoff was skipped but Insert mode was still
entered: text deleted at every occurrence, one cursor, and the error
logged only at debug level. Insert now rides on the handoff closure and
runs only after a successful add, and the failure is a warning.
The cursor operators made `toggle_dispose` skip any operator that sets
its own `dispose_after_operator`, but the README still said the action
flips the decision for whatever runs next. Say who the toggle applies to,
regenerate the vimdoc, and pin the exemption with a test on a custom
operator.
`change_cursors` from operator-pending mode and the `follow_cursors`
option were only covered at config resolution and at `mcursor.add`.
Drive both through the plugin.
The default keymap and operator tables were patched at module load based
on `mcursor.is_supported()`, which baked the gate into module state and
forced the stable-nvim test to unload and re-require `occurrence.Config`
under a stubbed `vim.api`. `config.default()` now applies the gate per
call, `config.new` builds on it, and the test just stubs the API.
The branch's docs commit was generated with the older vendored
panvimdoc. Regenerate after rebasing onto main so the file matches
the rendering CI's docs-check produces (two-line title, unindented
code fences in list items, plain space after "e.g."). No README
content changed.
@lettertwo
lettertwo force-pushed the feat/native-multicursor branch from 0789903 to d1dd279 Compare September 3, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant