自定义 keymap 插件
插件通常是接受 Keymap、注册一个或多个行为并返回 disposer 的函数。
重要的约束是自定义插件应保持在公共接口上。附带的插件使用相同的公共注册 API。
如果您想要了解附带的插件清单,请参阅内置插件。
插件形状
import type { Keymap, KeymapEvent } from "@opentui/keymap"
export function registerModeField<TTarget extends object, TEvent extends KeymapEvent>(
keymap: Keymap<TTarget, TEvent>,
): () => void {
const offField = keymap.registerBindingFields({
mode(value, ctx) {
ctx.require("app.mode", value)
ctx.attr("mode", value)
},
})
const offIntercept = keymap.intercept("key", ({ event, setData }) => {
if (event.name === "escape") {
setData("app.mode", "normal")
}
})
return () => {
offIntercept()
offField()
}
}
准则:
- 返回一个 disposer 来拆卸插件注册的所有内容。
- 当注册相互依赖时,按相反顺序清理。
- 优先组合公共注册方法,而不是直接访问内部服务。
公共注册 API
字段、令牌和模式
| API | 用途 |
|---|---|
registerToken(token) | 定义命名的单按键别名,如 leader |
registerSequencePattern(pattern) | 定义命名的运行时序列捕获,如 count |
registerLayerFields(fields) | 添加带有 require(...)、attr(...)、activeWhen(...) 的自定义层字段 |
registerBindingFields(fields) | 添加带有 require(...)、attr(...)、activeWhen(...) 的自定义绑定字段 |
registerCommandFields(fields) | 添加带有 require(...)、attr(...)、activeWhen(...) 的自定义命令字段 |
绑定管道
| API | 用途 |
|---|---|
prepend/appendLayerBindingsTransformer() | 在编译之前一次性重写整个层的绑定数组 |
prepend/appendBindingExpander() | 将一个按键字符串展开为一个或多个扩展对象 |
prepend/appendBindingParser() | 将字符串按键语法解析为规范化的序列部分 |
prepend/appendBindingTransformer() | 重写已解析的绑定或添加派生绑定 |
分发和命令解析
| API | 用途 |
|---|---|
prepend/appendCommandResolver() | 动态解析字符串命令 |
prepend/appendCommandTransformer() | 重写已注册的命令或添加派生命令 |
prepend/appendEventMatchResolver() | 添加替代的事件到击键匹配 |
prepend/appendDisambiguationResolver() | 为歧义序列选择精确与前缀行为 |
intercept("key", ...) | 在绑定分发之前观察或消费按键事件 |
intercept("key:after", ...) | 在绑定分发之后观察分发结果 |
intercept("raw", ...) | 在按键解析之前观察原始宿主输入 |
事件匹配解析器顺序是全局回退优先级:较早的匹配在所有活跃层中先于较晚的匹配被尝试。使用绑定扩展器或转换器进行层本地别名。
诊断和生命周期
| API | 用途 |
|---|---|
prepend/appendLayerAnalyzer() | 层编译时发出警告或错误 |
acquireResource(symbol, setup) | 在多个注册之间共享引用计数的设置和拆卸 |
keymap.on(...) | 订阅状态、分发和诊断事件 |
上述每个注册 API 都返回一个 disposer。
字段编译器
字段编译器将用户提供的字段转换为激活规则和公共元数据。层字段还馈入绑定管道回调。
心智模型:
| 术语 | 含义 | 受众 |
|---|---|---|
| 自定义字段 | 原始配置 / 插件 DSL 输入 | keymap 编译器和插件 |
| 编译后的元数据 | 公共元数据输出 | 命令面板、提示和帮助 |
字段可以是行为、元数据或两者兼有。编译后的元数据仅是元数据。
keymap.registerBindingFields({
mode(value, ctx) {
ctx.require("app.mode", value)
ctx.attr("mode", value)
},
})
keymap.registerLayer({
bindings: [{ key: "x", mode: "normal", cmd() {} }],
})
该 mode 字段既是条件也是元数据。绑定仅在 keymap.getData("app.mode") 为 "normal" 时活跃,元数据查询可以暴露 { mode: "normal" }。
| 上下文 | 方法 | 说明 |
|---|---|---|
LayerFieldContext | require(...)、attr(...)、activeWhen(...) | 层 attr 可以在图快照中显示 |
BindingFieldContext | require(...)、attr(...)、activeWhen(...) | 绑定 attr 可以在活跃绑定和活跃按键上显示 |
CommandFieldContext | require(...)、attr(...)、activeWhen(...) | 命令 attr 可以在命令查询和活跃按键上显示 |
attr(...)
attr(name, value) 发布编译后的元数据。它不影响激活。
attr 名称不必与字段名称匹配。当插件想要在暴露之前验证、规范化、派生或标准化元数据时很有用。
keymap.registerCommandFields({
title(value, ctx) {
ctx.attr("label", String(value).trim())
},
})
当字段已经是公共元数据形状时,使用同名 attr:
keymap.registerCommandFields({
desc(value, ctx) {
ctx.attr("desc", String(value).trim())
},
})
当字段仅是行为时,不使用 attr:
keymap.registerCommandFields({
enabled(value, ctx) {
ctx.activeWhen(() => value === true)
},
})
编译后的元数据通过 GraphLayer.attrs、ActiveBinding.attrs、ActiveBinding.commandAttrs、ActiveKey.bindingAttrs 和 ActiveKey.commandAttrs 等投影暴露。getCommands() 返回原始命令对象,而不是单独的元数据记录。
require(...) 与 activeWhen(...)
两者都限制激活。它们在条件来源方面有所不同。
| 方法 | 使用场景 | 求值模型 |
|---|---|---|
require(name, value) | 条件是与 keymap 数据的相等比较 | 检查 Object.is(keymap.getData(name), value) |
activeWhen(callback) | 条件是任意代码 | 在记录每次求值时调用回调 |
activeWhen(reactive) | 外部状态可以在变更时通知 | 在读取和分发时调用 reactive.get();subscribe() 通知状态监听器 |
使用 require(...) 用于 keymap 拥有的状态,如模式:
keymap.registerBindingFields({
mode(value, ctx) {
ctx.require("app.mode", value)
},
})
keymap.setData("app.mode", "insert")
使用 activeWhen(...) 用于派生或外部状态:
keymap.registerBindingFields({
hasSelection(_value, ctx) {
ctx.activeWhen(() => editor.selection.length > 0)
},
})
require(...) 是对运行时数据的键值相等检查。ReactiveMatcher 是支持订阅的匹配器形式:
interface ReactiveMatcher {
get(): boolean
subscribe(onChange: () => void): () => void
}
接口规则
| 接口 | 字段存储 | 元数据输出 |
|---|---|---|
| 层 | 额外层字段馈入层编译器、解析器、扩展器和转换器 | 图快照中的 GraphLayer.attrs |
| 绑定 | 额外绑定字段由绑定字段编译器消费 | ActiveBinding.attrs、ActiveKey.bindingAttrs |
| 命令 | 额外命令属性保留在命令对象上并馈入编译器 | commandAttrs 投影 |
层 attr 有意在图快照上暴露,而不是在活跃按键、活跃绑定或命令查询上。将它们用于调试和可视化元数据,如层名称。
未知的层和绑定字段被忽略并发出警告。未知的命令字段保留在命令对象上,因为命令元数据即使没有注册的命令字段编译器也是可查询的。
绑定管道回调
字符串绑定通过有序管道。
- 扩展器重写输入字符串。
- 解析器将字符串转换为
KeySequencePart[]。 - 转换器重写已解析的绑定或添加更多。
层绑定转换器比该管道更早运行,且仅运行一次(在层注册时)。当插件需要在编译之前替换或过滤整个 Binding[] 集时使用它们。
对象形式的按键如 { name: "return", ctrl: true } 跳过字符串解析并直接规范化。
BindingExpanderContext
| 成员 | 用途 |
|---|---|
input | 当前绑定字符串 |
displays | 来自较早扩展器的可选每击键显示标签 |
layer | 拥有层字段的只读视图 |
返回扩展对象以将一个绑定字符串替换为多个,或返回 undefined 保持当前候选项不变。displays 必须匹配该扩展的解析序列长度。
keymap.appendBindingExpander(({ input }) => {
if (input !== "save") return undefined
return [{ key: "ctrl+s", displays: ["save"] }]
})
BindingParserContext
| 成员 | 用途 |
|---|---|
input / index | 当前源字符串和解析器位置 |
layer | 只读层字段 |
tokens | 此解析器可用的已注册令牌 |
patterns | 此解析器可用的已注册序列模式 |
normalizeTokenName(token) | 一致地规范化令牌名称 |
createMatch(id) | 为自定义事件匹配创建不透明的匹配 id |
parseObjectKey(key, options?) | 规范化击键并可选地覆盖显示、匹配或令牌名称 |
返回 { parts, nextIndex } 以接管输入,或返回 undefined 让后续解析器尝试。
解析器语法应保持在解析器内部。使用裸语义名称(如 "leader" 或 "count")注册令牌和序列模式,然后让解析器将其拥有的任何语法(<leader>、{count}、[leader]、%count% 等)映射到这些名称。当您希望图/帮助 UI 保留该语法时,在解析的部分上使用 display。
BindingTransformerContext
| 成员 | 用途 |
|---|---|
layer | 只读层字段 |
parseKey(key) | 使用当前环境解析或规范化另一个按键输入 |
add(binding) | 添加派生的已解析绑定 |
skipOriginal() | 丢弃原始的已解析绑定 |
LayerBindingsTransformerContext
| 成员 | 用途 |
|---|---|
layer | 只读层定义 |
validateBindings(array) | 对绑定数组运行引擎的结构绑定验证 |
排序规则:
prepend*注册先于append*注册运行。clearBindingExpanders()、clearBindingParsers()和clearBindingTransformers()移除该 keymap 的整个阶段。
仅当您的插件拥有该整个阶段时才使用 clear*() 方法。它们对 keymap 是全局的,而不是限定在您的插件范围内。
命令、事件和消歧义回调
CommandTransformer
命令转换器在层中每个命令的命令字段编译之前运行一次。它们可以修改接收到的命令副本、添加派生命令或丢弃原始命令。
| 成员 | 用途 |
|---|---|
layer | 只读层定义 |
add(command) | 添加派生命令 |
skipOriginal() | 丢弃被转换的命令 |
CommandResolver
;(command, ctx) => Command | undefined
CommandResolverContext 暴露:
| 成员 | 用途 |
|---|---|
input | 当前调用输入 |
payload | 当前调用负载 |
setInput(input) | 替换传递给返回命令的输入 |
setPayload(payload) | 替换传递给返回命令的负载 |
getCommand(name) | 读取当前模式的已注册命令(如有) |
解析器命令使用与已注册命令相同的形状:name、run 和可选的自定义顶层字段。
当命令想要特定的失败原因时,从 run() 返回显式命令结果:
return {
name: ":write",
run() {
return { ok: false, reason: "invalid-args" }
},
}
解析器结果在命令查询、绑定、runCommand() 或 dispatchCommand() 需要时解析。
编程式的 runCommand() 和 dispatchCommand() 在每次调用时解析提供的命令字符串。
将每次执行行为放在返回的 run(ctx) 处理程序中。
EventMatchResolver
(event, ctx) => readonly KeyMatch[] | undefined
EventMatchResolverContext 暴露 resolveKey(key),允许解析器将宿主事件映射到与已解析绑定相同的匹配表示。
KeyDisambiguationResolver
当同一序列既是精确命令又是前缀时使用,如 g 和 gg。
在注册同层精确/前缀绑定之前注册消歧义解析器。没有解析器时,编译器会拒绝歧义绑定并保留有效的较早绑定。
KeyDisambiguationContext 暴露:
| 成员 | 用途 |
|---|---|
event | 不带变异方法的当前按键事件 |
focused | 当前聚焦的目标 |
sequence / stroke | 待处理序列和最新击键 |
exact | 精确匹配的绑定 |
continuations | 可达的下一个按键 |
getData() / setData() | 运行时数据访问 |
runExact() | 立即分发精确绑定 |
continueSequence() | 保持序列待处理 |
clear() | 清除待处理序列 |
defer(handler) | 启动可取消的异步消歧义工作 |
消歧义解析器本身必须同步返回。如果您需要异步行为,使用 ctx.defer(...)。处理程序接收 KeyDeferredDisambiguationContext:
| 成员 | 描述 |
|---|---|
signal | AbortSignal,当新按键、焦点变更或 clearPendingSequence() 取消延迟工作时中止 |
sequence | 调用 defer 时捕获的待处理序列 |
focused | 调用 defer 时捕获的聚焦目标 |
sleep(ms) | 如果超时已过则解析为 true,如果 signal 先中止则为 false |
runExact() | 决定:分发捕获的精确绑定 |
continueSequence() | 决定:保持前缀待处理 |
clear() | 决定:清除待处理序列 |
处理程序可以返回决定、Promise<decision> 或不返回任何内容。在 signal 中止后不返回内容会丢弃延迟工作而不分发。
命令处理程序有所不同:它们可以返回 promise,但只有同步 false 会拒绝当前候选项并允许较低优先级的处理程序继续。
LayerAnalyzer
分析器在层编译时运行,接收已编译绑定的投影视图。
LayerAnalysisContext 暴露:
| 成员 | 用途 |
|---|---|
target / order | 源层标识 |
sourceBindings | 层绑定转换器之后的原始绑定 |
bindings | 编译的绑定分析记录 |
hasTokenBindings | 是否有绑定使用了令牌 |
checkCommandResolution(command) | 探测字符串命令是否解析 |
warn(...) / warnOnce(...) / error(...) | 发出诊断 |
clearLayerAnalyzers() 移除 keymap 的所有已注册分析器。
钩子、拦截和运行时辅助函数
keymap.on(...)
| 事件 | 载荷 | 说明 |
|---|---|---|
state | void | 批量的”派生状态可能已变更”信号 |
pendingSequence | KeySequencePart[] | 同步待处理序列更新 |
dispatch | DispatchEvent | 序列和绑定执行跟踪事件 |
warning | WarningEvent | 验证或分析器警告 |
error | ErrorEvent | 注册、查询、解析器或监听器错误 |
keymap.intercept(...)
| 形式 | 上下文 |
|---|---|
intercept("key", fn, options?) | event、setData、getData、consume() |
intercept("key:after", fn, options?) | handled、reason、sequence、event、setData、getData、consume() |
intercept("raw", fn, options?) | sequence、stop() |
选项:
| 选项 | 适用于 | 说明 |
|---|---|---|
priority | 全部 | 数值大的先运行 |
release | 按键形式 | 监听释放事件而不是按下 |
key:after 是同步的分发后钩子,用于在观察 keymap 是否处理、拒绝、未命中或持有序列后可选地调用 preventDefault() / stopPropagation()。即使事件已被默认阻止或传播停止,它仍然会触发;这些标志影响宿主传递,不影响 after-hook 传递。
插件中有用的运行时辅助函数
| API | 用途 |
|---|---|
getData() / setData() | 共享的可变运行时状态 |
getPendingSequence() / clearPendingSequence() / popPendingSequence() | 序列感知的插件 |
createKeyMatcher(key) | 使用当前解析器和令牌配置匹配击键 |
getActiveKeys() / getGraphSnapshot(keymap) | 构建提示、图或调试 UI |
getCommands() / getCommandEntries() / getCommandBindings() | 检查命令元数据和绑定投影 |
getGraphSnapshot() 从 @opentui/keymap/extras/graph 导入;其他辅助函数是 Keymap 方法。
使用 acquireResource() 共享资源
acquireResource(symbol, setup) 是在同一 keymap 的多个插件注册之间共享设置和拆卸的公共方式。
行为:
- 对某个符号的首次获取运行
setup() - 后续对同一符号的获取复用现有资源
- 仅当最后一个持有者释放时资源 disposer 才运行
- 活跃资源在宿主被销毁时被释放
- 失败的
setup()不会被保留为部分注册的资源
这就是 OpenTUI textarea 辅助函数安全共享命令和挂起基础设施的方式。
测试插件
@opentui/keymap/testing 为插件作者提供了一个小型宿主无关的测试工具。它不依赖于 OpenTUI 渲染器或 @opentui/core/testing。
import { createTestKeymap } from "@opentui/keymap/testing"
import { registerMyAddon } from "./my-addon"
const { keymap, host, diagnostics, cleanup } = createTestKeymap({ defaultKeys: true })
registerMyAddon(keymap)
keymap.registerLayer({
commands: [{ name: "save", run() {} }],
bindings: [{ key: "x", cmd: "save" }],
})
host.press("x")
diagnostics.takeErrors()
cleanup()
测试导出包括:
| API | 用途 |
|---|---|
createTestKeymap(options?) | 创建 Keymap、假宿主、根目标和诊断捕获 |
createTestKeymapHost(options?) | 仅创建假 KeymapHost,用于测试需要手动 keymap 构建 |
createTestHostMetadata(options?) | 为平台/修饰键敏感的插件构建宿主元数据 |
captureKeymapDiagnostics(keymap) | 捕获警告/错误,不依赖特定测试框架 |
TestKeymapHost / TestKeymapTarget / TestKeymapEvent | 可复用的假宿主原语 |
假宿主支持按下/释放事件、焦点变更、父级遍历、目标销毁、宿主销毁和原始输入。仅当插件真正测试 OpenTUI 或 HTML 等适配器时才使用适配器特定的测试工具。
约束和特性
保留字段名称
| 注册接口 | 保留名称 |
|---|---|
| 层字段 | target、targetMode、priority、bindings、commands |
| 绑定字段 | key、cmd、event、preventDefault、fallthrough |
| 命令字段 | name、run |
警告与错误
- 未知的自定义层和绑定字段被忽略并发出警告。
- 未知的自定义命令字段保留在命令对象上。
- 绑定字符串中的未知令牌或序列模式被忽略并发出警告。
- 稍后注册缺失的令牌或模式会重新编译受影响的层。
重入
- 运行时/数据式重入在分发期间受支持。命令、拦截器和待处理序列监听器可以读写运行时数据。
- 结构重入在分发期间不受支持。在分发进行中时不要注册或注销层、解析器、解析器、令牌或类似的环境塑造状态。
宿主依赖行为
intercept("raw", ...)仅在宿主实现onRawInput()时有效。- 原始宿主在
onRawInput()监听器返回true时必须停止原始输入传播。 - 宿主被销毁后,
getActiveKeys()等宿主支持的读取不可用,但getCommands()等元数据读取和运行时数据访问仍然可用。