No description
Find a file
2026-09-27 15:09:15 +02:00
doc docs: tweaks 2026-09-19 09:38:50 +02:00
lua/leap fix(beacons): pad only if LeapLabel has bg color 2026-09-27 15:09:15 +02:00
plugin improv: <Plug>(visit) can take count, making it linewise 2026-08-30 18:11:14 +02:00
.editorconfig Farewell, Fennel... 2026-01-25 09:08:15 +01:00
.gitignore chore: add .gitignore 2022-05-13 17:49:54 +02:00
.mailmap chore: add .mailmap 2026-03-07 13:53:03 +01:00
LICENSE.md first commit 2022-03-23 14:19:24 +01:00
README.md docs: tweaks 2026-09-19 09:38:50 +02:00

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:

Leap in action
Copying a function from another window with help of Treesitter

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 keys feature 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 enter first (/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.