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.
Quick example
Section titled “Quick example”In init.lua:
keys.bind("list", "<leader>n", function() sys.notify("tmail", "n") end) -- <Space>n in the listkeys.bind("list", "x", "message.delete") -- an action namekeys.unbind("list", "dd") -- the preset's dd is gone from the listkeys.bind("gx", function() sys.notify("tmail", "gx") end) -- global: g, then xIn config.toml, the [keys] table sets the preset, the leader key and the timeout:
[keys]preset = "vim"leader = "<Space>"timeout_ms = 1000The defaults and ranges are in config.toml.
Notation
Section titled “Notation”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.
Contexts
Section titled “Contexts”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.
Counts
Section titled “Counts”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
1to9, continues with0to9and is capped at 9999. - Digits
1to9cannot start a binding or be the leader, because they always start a count.keys.bindandkeys.unbindraise a Lua error for them, and akeys.leaderof1to9is a configuration error. - A
0can 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.
Waiting for the next key
Section titled “Waiting for the next key”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 byxruns<leader>x.<Space>followed byjscrolls a page and then moves the cursor down: the binding of<Space>runs, andjis 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 aftertimeout_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.
The which-key panel
Section titled “The which-key panel”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.
The vim preset
Section titled “The vim preset”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 |
Action names
Section titled “Action names”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.
Binding from Lua
Section titled “Binding from Lua”keys.bind(lhs, rhs)binds inglobal, 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 formsrhsis a function or an action name.- A function can be followed by a description:
keys.bind(lhs, fn, "text")andkeys.bind(context, lhs, fn, "text"). The description is what the which-key panel shows next to the key; without one the panel sayslua 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.lhsis parsed as notation. A string of more than one character is a key sequence, not one literal key name:keys.bind("gx", fn)bindsgfollowed byx. 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
rhsthat is neither a function nor a string, or a nilrhsafter a context name, as inkeys.bind("list", "j", undefined_fn). - A reload of the scripts drops the key bindings the scripts made and restores the preset.