Skip to content
TildeMailv0.2.1

Key bindings

The key layer turns key presses into named actions and Lua functions. This page covers the key notation, the vim preset, the action names and binding keys from Lua.

The input services are in TildeMail.Core, with their contracts and key types in TildeMail.Abstractions; the layer has no user interface of its own. The terminal interface drives it: it reads these keys in the sidebar, the list and the reader. Reading, acting on messages, searching and writing all work.

In init.lua:

keys.bind("list", "<leader>n", function() sys.notify("tmail", "n") end) -- <Space>n in the list
keys.bind("list", "x", "message.delete") -- an action name
keys.unbind("list", "dd") -- the preset's dd is gone from the list
keys.bind("gx", function() sys.notify("tmail", "gx") end) -- global: g, then x

In config.toml, the [keys] table sets the preset, the leader key and the timeout:

[keys]
preset = "vim"
leader = "<Space>"
timeout_ms = 1000

The defaults and ranges are in config.toml.

Keys are written in vim notation. A plain character is one key; a character with its combining marks, or an emoji, is one key too. Everything else goes in angle brackets:

Written Key
j, G, /, é a character
gg, dd two keys, pressed one after the other
<Space>, <Tab>, <Esc>, <CR>, <BS>, <Del> a named key
<Up>, <PageDown>, <Home>, <F5> more named keys
<C-d> Control and d; <C-D> is the same key
<A-x> or <M-x> Alt and x
<S-Tab> Shift on a named key
<C-A-x> modifiers combine
<lt> a literal <
<leader>n the leader key, then n
  • Names are case-insensitive. The aliases are <Escape>, <Enter>, <Return>, <Backspace>, <Delete>, and <M-…> for <A-…>.
  • Shift is only valid on named keys: write X, not <S-x>.
  • A bare space character is not a key; write <Space>.
  • A sequence holds at most 8 keys, the leader included.
  • Malformed notation is rejected with a message that names the part at fault: an empty string, an unclosed <, <>, an unknown name such as <Nope>, or a ninth key.

Bindings belong to one of four contexts: global, sidebar, list and reader. A key is looked up first in the focused context and then in global, so a global binding such as q works everywhere unless the focused context binds the same sequence itself.

Digits typed before a key give the action a count: with the vim preset, 5j runs cursor.down with a count of 5.

  • A count starts with 1 to 9, continues with 0 to 9 and is capped at 9999.
  • Digits 1 to 9 cannot start a binding or be the leader, because they always start a count. keys.bind and keys.unbind raise a Lua error for them, and a keys.leader of 1 to 9 is a configuration error.
  • A 0 can be bound, and it works as a key while no count is being typed.
  • A Lua function is run once, without a count.
  • A key that has no binding after a count is dropped, and so is the count.

A sequence can be a complete binding and also the start of a longer one; the layer then waits for the next key.

In the vim preset d, g and z are only the start of dd, gg and za, and the leader key is the start of <leader>e (sidebar.toggle) in the sidebar and list contexts, where it has no other meaning: there <Space> waits for a second key, and a <Space> followed by a key that is not e is dropped together with that key. A key that waits otherwise appears as soon as you bind a <leader>… sequence while a context binds the leader key itself. For example, the reader context binds <Space>, the default leader, to page.down, and the preset does not bind <leader>e there so that <Space> scrolls at once. Once a <leader>… sequence is bound in the reader as well, <Space> waits in the reader:

  • <Space> followed by x runs <leader>x.
  • <Space> followed by j scrolls a page and then moves the cursor down: the binding of <Space> runs, and j is handled as a new key. In that case the layer gives the terminal interface two results instead of one, in the order to run them.
  • <Space> alone runs only after timeout_ms, because the layer cannot know that nothing follows.

If that wait bothers you, choose a leader that no context of the preset binds, such as , or \: a leader that a context binds (l, a, m, s, o, q, c, /, :, <Tab>, <CR> and others) waits in that context, because <leader>e makes it the start of a longer binding in the sidebar and list. The preset binds <leader>e in those two contexts and a context’s own binding is looked up before a global one: a keys.bind of <leader>e with no context, or from a plugin, still works in the reader but not in the sidebar and list; bind it in those contexts or choose another key.

While the layer waits:

  • when the next key completes a longer binding, that binding runs;
  • when the next key continues no binding and the pending keys are a binding, that binding runs with the count that was typed, and the next key is handled as the first key of a new sequence: it can be an action, a digit that starts a count, the start of another sequence, or a key that matches nothing;
  • when the next key continues no binding and the pending keys are not a binding, the pending keys are dropped and that key is dropped too, not replayed;
  • when the next key arrives in another context, the pending keys are dropped first, without running;
  • when the timer runs out, the binding that matches the pending keys exactly runs, if there is one, and the keys are dropped otherwise.

The layer has no clock. The host owns the timer, and the terminal interface is that host: when the last result for a key leaves the layer waiting, the host starts a timer of timeout_ms, cancels it on the next key, and calls the layer’s timeout resolution when it fires. A count that gets no key within timeout_ms is dropped the same way.

While a sequence is pending the interface lists the keys that can follow, after 400 ms (less when timeout_ms is shorter than 800 ms), and then waits for the next key instead of timing out. It reads the same bindings as the help, so a binding shows up there as soon as you make it.

preset = "vim" (the default) fills the four contexts with the bindings below; preset = "none" starts with none. Each key is followed by its action.

Context Bindings
global q app.quit, <Tab> pane.next, <S-Tab> pane.previous, / search.start, c message.compose, ? help.show, : command.start
sidebar j and <Down> cursor.down, k and <Up> cursor.up, gg and <Home> cursor.first, G and <End> cursor.last, <CR> and l folder.open; <leader>e sidebar.toggle
list the four cursor keys as in sidebar; <C-d> half_page.down, <C-u> half_page.up, <C-f> and <PageDown> page.down, <C-b> and <PageUp> page.up; <CR>, o and l message.open; r message.reply, R message.reply_all, f message.forward, dd message.delete, a message.archive, m message.move, U message.toggle_seen, s message.toggle_flag, V message.show_source; T messages.view_toggle, za messages.thread_toggle; <leader>e sidebar.toggle; <Esc> search.clear; h pane.previous
reader the four cursor keys as in sidebar; <C-d> half_page.down, <C-u> half_page.up; <C-f>, <PageDown> and <Space> page.down, <C-b>, <PageUp> and b page.up; r, R, f, dd, a, m, U, s and V as in list; n message.next, p message.previous; A attachment.next, S attachment.save; K signature.fetch_key; h and <Esc> pane.previous

An action is named in the form area.verb. The set is closed: 37 names, and a name outside it is an error.

Area Names
cursor cursor.down, cursor.up, cursor.first, cursor.last
scrolling page.down, page.up, half_page.down, half_page.up
folders folder.open
messages message.open, message.compose, message.reply, message.reply_all, message.forward, message.delete, message.archive, message.move, message.toggle_seen, message.toggle_flag, message.show_source, message.next, message.previous
message list messages.view_list, messages.view_threads, messages.view_toggle, messages.thread_toggle
search search.start, search.clear
panes pane.next, pane.previous, sidebar.toggle
attachments attachment.next, attachment.save
signatures signature.fetch_key
application app.quit, help.show
command line command.start

command.start (: in the vim preset) opens the command line at the bottom of the terminal interface, where built-in commands, action names and the commands defined in Lua can be typed.

  • keys.bind(lhs, rhs) binds in global, so a script can bind a single character such as "N" without naming a context. keys.bind(context, lhs, rhs) binds in the named context. In both forms rhs is a function or an action name.
  • A function can be followed by a description: keys.bind(lhs, fn, "text") and keys.bind(context, lhs, fn, "text"). The description is what the which-key panel shows next to the key; without one the panel says lua function. A description that is not a string is a Lua error, and an action name needs none, because every action has its own.
  • keys.unbind(context, lhs) removes that context’s binding, preset bindings included.
  • lhs is parsed as notation. A string of more than one character is a key sequence, not one literal key name: keys.bind("gx", fn) binds g followed by x. A literal < must be written <lt>, because a bare < is malformed notation.
  • Binding a key again in the same context replaces the binding. Binding it to an action removes a function bound there before, and the other way round.
  • A bad argument raises a Lua error: an unknown context or action name (the message lists the valid choices), a malformed key, an rhs that is neither a function nor a string, or a nil rhs after a context name, as in keys.bind("list", "j", undefined_fn).
  • A reload of the scripts drops the key bindings the scripts made and restores the preset.