myelinContext-bound keymaps and which-key menus for Go programs

☰ Contents · Concepts › How myelin works

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 its select, a web page setTimeout. 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 x never stops someone typing an x into 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. MayHold and MayDepth use 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.

Starting the terminal…