- Lua 100%
| doc | ||
| lua/leap | ||
| plugin | ||
| .editorconfig | ||
| .gitignore | ||
| .mailmap | ||
| LICENSE.md | ||
| README.md | ||
leap.nvim
Leap is a motion and selection plugin for Neovim, originally inspired by vim-sneak. Using some clever ideas, it allows you to jump to any position in the visible editor area very quickly, with near-zero mental overhead.
How to use it (TL;DR)
-
Initiate the command in a given scope, and start typing a 2-character search pattern (
{char1}{char2}). After typing{char1}, you can see label characters appearing on some pairs. They are not active yet, but this preview allows you to process them in the background. -
Typing
{char2}filters the matches, and the labels are now active. When the closest pair is not labeled, you automatically jump there, and might safely ignore the remaining labels (they will not conflict with any sensible command, and will disappear on the next keypress). Else type a label character to jump. When there are more matches than available labels,<space>and<backspace>can switch groups.
To target the last character on a line, type {char}<space>; to target empty
lines, type <space><space>. Use {char}<enter> as a shortcut to the closest
{char} match.
Note
To keep visual noise tolerable, preview is only enabled at word boundaries by default. Check
:h leap.opts.preview.
Why this method?
Not counting the trigger key, leaping to literally anywhere on the screen rarely takes more than 3 keystrokes in total. Often 2 is enough.
At the same time, it reduces mental effort by all possible means:
-
You don't have to weigh alternatives: a single universal motion type can be used in all non-trivial situations.
-
You don't have to compose motions: one command achieves one logical movement.
-
You don't have to be aware of the context: the eyes can keep focusing on the target the whole time.
-
You don't have to pause in the middle: if typing at a moderate speed, your mind can prepare for the next steps ahead of time.
It is important to emphasise that while Leap can bring you to the far end of the screen in almost an instant, it is also natural to use for, say, moving just a few words ahead on the same line - the true standout feature is this versatility.
Showcase
This efficient method of navigation allows building interesting features on top of it, like the so-called visitor mode. "Text editing at the speed of thought" has become a bit of an inflated phrase in the Vim world, but cloning an arbitrary syntax tree node from an arbitrary window with eight keystrokes speaks for itself:
Getting started
Help files are not exactly page-turners, but :help leap is
written with considerable care (by humans, for humans), and I suggest at least
skimming it, even if you don't have a specific question yet, as it contains
lots of additional information and details.
Requirements
-
Neovim >= 0.10.0 stable, or latest nightly
-
repeat.vim, for dot-repeating (
.) delete/change/etc. operations (optional)
Installation
vim.pack.add { 'https://codeberg.org/andyg/leap.nvim' }
Note
Lazy loading is all the rage now, but doing it manually is unnecessary, as Leap already lazy-loads itself, as it should. Using the
keysfeature of lazy.nvim might even cause problems.
Mappings & configuration
Recommended starter configuration:
-- See `:h leap-mappings`, `:h leap.visit-mappings` for more.
-- Jump
vim.keymap.set({ 'n', 'x', 'o' }, 's', '<Plug>(leap)')
vim.keymap.set('n', 'S', '<Plug>(leap-from-window)')
-- Visit (jump - operate - jump back)
vim.keymap.set({ 'n', 'x', 'o' }, 'gs', '<Plug>(leap-visit)')
vim.keymap.set({ 'x', 'o' }, 'ar', '<Plug>(leap-visit-text-object)')
vim.keymap.set({ 'x', 'o' }, 'ir', '<Plug>(leap-visit-inner-text-object)')
vim.keymap.set('o', 'rr', function() -- "visit line" shortcut
return (vim.v.count == 0 and '1' or '') .. '<Plug>(leap-visit)'
end, { expr = true })
vim.api.nvim_create_autocmd('User', { -- automatic paste on return
pattern = 'VisitDone',
group = vim.api.nvim_create_augroup('Visit', {}),
callback = function(event)
if
(event.data.mode:match('^[vV\22]') or (vim.v.operator == 'y'))
and event.data.register == '"'
then
vim.cmd('normal! p')
end
end,
})
-- Treeselect
vim.keymap.set({ 'x', 'o' }, 'an', function()
require('leap.treesitter').select {
opts = require('leap.user').with_traversal_keys('n', 'N')
}
end)
Extra modules
Visit ("spooky actions at a distance")
Prior art: leap-spooky.nvim, and flash.nvim's "remote operation" feature.
The visit() function allows you to perform an action in a remote location: it
forgets the current mode or pending operator, lets you leap to anywhere on the
tab page, then continues where it left off. Once returning to Normal mode, it
jumps back to the original position, as if you had operated from the distance.
vim.keymap.set({ 'n', 'x', 'o' }, 'gs', function()
require('leap').visit()
end)
With the above mapping, gs{leap}yap or ygs{leap}ap will yank the paragraph
at the position specified by {leap}.
Feeding input
The recommended way though is automatically entering Visual mode on arrival, so
that from Normal mode you can e.g. gs{leap}apy (leap-select-op). This is
the same number of keystrokes as gs{leap}yap (leap-op-select) or
ygs{leap}ap (op-leap-select), but you get visual feedback, can move around
freely with arbitrary motion combinations, and correct mistakes. The input
parameter lets you feed keystrokes (<Plug>(leap-visit) does this by default):
vim.keymap.set({ 'n', 'x', 'o' }, 'gs', function()
require('leap').visit { input = (vim.fn.mode(true) == 'n') and 'v' or '' }
end)
Remote text objects
By giving text objects as input, you can define "remote" text objects, for
an even more intuitive workflow (yarp{leap} - "yank a remote paragraph
at..."). Instead of doing this for each, you can use the convenient
<Plug>(leap-visit-text-object) and <Plug>(leap-visit-inner-text-object)
keys: they are simple wrappers that first read an additional input character
from the user, and then feed that prefixed with a and i, respectively, to
visit(). (In arp, the key itself is mapped to ar, and p is the dynamic
input.)
Icing on the cake: automatic paste on return
By setting an autocommand on VisitDone, you can clone regions in the blink of
an eye, even from another window (just ygs{leap}ap, or, with predefiend
remote text object, yarp{leap}, and voilà, the remote paragraph appears
there). See :h leap.visit-autopaste.
Region manipulations in Visual mode
Yet another useful feature. See :h leap.visit-visual.
Treesitter integration
You can either choose a node directly (van{label}), or, in Normal/Visual
mode, use the traversal keys for incremental selection. The labels are forced
to be safe, so you can operate on the selection right away then (vannny).
Traversal can "wrap around" backwards (vanN selects the root node).
vim.keymap.set({ 'x', 'o' }, 'an', function()
require('leap.treesitter').select {
-- To increase/decrease the selection in a clever-f-like manner,
-- with the trigger key itself (vannnNN...). The default keys
-- (<enter>/<backspace>) also work, so feel free to skip this.
opts = require('leap.user').with_traversal_keys('n', 'N')
}
end)
Tip
Use linewise mode (
Vannn...,yVan) whenever possible, as it filters out redundant nodes in the same line range, making the selection much more efficient.
Design considerations in detail
The ideal
Premise: Vim golf is incredibly fun, but efficient movement between point A and B on the screen, in particular, should rather be a non-issue. An ideal keyboard-driven interface would impose almost no more cognitive burden than using a mouse, without the constant context-switching required by the latter.
That is, you do not want to think about
-
the command: we need one fundamental targeting method that can bring you anywhere: a jetpack on the back, instead of airline routes (↔ EasyMotion and its derivatives)
-
the context: it should be enough to look at the target, and nothing else (↔ vanilla Vim motion combinations using relative line numbers and/or repeats)
-
the steps: the motion should be atomic (↔ Vim motion combos), and ideally you should be able to type the whole input sequence in one go, on more or less autopilot (↔ any kind of just-in-time labeling method, including the "search command on steroids" approach by Pounce and Flash)
All the while using as few keystrokes as possible, and getting distracted by as little incidental visual noise as possible.
How do we measure up?
It is obviously impossible to achieve all of the above at the same time, without
some trade-offs at least; but in our opinion Leap comes pretty close, occupying
a sweet spot in the design space. (The worst remaining offender might be visual
noise, but clever filtering in the preview phase can help - see :h leap.opts.preview.)
The one-step shift between perception and action is the big idea that cuts the Gordian knot: a fixed pattern length combined with previewing labels can eliminate the surprise factor, and make the search-based method (our "jetpack") work smoothly. Fortunately, even a 2-character pattern - the shortest one with which we can play this trick - is usually long enough to sufficiently narrow down the matches.
Fixed pattern length also makes (safe) automatic jump to the first target
possible. Even with preview, labels are a necessary evil, and we should
optimize for the common case as much as possible (something that Sneak got
absolutely right from the beginning). You cannot improve on jumping directly,
just like how f and t works, not having to use even <enter> to accept the
match. However, we can do this in a smart way: if there are many targets (more
than 15-20), we stay put, so we can use a bigger, "unsafe" label set - getting
the best of both worlds. The non-determinism we're introducing is less of an
issue here, since the outcome is known in advance.
In sum, compared to other methods based on labeling targets, Leap's approach is unique in that it
-
offers a smoother experience, by (somewhat) eliminating the pause before typing the label
-
feels natural to use for both distant and close targets
FAQ
Search and motions
Enhanced f/t motions (1-character search)
do
local function ft(kwargs)
require('leap').leap(
vim.tbl_deep_extend('keep', kwargs, {
inputlen = 1,
inclusive = true,
opts = {
-- Force autojump.
labels = '',
-- Match the modes where you don't need labels (`:h mode()`).
safe_labels = vim.fn.mode(1):match('no?') and '' or nil,
},
})
)
end
-- A helper function making it easier to set "clever-f" behavior
-- (using f/F or t/T instead of ;/, - see the plugin clever-f.vim).
local clever = require('leap.user').with_traversal_keys
local clever_f, clever_t = clever('f', 'F'), clever('t', 'T')
vim.keymap.set({ 'n', 'x', 'o' }, 'f', function()
ft { opts = clever_f }
end)
vim.keymap.set({ 'n', 'x', 'o' }, 'F', function()
ft { backward = true, opts = clever_f }
end)
vim.keymap.set({ 'n', 'x', 'o' }, 't', function()
ft { offset = -1, opts = clever_t }
end)
vim.keymap.set({ 'n', 'x', 'o' }, 'T', function()
ft { backward = true, offset = 1, opts = clever_t }
end)
end
Wildcard characters (one-way aliases)
The preview phase, unfortunately, makes them impossible, by design: for a
potential match, we might need to show two different labels - corresponding to
two different futures - at the same time (see :h leap-wildcard-problem for a
longer explanation). smartcase is experimentally supported, but it can only
be applied on the first input character (:h leap-smartcase).
Working with non-English text
If a language-mapping
('keymap') is active,
Leap waits for keymapped sequences as needed and searches for the keymapped
result.
Also check out opts.equivalence_classes, that lets you group certain
characters together as mutual aliases, e.g.:
{
' \t\r\n', 'aäàáâãā', 'dḍ', 'eëéèêē', 'gǧğ', 'hḥḫ',
'iïīíìîı', 'nñ', 'oō', 'sṣšß', 'tṭ', 'uúûüűū', 'zẓ'
}
Disable autojumping to the first match
require('leap').opts.safe_labels = ''
Force autojumping to the first match
require('leap').opts.labels = ''
"Clever s" (à la Sneak)
do
local clever_s = require('leap.user').with_traversal_keys('s', 'S')
vim.keymap.set({ 'n', 'x', 'o' }, 's', function()
require('leap').leap { opts = clever_s }
end)
vim.keymap.set({ 'n', 'x', 'o' }, 'S', function()
require('leap').leap { backward = true, opts = clever_s }
end)
end
Arbitrary remote actions instead of jumping
Basic template:
local function remote_action ()
require('leap').leap {
windows = require('leap.user').get_focusable_windows(),
action = function(target)
local winid = target.wininfo.winid
local lnum, col = unpack(target.pos) -- 1/1-based indexing!
-- ... do something at the given position ...
end,
}
end
See Extending Leap for more.
Labels and highlighting
Disable previewing labels
require('leap').opts.preview = false
Show all labels as uppercase
This might be helpful in case of having poorer eyesight or using relatively small font sizes. Obviously, only lowercase and non-alphabetic labels are expected in your label lists.
Warning: on_beacons is an experimental escape hatch, and this workaround
depends on implementation details.
require('leap').opts.on_beacons = function(targets)
for _, t in ipairs(targets) do
if t.label and t.beacon then
local vt = t.beacon[2].virt_text
if vt then vt[1][1] = vim.fn.toupper(vt[1][1]) end
end
end
end
Grey out the search area ("backdrop" highlight)
There is a helper function for that in the user module:
vim.api.nvim_create_autocmd('ColorScheme', {
group = vim.api.nvim_create_augroup('LeapBackdrop', {}),
callback = function()
if vim.g.colors_name == 'this_color_scheme_needs_backdrop' then
require('leap.user').set_backdrop_highlight('Comment')
end
end
})
NOTE: This is intended as an opt-in feature for end users. Color scheme plugins should make sure that the labels are clearly visible as they are, and should not rely on "greywashing" by default.
Restore the default highlighting
If a certain color scheme sets the highlight groups for Leap in a way that you don't particularly like, the simplest solution (besides a PR) is:
vim.api.nvim_create_autocmd('ColorScheme', {
group = vim.api.nvim_create_augroup('LeapColorTweaks', {}),
callback = function()
if vim.g.colors_name == 'bad_color_scheme' then
-- Forces using the defaults: sets `IncSearch` for labels,
-- `Search` for matches, and updates the look of concealed labels.
require('leap').init_hl(true)
end
end
})
Miscellaneous
Mappings for surround plugins
If s and S are used by Leap, I suggest either gz or gm as the prefix
for surround commands (e.g.: gz{motion}, gzz, gzd, gzr, etc.). Even if
unused in itself, gs is not an ideal choice, because gss cannot be mapped
to linewise surround then.
Was the name inspired by Jef Raskin's Leap?
To paraphrase Steve Jobs about their logo and Turing's poison apple, I wish it were, but it is a coincidence. That said, you can think of the name as a little tribute to the great pioneer of interface design, even though embracing the modal paradigm is a fundamental difference in Vim's approach.
Extending Leap
There are lots of ways you can extend the plugin and bend it to your will - see
:h leap.leap() and :h leap-events. Besides tweaking the basic parameters of
the function (search scope, jump offset, etc.), you can:
- feed it with a prepared search pattern
- feed it with prepared targets, and only use it as labeler/selector
- give it a custom action to perform, instead of jumping
- customize the behavior of specific calls via autocommands
Examples:
Search integration
When finishing a / or ? search command, automatically label visible
matches, so that you can jump to them directly.
vim.api.nvim_create_autocmd('CmdlineLeave', {
group = vim.api.nvim_create_augroup('LeapOnSearch', {}),
callback = function()
local ev = vim.v.event
local is_search_cmd = (ev.cmdtype == '/') or (ev.cmdtype == '?')
-- Allow CmdLineLeave-related chores to be completed before
-- invoking Leap.
vim.schedule(function()
local cnt = vim.fn.searchcount().total
if is_search_cmd and (not ev.abort) and (cnt > 1) then
-- We want "safe" labels, but no autojump (as the search
-- command already does that), so just use `safe_labels`
-- as `labels`, with n/N removed.
local labels = require('leap').opts.safe_labels:gsub('[nN]', '')
-- For `pattern` search, we never need to adjust conceallevel
-- (no user input). We cannot merge `nil` from a table, but
-- using the option's current value has the same effect.
local vim_opts = { ['wo.conceallevel'] = vim.wo.conceallevel }
require('leap').leap {
pattern = vim.fn.getreg('/'), -- last search pattern
windows = { vim.fn.win_getid() },
opts = { safe_labels = '', labels = labels, vim_opts = vim_opts, }
}
end
end)
end,
})
The above might be enough for your needs, but here is another snippet, which sets keys to leap to visible matches of the previous search pattern anytime. It also:
- allows traversing with the trigger key, so that you can
<c-s><c-s>.... - allows using the keys in Command-line mode too, so that you can exit and jump
(or traverse) right away, without needing to press
enterfirst (/pattern<c-s>{label},/pattern<c-s><c-s>...).
Rationale for the suggested keys: <c-s> is the default Leap trigger combined
with a modifier, to make it usable in Command-line mode; and with <c-q>, the
pair resembles c_CTRL-G and c_CTRL-T (s is - sort of - below q).
do
local function leap_search (key, is_reverse)
local cmdline_mode = vim.fn.mode(true):match('^c')
if cmdline_mode then
-- Finish the search command.
vim.api.nvim_feedkeys(vim.keycode('<enter>'), 't', false)
end
if vim.fn.searchcount().total < 1 then
return
end
-- Activate again if `:nohlsearch` has been used (Normal/Visual mode).
vim.go.hlsearch = vim.go.hlsearch
-- Allow the search command to complete its chores before
-- invoking Leap (Command-line mode).
vim.schedule(function()
require('leap').leap {
pattern = vim.fn.getreg('/'),
-- If you always want to go forward/backward with the given key,
-- regardless of the previous search direction, just set this to
-- `is_reverse`.
backward = (is_reverse and vim.v.searchforward == 1)
or (not is_reverse and vim.v.searchforward == 0),
opts = require('leap.user').with_traversal_keys(key, nil, {
-- Autojumping to the second match would be confusing without
-- 'incsearch'.
safe_labels = (cmdline_mode and not vim.o.incsearch) and ''
-- Keep n/N usable in any case.
or require('leap').opts.safe_labels:gsub('[nN]', '')
})
}
-- You might want to switch off the highlights after leaping.
-- vim.cmd('nohlsearch')
end)
end
vim.keymap.set({ 'n', 'x', 'o', 'c' }, '<c-s>', function()
leap_search('<c-s>', false)
end, { desc = 'Leap to search matches' })
vim.keymap.set({ 'n', 'x', 'o', 'c' }, '<c-q>', function()
leap_search('<c-q>', true)
end, { desc = 'Leap to search matches (reverse)' })
end
Jump to lines
vim.keymap.set({ 'n', 'x', 'o' }, '|', function()
local line = vim.fn.line('.')
-- Skip 3-3 lines around the cursor.
local top, bot = unpack { math.max(1, line - 3), line + 3 }
require('leap').leap {
pattern = '\\v(%<'..top..'l|%>'..bot..'l)$',
windows = { vim.fn.win_getid() },
opts = { safe_labels = '' }
}
end)
Shortcuts to Telescope results
local function get_targets (picker)
local scroller = require('telescope.pickers.scroller')
local wininfo = vim.fn.getwininfo(picker.results_win)[1]
local bottom = wininfo.botline - 2 -- skip the current row
local top = math.max(
scroller.top(picker.sorting_strategy,
picker.max_results,
picker.manager:num_results()),
wininfo.topline - 1
)
local targets = {}
for lnum = bottom, top, -1 do
table.insert(targets, { wininfo = wininfo, pos = { lnum + 1, 1 } })
end
return targets
end
local function pick_with_leap (buf)
local picker = require('telescope.actions.state').get_current_picker(buf)
require('leap').leap {
targets = get_targets(picker),
action = function(target)
picker:set_selection(target.pos[1] - 1)
require('telescope.actions').select_default(buf)
end,
}
end
require('telescope').setup {
defaults = {
mappings = {
i = { ['<a-p>'] = pick_with_leap },
}
}
}
Slopolicy
All of the source code and the documentation is written and maintained, since the beginning, by human beings who actually love their craft. I would like to keep it that way, if you don't mind.