Home

Awesome

<img align="left" width="150" height="85" src="../media/kangaroo.png?raw=true">

leap.nvim

Leap is a general-purpose motion plugin for Neovim, building and improving primarily on vim-sneak, with the ultimate goal of establishing a new standard interface for moving around in the visible area in Vim-like modal editors. It allows you to reach any target in a very fast, uniform way, and minimizes the required focus level while executing a jump.

showcase

How to use it (TL;DR)

Leap's default motions allow you to jump to any position in the visible editor area by entering a 2-character search pattern, and then potentially a label character to pick your target from multiple matches, in a manner similar to Sneak. The main novel idea in Leap is that you get a preview of the target labels - Leap shows you which key you will need to press before you actually need to do that.

Character pairs give you full coverage of the screen:

At any stage, <enter> consistently jumps to the next available target (<tab> steps back):

Why is this method cool?

It is ridiculously fast: not counting the trigger key, leaping to literally anywhere on the screen rarely takes more than 3 keystrokes in total, that can be typed in one go. Often 2 is enough.

At the same time, it reduces mental effort to almost zero:

Getting started

Status

The plugin is not 100% stable yet, but don't let that stop you - the usage basics are extremely unlikely to change. To follow breaking changes, subscribe to the corresponding issue.

Requirements

Dependencies

Installation

Use your preferred method or plugin manager. No extra steps needed besides defining keybindings - to use the default ones, put the following into your config (overrides s, S and gs in all modes):

require('leap').create_default_mappings() (init.lua)

lua require('leap').create_default_mappings() (init.vim)

<details> <summary>Alternative key mappings</summary>

Calling require('leap').create_default_mappings() is equivalent to:

vim.keymap.set({'n', 'x', 'o'}, 's',  '<Plug>(leap-forward)')
vim.keymap.set({'n', 'x', 'o'}, 'S',  '<Plug>(leap-backward)')
vim.keymap.set({'n', 'x', 'o'}, 'gs', '<Plug>(leap-from-window)')

A suggested alternative arrangement (bidirectional s for Normal mode):

vim.keymap.set('n',        's', '<Plug>(leap)')
vim.keymap.set('n',        'S', '<Plug>(leap-from-window)')
vim.keymap.set({'x', 'o'}, 's', '<Plug>(leap-forward)')
vim.keymap.set({'x', 'o'}, 'S', '<Plug>(leap-backward)')

Mapping to <Plug>(leap) is not recommended for Visual mode, as autojumping in a random direction might be too disorienting with the selection highlight on, and neither for Operator-pending mode, as dot-repeat cannot be used if the search is non-directional.

Note that compared to using separate keys for the two directions, you will get twice as many targets and thus half as many autojumps on average, but not needing to press the Shift key for backward motions might compensate for that. Another caveat is that you cannot traverse through the matches (:h leap-traversal), although invoking repeat right away (:h leap-repeat) can substitute for that.

<Plug>(leap) sorts matches by euclidean distance from the cursor, with the exception that the current line, and on the current line, forward direction is prioritized. That is, you can always be sure that the targets right in front of you will be the first ones.

See :h leap-custom-mappings for more.

</details> <details> <summary>Suggested additional tweaks</summary>
-- Define equivalence classes for brackets and quotes, in addition to
-- the default whitespace group.
require('leap').opts.equivalence_classes = { ' \t\r\n', '([{', ')]}', '\'"`' }

-- Override some old defaults - use backspace instead of tab (see issue #165).
require('leap').opts.special_keys.prev_target = '<backspace>'
require('leap').opts.special_keys.prev_group = '<backspace>'

-- Use the traversal keys to repeat the previous motion without explicitly
-- invoking Leap.
require('leap.user').set_repeat_keys('<enter>', '<backspace>')
</details> <details> <summary>Remote operations a.k.a. spooky actions at a distance (experimental)</summary>

Inspired by leap-spooky.nvim, and flash.nvim's similar feature. The API is not final.

This function allows you to perform any operation in a remote location: it forgets the current mode or pending operator, lets you leap with the cursor (to anywhere on the tab page), then continues where it left off (when coming from Normal mode, it starts Visual mode). Once an operation is finished, it moves the cursor back to the original position, as if you had operated from the distance.

-- If using the default mappings (`gs` for multi-window mode), you can
-- map e.g. `gS` here.
vim.keymap.set({'n', 'o'}, 'gs', function ()
  require('leap.remote').action()
end)

Example: gs{leap}apy yanks the paragraph at the position specified by {leap}. The Normal-mode command is recommended over Operator-pending mode (ygs{leap}ap), since it requires the same number of keystrokes, but you can visually select a region before operating on it, that is, more complex motions are possible, and mistakes can be corrected. It might be more intuitive too, since the jump does not tear the operator and the selection command apart.

Swapping regions becomes pretty simple, without needing a custom plugin: d{region1}gs{leap}{region2}pP. Example (swapping two words): diwgs{leap}iwpP.

Icing on the cake, no. 1: Automatic paste after yanking. With this, you can clone text objects or regions in the blink of an eye, even from another window.

vim.api.nvim_create_augroup('LeapRemote', {})
vim.api.nvim_create_autocmd('User', {
  pattern = 'RemoteOperationDone',
  group = 'LeapRemote',
  callback = function (event)
    -- Do not paste if some special register was in use.
    if vim.v.operator == 'y' and event.data.register == '"' then
      vim.cmd('normal! p')
    end
  end,
})

Icing on the cake, no. 2: The input parameter lets you create remote text objects, for a more intuitive workflow (e.g., to yank a paragraph, you just type yarp in one go, and then leap - combined with the above autocommand, it is almost like magic).

local default_text_objects = {
  'iw', 'iW', 'is', 'ip', 'i[', 'i]', 'i(', 'i)', 'ib',
  'i>', 'i<', 'it', 'i{', 'i}', 'iB', 'i"', 'i\'', 'i`',
  'aw', 'aW', 'as', 'ap', 'a[', 'a]', 'a(', 'a)', 'ab',
  'a>', 'a<', 'at', 'a{', 'a}', 'aB', 'a"', 'a\'', 'a`',
}
-- Create remote versions of all native text objects by inserting `r`
-- into the middle (`iw` becomes `irw`, etc.):
for _, tobj in ipairs(default_text_objects) do
  vim.keymap.set({'x', 'o'}, tobj:sub(1,1)..'r'..tobj:sub(2), function ()
    require('leap.remote').action { input = tobj }
  end)
end

You can also use it to create a forced linewise version of the command, by feeding V:

vim.keymap.set({'n', 'o'}, 'gS', function ()
  require('leap.remote').action { input = 'V' }
end)
</details> <details> <summary>Workaround for the duplicate cursor bug when autojumping</summary>

For Neovim versions < 0.10 (https://github.com/neovim/neovim/issues/20793):

-- Hide the (real) cursor when leaping, and restore it afterwards.
vim.api.nvim_create_autocmd('User', { pattern = 'LeapEnter',
    callback = function()
      vim.cmd.hi('Cursor', 'blend=100')
      vim.opt.guicursor:append { 'a:Cursor/lCursor' }
    end,
  }
)
vim.api.nvim_create_autocmd('User', { pattern = 'LeapLeave',
    callback = function()
      vim.cmd.hi('Cursor', 'blend=0')
      vim.opt.guicursor:remove { 'a:Cursor/lCursor' }
    end,
  }
)

Caveat: If you experience any problems after using the above snippet, check #70 and #143 to tweak it.

</details> <details> <summary>Lazy loading</summary>

...is all the rage now, but doing it via your plugin manager is unnecessary, as Leap lazy loads itself. Using the keys feature of lazy.nvim might even cause problems.

</details>

Next steps

Help files are not exactly page-turners, but I suggest at least skimming :help leap, even if you don't have a specific question yet (if nothing else: :h leap-usage, :h leap-config, :h leap-events). While Leap has deeply thought-through, opinionated defaults, its small(ish) but comprehensive API makes it pretty flexible.

Design considerations in detail

The ideal

Premise: jumping from point A to B on the screen should not be some exciting puzzle, for which you should train yourself; it should 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

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_filter.)

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 from the search-based method (which is the only viable approach - see "jetpack" above). Fortunately, a 2-character pattern - the shortest one with which we can play this trick - is also long enough to sufficiently narrow down the matches in the vast majority of cases.

Fixed pattern length also makes (safe) automatic jump to the first target possible. You cannot improve on jumping directly, just like how f and t works, not having to read a label at all, and not having to accept the match with <enter> either. 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

FAQ

Bugs

<details> <summary>Workaround for the duplicate cursor bug when autojumping</summary>

For Neovim versions < 0.10 (https://github.com/neovim/neovim/issues/20793):

-- Hide the (real) cursor when leaping, and restore it afterwards.
vim.api.nvim_create_autocmd('User', { pattern = 'LeapEnter',
    callback = function()
      vim.cmd.hi('Cursor', 'blend=100')
      vim.opt.guicursor:append { 'a:Cursor/lCursor' }
    end,
  }
)
vim.api.nvim_create_autocmd('User', { pattern = 'LeapLeave',
    callback = function()
      vim.cmd.hi('Cursor', 'blend=0')
      vim.opt.guicursor:remove { 'a:Cursor/lCursor' }
    end,
  }
)

Caveat: If you experience any problems after using the above snippet, check #70 and #143 to tweak it.

</details>

Defaults

<details> <summary>Why remap `s`/`S`?</summary>

Common operations should use the fewest keystrokes and the most comfortable keys, so it makes sense to take those over by Leap, especially given that both native commands have synonyms:

Normal mode

Visual mode

If you are not convinced, just head to :h leap-custom-mappings.

</details>

Features

<details> <summary>Search in all windows</summary>
vim.keymap.set('n', 's', function ()
  require('leap').leap {
    target_windows = require('leap.user').get_focusable_windows()
  }
end)

The additional trade-off here is that if you have multiple windows open on the tab page, then you will almost never get an autojump, except if all targets are in the same window (it would be too disorienting if the cursor could suddenly jump in/to a different window than your goal, right before selecting the target, not to mention that we don't want to change the state of a window we're not targeting).

</details> <details> <summary>Smart case sensitivity, wildcard characters (one-way aliases)</summary>

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. (1, 2, 3)

</details> <details> <summary>Arbitrary remote actions instead of jumping</summary>

Basic template:

local function remote_action ()
  require('leap').leap {
    target_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.

</details>

Configuration

<details> <summary>Disable auto-jumping to the first match</summary>
require('leap').opts.safe_labels = {}
</details> <details> <summary>Disable previewing labels</summary>
require('leap').opts.preview_filter = function () return false end
</details> <details> <summary>Greying out the search area</summary>
-- Or just set to grey directly, e.g. { fg = '#777777' },
-- if Comment is saturated.
vim.api.nvim_set_hl(0, 'LeapBackdrop', { link = 'Comment' })
</details> <details> <summary>Lightspeed-style highlighting</summary>
-- The below settings make Leap's highlighting closer to what you've been
-- used to in Lightspeed.

vim.api.nvim_set_hl(0, 'LeapBackdrop', { link = 'Comment' }) -- or some grey
vim.api.nvim_set_hl(0, 'LeapMatch', {
  -- For light themes, set to 'black' or similar.
  fg = 'white', bold = true, nocombine = true,
})
-- Deprecated option. Try it without this setting first, you might find
-- you don't even miss it.
require('leap').opts.highlight_unlabeled_phase_one_targets = true
</details> <details> <summary>Working with non-English text</summary>

If a language-mapping ('keymap') is active, Leap waits for keymapped sequences as needed and searches for the keymapped result as expected.

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ẓ'
}
</details>

Miscellaneous

<details> <summary>Was the name inspired by Jef Raskin's Leap?</summary>

To paraphrase Steve Jobs about their logo and Turing's poison apple, I wish it were, but it is a coincidence. "Leap" is just another synonym for "jump", that happens to rhyme with Sneak. That said, in some respects you can indeed think of leap.nvim as a spiritual successor to Raskin's work, and thus the name as a little tribute to the great pioneer of interface design, even though embracing the modal paradigm is a fundamental difference in our approach.

</details>

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:

Some practical examples:

<details> <summary>Linewise motions</summary>
local function get_line_starts(winid, skip_range)
  local wininfo =  vim.fn.getwininfo(winid)[1]
  local cur_line = vim.fn.line('.')
  -- Skip lines close to the cursor.
  local skip_range = skip_range or 2

  -- Get targets.
  local targets = {}
  local lnum = wininfo.topline
  while lnum <= wininfo.botline do
    local fold_end = vim.fn.foldclosedend(lnum)
    -- Skip folded ranges.
    if fold_end ~= -1 then
      lnum = fold_end + 1
    else
      if (lnum < cur_line - skip_range) or (lnum > cur_line + skip_range) then
        table.insert(targets, { pos = { lnum, 1 } })
      end
      lnum = lnum + 1
    end
  end

  -- Sort them by vertical screen distance from cursor.
  local cur_screen_row = vim.fn.screenpos(winid, cur_line, 1)['row']
  local function screen_rows_from_cur(t)
    local t_screen_row = vim.fn.screenpos(winid, t.pos[1], t.pos[2])['row']
    return math.abs(cur_screen_row - t_screen_row)
  end
  table.sort(targets, function (t1, t2)
    return screen_rows_from_cur(t1) < screen_rows_from_cur(t2)
  end)

  if #targets >= 1 then
    return targets
  end
end

-- You can pass an argument to specify a range to be skipped
-- before/after the cursor (default is +/-2).
function leap_line_start(skip_range)
  local winid = vim.api.nvim_get_current_win()
  require('leap').leap {
    target_windows = { winid },
    targets = get_line_starts(winid, skip_range),
  }
end

-- For maximum comfort, force linewise selection in the mappings:
vim.keymap.set('x', '|', function ()
  -- Only force V if not already in it (otherwise it would exit Visual mode).
  if vim.fn.mode(1) ~= 'V' then vim.cmd('normal! V') end
  leap_line_start()
end)
vim.keymap.set('o', '|', "V<cmd>lua leap_line_start()<cr>")
</details> <details> <summary>Select Tree-sitter nodes</summary>

Not as sophisticated as flash.nvim's implementation, but totally usable, in 50 lines:

local api = vim.api
local ts = vim.treesitter

local function get_ts_nodes()
  if not pcall(ts.get_parser) then
    return
  end
  local wininfo = vim.fn.getwininfo(api.nvim_get_current_win())[1]

  -- Get current node, and then its parent nodes recursively.
  local cur_node = ts.get_node()
  if not cur_node then return end
  local nodes = { cur_node }
  local parent = cur_node:parent()
  while parent do
    table.insert(nodes, parent)
    parent = parent:parent()
  end

  -- Create Leap targets from TS nodes.
  local targets = {}
  local startline, startcol
  for _, node in ipairs(nodes) do
    startline, startcol, endline, endcol = node:range()  -- (0,0)
    local startpos = { startline + 1, startcol + 1 }
    local endpos = { endline + 1, endcol + 1 }
    -- Add both ends of the node.
    if startline + 1 >= wininfo.topline then
      table.insert(targets, { pos = startpos, altpos = endpos })
    end
    if endline + 1 <= wininfo.botline then
      table.insert(targets, { pos = endpos, altpos = startpos })
    end
  end

  if #targets >= 1 then
    return targets
  end
end

local function select_node_range(target)
  local mode = api.nvim_get_mode().mode
  -- Force going back to Normal from Visual mode.
  if not mode:match('no?') then vim.cmd('normal! ' .. mode) end
  vim.fn.cursor(unpack(target.pos))
  local v = mode:match('V') and 'V' or mode:match('') and '' or 'v'
  vim.cmd('normal! ' .. v)
  vim.fn.cursor(unpack(target.altpos))
end

local function leap_ts()
  require('leap').leap {
    target_windows = { api.nvim_get_current_win() },
    targets = get_ts_nodes,
    action = select_node_range,
  }
end

vim.keymap.set({'x', 'o'}, '\\', leap_ts)
</details> <details> <summary>Shortcuts to Telescope results</summary>
-- NOTE: If you try to use this before entering any input, an error is thrown.
-- (Help would be appreciated, if someone knows a fix.)
local function get_targets (buf)
  local pick = require('telescope.actions.state').get_current_picker(buf)
  local scroller = require('telescope.pickers.scroller')
  local wininfo = vim.fn.getwininfo(pick.results_win)[1]
  local top = math.max(
    scroller.top(pick.sorting_strategy, pick.max_results, pick.manager:num_results()),
    wininfo.topline - 1
  )
  local bottom = wininfo.botline - 2  -- skip the current row
  local targets = {}
  for lnum = bottom, top, -1 do  -- start labeling from the closest (bottom) row
    table.insert(targets, { wininfo = wininfo, pos = { lnum + 1, 1 }, pick = pick, })
  end
  return targets
end

local function pick_with_leap (buf)
  require('leap').leap {
    targets = function () return get_targets(buf) end,
    action = function (target)
      target.pick: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 },
    }
  }
}
</details> <details> <summary>Enhanced f/t motions</summary>

See flit.nvim. Note that this is not a proper extension plugin, as it uses undocumented API too.

</details>