自定义 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" }

上下文方法说明
LayerFieldContextrequire(...)attr(...)activeWhen(...)层 attr 可以在图快照中显示
BindingFieldContextrequire(...)attr(...)activeWhen(...)绑定 attr 可以在活跃绑定和活跃按键上显示
CommandFieldContextrequire(...)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.attrsActiveBinding.attrsActiveBinding.commandAttrsActiveKey.bindingAttrsActiveKey.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.attrsActiveKey.bindingAttrs
命令额外命令属性保留在命令对象上并馈入编译器commandAttrs 投影

层 attr 有意在图快照上暴露,而不是在活跃按键、活跃绑定或命令查询上。将它们用于调试和可视化元数据,如层名称。

未知的层和绑定字段被忽略并发出警告。未知的命令字段保留在命令对象上,因为命令元数据即使没有注册的命令字段编译器也是可查询的。

绑定管道回调

字符串绑定通过有序管道。

  1. 扩展器重写输入字符串。
  2. 解析器将字符串转换为 KeySequencePart[]
  3. 转换器重写已解析的绑定或添加更多。

层绑定转换器比该管道更早运行,且仅运行一次(在层注册时)。当插件需要在编译之前替换或过滤整个 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)读取当前模式的已注册命令(如有)

解析器命令使用与已注册命令相同的形状:namerun 和可选的自定义顶层字段。

当命令想要特定的失败原因时,从 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

当同一序列既是精确命令又是前缀时使用,如 ggg

在注册同层精确/前缀绑定之前注册消歧义解析器。没有解析器时,编译器会拒绝歧义绑定并保留有效的较早绑定。

KeyDisambiguationContext 暴露:

成员用途
event不带变异方法的当前按键事件
focused当前聚焦的目标
sequence / stroke待处理序列和最新击键
exact精确匹配的绑定
continuations可达的下一个按键
getData() / setData()运行时数据访问
runExact()立即分发精确绑定
continueSequence()保持序列待处理
clear()清除待处理序列
defer(handler)启动可取消的异步消歧义工作

消歧义解析器本身必须同步返回。如果您需要异步行为,使用 ctx.defer(...)。处理程序接收 KeyDeferredDisambiguationContext

成员描述
signalAbortSignal,当新按键、焦点变更或 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(...)

事件载荷说明
statevoid批量的”派生状态可能已变更”信号
pendingSequenceKeySequencePart[]同步待处理序列更新
dispatchDispatchEvent序列和绑定执行跟踪事件
warningWarningEvent验证或分析器警告
errorErrorEvent注册、查询、解析器或监听器错误

keymap.intercept(...)

形式上下文
intercept("key", fn, options?)eventsetDatagetDataconsume()
intercept("key:after", fn, options?)handledreasonsequenceeventsetDatagetDataconsume()
intercept("raw", fn, options?)sequencestop()

选项:

选项适用于说明
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 等适配器时才使用适配器特定的测试工具。

约束和特性

保留字段名称

注册接口保留名称
层字段targettargetModeprioritybindingscommands
绑定字段keycmdeventpreventDefaultfallthrough
命令字段namerun

警告与错误

  • 未知的自定义层和绑定字段被忽略并发出警告。
  • 未知的自定义命令字段保留在命令对象上。
  • 绑定字符串中的未知令牌或序列模式被忽略并发出警告。
  • 稍后注册缺失的令牌或模式会重新编译受影响的层。

重入

  • 运行时/数据式重入在分发期间受支持。命令、拦截器和待处理序列监听器可以读写运行时数据。
  • 结构重入在分发期间不受支持。在分发进行中时不要注册或注销层、解析器、解析器、令牌或类似的环境塑造状态。

宿主依赖行为

  • intercept("raw", ...) 仅在宿主实现 onRawInput() 时有效。
  • 原始宿主在 onRawInput() 监听器返回 true 时必须停止原始输入传播。
  • 宿主被销毁后,getActiveKeys() 等宿主支持的读取不可用,但 getCommands() 等元数据读取和运行时数据访问仍然可用。