Keymap
OpenTUI Keymap 是一个面向终端和浏览器的宿主无关绑定引擎。它为您提供分层快捷键、可发现的命令、序列处理和框架就绪的状态,使一套按键系统可以驱动整个应用。
安装
bun add @opentui/keymap
keymap 包仅包含 JavaScript。您可以在 Node.js 中导入它,无需安装 Bun 或启用原生 FFI。 您也可以导入 OpenTUI 宿主辅助函数。但在 Node.js 中创建原生渲染器需要 Node.js 26.3.0 并启用实验性 FFI。详情请参见快速入门。
在线演示:HTML keymap 演示
绑定可以指向命名命令、内联处理函数或特定的按键事件:
;[
{ key: "x", cmd: "save-file" },
{ key: "ctrl+x", event: "release", cmd: "cut" },
{ key: "dd", cmd: "delete-line" },
{ key: "<leader>s", cmd: ":write session.log" },
{ key: "?", cmd: "toggle-help" },
{
key: "ctrl+k",
cmd({ keymap }) {
keymap.setData("palette.open", true)
},
},
{ key: "escape", event: "release", cmd: "close-help" },
{ key: { name: "return", ctrl: true }, cmd: "submit" },
]
注册
通过注册层、令牌和插件来塑造 keymap。每层声明将按键或按键序列映射到命令的绑定,可选择通过优先级限定到宿主目标。插件通过解析器、字段编译器、命令解析器等扩展引擎。
- 使用
registerLayer()添加绑定和命令。 - 使用
registerToken()定义命名按键别名,如leader,默认解析器通过<leader>引用。 - 使用
registerSequencePattern()定义运行时序列捕获,如count,默认解析器通过{count}引用。 - 使用插件安装解析器、字段编译器、消歧义和其他行为。
- 每次注册调用都返回一个用于清理的 disposer。
- 请参阅宿主了解目标、层次结构和焦点如何进入引擎。
分发
当按键事件从宿主到达时,引擎遍历活跃层以找到匹配的绑定,解析精确匹配与序列前缀之间的歧义,并运行获胜的命令。拦截器可以在绑定解析之前观察或消费输入。
- 绑定根据焦点、优先级和条件在活跃层中进行匹配。
- 多按键序列会累积待处理序列直到解析完成。
- 命令在您的应用空间中运行。同步返回
false允许较低优先级的处理程序继续执行;异步命令会被立即处理并通过诊断报告异步错误。 - 使用
intercept("key", ...)或intercept("raw", ...)钩入输入管道。 - 请参阅宿主了解内置宿主如何传递按下、释放、焦点和可选的原始输入事件。
查询
询问 keymap 当前可用的内容。查询结果反映实时状态——当焦点、待处理序列、运行时数据或条件改变时,它们会随之变化。
getActiveKeys()返回从当前焦点和待处理状态可以触发的按键。getCommands()、getCommandEntries()和getCommandBindings()返回命令元数据和绑定投影。getPendingSequence()返回当前的多按键前缀。runCommand()和dispatchCommand()以编程方式执行命令。- 订阅
on("state")获取批量变更通知。 - 请参阅宿主了解宿主焦点和目标生命周期如何影响活跃状态。
绑定
绑定是数据,而不是硬编码的事件监听器。每层贡献绑定记录,引擎将其编译为序列树,然后通过焦点、优先级、运行时条件和待处理序列状态进行投影。
绑定形状
绑定始终有一个 key,还可以指定 cmd、event、preventDefault、fallthrough 以及插件或应用代码安装的任何自定义字段。
key可以是字符串如"ctrl+x"、"dd"或"<leader>s",也可以是对象形式如{ name: "return", ctrl: true }。cmd可以是命名命令字符串或内联处理函数。event默认为"press";释放绑定使用"release"。preventDefault默认为true,调用event.preventDefault()和event.stopPropagation(),使匹配的按键不会到达宿主目标。fallthrough默认为false;设置为true允许同一分发链中后续匹配的绑定在当前命令之后仍然运行。它与preventDefault(控制事件是否逃逸到宿主)是独立的。
支持释放绑定,但仅限于单按键。带 event: "release" 的多按键释放绑定(如 "dd")会被拒绝。释放绑定在按键释放时分发,但它们不会出现在 getActiveKeys() 中,因为活跃按键发现基于按下/待处理序列状态。
解析器驱动的字符串
字符串按键由解析器驱动。引擎不会将一种绑定语法硬编码到核心中;它运行已注册的绑定扩展器、解析器和转换器,将绑定输入转换为编译后的序列。
- 扩展器可以将一个按键字符串重写为多个按键字符串。
- 解析器将字符串转换为一个或多个规范化的击键。
- 转换器可以在层编译之前重写或添加已解析的绑定。
共享的默认解析器接受单按键命名键、修饰键组合、字面标点符号、" " 表示空格、"+" 作为字面加号键、<token> 别名和 {pattern} 运行时捕获。它解析连接的多按键序列,如 dd、<leader>s 或 {count}j。
未知的 <token> 别名会使该扩展绑定在令牌注册之前保持非活跃状态。它们不会从序列中消失并将剩余按键暴露为不同的快捷方式。
对象形式的按键是不想使用字符串解析时的逃生舱口。{ name: "return", ctrl: true } 跳过字符串解析器并直接规范化为单次击键。
这种解析器驱动的模型使得同一引擎可以支持内置字符串如 "ctrl+x"、令牌化字符串如 "<leader>s"、模式字符串如 "{count}j",以及插件定义的语法如方括号令牌或 Emacs 风格的扩展。
令牌
令牌是在绑定字符串中使用的命名单按键别名。注册名称是语义化的且无分隔符;默认解析器将 <leader> 语法映射到名为 "leader" 的已注册令牌。
keymap.registerToken({ name: "leader", key: { name: "space" } })
keymap.registerLayer({
bindings: [{ key: "<leader>s", cmd: "save-file" }],
})
令牌是编译时输入语法,而不是单独的分发模式。它们解析为恰好一次击键,像任何其他击键一样参与序列匹配,并且可以用保留的显示形式进行查询。
引用未知令牌的绑定采用失败关闭行为。例如,"<leader>s" 在名为 "leader" 的令牌注册之前会被跳过;它永远不会回退到裸 "s"。当绑定扩展器创建多个替代项时,如 "ctrl+s,<leader>s" 配合 registerCommaBindings(),只有未解析的替代项被跳过,具体的替代项保持活跃。注册或释放令牌会重新编译受影响的层,使令牌化的绑定随当前令牌集变为活跃或休眠。
序列模式
序列模式是在绑定字符串中使用的运行时捕获。注册名称也是语义化的且无分隔符;默认解析器将 {count} 语法映射到名为 "count" 的已注册模式。
import type { Command, KeymapEvent } from "@opentui/keymap"
interface CountPayload {
count?: number
}
keymap.registerSequencePattern({
name: "count",
match(event) {
return /^\d$/.test(event.name) ? { value: event.name, display: event.name } : undefined
},
finalize(values) {
return Number(values.join(""))
},
})
const moveDownCommand: Command<object, KeymapEvent, CountPayload | undefined> = {
name: "move-down",
run({ payload }) {
moveDown(payload?.count ?? 1)
},
}
keymap.registerLayer({
commands: [moveDownCommand],
bindings: [
{ key: "j", cmd: "move-down" },
{ key: "{count}j", cmd: "move-down" },
],
})
默认情况下,命令负载键是模式名称,因此 name: "count" 生成 payload.count。仅当命令负载字段应与模式名称不同时才使用 payloadKey。
自定义绑定字段
绑定可以携带自定义字段,但这些字段只有在您通过 registerBindingFields() 注册绑定字段编译器后才有意义。绑定字段编译器可以:
- 使用
require(...)声明运行时要求 - 使用
activeWhen(...)添加运行时匹配器 - 使用
attr(...)暴露元数据
keymap.registerBindingFields({
mode(value, ctx) {
ctx.require("vim.mode", value)
ctx.attr("mode", value)
},
})
keymap.registerToken({ name: "leader", key: { name: "space" } })
keymap.registerLayer({
commands: [{ name: "write-file", run() {} }],
bindings: [{ key: "<leader>w", mode: "normal", cmd: "write-file" }],
})
在该示例中,绑定仅在 getData("vim.mode") 为 "normal" 时活跃,编译后的绑定元数据可以在请求元数据时通过查询 API 暴露 { mode: "normal" }。
未知的自定义绑定字段不是致命错误。如果没有为字段注册绑定字段编译器,keymap 会忽略该字段并发出警告。
层字段遵循相同的编译器模型。当您需要层作用域的激活、绑定管道输入或图快照的可视化元数据时,使用 registerLayerFields() 注册:
keymap.registerLayerFields({
name(value, ctx) {
ctx.attr("name", String(value).trim())
},
})
keymap.registerLayer({
name: "Global",
bindings: [{ key: "?", cmd: "toggle-help" }],
})
所有宿主辅助函数使用相同的注册、分发和查询模型。它们仅在如何提供目标、焦点和输入事件方面有所不同。
字段和元数据
自定义字段是原始配置输入。编译后的元数据是字段编译器暴露给查询 UI 的内容。
| 名称 | 含义 | 示例用途 |
|---|---|---|
| 自定义字段 | 层、绑定或命令声明的内容 | mode、enabled、desc、title、namespace |
| 编译后的元数据 | 字段编译器暴露给查询 UI 的内容 | desc、group、title、category、label |
字段可以影响行为。元数据不会。字段编译器可以使用 require(...) 或 activeWhen(...) 使记录有条件化,也可以使用 attr(...) 单独发布元数据。
keymap.registerBindingFields({
mode(value, ctx) {
ctx.require("vim.mode", value)
ctx.attr("mode", value)
},
})
该字段做两件事:它在 keymap.getData("vim.mode") 上限制绑定,并在请求时向活跃按键元数据暴露 { mode: value }。层字段也可以发布 attr,但它们出现在 GraphLayer.attrs 上用于图/调试投影,而不是出现在活跃按键上。
元数据可以在插件想要验证、规范化或呈现稳定的公共元数据形状时使用与字段不同的名称:
keymap.registerCommandFields({
title(value, ctx) {
ctx.attr("label", String(value).trim())
},
})
在配置 DSL 中使用顶层自定义字段。在字段编译器中使用 ctx.attr(...) 作为显示/查询元数据。
命令
命名命令是系统中的稳定应用操作。层可以注册带有 name、run() 处理程序和可选顶层字段(如 title、desc 或 namespace)的命令,绑定可以通过名称指向该命令。内联绑定处理程序也可以使用,但命名命令是驱动命令面板、搜索和编程执行的关键。
命令查询返回您注册的命令对象。查询搜索/过滤也可以使用命令字段插件产生的编译命令元数据。runCommand() 直接执行已注册的命令链,而 dispatchCommand() 通过活跃分发模型,当命令存在但当前不可分发时可以返回 inactive 或 disabled。
运行时数据
运行时数据是 keymap 的可变键值存储。setData() 和 getData() 让命令、拦截器和匹配器共享实时状态,如编辑器模式、leader 状态或搜索上下文。
该数据还驱动激活。层、绑定和命令字段可以通过 require(...) 和 activeWhen(...) 依赖运行时值,因此更改值会立即重新计算可达内容。如果数据更改后当前待处理序列不再匹配,引擎会清除它。
require(name, value) 是对 keymap 运行时数据的键值相等检查。用于 keymap 拥有的状态,如 vim.mode === "normal"。
activeWhen(matcher) 是任意运行时谓词或 ReactiveMatcher。用于派生或外部状态,如编辑器是否有选区。
应用模式
运行时数据对于仅存在于控制 keymap 激活的应用特定模式非常有用。将该状态放在 keymap 上,通过自定义字段暴露,并让层声明何时活跃。
keymap.registerLayerFields({
appMode(value, ctx) {
ctx.require("app.mode", value)
},
})
keymap.setData("app.mode", "base")
keymap.registerLayer({
appMode: "base",
bindings: [{ key: "ctrl+p", cmd: "palette.open" }],
})
keymap.registerLayer({
appMode: "palette",
bindings: [{ key: "escape", cmd: "palette.close" }],
})
更改 app.mode 会立即重新计算活跃层。这将 keymap 专有状态保留在框架上下文或组件属性之外,同时仍允许正常的层排序处理活跃模式内的冲突。如果多个独立的覆盖层可以同时打开,请在将运行时数据写回之前,使用栈、深度计数器或不同的模式值显式建模。
待处理序列和活跃按键
待处理序列是用户已经输入的前缀,例如在等待查看下一个按键是完成 gg 还是 gd 时输入的 g。getPendingSequence() 暴露该前缀,clearPendingSequence() / popPendingSequence() 让插件或应用代码显式管理它。
getActiveKeys() 是配套查询。它不会列出所有已注册的绑定;它投影从当前焦点和待处理状态可达的下一个击键。这使其成为按键提示、which-key 风格 UI 和其他实时发现界面的正确 API。
入口点
| 包 | 描述 |
|---|---|
@opentui/keymap | 主引擎入口:Keymap、按键字符串化工具和共享类型 |
@opentui/keymap/addons | 用于解析器阶段、元数据、诊断和序列的通用插件 |
@opentui/keymap/addons/opentui | 通用插件加上 OpenTUI 特定的基础布局和编辑缓冲区辅助函数 |
@opentui/keymap/extras | 纯配置和格式化辅助函数 |
@opentui/keymap/extras/graph | 用于调试和图 UI 的图快照辅助函数 |
@opentui/keymap/testing | 宿主无关的假宿主和诊断,用于插件测试 |
@opentui/keymap/opentui | OpenTUI 宿主适配器,用于基于 @opentui/core 构建的终端应用 |
@opentui/keymap/html | DOM 宿主适配器,用于以 HTMLElement 为根的浏览器 UI |
@opentui/keymap/react | React 提供者和钩子,用于 OpenTUI keymap |
@opentui/keymap/solid | Solid 提供者和钩子,用于 OpenTUI keymap |
适配器入口点有意导出适配器特定的辅助函数。从 @opentui/keymap 导入共享的 Keymap、字符串化工具和核心类型。
裸版与默认辅助函数
| 辅助函数 | 创建的内容 |
|---|---|
new Keymap(host) | 为提供的宿主创建裸 keymap 引擎 |
createOpenTuiKeymap(renderer) | Keymap 加上 OpenTUI 宿主适配器 |
createHtmlKeymap(root) | Keymap 加上 HTML 宿主适配器 |
createDefaultOpenTuiKeymap(...) | OpenTUI keymap 加上默认按键、启用字段和元数据字段 |
createDefaultHtmlKeymap(...) | HTML keymap 加上默认按键、启用字段、元数据字段和事件匹配 |
默认辅助函数有意保持精简。Leader 令牌、ex 命令、计时消歧义、警告分析器和 OpenTUI textarea 辅助函数是独立的插件。