README
¶
key-sequence-processor
WordStar/JOE-style key sequences and chords for Go: multi-key sequences, precedence levels, wildcards, aliases and context help topics. If you use this, please support me on ko-fi: https://ko-fi.com/jeffday
Pure Go, standard library only.
Features
- Multi-key sequences (
^K X,^Q F) with prefix disambiguation - Precedence levels (
capture/override) so a hosted layer can claim keys - Per-level wildcards, and specific bindings that shadow them
- A binding can decline a key and drop resolution to the level below
- Configurable key aliases (
^I=Tab,^M=Return, …) - Context-sensitive help topics per sequence prefix
- Completion listing for a partially typed sequence
- Optional macOS Option-character layer for unbound Meta keys, on any platform
- No opinion about your key names, your commands, or what an unbound key does
Installation
go get github.com/phroun/key-sequence-processor/keyseq
Quick Start
package main
import (
"fmt"
"github.com/phroun/key-sequence-processor/keyseq"
)
func main() {
p := keyseq.NewProcessor(func(key, command string) bool {
fmt.Printf("%s -> %s\n", key, command)
return true // handled; a clean false declines the key
})
p.SetMappings(map[string]string{
"^K X": "save_and_exit",
"^K B": "block_begin",
"^Q F": "find",
"^K help": "\"Block commands\"", // shown while ^K is pending
})
// An unbound key is the application's business, not the processor's.
p.SetDefaultHandler(func(key string) string {
if len([]rune(key)) == 1 {
return "insert '" + key + "'"
}
return ""
})
p.ProcessKey("^K") // opens the sequence, dispatches nothing
p.ProcessKey("X") // -> save_and_exit
p.ProcessKey("q") // -> insert 'q'
}
Key names
The processor never parses a keyboard; it resolves whatever names you feed it.
A key event is a string, and a sequence is those strings joined by spaces —
so "^K X" is Ctrl-K then X, and a name may not itself contain a space (spell
the spacebar space, and "^B space" is a chord ending on it).
Names are yours to choose. If you decode a terminal with
direct-key-handler, its
Options.KeyNames lets you emit exactly the vocabulary you bind against, so
nothing has to be translated in between.
Precedence levels
Prefix a mapping with capture (+1) or override (+2) to raise its level;
they compound, so a layer can always outbid another by writing one more word.
p.SetMappings(map[string]string{
"up": "cursor_up", // level 0: the base keymap
"capture *": "terminal_key", // level 1: a hosted terminal claims everything
"capture ^C": "false", // ...except ^C, which it hands back
})
Resolution runs from the highest level down:
- The longest live sequence wins across all levels — nothing single-key outranks a chord in progress.
- Among equal-length matches, the highest level goes first.
- Within a level, a specific binding shadows that level's wildcard.
- A candidate that declines (a clean
falsefrom the executor) drops one level and re-runs. - Exhausted candidates fall to the default handler.
Declining a key
CommandExecutor returns whether it handled the key. Only a clean false
means "not mine" — success, an async suspension, or an error all hold the key,
so a command that merely went wrong can never volunteer its keystroke to a
layer below it.
Resolution-only mode
Pass a nil executor and nothing is dispatched: ProcessKey reports the
top-precedence command in ProcessResult.Command instead. Useful for showing a
user what a key would do.
Key aliases
A terminal sends the same byte for Tab as for ^I, so a binding written
either way should reach the same place. DefaultAliasGroups covers those cases
in direct-key-handler's
vocabulary. The first entry of each group is the primary; the rest are
spellings a binding may use.
| Key | Spellings | Why |
|---|---|---|
Backspace |
^H, ^8 |
^H is BS (8); ^8 is DEL (127), which arrives as Backspace |
Tab |
^I |
one byte (9) for both |
Return |
^M |
one byte (13) for both |
Escape |
^[, ^3, Esc |
one byte (27); ^3 is how a keyboard makes it with a digit |
^@ |
^2, ^space |
NUL, also Ctrl+Space |
^\ |
^4 |
FS |
^] |
^5 |
GS |
^^ |
^6 |
RS |
^_ |
^7 |
US |
Return and Enter are deliberately not aliased: they are two physical
keys, and folding them is an application's decision, not a default that quietly
discards the distinction.
Aliases are lookups, never rewrites. The spelling as pressed is tried first, so
naming both ^H and Backspace keeps them separately bindable — a group only
fills in when the keymap left one of its spellings unnamed.
Spellings
A second kind of entry names a key that has only one real token. Nothing ever
emits Minus; the word exists so a binding can be written without fighting
the syntax it is written in — - is the modifier separator, so M-- reads
badly and ^- cannot show where the modifier stops. (The processor already
depends on this for space, which cannot be spelled literally at all.)
| Key | Spelling | Key | Spelling | |
|---|---|---|---|---|
- |
Minus |
\ |
Backslash |
|
+ |
Plus |
/ |
Slash |
|
= |
Equals |
; |
Semicolon |
|
' |
Apos |
: |
Colon |
|
" |
Quote |
| | Pipe |
|
~ |
Tilde, Wave |
, |
Comma |
|
` |
Backtick |
. |
Period, Dot |
|
# |
Octothorpe |
For four of these the word is not a convenience but the only way in. A
keymap usually lives in a config file, and that file's own metacharacters are
exactly the keys that cannot appear literally on the left of a binding: a line
starting with ; or # is a comment, = separates key from command, and a
comma reads as a list separator. Semicolon, Octothorpe, Equals and
Comma are how those keys get bound at all.
The named keys carry their conventional abbreviations too — Esc, PgUp,
PgDn/PgDown, Ins, PrtSc.
Delete has no abbreviation, deliberately. Del and Delete are not one
key under two names: on a PC, Del is forward delete, while the key a Mac
labels delete is Backspace. Folding them would silently bind the wrong key on
one platform or the other — the one place here where the short form means
something different from the word it shortens. An application that wants an
abbreviation declares which key it means.
Spellings resolve through modifiers, not just on a bare key: the prefix stack is
peeled off, the base varied, and the prefix put back, so M-Minus and M--
name one key. ^ and C- are one modifier under two spellings, so ^-,
^Minus, C-- and C-Minus all reach the same binding.
An application with its own key names supplies its own groups — first entry is the primary, the rest are spellings a binding may use:
p.SetAliasGroups([]keyseq.AliasGroup{
{"esc", "escape", "^["},
{"back", "^H", "backspace"},
})
Pass nil to drop aliasing entirely.
Modifiers
| Prefix | Modifier |
|---|---|
C-, ^ |
Control — two spellings of one modifier |
G- |
Glyph (AltGr / ISO_Level3_Shift; a private kitty bit) |
M- |
Meta, as induced by the PC Alt key |
m- |
Meta proper — the key a Space Cadet keyboard had its own cap for |
S- |
Shift |
s- |
Super / Command |
H- |
Hyper |
Order is not meaning. A keymap that writes S-C-Up names the same key as
one that writes C-S-Up, and either spelling of the press finds either
spelling of the binding. This matters because input layers disagree: one
composes S-M-Left, another M-S-Left, for the same chord. A keymap should
not inherit that argument.
The canonical order is the order above — which is the order macOS renders
modifiers (⌃⌥⇧⌘), extended with the ones a Mac keyboard has no cap for.
Control's caret form sorts last so it lands against the base key: M-S-^X,
not ^M-S-X.
M- and m- are two different modifiers that fall back to each other, the
same shape as ^H/Backspace: bind one and either reaches it, bind both and
they stay apart. Most keyboards only produce the first. ^ and C-, by
contrast, are one modifier under two spellings — there is nothing there to tell
apart.
There is no A-. The PC Alt key induces Meta, and M- is what that is
called here.
The Option-character layer
A terminal forces an either/or: Option is Meta, or Option types characters —
not both. SetMacOptionInsert(true) restores the missing half from the binding
side. An M- key no binding claimed reports the character Option composes, so
bindings take the combos they name and every other combo still types:
p.SetMacOptionInsert(true)
p.SetDefaultHandler(func(key string) string {
if ch, ok := p.MacOptionChar(key); ok { // "M-d" -> "∂"
return "insert '" + ch + "'"
}
return ""
})
It is a user setting, never a property of the host OS — nothing here reads
runtime.GOOS. Someone typing on a Mac keyboard through an SSH session to a
Linux box wants this layer, and the far end cannot see their keyboard. It also
gives a Linux or Windows terminal mac-style Option typing it never had.
The inverse — decoding the composed character back into an M- name so it can
be bound at all — is the input decoder's half, and
direct-key-handler carries it
as DecodeMacOSOption. The two are useful independently: with the decoder on,
M-x is bindable without writing ≈; with only this layer, a terminal already
in Meta mode can still type Option characters.
Help topics
Map the reserved pseudo-key help under a prefix and HelpTopic finds the
most specific one for the sequence in progress — "^K B help", then
"^K help", then "help" — which is how an on-screen helper narrates a chord
as the user types it.
License
MIT — see LICENSE.
Change Log
0.1.0
- Extracted from the mew editor, where this
ran as
internal/keys, and relicensed MIT. - Default handling for unbound keys became the application's, through
SetDefaultHandler, rather than a table of one editor's command names. SequenceProcessoris nowkeyseq.Processor.- The Option-character layer stayed, and no longer consults the host OS:
SetMacOptionInsert/MacOptionCharreport the character, the application spells the command.
0.1.1
- Key aliases became configurable (
SetAliasGroups,AliasGroup), and the defaults now use direct-key-handler's vocabulary (Tab,Return,Escape,Backspace) rather than one editor's.ReturnandEnterare no longer aliased to each other. - Upgrading: an application whose key names differ from the defaults must now declare its own groups; previously one editor's were assumed.
0.1.3
- Modifier order no longer matters.
S-C-UpandC-S-Upname one key, in bindings and in presses alike. Matching compared whole tokens before, so the two were unrelated strings — while the input layers composing them disagreed about the order, which made the keymap inherit the argument. - Added
H-(Hyper) andm-(Meta proper, distinct from the Alt-inducedM-) as first-class modifiers.m-falls back toM-unless a keymap names both. - Modifier prefixes have a canonical order —
C- G- M- m- S- s- H- ^— andA-is gone: the PC Alt key induces Meta, spelledM-. - Added
Comma,Period/DotandOctothorpeto the punctuation spellings. Together withSemicolonandEqualsthese are what make a config file's own metacharacters bindable at all —;and#start comments,=separates key from command, and a comma reads as a list separator. - Added the named keys' conventional abbreviations to the defaults:
Esc,PgUp,PgDn/PgDown,Ins,PrtSc.Deleteis deliberately left without one — see above.
0.1.2
- Aliases resolve in every position of a sequence, not only at the tail and
not only one per chord. A chord bound
esc xcould not be typed^[ xat all — the initiator was not recognized, so the chord never began — and^K Minus Minusmatched neither slot. Alias spellings of a bound initiator are now registered as initiators too. - Aliases resolve through modifier prefixes: the prefix stack is peeled off,
the base varied, and the prefix restored, so
M-MinusandM--name one key. ^andC-are recognized as one modifier under two spellings, in bindings and in control-chord detection alike (C-B Mcompletes onC-B ^M).- Added word spellings for punctuation to the defaults —
Minus,Plus,Equals,Apos,Quote,Tilde/Wave,Backtick,Backslash,Slash,Semicolon,Colon,Pipe— so a binding can name a key the binding syntax would otherwise fight over. - The control/case ladder is unchanged and still contextual: a plain letter admits its control form only inside a control-started chord.
- Fixed: a bare
^is the caret character and is mappable as one. It was read as a control chord, which switched the control/case ladder on for whatever followed it, so a sequence bound^ Malso answered to caret then Ctrl-M. - Fixed: the first key of a chord now varies exactly as the others do. A chord
bound
M xcould not be opened with a lowercasem— the prefix was never held, so the tail's case-insensitivity was unreachable. Only single-letter initiators are affected.
Note on letter initiators: binding a chord whose first key is an ordinary
character makes that character a prefix, so typing it waits for the next key
and an abandoned chord discards both. That has always been true of the bound
spelling; it now applies to the other case as well. Control keys and named
keys (^K, esc) are the ordinary choice for a reason.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package keyseq resolves key events into application commands: multi-key sequences (WordStar/JOE-style chords such as ^K X), precedence levels, per-level wildcards, key aliases, and context-sensitive help topics.
|
Package keyseq resolves key events into application commands: multi-key sequences (WordStar/JOE-style chords such as ^K X), precedence levels, per-level wildcards, key aliases, and context-sensitive help topics. |