Skip to content

ggandor/leap.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

leap.nvim

Leap is a general-purpose motion plugin for Neovim, building and improving primarily on 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.

showcase

How to use it (TL;DR)

  • Initiate the search in the current window (s) or in the other windows (S), and start typing a 2-character pattern ({char1}{char2}).

  • After typing {char1}, you see "labels" appearing next to some pairs. This is just a preview - labels only get active after finishing the pattern.

  • Type {char2}, which filters the matches. When the closest pair is unlabeled, you automatically jump there. In case that was your target, you can safely ignore the remaining labels - those will not conflict with any sensible command, and will disappear on the next keypress.

  • Else: type the label character to jump to the given position. If there are more matches than available labels, you can move between groups with <space> and <backspace>.

To move to the last character on a line, type s{char}<space>. To move to an empty line, type s<space><space>.

At any stage, <enter> jumps to the next/closest available target: s<enter> repeats the previous search; s{char}<enter> accepts the closest {char} match.

Advantages

  • Universal: the same command can be used for any non-trivial jumps.

  • Reliable: no blind spots - any position where you can put your cursor is targetable.

  • Atomic: no need to compose motions - one command achieves one logical movement.

  • Context-blind: no need to count matches or read line numbers - your eyes can focus on the target the whole time.

  • Efficient: three or four keystrokes bring you anywhere.

  • Smooth: while typing the search pattern, your brain can already start processing the label.

Extras

While Leap is built around its deeply thought-through default motions, it is also highly extensible, and provides API for lots of useful orthogonal features, like:

  • Native search integration: when finishing a / or ? search, visible matches can automatically be labeled for quick access.

  • Treesitter integration: parent nodes can be selected either directly via labels, or in an incremental way (with labels being available the whole time).

  • Remote actions: do operations at a distance, or even predefine remote text objects for extra comfort. For example, yanking a paragraph from a different window can be as simple as typing yarp, then pointing to anywhere within the paragraph with a regular leaping motion as the "laser pen".

🚀 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

  • Neovim >= 0.10.0 stable, or latest nightly

  • repeat.vim, for dot-repeats (.) to work

Installation

Use your preferred plugin manager. No extra steps needed besides defining keybindings - to use the defaults, call require('leap').set_default_mappings().

For alternative key mappings and arrangements (e.g. Sneak-style), see :h leap-mappings.

Suggested additional tweaks

Highly recommended: define a preview filter to reduce visual noise and the blinking effect after the first keypress (:h leap.opts.preview_filter). You can still target any visible positions if needed, but you can define what is considered an exceptional case.

-- Exclude whitespace and the middle of alphabetic words from preview:
--   foobar[baaz] = quux
--   ^----^^^--^^-^-^--^
require('leap').opts.preview_filter =
  function (ch0, ch1, ch2)
    return not (
      ch1:match('%s') or
      ch0:match('%a') and ch1:match('%a') and ch2:match('%a')
    )
  end

Define equivalence classes for brackets and quotes, in addition to the default whitespace group:

require('leap').opts.equivalence_classes = { ' \t\r\n', '([{', ')]}', '\'"`' }

Use the traversal keys to repeat the previous motion without explicitly invoking Leap:

require('leap.user').set_repeat_keys('<enter>', '<backspace>')
Lazy loading

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

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, as it contains lots of additional information and details.

Experimental modules

Treesitter integration

You can either choose a node directly (vR{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 (vRRRy). Traversal can "wrap around" backwards (vRr selects the root node).

It is also worth noting that linewise mode (VRRR..., yVR) filters out redundant nodes (only the outermost are kept in a given line range), making the selection much more efficient.

vim.keymap.set({'x', 'o'}, 'R',  function ()
  require('leap.treesitter').select {
    -- To increase/decrease the selection in a clever-f-like manner,
    -- with the trigger key itself (vRRRRrr...). The default keys
    -- (<enter>/<backspace>) also work, so feel free to skip this.
    opts = require('leap.user').with_traversal_keys('R', 'r')
  }
end)
Remote actions

Inspired by leap-spooky.nvim, and flash.nvim's similar feature.

This 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 an operation or insertion is finished, it moves back to the original position, as if you had operated from the distance.

vim.keymap.set({'n', 'x', 'o'}, 'gs', function ()
  require('leap.remote').action()
end)

Example: gs{leap}yap, vgs{leap}apy, or ygs{leap}ap yank the paragraph at the position specified by {leap}.

Note: The remote module is not really an extension, but more of an "inverse plugin" bundled with Leap; the jump logic is not hardcoded - action can use any function via the jumper parameter, be it a custom leap() call or something entirely different.

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 (just ygs{leap}ap, or, with predefiend remote text object, yarp{leap}, and voilà, the remote paragraph appears there):

vim.api.nvim_create_autocmd('User', {
  pattern = 'RemoteOperationDone',
  group = vim.api.nvim_create_augroup('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 - giving input ahead of time (remote text objects)

The input parameter lets you feed keystrokes automatically after the jump:

-- Trigger visual selection right away, so that you can `gs{leap}apy`:
vim.keymap.set({'n', 'o'}, 'gs', function ()
  require('leap.remote').action { input = 'v' }
end)

-- Other ideas: `V` (forced linewise), `K`, `gx`, etc.

By feeding text objects as input, you can create remote text objects, for an even more intuitive workflow (yarp{leap} - "yank a remote paragraph at..."):

-- Create remote versions of all a/i text objects by inserting `r`
-- into the middle (`iw` becomes `irw`, etc.).
-- A trick to avoid having to create separate hardcoded mappings for
-- each text object: when entering `ar`/`ir`, consume the next
-- character, and create the input from that character concatenated to
-- `a`/`i`.
do
  local remote_text_object = function (prefix)
     local ok, ch = pcall(vim.fn.getcharstr)  -- pcall for handling <C-c>
     if not ok or (ch == vim.keycode('<esc>')) then
       return
     end
     require('leap.remote').action { input = prefix .. ch }
  end
  vim.keymap.set({'x', 'o'}, 'ar', function () remote_text_object('a') end)
  vim.keymap.set({'x', 'o'}, 'ir', function () remote_text_object('i') end)
end

A very handy custom mapping - remote line(s), with optional count (yaa{leap}, y3aa{leap}):

vim.keymap.set({'x', 'o'}, 'aa', function ()
  -- Force linewise selection.
  local V = vim.fn.mode(true):match('V') and '' or 'V'
  -- In any case, move horizontally, to trigger operations.
  local input = vim.v.count > 1 and (vim.v.count - 1 .. 'j') or 'hl'
  -- With `count=false` you can skip feeding count to the command
  -- automatically (we need -1 here, see above).
  require('leap.remote').action { input = V .. input, count = false }
end)

Swapping regions

It deserves mention that this feature also makes exchanging two regions of text moderately simple, without needing a custom plugin: d{region1} gs{leap} v{region2}p <jumping-back-here> P.

Example (swapping two words): diw gs{leap} viwp P.

With remote text objects, the swap is even simpler, almost on par with vim-exchange: diw virw{leap}p P.

Using remote text objects and combining them with an exchange operator is pretty much text editing at the speed of thought: cxiw cxirw{leap}.

🔍 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; note that the "search command on steroids" approach by Pounce and Flash, where you can type as many characters as you want, and the labels appear at an unknown time by design, makes this last goal impossible)

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, 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 (thanks to smart auto-jumping)

❔ FAQ

Defaults

Why remap `s`/`S`?

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

  • s = cl (or xi)
  • S = cc

Visual mode

  • s = c
  • S = Vc, or c if already in linewise mode

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

Features

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

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 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ïīíìîı', '', '', 'sṣšß', 'tṭ', 'uúûüűū', 'zẓ'
}
Arbitrary remote actions instead of jumping

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.

Configuration

Disable auto-jumping to the first match
require('leap').opts.safe_labels = {}
Force auto-jumping to the first match
require('leap').opts.labels = {}
Disable previewing labels
require('leap').opts.preview_filter = function () return false end
Always show labels at the beginning of the match

Warning: on_beacons is an experimental escape hatch, and this workaround depends on implementation details.

-- `on_beacons` hooks into `beacons.light_up_beacons`, the function
-- responsible for displaying stuff.
require('leap').opts.on_beacons = function (targets, _, _)
  for _, t in ipairs(targets) do
    -- Overwrite the `offset` value in all beacons.
    -- target.beacon looks like: { <offset>, <extmark_opts> }
    if t.label and t.beacon then t.beacon[1] = 0 end
  end
end
Grey out the search area

Set the LeapBackdrop highlight group (usually linking to Comment is preferable):

vim.api.nvim_set_hl(0, 'LeapBackdrop', { link = 'Comment' })
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, removes `LeapBackdrop`, and updates the
      -- look of concealed labels.
      require('leap').init_hl(true)
    end
  end
})

Miscellaneous

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. "Leap" is just another synonym for "jump", that happens to rhyme with Sneak. 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.

Note: pattern is an experimental feature at the moment.

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 == '?')
    local cnt = vim.fn.searchcount().total

    if is_search_cmd and (not ev.abort) and (cnt > 1) then
      -- Allow CmdLineLeave-related chores to be completed before
      -- invoking Leap.
      vim.schedule(function ()
        -- We want "safe" labels, but no auto-jump (as the search
        -- command already does that), so just use `safe_labels`
        -- as `labels`, with n/N removed.
        local safe_labels = require('leap').opts.safe_labels
        if type(safe_labels) == 'string' then
          safe_labels = vim.fn.split(safe_labels, '\\zs')
        end
        local labels = vim.tbl_filter(function (l) return l:match('[^nN]') end,
                                      safe_labels)
        -- For `pattern` search, we never need to adjust conceallevel
        -- (no user input).
        local vim_opts = require('leap').opts.vim_opts
        vim_opts['wo.conceallevel'] = nil

        require('leap').leap {
          pattern = vim.fn.getreg('/'),  -- last search pattern
          target_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 ()
      local leap = require('leap')
      -- Allow traversing with the trigger key.
      local next_target = vim.deepcopy(leap.opts.keys.next_target)
      if type(next_target) == 'string' then
        next_target = { next_target }
      end
      table.insert(next_target, key)

      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 = {
          keys = { next_target = next_target },
          -- Auto-jumping 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 vim.tbl_filter(function (l) return l:match('[^nN]') end,
                              leap.opts.safe_labels),
        }
      }
      -- 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
1-character search (enhanced f/t motions)

Note: inputlen is an experimental feature at the moment.

do
  -- Returns an argument table for `leap()`, tailored for f/t-motions.
  local function as_ft (key_specific_args)
    local common_args = {
      inputlen = 1,
      inclusive_op = true,
      -- To limit search scope to the current line:
      -- pattern = function (pat) return '\\%.l'..pat end,
      opts = {
        labels = {},  -- force autojump
        safe_labels = vim.fn.mode(1):match('o') and {} or nil,  -- [1]
        case_sensitive = true,                                  -- [2]
      },
    }
    return vim.tbl_deep_extend('keep', common_args, key_specific_args)
  end

  local clever = require('leap.user').with_traversal_keys       -- [3]
  local clever_f = clever('f', 'F')
  local clever_t = clever('t', 'T')

  for key, args in pairs {
    f = { opts = clever_f, },
    F = { backward = true, opts = clever_f },
    t = { offset = -1, opts = clever_t },
    T = { backward = true, offset = 1, opts = clever_t },
  } do
    vim.keymap.set({'n', 'x', 'o'}, key, function ()
      require('leap').leap(as_ft(args))
    end)
  end
end

------------------------------------------------------------------------
-- [1] Match the modes here for which you don't want to use labels
--     (`:h mode()`, `:h lua-pattern`).
-- [2] For 1-char search, you might want to aim for precision instead of
--     typing comfort, to get as many direct jumps as possible.
-- [3] This helper function makes it easier to set "clever-f"-like
--     functionality (https://github.com/rhysd/clever-f.vim), returning
--     an `opts` table derived from the defaults, where:
--     * the given keys are added to `keys.next_target` and
--       `keys.prev_target`
--     * the forward key is used as the first label in `safe_labels`
--     * the backward (reverse) key is removed from `safe_labels`
Jump to lines

Note: pattern is an experimental feature at the moment.

vim.keymap.set({'n', 'x', 'o'}, '|', function ()
  local _, l, c = unpack(vim.fn.getpos('.'))
  local pattern =
    '\\v'
       -- Skip 3-3 lines around the cursor (`:help /\%l`).
    .. "(%<"..(math.max(1,l-3)).."l" .. '|' .. "%>"..(l+3).."l)"
       -- Cursor column or EOL before the cursor (`:help /\%c`).
    .. "(%"..c.."v" .. '|' .. "%<"..c.."v$)"

  require('leap').leap {
    pattern = pattern,
    target_windows = { vim.fn.win_getid() },
    opts = { safe_labels = '' }
  }
end)
Shortcuts to Telescope results
local function get_targets (buf)
  local picker = require('telescope.actions.state').get_current_picker(buf)
  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 = {}
  -- Start labeling from the closest (bottom) row.
  for lnum = bottom, top, -1 do
    table.insert(targets,
                 { wininfo = wininfo, pos = { lnum + 1, 1 }, picker = picker, })
  end

  return targets
end

local function pick_with_leap (buf)
  require('leap').leap {
    targets = get_targets(buf),
    action = function (target)
      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 },
    }
  }
}

About

Neovim's answer to the mouse 🦘

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published