How myelin works
myelin makes a few choices that shape how a program uses it. This page says what they are and why.
The keymap is data
A program hands myelin its keymap as values (documents of bindings) and gets values back: a match, a menu, an explanation, the state of a sequence. myelin never reads a file.
- Each program keeps its own format. One reads KDL, another YAML, another a plugin's manifest. They all build the same documents, so keys, contexts and precedence mean the same in all of them.
- Everything can be tested as values. A keymap, a path and a key go in, and the answer comes out, with no terminal or file system involved.
- Values can cross a process. Every data type has a generated msgpack codec, so a keymap built in one process can be matched in another, or a menu drawn by a front end in another language.
Only three types hold more than data: Env, Keymap and Context hold
compiled CEL. They are built once and are safe for concurrent use.
Time is a value
A key sequence such as g g has to wait for its second key, and give up
after a while. myelin doesn't start a timer for that. Feed takes the
current time and returns a deadline, and the program wakes itself at the
deadline and calls Expire.
- The program owns its event loop. A Bubble Tea program uses
tea.Tick, a server a timer in itsselect, a web pagesetTimeout. myelin fits all of them because it has no loop of its own. - Tests need no clock. Pass any time and the result is the same every run.
- A stale wake-up is harmless. Expire before the deadline does nothing.
A key is a name
A key is its canonical name, in Ghostty's
vocabulary: ctrl+d, shift+g, page_down, ctrl+key_w. Two keys are
the same keystroke exactly when their names are equal. A terminal's bytes,
a Bubble Tea key and a hotty host's key event come out as the same name,
so one keymap serves them all.
A key is a character, which follows the keyboard layout, or a physical
key, which stays in its place. The program decides which it sends for
each key. That keeps a key one name: ctrl+w means "the key that types
w" on every layout, and a game's key_w means the same place on every
layout.
Keys are a package of their own, github.com/neuroplastio/myelin/myelinkey,
which needs only the standard library and builds with TinyGo. Whatever
reads input can name keys with it without taking on CEL.
People write keys as they would for Ghostty, which reads modifiers in any order and case, W3C code values and a letter in either case. A program that wants one spelling per key reads strictly, and then every other spelling is refused with the one to use. Either way a listing, a menu and an error message show a key by its canonical name.
Depth, then layer, then order
The focus is a path of nodes, from the outside in, such as
window > list > field: the focused node and the nodes around it. A binding's context is an expression over that
path. When several bindings of the same keys hold, the one whose context
reaches deepest wins.
Depth comes first because the deepest context is the most specific. A key
bound for a text field has to beat the same key bound for the whole
window, but only while the field is open. A context that only says what
isn't there, such as !dialog, adds no depth, because it says nothing
about where the focus is.
At the same depth, the higher layer wins, so a user's binding overrides a default that is just as specific. Within a layer, the later binding wins, as it would in any config file.
Three rules follow from depth:
- An unbind is a binding to nothing, and wins the same way. To take away a key that a default binds in a context, unbind it in that context.
- A text field keeps its text. While a node that takes text has the
focus, a key that types text goes to it, unless a binding at least as
deep takes the key. A binding of
xnever stops someone typing anxinto a filter. - A mode is a fence. Where a fence's context holds, it can make that rule apply to every key at the fence's depth, so the bindings shallower than the mode stop applying. It can also send the keys that nothing takes nowhere instead of back to the program. The deepest fence decides, as the deepest binding wins, so a palette opened in a mode can give the default back.
Why CEL
Contexts are CEL expressions, compiled once when the keymap loads.
- Checked against the schema. A misspelled node, an attribute of the wrong type or an expression that isn't a boolean fails at load, with the names the schema has. A keymap that loads never fails on a key.
- Fast. A compiled context evaluates in well under a microsecond, so matching a key with contexts costs under half a microsecond, and a key no binding starts with costs one map lookup.
- Partial evaluation. With some attributes unknown, CEL can still say
whether an expression might hold.
MayHoldandMayDepthuse that to answer questions about every list, not one list: could this binding hold wherever a list has the focus, whatever the list shows?
Contexts run on cel-lite,
which implements the part of CEL they need with Go's standard library
alone: no protobuf, no parser generator, and a small program on myelin
is about 2.5 MB. An expression means what CEL's language definition
says. What cel-lite doesn't implement, such as the macros (exists,
all), matches, timestamps and durations, fails at load and says what
it is. Its language page
lists what it holds and what it refuses.
What it costs
Little enough to ask on every key. A key no binding starts with is one map lookup, a match in contexts takes under half a microsecond and allocates nothing, and the leader's menu takes tens of microseconds. Performance has the numbers, how they were measured, and what the gate holds.