Keyboard-engine
上一篇介绍了我写的键盘引擎的设计思路,这篇我们聚焦于API与代码
首先你可以安装已经用React-ink封装好了引擎的完整库使用(本文章将使用React-ink进行讲解)
bash
npm install ink-cartridge
ink-cartridge 内置了 @cartridge-engine/keyboard-engine,React 这套适配层ink-cartridge本身已经封装好了。但是如果你用的是其他框架,你可以安装@cartridge-engine/keyboard-engine 直接接入你正在使用的框架,正确接入后,你的应用会获得一套完整的键盘系统。
10 秒跑起来
tsx
import { KeyboardProvider, useKeyboard } from 'ink-cartridge';
import { useInput } from 'ink';
function App() {
return (
<KeyboardProvider>
<MyScreen />
</KeyboardProvider>
);
}
function MyScreen() {
const { boundKeyboard } = useKeyboard();
useEffect(() => {
return boundKeyboard(['q'], () => process.exit(0));
}, []);
return <Text>按 q 退出</Text>;
}
KeyboardProvider 会创建一个引擎实例并接入 React 生命周期。useKeyboard 取出当前层级的方法。boundKeyboard 返回一个 unbind 函数,直接丢给 useEffect 的 return 就自动管理生命周期------组件卸载的时候绑定自动清理(如果不清理引擎默认你这个键会冒泡到下一层)。
1. boundKeyboard:核心方法,按什么键干什么事
基本用法
tsx
const { boundKeyboard } = useKeyboard();
useEffect(() => {
// 单键
const unbind1 = boundKeyboard(['ctrl+s'], () => saveFile());
// 多键触发同一个回调
const unbind2 = boundKeyboard(['q', 'escape'], () => back());
// 可以用 * 匹配任意按键
const unbind3 = boundKeyboard(['*'], (input) => handleAnyKey(input));
return () => { unbind1(); unbind2(); unbind3(); };
}, []);
每个页面只管自己的绑定,切走就自动解绑,不会污染别的页面。
onlyThis:只在栈顶生效
你的列表页绑了 j/k 导航,设置页也绑了 j/k 做别的事。两个页面叠在一起的时候,引擎按管线优先级裁决。但如果列表页加了 onlyThis: true,别的页面叠上来时列表页的 j/k 自动失活。
tsx
function ListScreen() {
const { boundKeyboard } = useKeyboard();
useEffect(() => {
return boundKeyboard(['j'], () => moveDown(), { onlyThis: true });
}, []);
// ...
}
times:按多少次才触发
tsx
// 按两次 q 才退出
boundKeyboard(['q'], () => process.exit(0), { times: 2 });
// observer 能拿到还剩几次
boundKeyboard(['space'], () => superMove(), {
times: 3,
observer: (remaining) => console.log(`还剩 ${remaining} 次`),
});
once:一次性绑定,"按任意键继续"那种
tsx
boundKeyboard(['*'], () => nextScreen(), { once: true });
// 同时times可以与once进行配合
boundKeyboard(['a'], () => nextScreen(), { once: true, times: 2})
// 此时现在的情况就是按了两次之后 这个键不再响应 相当于自动销毁了
// 而与不带once的区别则是 按键再循环了一次之后并不会自动销毁 会继续响应
触发后自动解绑,回调里抛异常也不影响解绑。
when:条件绑定
一个布尔值管多个绑定的开关:
tsx
const [isEditing, setEditing] = useState(false);
useEffect(() => {
return boundKeyboard(['j'], () => moveDown(), { when: () => !isEditing });
}, [isEditing]);
isEditing 变了之后下次按键时条件就会重新评估,不需要手动 bind/unbind。
同时我们也支持条件命名:
tsx
// 不用条件系统:每个绑定各自判断,同一个条件写好几遍
boundKeyboard(['j'], () => moveDown(), { when: () => !isEditing });
boundKeyboard(['k'], () => moveUp(), { when: () => !isEditing });
boundKeyboard(['/'], () => startSearch(), { when: () => !isEditing });
// isEditing 的逻辑散落在三处,改起来要命
// 用条件系统:注册一次,到处引用
addCondition('isEditing', false);
boundKeyboard(['j'], () => moveDown(), { when: 'isEditing' });
boundKeyboard(['k'], () => moveUp(), { when: 'isEditing' });
boundKeyboard(['/'], () => startSearch(), { when: 'isEditing' });
// 一切换,三个绑定同时变
setCondition('isEditing', true);
mode:比 when 更干净的多模式切换
tsx
<KeyboardProvider modes={['normal', 'insert']} defaultMode="normal">
<Editor />
</KeyboardProvider>
function Editor() {
const { boundKeyboard, setMode } = useKeyboard();
useEffect(() => {
const unbinds = [
boundKeyboard(['j'], () => moveDown(), { mode: 'normal' }),
boundKeyboard(['k'], () => moveUp(), { mode: 'normal' }),
boundKeyboard(['*'], (input) => insertChar(input), { mode: 'insert' }),
// escape 不指定 mode,所有模式都生效
boundKeyboard(['escape'], () => setMode('normal')),
];
return () => unbinds.forEach(u => u());
}, []);
return (
<Box>
<Text>当前模式: normal --- 按 i 进入编辑</Text>
</Box>
);
}
这里仿照了vim 但更加轻量
2. penetration 和 stop:控制事件怎么流
penetration:把键"放生",让它透到下面的层
搜索框浮在列表页上面,你希望字母被搜索框吃掉,但 j/k/n/p 继续导航列表。a-f 这种字符键让搜索框处理,列表导航键穿透:
tsx
function SearchOverlay() {
const { boundKeyboard, penetration } = useKeyboard();
useEffect(() => {
// j/k 和 n/p 穿透,下面的列表页继续响应
const undoPenetration = penetration(['j', 'k', 'n', 'p']);
const unbind = boundKeyboard(['escape'], () => closeSearch());
return () => { undoPenetration(); unbind(); };
}, []);
// ...
}
还可以加条件------只在搜索框为空时才穿透:
tsx
penetration(['j', 'k'], { when: () => query.length === 0 });
也可以限制到某个焦点目标:
tsx
penetration(['j', 'k'], { focusId: 'searchInput' });
stop:阻断传播,不要再往下走了
tsx
// 这些键在当前层处理完就停了,不往下传
stop(['escape', 'return']);
// 按 action ID 阻断------哪怕某天把 ctrl+s 改成了 cmd+s,stop 依然有效
stop(['save'], { stopAction: true });
// 条件阻断:有未保存的修改时才阻断,没修改就放行
stop(['escape'], { when: () => hasUnsavedChanges });
2.5. 浮层:多个弹窗同时在线,互不抢键盘
浮层不像模态框那么霸道------它默认不拦截任何按键,所有活跃的浮层并行接收同一个键盘事件,然后事件继续往下流到全局键和页面栈。
scss
function DropdownOverlay() {
const { boundKeyboard } = useKeyboard();
useEffect(() => {
const unbinds = [ boundKeyboard(['j'], () => moveDown()),
boundKeyboard(['k'], () => moveUp()),
boundKeyboard(['enter'], () => selectItem()),
boundKeyboard(['escape'], () => closeDropdown()),
];
return () => unbinds.forEach(u => u());
}, []);
// ...
}
同一个按键,多个浮层怎么分? 浮层阶段是广播------j 按键事件同时发给所有活跃浮层。如果搜索框和下拉菜单同时在屏幕上,两个都收到 j,各自处理各自的。都处理完之后事件继续往下走。浮层之间不互相干扰,也不吃事件。
怕浮层和下面的页面打架? 用 penetration 和 stop 控制流向就够了------浮层上你只声明自己关心的键,不关心的键要么穿透要么就是不绑定,页面依旧响应。
3. 全局快捷键:globalKeys / globalSequence
boundKeyboard 是跟着页面走的,切走就没了。全局快捷键是应用生命周期的。
tsx
function App() {
const { globalKeys } = useKeyboard();
useEffect(() => {
globalKeys([
{
key: 'ctrl+c',
operate: () => process.exit(0),
affectOverlay: true, // 有浮层时也生效
},
{
key: 'escape',
operate: () => closeTopOverlay(),
category: '*', // 所有页面都能用
},
{
key: 'f1',
operate: () => openHelp(),
category: [Menu, Game], // 只在 Menu 或 Game 页面生效
},
]);
}, []);
}
affectOverlay 解决什么问题
一个典型的坑:全局绑定 escape 为"返回上一页"。弹窗弹出来的时候用户按 escape,应该关弹窗还是返回上一页?
affectOverlay: true--- 弹窗弹出来时这个键依然全应用生效(Ctrl+C 退出,任何情况都要能用)affectOverlay: false(默认)--- 有弹窗时弹窗优先,弹窗没处理才轮到全局键
cover:允许页面覆盖吗
tsx
globalKeys([{
key: 'h',
operate: () => showHelp(),
cover: false, // 页面不能覆盖这个键,强行覆盖会抛异常
}]);
globalSequence:全局多键序列
tsx
globalSequence([
{
keys: ['g', 'g'],
operate: () => scrollToTop(),
timeout: 500,
},
{
keys: ['ctrl+w', 'q'],
operate: () => process.exit(0),
exclusive: true, // 只认这个序列,按错不触发别的
},
]);
查一下有没有序列在等待中,UI 可以提示:
tsx
const { thereGlobalQueueWaiting, getGlobalPendingSequence } = useKeyboard();
// 简单版
if (thereGlobalQueueWaiting()) {
return <Text dimColor>等待序列下一键...</Text>;
}
// 详细版------比如在状态栏显示
const pending = getGlobalPendingSequence();
if (pending) {
console.log(`等待第 ${pending.nextIndex + 1}/${pending.sequences.length} 个键`);
}
4. boundSequence:页面级别的多键序列
和 globalSequence 一样的机制,不过是跟着页面生命周期走的:
tsx
function MyScreen() {
const { boundSequence } = useKeyboard();
useEffect(() => {
// Ctrl+K 然后 Ctrl+C
const unbind1 = boundSequence(['ctrl+k', 'ctrl+c'], () => console.log('触发'));
// 自定义超时(默认 500ms)
const unbind2 = boundSequence(['j', 'k'], () => escapeInsert(), { timeout: 300 });
// exclusive 模式:按错了继续等,不回退到单键绑定
const unbind3 = boundSequence(['ctrl+b', 'd'], () => detach(), { exclusive: true });
return () => { unbind1(); unbind2(); unbind3(); };
}, []);
}
序列也有 action 注册机制,多个页面共用一套序列定义:
tsx
const { defineSequenceAction, boundSequence } = useKeyboard();
defineSequenceAction([
{ sequenceActionId: 'quit', action: () => process.exit(0), keys: ['q', 'q'] },
]);
boundSequence('quit'); // 直接用 actionId
5. 焦点系统
终端没有 autofocus,焦点全你自己管。引擎提供了一个集成的焦点方案:
tsx
function LoginForm() {
const { focusSet } = useKeyboard();
useEffect(() => {
focusSet('username');
// 除了手动设置焦点,引擎本身处理了tab键和shift+tab的焦点轮转
}, []);
return (
<Box flexDirection="column">
<TextInput value={username} onChange={setUsername} focusId="username" />
<TextInput value={password} onChange={setPassword} focusId="password" mask="*" />
</Box>
);
}
绑定也可以挂在焦点上------只在密码框获得焦点时才响应:
tsx
boundKeyboard(['enter'], () => togglePasswordVisible(), { focusId: 'password' });
6. 操作注册(Action System)
多个页面共享同一组行为的时候,把行为和按键解耦:
tsx
const { defineShortcutAction, boundKeyboard, addAction, modifyAction } = useKeyboard();
// 注册操作
defineShortcutAction([
{ actionId: 'save', action: () => saveFile() },
{ actionId: 'quit', action: () => process.exit(0) },
{ actionId: 'undo', action: () => undoLastAction() },
]);
// 用操作名来绑定,按键和操作名解耦
boundKeyboard(['ctrl+s'], 'save');
boundKeyboard(['ctrl+q'], 'quit');
boundKeyboard(['ctrl+z'], 'undo');
// 给操作加预设键
addAction({ actionId: 'redo', action: () => redo(), keys: ['ctrl+y'] });
boundKeyboard('redo');
// 动态改预设键(用户改了快捷键配置)
modifyAction('save', ['ctrl+s', 'cmd+s']);
操作注册本身不绑定键,只是起一个中间层。这个设计的真正好处体现在你让用户自定义快捷键 的时候------所有键位都集中在操作层,不需要满代码翻 boundKeyboard 调用。
7. Modal:模态框 + allowModal + useModalMissListener
模态框打开后,默认拦截所有按键 。这个设计是故意为之的------确认对话框弹出来的时候,按 escape 必须是关闭对话框,而不是在背后偷偷退出了程序。
tsx
function ConfirmDialog({ onConfirm, onCancel }) {
const { boundKeyboard, allowModal } = useKeyboard();
useEffect(() => {
const unbinds = [
boundKeyboard(['y'], () => onConfirm()),
boundKeyboard(['n'], () => onCancel()),
];
// 放行 escape,让它穿透模态框触发全局关闭逻辑
const undoAllow = allowModal(['escape']);
return () => { unbinds.forEach(u => u()); undoAllow(); };
}, []);
//...
}
useModalMissListener:监听"没人处理的键"
模态框里按了一个没有绑定的键,默认就是被拦截了。你可以用这个来在 UI 上提示用户:
tsx
function SearchModal() {
const { useModalMissListener } = useKeyboard();
const [hint, setHint] = useState('');
useEffect(() => {
return useModalMissListener((evt) => {
if (evt.miss) {
setHint(`没有绑定: ${evt.eventNames.join(', ')}. 试试 / 搜索, escape 退出`);
}
});
}, []);
return (
<Box flexDirection="column">
<Text>{hint}</Text>
{/* ... */}
</Box>
);
}
8. 条件系统(Conditions)
有一些状态不是模式,但影响同一批绑定。比如"是否正在编辑""是否已经保存"。这里用布尔值到处传很烦,条件系统统一管理这些值:
tsx
const { addCondition, setCondition, boundKeyboard, globalKeys } = useKeyboard();
// 注册条件
addCondition('isEditing', false);
addCondition('hasUnsavedChanges', false);
// 绑定里直接写条件名 ------ 字符串引用,不用传函数
useEffect(() => {
const unbinds = [
boundKeyboard(['j'], () => moveDown(), { when: 'isEditing' }),
boundKeyboard(['ctrl+s'], () => save(), { when: 'hasUnsavedChanges' }),
];
return () => unbinds.forEach(u => u());
}, []);
// 全局键也能用
useEffect(() => {
globalKeys([{ key: 'escape', operate: () => closePanel(), when: 'isEditing' }]);
}, []);
// 后面直接切条件,所有引用它的绑定自动切换
setCondition('isEditing', true);
setCondition('hasUnsavedChanges', false);
还有个方便的------when 可以同时支持函数和字符串,灵活的用函数,稳定的用字符串。
9. 自定义处理器(Custom Processors)
7 阶段管线是默认配置,但你可以往里插自己的处理器。比如插一个 key logger:
tsx
function App() {
const { addProcessor } = useKeyboard();
useEffect(() => {
addProcessor(
{
id: 'key-logger',
process: (ctx) => {
// 对开发者:F12 打开 DevTool 用的
console.log(`[key] 当前模式:${ctx.currentMode} 按键:${ctx.eventNames.join(',')}`);
return false; // 始终不消费,事件继续
},
},
{ after: 'modal' },
);
}, []);
}
也可以删掉或者重置:
tsx
const { removeProcessor, resetProcessors, getProcessors } = useKeyboard();
removeProcessor('key-logger');
resetProcessors(); // 一键恢复默认 7 阶段管线
console.log(getProcessors().map(p => p.id));
// ['modal', 'global-sequence-overlay', 'global-key-overlay', ...]
10. 这引擎本身是可以单拎出来用的
所有上面说的功能------管线、焦点、序列、模式、全局键、自定义处理器------都不只是 React 的。它们是一个纯 TypeScript 类的状态机 只不过ink-cartridge对他进行了适配并进行了封装:
ts
import { KeyboardEngine } from '@cartridge-engine/keyboard-engine';
const engine = new KeyboardEngine({
normalizeKeyNames: (input, key) => {
// 你的框架的 key 对象 → 标准化名字符串
if (key.ctrl && key.name) return [`ctrl+${key.name}`];
return [key.name ?? input];
},
});
ink-cartridge 只是用 React hooks 把它包了起来,包了三样东西:
- 一个
<KeyboardProvider>(创建引擎实例、接 Ink 的useInput、每帧 sync) - 一套 hooks(
useKeyboard、boundKeyboard、penetration......) - 一套屏幕树系统(和引擎的管线配合,自动管理 overlay/modal 层级)
即使是框架换了,引擎不用换。你的键盘逻辑不会因为哪天从 Ink 迁到别处而报废。
最后
这篇文章并没有把更多高级特性讲完,这些仅仅只是基础,更多的特性还得看看仓库和源码 使用这个引擎或者直接使用ink-cartridge可以为你减去许多负担 但这个框架本身也有一定的使用难度,他对使用者有一定的心智负担和反直觉设计,但我们一定会努力让这个轮子跑的更稳
另外 这篇文章使用的React-ink来讲例子 如果是其他框架 可以仿照ink-cartridge的做法进行接入
我们非常欢迎提issue(不管是bug报告 还是文档等问题 还是新功能要求等 我们都非常欢迎)
欢迎提任何PR