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 引擎概念概览

注册

通过注册层、令牌和插件来塑造 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,还可以指定 cmdeventpreventDefaultfallthrough 以及插件或应用代码安装的任何自定义字段。

  • 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 的内容。

名称含义示例用途
自定义字段层、绑定或命令声明的内容modeenableddesctitlenamespace
编译后的元数据字段编译器暴露给查询 UI 的内容descgrouptitlecategorylabel

字段可以影响行为。元数据不会。字段编译器可以使用 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(...) 作为显示/查询元数据。

命令

命名命令是系统中的稳定应用操作。层可以注册带有 namerun() 处理程序和可选顶层字段(如 titledescnamespace)的命令,绑定可以通过名称指向该命令。内联绑定处理程序也可以使用,但命名命令是驱动命令面板、搜索和编程执行的关键。

命令查询返回您注册的命令对象。查询搜索/过滤也可以使用命令字段插件产生的编译命令元数据。runCommand() 直接执行已注册的命令链,而 dispatchCommand() 通过活跃分发模型,当命令存在但当前不可分发时可以返回 inactivedisabled

运行时数据

运行时数据是 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 时输入的 ggetPendingSequence() 暴露该前缀,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/opentuiOpenTUI 宿主适配器,用于基于 @opentui/core 构建的终端应用
@opentui/keymap/htmlDOM 宿主适配器,用于以 HTMLElement 为根的浏览器 UI
@opentui/keymap/reactReact 提供者和钩子,用于 OpenTUI keymap
@opentui/keymap/solidSolid 提供者和钩子,用于 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 辅助函数是独立的插件。

下一步

  • 宿主解释内置宿主适配器和 KeymapHost 契约。
  • Core文档涵盖共享运行时、查询、拦截和扩展点。
  • 内置插件涵盖附带的插件包。
  • 自定义插件涵盖公共插件编写 API 和扩展点约束。
  • 宿主ReactSolid 涵盖内置宿主和框架特定的接口。