myelin decides what a key does in your Go program, given what is focused
right now. You declare the things that can be focused (a window, a list, a
text field), bind keys in contexts over them, and layer the user's config
over your defaults. Sequences like g g, a leader key and
which-key menus are built in. It has no UI and reads no files, so it works
the same in a TUI, a GUI or anything else that takes keys.
Why myelin
Editors worked out key binding a long time ago.
- In VS Code, each rule in
keybindings.json has a key, a command and a
when clause. Your rules go after the defaults, and the last rule that
matches wins, so yours do.
- In Zed, bindings sit in contexts, in a tree of what's focused:
Workspace, Pane, Editor. A binding in a deeper context beats one higher
up, and a later one beats an earlier one. Sequences like
g g and a
which-key popup come built in.
Most other programs, terminal ones especially, hard-code their keys in a
switch: no overrides, no menus, and ctrl+d means something else in each.
myelin takes the editors' model and makes it a Go library.
| VS Code | Zed | myelin |
| A binding | key, command, when, args | a key and an action, in a context | Keys, Action, When, Args |
| Context | when over context keys | a predicate over the context tree | CEL over the path your program declares |
| Which wins | the last rule that matches | the deepest context, then the later binding | the deepest context, then the layer, then the later binding |
| Your config | appended after the defaults | loaded after the defaults | a layer above the defaults, which it can drop |
| Unbinding | -command | null | Unbind, which wins like a binding |
| Sequences | chords: ctrl+k ctrl+w | g g, with a timeout | g g, <leader> f n, with a deadline you wake at |
| Menus | — | a which-key popup | which-key levels and the whole tree, as data |
From VS Code it takes flat rules, and a user's config layered over the
defaults. From Zed it takes contexts over a tree, the rule that the deepest
wins, and unbinding that wins the same way. The menus under a leader key
come from Spacemacs, and the key names from Ghostty.
Quick start
A file manager: a window, a list of files, and a filter field over the
list.
1. Declare what can be focused. Each kind of node can have typed
attributes, and a node that takes text keeps the keys that type it.
env, err := myelin.NewEnv(myelin.Schema{
Nodes: []myelin.NodeType{
{Name: "window"},
{Name: "list", Attrs: map[string]myelin.Type{"dir": "string"}},
{Name: "field", Text: true},
},
Layers: []string{"defaults", "user"},
Timeout: 500 * time.Millisecond,
})
if err != nil {
panic(err)
}
2. Write the bindings. A program usually reads them from its files and
the user's; here they are literals.
defaults := myelin.Doc{Layer: "defaults", Binds: []myelin.Bind{
{Keys: "j", Action: "down"},
{Keys: "g g", Action: "top"},
{Keys: "escape", Action: "back"},
{Keys: "escape", Action: "close-filter", When: "field"},
{Keys: "enter", Action: "open", When: "list"},
}}
user := myelin.Doc{Layer: "user", Binds: []myelin.Bind{
{Keys: "enter", Action: "preview", When: "list.dir.startsWith('/tmp')"},
}}
3. Build the keymap. Every mistake is reported at once, with where it
was written.
km, problems := env.Build([]myelin.Doc{defaults, user}, nil)
if km == nil {
panic(fmt.Sprint(problems))
}
4. Ask what keys do. The path is the focused node and the nodes around
it, outermost first.
home := myelin.Path{{Name: "window"}, {Name: "list", Attrs: map[string]any{"dir": "/home/me"}}}
tmp := myelin.Path{{Name: "window"}, {Name: "list", Attrs: map[string]any{"dir": "/tmp"}}}
filter := append(slices.Clone(home), myelin.Step{Name: "field"})
match := func(keys string, p myelin.Path) string {
seq, err := myelinkey.ParseSeq(keys)
if err != nil {
return err.Error()
}
switch m := km.Match(seq, p); {
case m.Prefix:
return "wait for more"
case m.Exact != nil:
return m.Exact.Action
}
return "nothing"
}
fmt.Println("enter in a list:", match("enter", home))
fmt.Println("enter in /tmp:", match("enter", tmp))
fmt.Println("escape in the filter:", match("escape", filter))
fmt.Println("j in the filter:", match("j", filter))
fmt.Println("g:", match("g", home))
// Output:
// enter in a list: open
// enter in /tmp: preview
// escape in the filter: close-filter
// j in the filter: nothing
// g: wait for more
j does nothing in the filter because the field takes text: there, j
types a j.