Skip to content
NoetherVim is alpha. Breaking changes land without deprecation shims. These docs track main.

Notable keybindings

The keybinding philosophy covers how the keyspace is organised, and :help noethervim-keymaps lists every binding the distribution sets. This page covers a handful of individual choices that are interesting to single out and make copiable.

Every snippet here is standalone. None of it depends on NoetherVim, and none of it depends on a plugin unless the entry says so.

j and k know the difference between a hop and a jump

Section titled “j and k know the difference between a hop and a jump”

Wrapped lines force a choice. Map j to gj and short movements inside a wrapped paragraph behave the way the screen looks, but 10j now counts screen rows instead of lines, and none of it enters the jumplist, so <C-o> cannot take you back. Leave j alone and every wrapped paragraph becomes a single unnavigable line.

The split is by count. A bare press or a small count moves by visual line. A count above five sets a jumplist mark first and then moves by logical line, because a movement that large is a jump, and jumps should be undoable.

local function vline_move(key)
local n = vim.v.count
if n > 5 then
return "m'" .. n .. key -- set jump mark, then logical-line move
elseif n > 0 then
return n .. "g" .. key -- counted visual-line move
else
return "g" .. key -- single visual-line hop
end
end
vim.keymap.set("n", "j", function() return vline_move("j") end, { expr = true })
vim.keymap.set("n", "k", function() return vline_move("k") end, { expr = true })

The threshold of five is arbitrary and worth tuning. Set it to whatever number of lines you can cross without losing your place.

After /pattern, n goes down. After ?pattern, n goes up. This is consistent behaviour, but if you work on something else and then press n you often forget which key opened the search. This keymaps makes the direction of the key be a property of the key:

vim.keymap.set({ "n", "v" }, "n",
function() return vim.v.searchforward == 1 and "n" or "N" end,
{ expr = true, silent = true })
vim.keymap.set({ "n", "v" }, "N",
function() return vim.v.searchforward == 1 and "N" or "n" end,
{ expr = true, silent = true })

? still searches backward, namely it starts the search upward. It just no longer inverts the two keys you press afterwards.

Vim ships ZZ (write and quit) and ZQ (quit, discarding changes). Two keys that read as arbitrary until you notice they are the two corners of a table:

  • Rows are how much you are willing to lose. Columns are what you are closing.
this window everything this buffer
force, discard changes ZQ :q! ZW :qa! ZE :bd!
refuse if dirty ZA :q ZS :qa ZD :bd
save first ZZ :x ZX :wa|qa! ZC :w|bd

Concretely:

vim.keymap.set("n", "ZA", "<cmd>q<cr>")
vim.keymap.set("n", "ZS", "<cmd>qa<cr>")
vim.keymap.set("n", "ZW", "<cmd>qa!<cr>")
vim.keymap.set("n", "ZX", "<cmd>wa<bar>qa!<cr>")
vim.keymap.set("n", "ZD", "<cmd>bdelete<cr>")
vim.keymap.set("n", "ZE", "<cmd>bdelete!<cr>")
vim.keymap.set("n", "ZC", "<cmd>write<bar>bdelete<cr>")

The arrow keys duplicate hjkl and sit far enough from home row that they see little use as motions. That leaves four keys free, and four more with shift.

The direction rule matters more than the binding. Vim’s own :resize grows the current window by preferring its right or bottom border, and falls back to the opposite border once the window is against the screen edge, so an arrow binding built on it reverses direction in that case. These bind the arrow to the direction the border moves instead:

  • <Right> pushes the right edge rightward, <S-Right> pulls the left edge rightward, and the same for the other three axes.
  • Each operation is a no-op when there is no neighbour on the moving edge, rather than resizing the opposite side.
local function neighbor(dir)
local cur, nbr = vim.fn.winnr(), vim.fn.winnr("1" .. dir)
return nbr ~= cur and vim.fn.win_getid(nbr) or nil
end
vim.keymap.set("n", "<Right>", function()
if neighbor("l") then vim.fn.win_move_separator(0, 2) end
end)
vim.keymap.set("n", "<Left>", function()
local w = neighbor("h")
if w then vim.fn.win_move_separator(w, -2) end
end)
vim.keymap.set("n", "<Down>", function()
if neighbor("j") then vim.fn.win_move_statusline(0, 2) end
end)
vim.keymap.set("n", "<Up>", function()
local w = neighbor("k")
if w then vim.fn.win_move_statusline(w, -2) end
end)

win_move_separator() and win_move_statusline() treat the resize as a drag of the border between two windows, which is why the neighbour check is required: on the bottom-most window, win_move_statusline() takes its rows from 'cmdheight' instead.

NoetherVim adds one branch on top. When the tab holds a single non-floating window, or the cursor is inside a float, there is nothing to resize against, so the arrows fall through to hjkl motion. Counts pass through with them, so 5<Down> still moves five lines.

<Esc> leaves the current mode. In normal mode it does nothing, so it is free to carry a second meaning: clear whatever the screen is still holding.

Search highlighting, notification toasts and LSP hover floats each have their own way out. This routes all of them through one key.

vim.keymap.set({ "n", "v" }, "<Esc>", function()
vim.cmd.stopinsert()
vim.cmd.noh()
-- vim.lsp.util sets this on the source buffer to the float's winid
local float = vim.b.lsp_floating_preview
if float and vim.api.nvim_win_is_valid(float) then
pcall(vim.api.nvim_win_close, float, true)
end
if package.loaded["snacks"] then require("snacks").notifier.hide() end
if package.loaded["notify"] then require("notify").dismiss() end
end, { silent = true })

One constraint keeps the list from growing without bound: <Esc> may dismiss things and may not change the buffer, so anything it closes can be brought back by repeating whatever opened it.

Keeping the unnamed register off the clipboard

Section titled “Keeping the unnamed register off the clipboard”

Setting clipboard=unnamedplus sends every yank to the system clipboard, and sends x, dd, c and every other delete-shaped operation there too. Copy something in a browser, delete a line in Neovim to make room for it, and the paste is gone.

Neovim leaves 'clipboard' empty by default. What is left is to build explicit bridges, so that reaching the clipboard on purpose takes one key rather than a register prefix, and to stop transient edits from overwriting the unnamed register:

-- Transient edits should not cost you the register
vim.keymap.set("n", "s", '"_s') -- substitute char, keep the register
vim.keymap.set("v", "p", '"_dP') -- paste over a selection, keep the register
-- Explicit bridges, in both directions
vim.keymap.set({ "n", "v" }, "<Leader>y", '"*y')
vim.keymap.set("n", "<Leader>Y", '"*yy')
vim.keymap.set("n", "<Leader>p", '"*p')
vim.keymap.set("n", "<Leader>P", '"*P')
vim.keymap.set("v", "Y", '"*y')
vim.keymap.set("v", "P", '"_d"*P') -- clipboard in, both registers intact

Visual p shows the difference most directly. Selecting a word and pasting over it normally moves the replaced text into the unnamed register, so pasting over a second word inserts the word that was just overwritten.

: and ; are the same physical key, and by default ; repeats the last f / t motion. Consider the following remapping:

vim.keymap.set({ "n", "v" }, ";", ":")

The trade depends on how much you use f{char} with ; to repeat: if you use it often, keep ; and put : somewhere else. NoetherVim ships this mapping, since entering the commandline is one of the most common actions and making so making it as effortless as possible outweighs the default;. :help noethervim-semicolon gives the one-line revert.

  • gC: inverts comments line by line in visual mode. The builtin gc operator picks one direction for the whole range based on the majority state, so a half-commented block becomes fully commented or fully uncommented. gC toggles each line on its own, leaving a mixed selection with every line in the opposite state.
  • |, _ and +: splits shaped like what they make. | splits vertically, _ splits horizontally, + opens a tab. They displace “go to screen column”, “down N-1 lines to first non-blank” and “down one line to first non-blank”. All three open an empty scratch buffer rather than a second view of the current one, which is what you want when the split is for something new; <C-w>v and <C-w>s still give you the second view. Leave the scratch untouched and it disappears with the window; type in it and Neovim will not let you abandon it unsaved.
  • -: highlights every instance of the word under the cursor and reports how many there are, leaving the cursor where it was. * does the highlight but moves to the next match.
  • <c-l> in insert mode in a buffer with writing (latex, markdown, etc.) will auto-fix the last spelling mistake: if you wrote helllo world | and press <c-l> when at | then it will give you hello world | and bring you back to the same cursor position.
  • [f and ]f: previous and next file in the current directory, alphabetically, wrapping at the ends. Useful for numbered or dated files: chapters, notes, migrations.
  • il and al: the line text objects Vim leaves out. il runs from the first non-blank character to the last, so dil clears a line’s contents without touching its indentation and cil retypes it in place. al is the whole line up to the break, so dal empties a line and keeps it, where dd would take the line with it, and yal puts the line in a register as text, ready for p to drop it inside another line instead of below it.
  • zv and zx: zz10<C-e> and zz10<C-y>. Centre the cursor line, then scroll the window ten lines, leaving the cursor on the same buffer line.
  • <C-w>t: a twelve-line terminal along the bottom that toggles rather than stacking a new buffer per press, so the shell from five minutes ago is still there. <Esc><Esc> leaves terminal mode. 'timeoutlen' is global and defaults to a full second, which would hold a single <Esc> back for that long before passing it to the program in the terminal, so NoetherVim drops it to 150ms on TermEnter and restores it on TermLeave.
  • Oil’s yank family: case picks the destination. yp / yd / yn yank the full path, the parent directory and the bare filename; Yp / Yd / Yn do the same into the system clipboard.
  • yc and yC: nvim-surround’s yss and ySS under single letters. Those two are the only mappings in the plugin that double a letter.
  • Command-line <C-o>: jumps to the start of the line, prefixes it with Redir, and returns to the end, so :hi<C-o><CR> puts the output in a scratch buffer rather than the pager. The keymap is "<c-b>Redir <c-e>"; the :Redir command behind it is NoetherVim’s, and covers both :commands and !shell. <C-l> inserts the current file’s directory, and <C-y> copies the command line itself to the clipboard.
  • Select mode: letters replace the selection instead of running commands, <Esc> twice returns to normal, and <C-a> jumps past the end of what was selected. Select mode is where a snippet placeholder leaves you.
  • J/K: This one is probably the most controversial one on this list, but these bindings have survived my config from the time they were added a few months into me getting into vim. In normal mode, J maps to j<c-e> and K maps to k<c-y>. In normal-mode, <c-e> and <c-y> move the buffer down/up while keeping your cursor position the same, so combining with j and k moves both the cursor and the buffer. K is by default the hover key when inspecting code: I remapped it to L whose default behaviour is to go to the end of the line.

:help noethervim-keymaps is the full list, including the prefix namespaces and a table of every Vim default the distribution shadows. Inside a running Neovim, <Space>? shows the same thing read from live state, and <CR> on any line jumps to where that keymap is defined.