From 0b7eac364f6a6b0f97921956d92109f6f8771b0a Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 23 Oct 2026 20:41:40 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8C=89=E9=94=AE=E6=B3=A8?= =?UTF-8?q?=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client-base/src/hotkey/hotkey.ts | 25 +- packages-user/client-base/src/hotkey/types.ts | 23 +- packages/common/src/logger.json | 3 +- .../src/hotkey/__test__/command.test.ts | 285 ++-- .../system/src/hotkey/__test__/system.test.ts | 1494 ++++++++++------- packages/system/src/hotkey/command.ts | 33 +- packages/system/src/hotkey/system.ts | 165 +- packages/system/src/hotkey/type.ts | 33 +- src/client/misc/hotkey.ts | 345 +++- 9 files changed, 1550 insertions(+), 856 deletions(-) diff --git a/packages-user/client-base/src/hotkey/hotkey.ts b/packages-user/client-base/src/hotkey/hotkey.ts index 4fa343b..5640abd 100644 --- a/packages-user/client-base/src/hotkey/hotkey.ts +++ b/packages-user/client-base/src/hotkey/hotkey.ts @@ -4,28 +4,41 @@ import { IHotkeySystem, KeyCode } from '@motajs/system'; -import { IClientHotkey, IClientHotkeySave } from './types'; +import { IClientHotkey, IClientHotkeySave, IHotkeyCommandGroup } from './types'; import { logger } from '@motajs/common'; export class ClientHotkey implements IClientHotkey { system: IHotkeySystem = new HotkeySystem(); /** 按键分组 */ - private readonly groups: Map = new Map(); + private readonly groups: Map = new Map(); - setGroup(group: string, commands: IHotkeyCommand[]): void { + setGroup(group: string, name: string, commands: IHotkeyCommand[]): void { if (commands.length > 0) { if (this.groups.has(group)) { logger.warn(208, group); } - this.groups.set(group, commands); + this.groups.set(group, { + name, + commands + }); } else { this.groups.delete(group); } } - getGroup(group: string): IHotkeyCommand[] { - return this.groups.get(group) ?? []; + addCommandToGroup(group: string, command: IHotkeyCommand): void { + const obj = this.groups.get(group); + if (!obj) return; + if (obj.commands.includes(command)) { + logger.warn(210, command.command, group); + return; + } + obj.commands.push(command); + } + + getGroup(group: string): IHotkeyCommandGroup | null { + return this.groups.get(group) ?? null; } getUngrouped(): Iterable { diff --git a/packages-user/client-base/src/hotkey/types.ts b/packages-user/client-base/src/hotkey/types.ts index 5c78117..c885257 100644 --- a/packages-user/client-base/src/hotkey/types.ts +++ b/packages-user/client-base/src/hotkey/types.ts @@ -1,6 +1,13 @@ import { IHotkeyCommand, IHotkeySystem, KeyCode } from '@motajs/system'; import { ISaveableContent } from '@user/data-common'; +export interface IHotkeyCommandGroup { + /** 按键分组的名称 */ + readonly name: string; + /** 分组包含的按键指令列表 */ + readonly commands: IHotkeyCommand[]; +} + export interface IClientHotkeySave { /** 按键指令列表 */ readonly keys: ReadonlyMap; @@ -12,16 +19,24 @@ export interface IClientHotkey extends ISaveableContent { /** * 设置指定分组的按键指令列表,若不存在分组则创建,若传入空数组则删除 - * @param group 按键分组名 + * @param group 按键分组 id + * @param name 按键分组名称,用于显示 * @param commands 分组中包含的按键指令名 */ - setGroup(group: string, commands: IHotkeyCommand[]): void; + setGroup(group: string, name: string, commands: IHotkeyCommand[]): void; /** - * 获取指定按键分组中包含的按键,如果不存在则返回空数组 + * 向按键分组中添加新的按键指令 + * @param group 按键分组 id + * @param command 要添加的按键指令 + */ + addCommandToGroup(group: string, command: IHotkeyCommand): void; + + /** + * 获取指定按键分组中包含的按键 * @param group 按键分组名 */ - getGroup(group: string): IHotkeyCommand[]; + getGroup(group: string): IHotkeyCommandGroup | null; /** * 获取未分组的按键列表 diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 0c2d6de..0d56aca 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -299,7 +299,8 @@ "206": "Cannot set command key combination since delivered index is invalid: expected positive integer, found $1.", "207": "Key combination count needs to be a positive integer, but got $1.", "208": "Duplicate hotkey command group name: $1, old one will be overwritten.", - "209": "Cannot load key combinations for hotkey command $1 since no command named it.", + "209": "Cannot load key combinations for hotkey command '$1' since no command named it.", + "210": "Cannot add duplicate hotkey command '$1' to group '$2'.", "1001": "Event(setBlock): Unknown tile '$1' when setting block." } } diff --git a/packages/system/src/hotkey/__test__/command.test.ts b/packages/system/src/hotkey/__test__/command.test.ts index f7b16d1..7ab8756 100644 --- a/packages/system/src/hotkey/__test__/command.test.ts +++ b/packages/system/src/hotkey/__test__/command.test.ts @@ -1,7 +1,7 @@ -// 测试按键指令对象的组合管理、模式切换与非法组合告警 +// 测试按键指令对象的构造缺省、组合增删、参数校验与模式切换行为 import { logger } from '@motajs/common'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; -import { HotkeyCommand, isAuxiliaryKey } from '../command'; +import { HotkeyCommand } from '../command'; import { KeyCode } from '../keyCodes'; import { HotkeyEmitMode, IHotkeyCommandInit } from '../type'; @@ -30,27 +30,29 @@ function createInit( }; } +// 构造一个用于测试的按键指令,显示名称与标识符一致 +function createCommand( + command: string, + init: IHotkeyCommandInit +): HotkeyCommand { + return new HotkeyCommand(command, command, init); +} + describe('HotkeyCommand', () => { - // 辅助键判定覆盖 Ctrl / Shift / Alt / Meta 四个按键 - it('recognizes auxiliary keys', () => { - expect(isAuxiliaryKey(KeyCode.Ctrl)).toBe(true); - expect(isAuxiliaryKey(KeyCode.Shift)).toBe(true); - expect(isAuxiliaryKey(KeyCode.Alt)).toBe(true); - expect(isAuxiliaryKey(KeyCode.Meta)).toBe(true); - }); - - // 常规按键不会被判定为辅助键 - it('rejects regular keys as auxiliary', () => { - expect(isAuxiliaryKey(KeyCode.KeyA)).toBe(false); - expect(isAuxiliaryKey(KeyCode.Digit1)).toBe(false); - expect(isAuxiliaryKey(KeyCode.Space)).toBe(false); - }); - - // 构造器应用缺省值并将默认组合拷贝进指令 - it('applies defaults and copies the default combinations', () => { - const defaults = [[KeyCode.Ctrl, KeyCode.KeyS]]; - const command = new HotkeyCommand('save', createInit({ defaults })); + // 构造器应用缺省值并写入默认组合 + it('applies defaults on construction', () => { + const command = createCommand( + 'save', + createInit({ + priority: 7, + defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] + }) + ); + expect(command.command).toBe('save'); + expect(command.name).toBe('save'); + expect(command.priority).toBe(7); + expect(command.block).toBe(false); expect(command.mode).toBe(HotkeyEmitMode.KeyUp); expect(command.timeout).toBe(0); expect(command.interval).toBe(0); @@ -60,29 +62,71 @@ describe('HotkeyCommand', () => { KeyCode.Ctrl, KeyCode.KeyS ]); + }); + + // 未指定优先级时默认为 0 + it('defaults priority to zero', () => { + const command = new HotkeyCommand('save', 'save', { defaults: [] }); + + expect(command.priority).toBe(0); + }); + + // 构造器拷贝默认组合,调用方后续修改原数组不影响指令内部 + it('copies the default combinations', () => { + const defaults = [[KeyCode.Ctrl, KeyCode.KeyS]]; + const command = createCommand('save', createInit({ defaults })); defaults[0].push(KeyCode.KeyA); + expect(command.getKeyCombination(0)).toEqual([ KeyCode.Ctrl, KeyCode.KeyS ]); }); - // getKeyCombination 返回副本,修改不会影响指令内部组合 - it('returns a copy from getKeyCombination', () => { - const command = new HotkeyCommand( + // 默认组合为空数组时既不告警,长度也为 0 + it('accepts an empty defaults list without warning', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const command = createCommand('save', createInit()); + + expect(warn).not.toHaveBeenCalled(); + expect(command.length).toBe(0); + }); + + // setKeys(0) 合法:不告警、缩容到空且迭代结果为空 + it('shrinks to zero combinations with setKeys(0)', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const command = createCommand( 'save', createInit({ defaults: [[KeyCode.KeyS]] }) ); - const keys = command.getKeyCombination(0); - keys.push(KeyCode.KeyA); + command.setKeys(0); + + expect(warn).not.toHaveBeenCalled(); + expect(command.length).toBe(0); + expect([...command.iterateKeyCombinations()]).toEqual([]); + }); + + // setKeys 对负值与非整数各告警一次 207 并保持原状 + it('warns 207 for negative and non-integer counts in setKeys', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const command = createCommand( + 'save', + createInit({ defaults: [[KeyCode.KeyS]] }) + ); + + command.setKeys(-1); + command.setKeys(1.5); + + expect(warn.mock.calls.map(c => c[0])).toEqual([207, 207]); + expect(command.length).toBe(1); expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]); }); // setKeys 扩容补空组合、缩容截断并同步 length it('resizes combinations with setKeys', () => { - const command = new HotkeyCommand('save', createInit()); + const command = createCommand('save', createInit()); command.setKeys(2); command.setKeyCombination(0, [KeyCode.KeyS]); command.setKeyCombination(1, [KeyCode.KeyD]); @@ -97,125 +141,134 @@ describe('HotkeyCommand', () => { expect(command.getKeyCombination(1)).toEqual([]); }); - // setKeys 忽略负值与非整数 - it('ignores invalid counts in setKeys', () => { - const command = new HotkeyCommand( - 'save', - createInit({ defaults: [[KeyCode.KeyS]] }) - ); - command.setKeys(-1); - command.setKeys(1.5); - - expect(command.length).toBe(1); - expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]); - }); - - // setKeyCombination 忽略越界索引 - it('ignores out-of-range combination indexes', () => { - const command = new HotkeyCommand( - 'save', - createInit({ defaults: [[KeyCode.KeyS]] }) - ); - command.setKeyCombination(3, [KeyCode.KeyA]); - command.setKeyCombination(-1, [KeyCode.KeyD]); - - expect(command.length).toBe(1); - expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]); - expect(command.getKeyCombination(3)).toEqual([]); - }); - - // iterateKeyCombinations 按索引升序产出并返回副本 - it('iterates combinations in order with copies', () => { - const command = new HotkeyCommand( - 'save', - createInit({ - defaults: [[KeyCode.KeyS], [KeyCode.Ctrl, KeyCode.KeyD]] - }) - ); - const entries = [...command.iterateKeyCombinations()]; - - expect(entries.map(v => v[0])).toEqual([0, 1]); - expect(entries[1][1]).toEqual([KeyCode.Ctrl, KeyCode.KeyD]); - entries[0][1].push(KeyCode.KeyA); - expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]); - }); - - // KeyUp 模式下两个常规键的组合会发一次 203 且组合仍可读取 - it('warns 203 once for an invalid KeyUp combination', () => { + // setKeyCombination 对非整数、负数、越界索引各告警一次 206 并保持原状 + it('warns 206 for invalid combination indexes', () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - const command = new HotkeyCommand( + const command = createCommand( + 'save', + createInit({ defaults: [[KeyCode.KeyS]] }) + ); + + command.setKeyCombination(1.5, [KeyCode.KeyA]); + command.setKeyCombination(-1, [KeyCode.KeyA]); + command.setKeyCombination(3, [KeyCode.KeyA]); + + expect(warn.mock.calls.map(c => c[0])).toEqual([206, 206, 206]); + expect(command.length).toBe(1); + expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]); + }); + + // setKeyCombination 写入副本,调用方后续修改原数组不影响指令内部 + it('copies the delivered combination', () => { + const command = createCommand( + 'save', + createInit({ defaults: [[KeyCode.KeyS]] }) + ); + const keys: KeyCode[] = [KeyCode.Ctrl, KeyCode.KeyS]; + + command.setKeyCombination(0, keys); + keys.push(KeyCode.KeyA); + + expect(command.getKeyCombination(0)).toEqual([ + KeyCode.Ctrl, + KeyCode.KeyS + ]); + }); + + // KeyUp 模式接受包含多个常规键的组合且不告警 + it('accepts multiple regular keys without warning', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const command = createCommand( 'save', createInit({ defaults: [[KeyCode.KeyA, KeyCode.KeyB]] }) ); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith(203, 'save'); + expect(warn).not.toHaveBeenCalled(); expect(command.getKeyCombination(0)).toEqual([ KeyCode.KeyA, KeyCode.KeyB ]); }); - // KeyUp 模式下单个常规键加辅助键不会告警 - it('accepts one regular key with auxiliary keys', () => { + // KeyUp 模式接受多个辅助键或空组合且不告警 + it('accepts auxiliary and empty combinations without warning', () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - new HotkeyCommand( - 'save', - createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) - ); - new HotkeyCommand( - 'save', + createCommand( + 'both', createInit({ defaults: [[KeyCode.Ctrl, KeyCode.Shift]] }) ); + createCommand('empty', createInit({ defaults: [[]] })); expect(warn).not.toHaveBeenCalled(); }); - // keyUp() 切换到 KeyUp 并对已有非法组合只发一次 203 - it('switches to KeyUp and warns once for invalid combinations', () => { - const command = new HotkeyCommand( + // getKeyCombination 越界返回空数组,每次调用都返回新的副本 + it('returns empty arrays out of range and fresh copies in range', () => { + const command = createCommand( 'save', - createInit({ - mode: HotkeyEmitMode.Once, - defaults: [ - [KeyCode.KeyA, KeyCode.KeyB], - [KeyCode.KeyC, KeyCode.KeyD] - ] - }) + createInit({ defaults: [[KeyCode.KeyS]] }) ); - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - command.keyUp(120); + const first = command.getKeyCombination(0); + const second = command.getKeyCombination(0); - expect(command.mode).toBe(HotkeyEmitMode.KeyUp); - expect(command.timeout).toBe(120); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith(203, 'save'); + expect(command.getKeyCombination(5)).toEqual([]); + expect(first).toEqual([KeyCode.KeyS]); + expect(first).not.toBe(second); }); - // 四个模式方法设置模式与时间参数并保留未传入的值 - it('updates mode and timing parameters', () => { - const command = new HotkeyCommand( + // iterateKeyCombinations 按索引升序产出,且每次迭代都产出新的副本 + it('iterates combinations in order with fresh copies', () => { + const command = createCommand( + 'save', + createInit({ + defaults: [[KeyCode.KeyS], [KeyCode.Ctrl, KeyCode.KeyD]] + }) + ); + const entries = [...command.iterateKeyCombinations()]; + const again = [...command.iterateKeyCombinations()]; + + expect(entries.map(v => v[0])).toEqual([0, 1]); + expect(entries[1][1]).toEqual([KeyCode.Ctrl, KeyCode.KeyD]); + expect(entries[0][1]).not.toBe(again[0][1]); + }); + + // keyUp 与 once 设置对应的模式与首次触发延时,并返回自身 + it('switches between KeyUp and Once with a timeout', () => { + const command = createCommand( 'save', createInit({ defaults: [[KeyCode.KeyS]] }) ); - command.continuous(30, 60); - expect(command.mode).toBe(HotkeyEmitMode.Continuous); - expect(command.interval).toBe(30); - expect(command.timeout).toBe(60); + expect(command.keyUp(120)).toBe(command); + expect(command.mode).toBe(HotkeyEmitMode.KeyUp); + expect(command.timeout).toBe(120); - command.throttle(10); - expect(command.mode).toBe(HotkeyEmitMode.Throttle); - expect(command.dt).toBe(10); - expect(command.interval).toBe(30); - expect(command.timeout).toBe(60); - - command.once(15); + expect(command.once(15)).toBe(command); expect(command.mode).toBe(HotkeyEmitMode.Once); expect(command.timeout).toBe(15); + }); - command.keyUp(5); - expect(command.mode).toBe(HotkeyEmitMode.KeyUp); - expect(command.timeout).toBe(5); + // continuous 写入三个时间参数,缺省调用时三者归零,并返回自身 + it('sets the Continuous timing parameters', () => { + const command = createCommand( + 'save', + createInit({ defaults: [[KeyCode.KeyS]] }) + ); + + expect(command.continuous(30, 60, 90)).toBe(command); + expect(command.mode).toBe(HotkeyEmitMode.Continuous); + expect(command.interval).toBe(30); + expect(command.dt).toBe(60); + expect(command.timeout).toBe(90); + + command.continuous(); + expect(command.interval).toBe(0); + expect(command.dt).toBe(0); + expect(command.timeout).toBe(0); + }); + + // 直接构造时仍然保留传入的指令标识符 + it('keeps the command identifier passed to the constructor', () => { + expect(createCommand('fire', createInit()).command).toBe('fire'); }); }); diff --git a/packages/system/src/hotkey/__test__/system.test.ts b/packages/system/src/hotkey/__test__/system.test.ts index 2185993..1c6baab 100644 --- a/packages/system/src/hotkey/__test__/system.test.ts +++ b/packages/system/src/hotkey/__test__/system.test.ts @@ -1,10 +1,10 @@ -// 测试按键系统的注册表、作用域、KeyUp 与 KeyDown 派发、时序与监听行为 +// 测试按键系统的注册表、作用域、KeyUp 规则、holding、优先级、监听、条件与时序行为 import { IExcitable, IExcitation } from '@motajs/animate'; import { logger } from '@motajs/common'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; import { KeyCode } from '../keyCodes'; import { HotkeySystem } from '../system'; -import { HotkeyEmitMode, HotkeyEmitType, IHotkeyCommandInit } from '../type'; +import { HotkeyEmitMode, IHotkeyCommand, IHotkeyCommandInit } from '../type'; vi.hoisted(() => { vi.stubGlobal('main', { replayChecking: true }); @@ -31,6 +31,15 @@ function createInit( }; } +// 以指令标识符作为显示名称注册按键指令 +function addCommand( + system: HotkeySystem, + command: string, + init: IHotkeyCommandInit +): IHotkeyCommand { + return system.addCommand(command, command, init); +} + // 最小化的假元素,记录注册的监听器并支持手动派发事件 class FakeElement { readonly listeners: Map void)[]> = @@ -87,33 +96,28 @@ function createKeyEvent( }; } -interface FakeExcitation extends IExcitation { - controllers: { revoke: ReturnType }[]; -} - -// 构造一个可手动激励的假激励源,并记录创建的控制器 -function createExcitation(): FakeExcitation { +// 构造一个可手动激励的假激励源,激励负载就是传入的时间值 +function createExcitation(): IExcitation { const excitables = new Set>(); - const controllers: { revoke: ReturnType }[] = []; + let current = 0; return { - controllers, - payload: () => 0, + payload: () => current, excite: payload => { + current = payload; excitables.forEach(v => v.excited(payload)); }, add: object => { excitables.add(object); - const controller = { + return { excitable: object, - revoke: vi.fn(() => { + revoke: () => { excitables.delete(object); - }), + }, excite: (payload: number) => { + current = payload; object.excited(payload); } }; - controllers.push(controller); - return controller; }, remove: object => excitables.delete(object), destroy: () => { @@ -123,82 +127,55 @@ function createExcitation(): FakeExcitation { } describe('HotkeySystem', () => { - // 没有作用域时 when 发出告警 201 并返回自身 - it('warns 201 when setting a condition without a scope', () => { - const system = new HotkeySystem(); - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - - expect(system.when(() => true)).toBe(system); - expect(warn).toHaveBeenCalledWith(201); - }); - - // 没有作用域时 implement 发出告警 201 并返回自身 - it('warns 201 when implementing without a scope', () => { - const system = new HotkeySystem(); - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - - expect(system.implement('fire', () => {})).toBe(system); - expect(warn).toHaveBeenCalledWith(201); - }); - - // implement 未注册的指令发出告警 202 - it('warns 202 when implementing a nonexistent command', () => { - const system = new HotkeySystem(); - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - system.enterScope(); - - expect(system.implement('missing', () => {})).toBe(system); - expect(warn).toHaveBeenCalledWith(202, 'missing'); - }); - // 重复注册同名指令发出告警 204 并返回已有对象 it('warns 204 for duplicate commands and returns the existing one', () => { const system = new HotkeySystem(); const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); const init = createInit({ defaults: [[KeyCode.KeyS]] }); - const first = system.addCommand('save', init); + const first = addCommand(system, 'save', init); - const second = system.addCommand('save', init); + const second = addCommand(system, 'save', init); expect(second).toBe(first); expect(warn).toHaveBeenCalledWith(204, 'save'); expect([...system.iterateCommands()].length).toBe(1); }); - // getCommand 对未注册的标识符返回 null - it('returns null for an unregistered command', () => { + // getCommand 对未注册的标识符返回 null,注册后返回同一对象 + it('returns null for an unregistered command and the same object after adding', () => { const system = new HotkeySystem(); expect(system.getCommand('missing')).toBeNull(); + + const command = addCommand(system, 'save', createInit()); + expect(system.getCommand('save')).toBe(command); }); - // iterateCommands 遍历所有已注册的指令 - it('iterates every registered command', () => { - const system = new HotkeySystem(); - const first = system.addCommand('save', createInit()); - const second = system.addCommand('load', createInit()); - - expect([...system.iterateCommands()]).toEqual([first, second]); - }); - - // deleteCommand 移除指令并清理作用域中的实现 - it('deletes a command and cleans its handler', () => { + // deleteCommand 移除指令、清理作用域实现与待触发项,未知标识符不抛错 + it('deletes a command and drops its pending items', () => { const system = new HotkeySystem(); const handler = vi.fn(); - const command = system.addCommand( - 'fire', - createInit({ defaults: [[KeyCode.KeyA]] }) - ); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); system.enterScope(); system.implement('fire', handler); - - expect(system.getCommand('fire')).toBe(command); - system.deleteCommand('fire'); system.down(KeyCode.KeyA); + + system.deleteCommand('fire'); system.up(KeyCode.KeyA); expect(system.getCommand('fire')).toBeNull(); + expect([...system.iterateCommands()]).toEqual([]); expect(handler).not.toHaveBeenCalled(); + expect(() => system.deleteCommand('missing')).not.toThrow(); + }); + + // iterateCommands 按注册顺序产出全部指令 + it('iterates every registered command in registration order', () => { + const system = new HotkeySystem(); + const first = addCommand(system, 'save', createInit()); + const second = addCommand(system, 'load', createInit()); + + expect([...system.iterateCommands()]).toEqual([first, second]); }); // enterScope 返回新作用域的标识符 @@ -208,12 +185,13 @@ describe('HotkeySystem', () => { expect(typeof system.enterScope()).toBe('symbol'); }); - // leaveScope 无参退出栈顶作用域并恢复上一级 - it('leaves the top scope and restores the parent', () => { + // 无参 leaveScope 只弹出栈顶一级且不误发 205(修正 3 的直接回归) + it('pops exactly one scope on no-arg leaveScope without warning', () => { const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); const parentHandler = vi.fn(); const childHandler = vi.fn(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); system.enterScope(); system.implement('fire', parentHandler); system.enterScope(); @@ -225,15 +203,25 @@ describe('HotkeySystem', () => { expect(childHandler).not.toHaveBeenCalled(); expect(parentHandler).toHaveBeenCalledTimes(1); + expect(warn).not.toHaveBeenCalled(); }); - // leaveScope(sym) 保留指定作用域并删除其上方的作用域 + // 空栈调用无参 leaveScope 发出告警 205 且不抛错 + it('warns 205 when leaving with an empty scope stack', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + expect(() => system.leaveScope()).not.toThrow(); + expect(warn).toHaveBeenCalledWith(205); + }); + + // leaveScope(sym) 保留指定作用域并丢弃其上方的作用域 it('leaves back to a specific scope and drops scopes above it', () => { const system = new HotkeySystem(); const firstHandler = vi.fn(); const secondHandler = vi.fn(); const thirdHandler = vi.fn(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); const first = system.enterScope(); system.implement('fire', firstHandler); system.enterScope(); @@ -250,64 +238,50 @@ describe('HotkeySystem', () => { expect(thirdHandler).not.toHaveBeenCalled(); }); - // leaveScope 指定不存在的作用域发出告警 205 - it('warns 205 when leaving an unknown scope', () => { + // leaveScope 指定不存在的作用域发出告警 205 且栈顶实现仍然生效 + it('warns 205 when leaving an unknown scope and keeps the top scope', () => { const system = new HotkeySystem(); const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const handler = vi.fn(); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); system.enterScope(); + system.implement('fire', handler); system.leaveScope(Symbol()); - - expect(warn).toHaveBeenCalledWith(205); - }); - - // 空栈调用无参 leaveScope 发出告警 205 - it('warns 205 when leaving with an empty scope stack', () => { - const system = new HotkeySystem(); - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - - system.leaveScope(); - - expect(warn).toHaveBeenCalledWith(205); - }); - - // 子作用域激活时父作用域的实现不生效,退出后恢复 - it('uses only the top scope handlers and restores the parent', () => { - const system = new HotkeySystem(); - const parentHandler = vi.fn(); - const childHandler = vi.fn(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); - system.enterScope(); - system.implement('fire', parentHandler); - system.enterScope(); - system.implement('fire', childHandler); - system.down(KeyCode.KeyA); system.up(KeyCode.KeyA); - expect(childHandler).toHaveBeenCalledTimes(1); - expect(parentHandler).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(205); + expect(handler).toHaveBeenCalledTimes(1); }); - // 栈顶作用域条件为 false 时按下与抬起都不触发实现 - it('does not trigger when the top scope condition is false', () => { + // 没有作用域时 when 与 implement 各发出告警 201 并返回自身 + it('warns 201 for when and implement without an active scope', () => { const system = new HotkeySystem(); - const handler = vi.fn(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); - system.enterScope(); - system.implement('fire', handler); - system.when(() => false); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - expect(system.down(KeyCode.KeyA)).toBe(false); - expect(system.up(KeyCode.KeyA)).toBe(false); - expect(handler).not.toHaveBeenCalled(); + expect(system.when(() => true)).toBe(system); + expect(system.implement('fire', () => {})).toBe(system); + expect(warn).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalledWith(201); }); - // KeyUp 组合在辅助键仍按住时触发 + // implement 未注册的指令发出告警 202 并返回自身 + it('warns 202 when implementing a nonexistent command', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + system.enterScope(); + + expect(system.implement('missing', () => {})).toBe(system); + expect(warn).toHaveBeenCalledWith(202, 'missing'); + }); + + // KeyUp 组合在辅助键仍按住时抬起主键触发,且上下文按活引用口径构造 it('triggers a KeyUp combination while auxiliary keys are held', () => { const system = new HotkeySystem(); const handler = vi.fn(); - system.addCommand( + addCommand( + system, 'save', createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) ); @@ -319,13 +293,26 @@ describe('HotkeySystem', () => { system.up(KeyCode.KeyS); expect(handler).toHaveBeenCalledTimes(1); + const info = handler.mock.calls[0][0]; + expect(info.mode).toBe(HotkeyEmitMode.KeyUp); + expect(info.index).toBe(0); + expect(info.keys).toEqual([KeyCode.Ctrl, KeyCode.KeyS]); + expect(info.command.command).toBe('save'); + expect(info.command.mode).toBe(HotkeyEmitMode.KeyUp); + // holding 是活引用,仅修饰键布尔量是匹配时刻快照 + expect(info.context.holding).toEqual([KeyCode.Ctrl]); + expect(info.context.ctrl).toBe(true); + expect(info.context.shift).toBe(false); + expect(info.context.alt).toBe(false); + expect(info.context.meta).toBe(false); }); - // KeyUp 组合在辅助键已松开后不触发 + // KeyUp 组合在辅助键先松开时抬起主键不触发 it('does not trigger when an auxiliary key was released first', () => { const system = new HotkeySystem(); const handler = vi.fn(); - system.addCommand( + addCommand( + system, 'save', createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) ); @@ -340,11 +327,11 @@ describe('HotkeySystem', () => { expect(handler).not.toHaveBeenCalled(); }); - // 仅含一个辅助键的组合在该键抬起时触发 + // 仅含一个辅助键的组合在该键抬起时触发,抬起后 holding 不再包含它 it('triggers a single auxiliary key combination on release', () => { const system = new HotkeySystem(); const handler = vi.fn(); - system.addCommand('ctrl', createInit({ defaults: [[KeyCode.Ctrl]] })); + addCommand(system, 'ctrl', createInit({ defaults: [[KeyCode.Ctrl]] })); system.enterScope(); system.implement('ctrl', handler); @@ -352,13 +339,18 @@ describe('HotkeySystem', () => { system.up(KeyCode.Ctrl); expect(handler).toHaveBeenCalledTimes(1); + const info = handler.mock.calls[0][0]; + // holding 是活引用,仅修饰键布尔量是匹配时刻快照 + expect(info.context.holding).toEqual([]); + expect(info.context.ctrl).toBe(true); }); - // 仅含两个辅助键的组合不会触发 - it('does not trigger a combination of two auxiliary keys', () => { + // 两个辅助键的组合在最后一个键抬起时触发 + it('triggers a combination of two auxiliary keys on releasing the last key', () => { const system = new HotkeySystem(); const handler = vi.fn(); - system.addCommand( + addCommand( + system, 'both', createInit({ defaults: [[KeyCode.Ctrl, KeyCode.Shift]] }) ); @@ -368,16 +360,23 @@ describe('HotkeySystem', () => { system.down(KeyCode.Ctrl); system.down(KeyCode.Shift); system.up(KeyCode.Shift); - system.up(KeyCode.Ctrl); - expect(handler).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledTimes(1); + const info = handler.mock.calls[0][0]; + expect(info.keys).toEqual([KeyCode.Ctrl, KeyCode.Shift]); + // holding 是活引用,抬起后只保留仍按住的 Ctrl + expect(info.context.holding).toEqual([KeyCode.Ctrl]); + + system.up(KeyCode.Ctrl); }); - // 包含两个常规键的 KeyUp 组合告警后永不触发 - it('never triggers a KeyUp combination with two regular keys', () => { + // 两个常规键的 KeyUp 组合在最后一个键抬起时触发且不告警 + it('triggers a KeyUp combination with two regular keys', () => { const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); const handler = vi.fn(); - system.addCommand( + addCommand( + system, 'invalid', createInit({ defaults: [[KeyCode.KeyA, KeyCode.KeyB]] }) ); @@ -389,7 +388,16 @@ describe('HotkeySystem', () => { system.up(KeyCode.KeyB); system.up(KeyCode.KeyA); - expect(handler).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(system.getCommand('invalid')!.getKeyCombination(0)).toEqual([ + KeyCode.KeyA, + KeyCode.KeyB + ]); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].keys).toEqual([ + KeyCode.KeyA, + KeyCode.KeyB + ]); }); // 空组合在 KeyUp 模式下不触发且不产生告警 @@ -397,7 +405,7 @@ describe('HotkeySystem', () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); const system = new HotkeySystem(); const handler = vi.fn(); - system.addCommand('empty', createInit({ defaults: [[]] })); + addCommand(system, 'empty', createInit({ defaults: [[]] })); system.enterScope(); system.implement('empty', handler); @@ -408,306 +416,33 @@ describe('HotkeySystem', () => { expect(warn).not.toHaveBeenCalled(); }); - // KeyUp 上下文不包含被抬起的按键且辅助键状态正确 - it('builds the KeyUp context without the released key', () => { + // holding 按按下顺序排列,重复按下同一按键不改变顺序也不重复入栈 + it('keeps holding ordered and de-duplicated', () => { const system = new HotkeySystem(); - const handler = vi.fn(); - system.addCommand( - 'save', - createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) - ); system.enterScope(); - system.implement('save', handler); system.down(KeyCode.Ctrl); system.down(KeyCode.KeyS); - system.up(KeyCode.KeyS); + system.down(KeyCode.Ctrl); - const info = handler.mock.calls[0][0]; - expect(info.command).toBe('save'); - expect(info.index).toBe(0); - expect(info.keys).toEqual([KeyCode.Ctrl, KeyCode.KeyS]); - expect(info.context.type).toBe(HotkeyEmitType.KeyUp); - expect(info.context.key).toBe(KeyCode.KeyS); - expect(info.context.holding).toEqual([KeyCode.Ctrl]); - expect(info.context.ctrl).toBe(true); - expect(info.context.shift).toBe(false); - expect(info.context.alt).toBe(false); - expect(info.context.meta).toBe(false); + expect(system.holding).toEqual([KeyCode.Ctrl, KeyCode.KeyS]); }); - // 同一优先级下的所有已实现指令都会触发 - it('triggers every implemented command at the same priority', () => { - const system = new HotkeySystem(); - const first = vi.fn(); - const second = vi.fn(); - system.addCommand( - 'first', - createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) - ); - system.addCommand( - 'second', - createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) - ); - system.enterScope(); - system.implement('first', first); - system.implement('second', second); - - system.down(KeyCode.KeyA); - system.up(KeyCode.KeyA); - - expect(first).toHaveBeenCalledTimes(1); - expect(second).toHaveBeenCalledTimes(1); - }); - - // 优先级更高的指令先执行 - it('runs higher priority commands first', () => { - const system = new HotkeySystem(); - const order: string[] = []; - system.addCommand( - 'low', - createInit({ priority: 1, defaults: [[KeyCode.KeyA]] }) - ); - system.addCommand( - 'high', - createInit({ priority: 10, defaults: [[KeyCode.KeyA]] }) - ); - system.enterScope(); - system.implement('low', () => order.push('low')); - system.implement('high', () => order.push('high')); - - system.down(KeyCode.KeyA); - system.up(KeyCode.KeyA); - - expect(order).toEqual(['high', 'low']); - }); - - // block 指令阻断严格更低优先级的指令 - it('blocks strictly lower priority commands', () => { - const system = new HotkeySystem(); - const high = vi.fn(); - const low = vi.fn(); - system.addCommand( - 'high', - createInit({ - priority: 10, - block: true, - defaults: [[KeyCode.KeyA]] - }) - ); - system.addCommand( - 'low', - createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) - ); - system.enterScope(); - system.implement('high', high); - system.implement('low', low); - - system.down(KeyCode.KeyA); - system.up(KeyCode.KeyA); - - expect(high).toHaveBeenCalledTimes(1); - expect(low).not.toHaveBeenCalled(); - }); - - // block 不阻断同优先级指令 - it('does not block commands at the same priority', () => { - const system = new HotkeySystem(); - const blocking = vi.fn(); - const same = vi.fn(); - system.addCommand( - 'blocking', - createInit({ priority: 5, block: true, defaults: [[KeyCode.KeyA]] }) - ); - system.addCommand( - 'same', - createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) - ); - system.enterScope(); - system.implement('blocking', blocking); - system.implement('same', same); - - system.down(KeyCode.KeyA); - system.up(KeyCode.KeyA); - - expect(blocking).toHaveBeenCalledTimes(1); - expect(same).toHaveBeenCalledTimes(1); - }); - - // 匹配但未实现的指令不会阻断更低优先级 - it('does not let an unimplemented command block lower priorities', () => { - const system = new HotkeySystem(); - const low = vi.fn(); - system.addCommand( - 'high', - createInit({ - priority: 10, - block: true, - defaults: [[KeyCode.KeyA]] - }) - ); - system.addCommand( - 'low', - createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) - ); - system.enterScope(); - system.implement('low', low); - - system.down(KeyCode.KeyA); - system.up(KeyCode.KeyA); - - expect(low).toHaveBeenCalledTimes(1); - }); - - // down 与 up 返回是否触发了已实现的指令 - it('returns whether a release triggered an implemented command', () => { - const system = new HotkeySystem(); - const handler = vi.fn(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); - system.enterScope(); - system.implement('fire', handler); - - expect(system.down(KeyCode.KeyA)).toBe(false); - expect(system.up(KeyCode.KeyA)).toBe(true); - }); - - // up 对未处于按下状态的按键返回 false - it('returns false when releasing a key that is not held', () => { - const system = new HotkeySystem(); - - expect(system.up(KeyCode.KeyA)).toBe(false); - }); - - // listen 将按下与抬起映射到按键,只在触发时阻止默认行为 - it('listens to keydown and keyup and prevents default on triggers', () => { - const system = new HotkeySystem(); - const handler = vi.fn(); - const target = new FakeElement(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); - system.enterScope(); - system.implement('fire', handler); - system.listen(target as unknown as HTMLElement); - - const downEvent = createKeyEvent(65); - const upEvent = createKeyEvent(65); - target.dispatch('keydown', downEvent); - target.dispatch('keyup', upEvent); - - expect(handler).toHaveBeenCalledTimes(1); - expect(downEvent.preventDefault).not.toHaveBeenCalled(); - expect(upEvent.preventDefault).toHaveBeenCalledTimes(1); - }); - - // listen 忽略 repeat 事件的按下 - it('ignores repeated keydown events', () => { - const system = new HotkeySystem(); - const handler = vi.fn(); - const target = new FakeElement(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); - system.enterScope(); - system.implement('fire', handler); - system.listen(target as unknown as HTMLElement); - - target.dispatch('keydown', createKeyEvent(65, true)); - target.dispatch('keyup', createKeyEvent(65)); - expect(handler).not.toHaveBeenCalled(); - - target.dispatch('keydown', createKeyEvent(65)); - target.dispatch('keyup', createKeyEvent(65)); - expect(handler).toHaveBeenCalledTimes(1); - }); - - // listen 将 91 / 92 / 224 映射为 Meta 并触发 Meta 组合 - it('maps key codes 91, 92 and 224 to Meta', () => { - for (const code of [91, 92, 224]) { - const system = new HotkeySystem(); - const handler = vi.fn(); - const target = new FakeElement(); - system.addCommand( - 'meta', - createInit({ defaults: [[KeyCode.Meta]] }) - ); - system.enterScope(); - system.implement('meta', handler); - system.listen(target as unknown as HTMLElement); - - target.dispatch('keydown', createKeyEvent(code)); - target.dispatch('keyup', createKeyEvent(code)); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].context.key).toBe(KeyCode.Meta); - } - }); - - // listen(null) 解绑全部监听,后续事件不再触发 - it('unbinds listeners when listening to null', () => { - const system = new HotkeySystem(); - const handler = vi.fn(); - const target = new FakeElement(); - system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); - system.enterScope(); - system.implement('fire', handler); - - system.listen(target as unknown as HTMLElement); - expect(target.count()).toBe(2); - system.listen(null); - expect(target.count()).toBe(0); - target.dispatch('keydown', createKeyEvent(65)); - target.dispatch('keyup', createKeyEvent(65)); - - expect(handler).not.toHaveBeenCalled(); - expect(system.listening).toBeNull(); - }); - - // 重复监听同一元素不会重复注册监听器 - it('does not register duplicate listeners for the same element', () => { - const system = new HotkeySystem(); - const target = new FakeElement(); - - system.listen(target as unknown as HTMLElement); - system.listen(target as unknown as HTMLElement); - - expect(target.count()).toBe(2); - expect(system.listening).toBe(target); - }); - - // Once 模式下按下立即触发一次,重复按下不触发,抬起后可再次触发 - it('triggers Once commands once per press', () => { - const system = new HotkeySystem(); - const handler = vi.fn(); - system.addCommand( - 'once', - createInit({ - mode: HotkeyEmitMode.Once, - defaults: [[KeyCode.KeyA]] - }) - ); - system.enterScope(); - system.implement('once', handler); - - expect(system.down(KeyCode.KeyA)).toBe(true); - expect(handler).toHaveBeenCalledTimes(1); - expect(system.down(KeyCode.KeyA)).toBe(false); - expect(handler).toHaveBeenCalledTimes(1); - - system.up(KeyCode.KeyA); - system.down(KeyCode.KeyA); - expect(handler).toHaveBeenCalledTimes(2); - }); - - // KeyDown 组合需被 holding 满足,未写入组合的辅助键不构成限制 + // KeyDown 组合按 holding 子集匹配,未写入组合的辅助键不构成限制 it('matches KeyDown combinations as a subset of held keys', () => { const system = new HotkeySystem(); const comboHandler = vi.fn(); const plainHandler = vi.fn(); - system.addCommand( + addCommand( + system, 'combo', createInit({ mode: HotkeyEmitMode.Once, defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) ); - system.addCommand( + addCommand( + system, 'plain', createInit({ mode: HotkeyEmitMode.Once, @@ -727,85 +462,496 @@ describe('HotkeySystem', () => { expect(plainHandler).toHaveBeenCalledTimes(1); }); - // Continuous 在 timeout 为 0 时当刻触发,之后每次激励都触发 - it('triggers Continuous commands immediately and on excitation', () => { + // 只有一组候选时,即使还按着其它按键也直接触发 + it('triggers a single candidate even with extra held keys', () => { const system = new HotkeySystem(); - const excitation = createExcitation(); const handler = vi.fn(); - system.addCommand( - 'continuous', + addCommand( + system, + 'fire', createInit({ - mode: HotkeyEmitMode.Continuous, - defaults: [[KeyCode.KeyA]] + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.KeyS]] }) ); system.enterScope(); - system.implement('continuous', handler); - system.excitedBy(excitation); + system.implement('fire', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.Alt); + system.down(KeyCode.KeyS); - system.down(KeyCode.KeyA); expect(handler).toHaveBeenCalledTimes(1); - - excitation.excite(10); - expect(handler).toHaveBeenCalledTimes(2); - excitation.excite(20); - expect(handler).toHaveBeenCalledTimes(3); + const info = handler.mock.calls[0][0]; + expect(info.index).toBe(0); + expect(info.keys).toEqual([KeyCode.KeyS]); }); - // Continuous 的 timeout 到期前不触发,之后按 interval 触发第二次 - it('waits for the Continuous timeout and then the interval', () => { + // 多组候选时只触发辅助键精确匹配的那一组 + it('triggers only the exact combination among multiple candidates', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + addCommand( + system, + 'fire', + createInit({ + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.Ctrl, KeyCode.KeyS], [KeyCode.KeyS]] + }) + ); + system.enterScope(); + system.implement('fire', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.KeyS); + + expect(handler).toHaveBeenCalledTimes(1); + const info = handler.mock.calls[0][0]; + expect(info.index).toBe(0); + expect(info.keys).toEqual([KeyCode.Ctrl, KeyCode.KeyS]); + }); + + // 多组候选且没有精确匹配时不触发 + it('does not trigger when multiple candidates have no exact match', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + addCommand( + system, + 'fire', + createInit({ + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.Ctrl, KeyCode.KeyS], [KeyCode.KeyS]] + }) + ); + system.enterScope(); + system.implement('fire', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.Alt); + system.down(KeyCode.KeyS); + + expect(handler).not.toHaveBeenCalled(); + }); + + // 返回值反映调用后仍存在的待触发项:KeyUp 按下为真,抬起触发后为假 + it('returns whether pending items remain after down and up', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + + expect(system.down(KeyCode.KeyA)).toBe(true); + expect(system.up(KeyCode.KeyA)).toBe(false); + }); + + // up 对未处于按下状态的按键返回 false + it('returns false when releasing a key that is not held', () => { + const system = new HotkeySystem(); + + expect(system.up(KeyCode.KeyC)).toBe(false); + }); + + // 优先级更高的指令先执行 + it('runs higher priority commands first', () => { + const system = new HotkeySystem(); + const order: string[] = []; + addCommand( + system, + 'low', + createInit({ priority: 1, defaults: [[KeyCode.KeyA]] }) + ); + addCommand( + system, + 'high', + createInit({ priority: 10, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('low', () => order.push('low')); + system.implement('high', () => order.push('high')); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(order).toEqual(['high', 'low']); + }); + + // block 指令阻断严格更低优先级的指令 + it('blocks strictly lower priority commands', () => { + const system = new HotkeySystem(); + const high = vi.fn(); + const low = vi.fn(); + addCommand( + system, + 'high', + createInit({ + priority: 10, + block: true, + defaults: [[KeyCode.KeyA]] + }) + ); + addCommand( + system, + 'low', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('high', high); + system.implement('low', low); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(high).toHaveBeenCalledTimes(1); + expect(low).not.toHaveBeenCalled(); + }); + + // block 不阻断同优先级指令 + it('does not block commands at the same priority', () => { + const system = new HotkeySystem(); + const blocking = vi.fn(); + const same = vi.fn(); + addCommand( + system, + 'blocking', + createInit({ priority: 5, block: true, defaults: [[KeyCode.KeyA]] }) + ); + addCommand( + system, + 'same', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('blocking', blocking); + system.implement('same', same); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(blocking).toHaveBeenCalledTimes(1); + expect(same).toHaveBeenCalledTimes(1); + }); + + // listen 注册按下与抬起两个监听,只在 down 返回真时阻止默认行为 + it('listens to keydown and keyup and prevents default only on down', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + system.listen(target as unknown as HTMLElement); + + const downEvent = createKeyEvent(65); + const upEvent = createKeyEvent(65); + target.dispatch('keydown', downEvent); + target.dispatch('keyup', upEvent); + + expect(target.count()).toBe(2); + expect(handler).toHaveBeenCalledTimes(1); + // 当前实现:down 建立了待触发项返回真,KeyUp 抬起触发后已无待触发项返回假 + expect(downEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(upEvent.preventDefault).not.toHaveBeenCalled(); + }); + + // 重复按下不会重复触发,抬起后仍只触发一次 + it('ignores repeated keydown events and triggers once', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + system.listen(target as unknown as HTMLElement); + + target.dispatch('keydown', createKeyEvent(65)); + target.dispatch('keydown', createKeyEvent(65, true)); + target.dispatch('keyup', createKeyEvent(65)); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + // listen 将 91 / 92 / 224 都映射为 Meta 并触发 Meta 组合 + it('maps key codes 91, 92 and 224 to Meta', () => { + for (const code of [91, 92, 224]) { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + addCommand( + system, + 'meta', + createInit({ defaults: [[KeyCode.Meta]] }) + ); + system.enterScope(); + system.implement('meta', handler); + system.listen(target as unknown as HTMLElement); + + target.dispatch('keydown', createKeyEvent(code)); + target.dispatch('keyup', createKeyEvent(code)); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].context.meta).toBe(true); + } + }); + + // 没有映射的按键码被跳过,既不进入 holding 也不触发 + it('skips key codes without a mapping', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + system.listen(target as unknown as HTMLElement); + + target.dispatch('keydown', createKeyEvent(999)); + target.dispatch('keyup', createKeyEvent(999)); + + expect(handler).not.toHaveBeenCalled(); + expect(system.holding).toEqual([]); + }); + + // listen(null) 解绑全部监听,后续事件不再触发 + it('unbinds listeners when listening to null', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + + system.listen(target as unknown as HTMLElement); + expect(target.count()).toBe(2); + system.listen(null); + expect(target.count()).toBe(0); + target.dispatch('keydown', createKeyEvent(65)); + target.dispatch('keyup', createKeyEvent(65)); + + expect(handler).not.toHaveBeenCalled(); + expect(system.listening).toBeNull(); + }); + + // 同一元素重复 listen 不重复注册,切换到新元素时旧监听被解绑 + it('does not register duplicate listeners and rebinds on a new element', () => { + const system = new HotkeySystem(); + const target = new FakeElement(); + const other = new FakeElement(); + + system.listen(target as unknown as HTMLElement); + system.listen(target as unknown as HTMLElement); + expect(target.count()).toBe(2); + expect(system.listening).toBe(target); + + system.listen(other as unknown as HTMLElement); + expect(target.count()).toBe(0); + expect(other.count()).toBe(2); + expect(system.listening).toBe(other); + }); + + // KeyDown 按下时条件为假仍建立待触发项,随后的激励不会触发 + it('tracks a KeyDown pending even when the condition is false', () => { const system = new HotkeySystem(); const excitation = createExcitation(); const handler = vi.fn(); - system.addCommand( - 'continuous', + const ok = false; + system.excitedBy(excitation); + addCommand( + system, + 'fire', createInit({ - mode: HotkeyEmitMode.Continuous, - timeout: 100, - interval: 50, + mode: HotkeyEmitMode.Once, + timeout: 0, defaults: [[KeyCode.KeyA]] }) ); system.enterScope(); - system.implement('continuous', handler); - system.excitedBy(excitation); + system.implement('fire', handler); + system.when(() => ok); expect(system.down(KeyCode.KeyA)).toBe(true); - expect(handler).not.toHaveBeenCalled(); + excitation.excite(10); + expect(handler).not.toHaveBeenCalled(); + }); + + // KeyDown 条件为假且没有新的按键匹配时永远不会触发 + it('never emits a KeyDown pending whose condition stays false', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + const ok = false; + system.excitedBy(excitation); + addCommand( + system, + 'fire', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 0, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('fire', handler); + system.when(() => ok); + + system.down(KeyCode.KeyA); + excitation.excite(10); + excitation.excite(20); + system.up(KeyCode.KeyA); + excitation.excite(30); + + expect(handler).not.toHaveBeenCalled(); + }); + + // KeyDown 松键后条件恢复再按下可以再次触发且不重复 + it('fires once after releasing and pressing again with the condition restored', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + let ok = false; + system.excitedBy(excitation); + addCommand( + system, + 'fire', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 0, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('fire', handler); + system.when(() => ok); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + ok = true; + system.down(KeyCode.KeyA); + + excitation.excite(10); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(20); + expect(handler).toHaveBeenCalledTimes(1); + }); + + // KeyUp 抬起时不重新求值,匹配时刻条件为假就不触发 + it('does not re-evaluate the condition when a KeyUp command is released', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + let ok = false; + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyS]] })); + system.enterScope(); + system.implement('fire', handler); + system.when(() => ok); + + expect(system.down(KeyCode.KeyS)).toBe(true); + ok = true; + system.up(KeyCode.KeyS); + + expect(handler).not.toHaveBeenCalled(); + }); + + // 条件为真时正常触发 + it('triggers when the condition returns true', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + addCommand(system, 'fire', createInit({ defaults: [[KeyCode.KeyS]] })); + system.enterScope(); + system.implement('fire', handler); + system.when(() => true); + + system.down(KeyCode.KeyS); + system.up(KeyCode.KeyS); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + // 阻断线只由真的会触发的指令抬高,条件为假的高优先级指令不阻断低优先级 + it('keeps blocking based only on commands that really fire', () => { + const system = new HotkeySystem(); + const high = vi.fn(); + const low = vi.fn(); + let calls = 0; + addCommand( + system, + 'high', + createInit({ + priority: 10, + block: true, + defaults: [[KeyCode.KeyA]] + }) + ); + addCommand( + system, + 'low', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('high', high); + system.implement('low', low); + // 顺序化条件:先注册的 high 取到假,后注册的 low 取到真 + system.when(() => { + calls++; + return calls > 1; + }); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(calls).toBe(2); + expect(high).not.toHaveBeenCalled(); + expect(low).toHaveBeenCalledTimes(1); + }); + + // Once 指令在 timeout 到期的那次激励才首次触发,之后不再触发 + it('waits for the Once timeout before the first emit', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.excitedBy(excitation); + addCommand( + system, + 'fire', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('fire', handler); + + system.down(KeyCode.KeyA); excitation.excite(99); expect(handler).not.toHaveBeenCalled(); excitation.excite(100); expect(handler).toHaveBeenCalledTimes(1); - excitation.excite(120); - expect(handler).toHaveBeenCalledTimes(1); - excitation.excite(150); - expect(handler).toHaveBeenCalledTimes(2); - - excitation.excite(160); - expect(handler).toHaveBeenCalledTimes(3); + expect(handler).toHaveBeenCalledTimes(1); }); - // Throttle 第二次触发需经过 interval,之后需经过 dt - it('applies interval and dt to Throttle commands', () => { + // Continuous 指令的第二次触发按 interval、其后按 dt + it('applies interval and dt to Continuous commands', () => { const system = new HotkeySystem(); const excitation = createExcitation(); const handler = vi.fn(); - system.addCommand( - 'throttle', + system.excitedBy(excitation); + addCommand( + system, + 'continuous', createInit({ - mode: HotkeyEmitMode.Throttle, + mode: HotkeyEmitMode.Continuous, + timeout: 0, interval: 50, dt: 200, defaults: [[KeyCode.KeyA]] }) ); system.enterScope(); - system.implement('throttle', handler); - system.excitedBy(excitation); + system.implement('continuous', handler); system.down(KeyCode.KeyA); expect(handler).toHaveBeenCalledTimes(1); @@ -823,12 +969,14 @@ describe('HotkeySystem', () => { expect(handler).toHaveBeenCalledTimes(3); }); - // 松开按键后 Continuous 的待触发项被丢弃,不再随激励触发 - it('stops Continuous commands after the key is released', () => { + // Continuous 的 interval 与 dt 都为 0 时每次激励都触发 + it('emits on every excitation when interval and dt are zero', () => { const system = new HotkeySystem(); const excitation = createExcitation(); const handler = vi.fn(); - system.addCommand( + system.excitedBy(excitation); + addCommand( + system, 'continuous', createInit({ mode: HotkeyEmitMode.Continuous, @@ -837,7 +985,97 @@ describe('HotkeySystem', () => { ); system.enterScope(); system.implement('continuous', handler); + + system.down(KeyCode.KeyA); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(10); + expect(handler).toHaveBeenCalledTimes(2); + + excitation.excite(20); + expect(handler).toHaveBeenCalledTimes(3); + }); + + // Continuous 在 timeout 到期前不触发,且到期的那次激励只触发一次 + it('waits for the Continuous timeout and emits once', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); system.excitedBy(excitation); + addCommand( + system, + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + + system.down(KeyCode.KeyA); + expect(handler).not.toHaveBeenCalled(); + + excitation.excite(99); + expect(handler).not.toHaveBeenCalled(); + + excitation.excite(100); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(110); + expect(handler).toHaveBeenCalledTimes(2); + }); + + // Continuous 未到 timeout 就松键时,在到期的那次激励补触发一次 + it('fires a released Continuous command once at its timeout', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.excitedBy(excitation); + addCommand( + system, + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + + system.down(KeyCode.KeyA); + excitation.excite(50); + expect(handler).not.toHaveBeenCalled(); + + system.up(KeyCode.KeyA); + excitation.excite(99); + expect(handler).not.toHaveBeenCalled(); + + excitation.excite(100); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(200); + expect(handler).toHaveBeenCalledTimes(1); + }); + + // 松开按键后 Continuous 不再随激励触发 + it('stops Continuous commands after the key is released', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.excitedBy(excitation); + addCommand( + system, + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); system.down(KeyCode.KeyA); system.up(KeyCode.KeyA); @@ -846,13 +1084,15 @@ describe('HotkeySystem', () => { expect(handler).toHaveBeenCalledTimes(1); }); - // Once 的 timeout 未到就松键时不会触发 - it('cancels a delayed Once command when the key is released', () => { + // 未触发的 Once 松键后转入待触发并在到期的那次激励触发一次(松键不是取消) + it('keeps a pending Once alive after release and fires it once', () => { const system = new HotkeySystem(); const excitation = createExcitation(); const handler = vi.fn(); - system.addCommand( - 'once', + system.excitedBy(excitation); + addCommand( + system, + 'fire', createInit({ mode: HotkeyEmitMode.Once, timeout: 100, @@ -860,200 +1100,226 @@ describe('HotkeySystem', () => { }) ); system.enterScope(); - system.implement('once', handler); - system.excitedBy(excitation); + system.implement('fire', handler); system.down(KeyCode.KeyA); system.up(KeyCode.KeyA); excitation.excite(200); + expect(handler).toHaveBeenCalledTimes(1); - expect(handler).not.toHaveBeenCalled(); + excitation.excite(300); + expect(handler).toHaveBeenCalledTimes(1); }); - // excitedBy 切换激励源时先撤销旧控制器 - it('revokes the previous excitation controller when switching sources', () => { - const system = new HotkeySystem(); - const first = createExcitation(); - const second = createExcitation(); - system.excitedBy(first); - - system.excitedBy(second); - - expect(first.controllers[0].revoke).toHaveBeenCalledTimes(1); - expect(second.controllers.length).toBe(1); - }); - - // 未绑定激励源时延时触发的指令不会执行 - it('never fires delayed commands without an excitation source', () => { - const system = new HotkeySystem(); - const handler = vi.fn(); - system.addCommand( - 'once', - createInit({ - mode: HotkeyEmitMode.Once, - timeout: 100, - defaults: [[KeyCode.KeyA]] - }) - ); - system.enterScope(); - system.implement('once', handler); - - system.down(KeyCode.KeyA); - - expect(handler).not.toHaveBeenCalled(); - }); - - // 指令模式切换后旧的待触发项被丢弃 - it('drops pending items when the command mode changes', () => { + // KeyUp 延时项的上下文是活引用,派发时不含被抬起的主键 + it('uses the live holding array for a delayed KeyUp command', () => { const system = new HotkeySystem(); const excitation = createExcitation(); const handler = vi.fn(); - const command = system.addCommand( - 'once', - createInit({ - mode: HotkeyEmitMode.Once, - timeout: 100, - defaults: [[KeyCode.KeyA]] - }) - ); - system.enterScope(); - system.implement('once', handler); system.excitedBy(excitation); - system.down(KeyCode.KeyA); - - command.keyUp(); - excitation.excite(200); - - expect(handler).not.toHaveBeenCalled(); - }); - - // 入队场景下 KeyDown 的 down 与 KeyUp 的 up 仍返回真 - it('returns true when a command is enqueued', () => { - const downSystem = new HotkeySystem(); - downSystem.addCommand( - 'once', - createInit({ - mode: HotkeyEmitMode.Once, - timeout: 100, - defaults: [[KeyCode.KeyA]] - }) - ); - downSystem.enterScope(); - downSystem.implement('once', () => {}); - - const upSystem = new HotkeySystem(); - upSystem.addCommand( - 'up', - createInit({ timeout: 100, defaults: [[KeyCode.KeyB]] }) - ); - upSystem.enterScope(); - upSystem.implement('up', () => {}); - - expect(downSystem.down(KeyCode.KeyA)).toBe(true); - expect(upSystem.down(KeyCode.KeyB)).toBe(false); - expect(upSystem.up(KeyCode.KeyB)).toBe(true); - }); - - // 延时触发时使用触发时刻的 holding 重建上下文 - it('rebuilds the delayed context from the current holding', () => { - const system = new HotkeySystem(); - const excitation = createExcitation(); - const handler = vi.fn(); - system.addCommand( - 'continuous', - createInit({ - mode: HotkeyEmitMode.Continuous, - timeout: 100, - defaults: [[KeyCode.KeyS]] - }) - ); - system.enterScope(); - system.implement('continuous', handler); - system.excitedBy(excitation); - - system.down(KeyCode.KeyS); - system.down(KeyCode.Digit1); - excitation.excite(100); - - expect(handler).toHaveBeenCalled(); - expect(handler.mock.calls[0][0].context.holding).toEqual([ - KeyCode.KeyS, - KeyCode.Digit1 - ]); - }); - - // 延时触发的 KeyUp 上下文不包含已经抬起的按键 - it('rebuilds the delayed KeyUp context without the released key', () => { - const system = new HotkeySystem(); - const excitation = createExcitation(); - const handler = vi.fn(); - system.addCommand( - 'save', + addCommand( + system, + 'fire', createInit({ timeout: 100, defaults: [[KeyCode.KeyS]] }) ); system.enterScope(); - system.implement('save', handler); - system.excitedBy(excitation); + system.implement('fire', handler); system.down(KeyCode.KeyS); system.up(KeyCode.KeyS); expect(handler).not.toHaveBeenCalled(); - system.down(KeyCode.Digit1); - excitation.excite(100); + excitation.excite(200); - const info = handler.mock.calls[0][0]; - expect(info.context.key).toBe(KeyCode.KeyS); - expect(info.context.holding).toEqual([KeyCode.Digit1]); + expect(handler).toHaveBeenCalledTimes(1); + // holding 是活引用,仅修饰键布尔量是匹配时刻快照 + expect(handler.mock.calls[0][0].context.holding).toEqual([]); }); - // listen 在 KeyDown 模式下阻止默认行为,并把 Meta 映射进上下文 - it('prevents default on KeyDown triggers and sets meta context', () => { - for (const code of [91, 92, 224]) { - const system = new HotkeySystem(); - const handler = vi.fn(); - const target = new FakeElement(); - system.addCommand( - 'meta', - createInit({ - mode: HotkeyEmitMode.Once, - defaults: [[KeyCode.Meta]] - }) - ); - system.enterScope(); - system.implement('meta', handler); - system.listen(target as unknown as HTMLElement); - - const event = createKeyEvent(code); - target.dispatch('keydown', event); - - expect(handler).toHaveBeenCalledTimes(1); - expect(event.preventDefault).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].context.meta).toBe(true); - } - }); - - // 退出作用域后其中入队的待触发项被丢弃 - it('drops pending items when their scope is left', () => { + // KeyUp 的 timeout 从抬起时刻开始计时 + it('starts the KeyUp timeout at release', () => { const system = new HotkeySystem(); const excitation = createExcitation(); const handler = vi.fn(); - system.addCommand( - 'once', + system.excitedBy(excitation); + addCommand( + system, + 'fire', + createInit({ timeout: 100, defaults: [[KeyCode.KeyS]] }) + ); + system.enterScope(); + system.implement('fire', handler); + + system.down(KeyCode.KeyS); + excitation.excite(150); + expect(handler).not.toHaveBeenCalled(); + + system.up(KeyCode.KeyS); + excitation.excite(200); + expect(handler).not.toHaveBeenCalled(); + + excitation.excite(250); + expect(handler).toHaveBeenCalledTimes(1); + }); + + // KeyUp 带 timeout 且匹配时条件为假,抬起不触发也不会补发 + it('drops a KeyUp pending captured with a false condition', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + const ok = false; + system.excitedBy(excitation); + addCommand( + system, + 'fire', + createInit({ timeout: 100, defaults: [[KeyCode.KeyS]] }) + ); + system.enterScope(); + system.implement('fire', handler); + system.when(() => ok); + + system.down(KeyCode.KeyS); + system.up(KeyCode.KeyS); + excitation.excite(200); + + expect(handler).not.toHaveBeenCalled(); + }); + + // KeyUp 带 timeout 且匹配时条件为真,到期前转假仍按捕获值触发一次 + it('fires a delayed KeyUp pending with the captured condition', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + let ok = true; + system.excitedBy(excitation); + addCommand( + system, + 'fire', + createInit({ timeout: 100, defaults: [[KeyCode.KeyS]] }) + ); + system.enterScope(); + system.implement('fire', handler); + system.when(() => ok); + + system.down(KeyCode.KeyS); + system.up(KeyCode.KeyS); + ok = false; + + excitation.excite(200); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + // excitedBy 切换激励源后只有新的激励源能驱动系统 + it('honours only the latest excitation source', () => { + const system = new HotkeySystem(); + const first = createExcitation(); + const second = createExcitation(); + const handler = vi.fn(); + system.excitedBy(first); + addCommand( + system, + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + + system.down(KeyCode.KeyA); + expect(handler).toHaveBeenCalledTimes(1); + + system.excitedBy(second); + first.excite(10); + expect(handler).toHaveBeenCalledTimes(1); + + second.excite(10); + expect(handler).toHaveBeenCalledTimes(2); + }); + + // excitedBy(null) 后旧激励源不再驱动,系统本身仍能正常触发 + it('stops excitation after unbinding the source', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.excitedBy(excitation); + addCommand( + system, + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + + system.down(KeyCode.KeyA); + system.excitedBy(null); + excitation.excite(10); + expect(handler).toHaveBeenCalledTimes(1); + + system.up(KeyCode.KeyA); + system.down(KeyCode.KeyA); + expect(handler).toHaveBeenCalledTimes(2); + }); + + // 未绑定激励源时延时触发的指令永远不会执行 + it('never fires delayed commands without an excitation source', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + addCommand( + system, + 'fire', createInit({ mode: HotkeyEmitMode.Once, timeout: 100, defaults: [[KeyCode.KeyA]] }) ); - system.excitedBy(excitation); system.enterScope(); - system.implement('once', handler); - system.down(KeyCode.KeyA); + system.implement('fire', handler); - system.leaveScope(); - excitation.excite(200); + system.down(KeyCode.KeyA); expect(handler).not.toHaveBeenCalled(); }); + + // 触发信息中的指令为对象,且上下文包含按下顺序的全部按键 + it('delivers the command object and the full held keys', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + addCommand( + system, + 'fire', + createInit({ + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.KeyS]] + }) + ); + system.enterScope(); + system.implement('fire', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.Shift); + system.down(KeyCode.KeyS); + + expect(handler).toHaveBeenCalledTimes(1); + const info = handler.mock.calls[0][0]; + expect(info.command.command).toBe('fire'); + expect(info.command.mode).toBe(HotkeyEmitMode.Once); + expect(info.index).toBe(0); + expect(info.keys).toEqual([KeyCode.KeyS]); + expect(info.context.holding).toEqual([ + KeyCode.Ctrl, + KeyCode.Shift, + KeyCode.KeyS + ]); + expect(info.context.ctrl).toBe(true); + expect(info.context.shift).toBe(true); + expect(info.context.alt).toBe(false); + expect(info.context.meta).toBe(false); + }); }); diff --git a/packages/system/src/hotkey/command.ts b/packages/system/src/hotkey/command.ts index 35af4e3..d1d9ce4 100644 --- a/packages/system/src/hotkey/command.ts +++ b/packages/system/src/hotkey/command.ts @@ -2,19 +2,6 @@ import { logger } from '@motajs/common'; import { KeyCode } from './keyCodes'; import { HotkeyEmitMode, IHotkeyCommand, IHotkeyCommandInit } from './type'; -/** - * 判断按键是否为辅助按键,即 Ctrl / Shift / Alt / Meta 之一 - * @param key 按键码 - */ -export function isAuxiliaryKey(key: KeyCode): boolean { - return ( - key === KeyCode.Ctrl || - key === KeyCode.Shift || - key === KeyCode.Alt || - key === KeyCode.Meta - ); -} - export class HotkeyCommand implements IHotkeyCommand { readonly priority: number; readonly block: boolean; @@ -29,10 +16,11 @@ export class HotkeyCommand implements IHotkeyCommand { constructor( readonly command: string, + readonly name: string, init: IHotkeyCommandInit ) { this.command = command; - this.priority = init.priority; + this.priority = init.priority ?? 0; this.block = init.block ?? false; this.mode = init.mode ?? HotkeyEmitMode.KeyUp; this.timeout = init.timeout ?? 0; @@ -50,7 +38,7 @@ export class HotkeyCommand implements IHotkeyCommand { logger.warn(207, 'float'); return; } - if (count <= 0) { + if (count < 0) { logger.warn(207, 'negative integer'); return; } @@ -80,12 +68,6 @@ export class HotkeyCommand implements IHotkeyCommand { return; } this.keys[index] = keys.slice(); - if (this.mode === HotkeyEmitMode.KeyUp) { - const regular = keys.filter(v => !isAuxiliaryKey(v)); - if (regular.length >= 2) { - logger.warn(203, this.command); - } - } } getKeyCombination(index: number): readonly KeyCode[] { @@ -100,24 +82,27 @@ export class HotkeyCommand implements IHotkeyCommand { } } - keyUp(timeout: number = 0): void { + keyUp(timeout: number = 0): this { this.mode = HotkeyEmitMode.KeyUp; this.timeout = timeout; + return this; } - once(timeout: number = 0): void { + once(timeout: number = 0): this { this.mode = HotkeyEmitMode.Once; this.timeout = timeout; + return this; } continuous( interval: number = 0, dt: number = 0, timeout: number = 0 - ): void { + ): this { this.mode = HotkeyEmitMode.Continuous; this.interval = interval; this.dt = dt; this.timeout = timeout; + return this; } } diff --git a/packages/system/src/hotkey/system.ts b/packages/system/src/hotkey/system.ts index 88a183f..4da598b 100644 --- a/packages/system/src/hotkey/system.ts +++ b/packages/system/src/hotkey/system.ts @@ -1,6 +1,6 @@ import { IExcitable, IExcitation } from '@motajs/animate'; -import { logger, matchArray } from '@motajs/common'; -import { HotkeyCommand, isAuxiliaryKey } from './command'; +import { logger } from '@motajs/common'; +import { HotkeyCommand } from './command'; import { EVENT_KEY_CODE_MAP, KeyCode } from './keyCodes'; import { HotkeyEmitMode, @@ -23,7 +23,7 @@ interface IHotkeyScope { interface IHotkeyCommandPending { /** 按键指令对象 */ readonly command: IHotkeyCommand; - /** 按键组合被按下的时刻 */ + /** 本次触发计时的起点,按下触发时为按下时刻,KeyUp 触发时为抬起时刻 */ start: number; /** 按键组合的索引 */ keyIndex: number; @@ -31,6 +31,8 @@ interface IHotkeyCommandPending { keys: readonly KeyCode[]; /** 按键触发上下文 */ context: IHotkeyEmitContext; + /** 按键组合匹配时刻的条件判定结果 */ + matchedWhen: boolean; /** 当前已经触发了几次 */ emitted: number; /** 上一次的触发时刻 */ @@ -124,18 +126,9 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { */ private matchKeyUp(keys: readonly KeyCode[], key: KeyCode): boolean { if (keys.length === 0) return false; - // 抬起按键仅允许一个非辅助按键 - const [main, auxiliary] = matchArray(keys, v => isAuxiliaryKey(v)); - if (main.length > 1) return false; - - // 如果只有一个按键,那么可以触发,哪怕它是辅助按键 - if (main.length === 0) { - return auxiliary.length === 1 && auxiliary[0] === key; - } - - // 否则需要判断触发的按键 - if (main[0] !== key) return false; - return auxiliary.every(v => this.holdingSet.has(v)); + // 最后一个按键为主按键,其余为辅助按键 + if (keys.at(-1) !== key) return false; + return keys.slice(0, -1).every(v => this.holdingSet.has(v)); } /** @@ -165,6 +158,7 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { this.scopes.pop(); this.holdingCmds.clear(); this.waitTimeout.clear(); + return; } const index = this.scopes.findIndex(v => v.symbol === sym); if (index < 0) { @@ -209,13 +203,17 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { return this.commands.get(command) ?? null; } - addCommand(command: string, init: IHotkeyCommandInit): IHotkeyCommand { + addCommand( + command: string, + name: string, + init: IHotkeyCommandInit + ): IHotkeyCommand { const existing = this.commands.get(command); if (existing) { logger.warn(204, command); return existing; } - const instance = new HotkeyCommand(command, init); + const instance = new HotkeyCommand(command, name, init); this.commands.set(command, instance); return instance; } @@ -247,15 +245,16 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { /** * 触发指定按键指令 - * @param command 按键指令对象 * @param pending 按键指令的 pending 信息 + * @returns 是否成功执行指令 */ - private emit(pending: IHotkeyCommandPending) { + private emit(pending: IHotkeyCommandPending): boolean { + if (!pending.matchedWhen) return false; const command = pending.command; const scope = this.getScope(); - if (!scope) return; + if (!scope) return false; const impl = scope.implements.get(command); - if (!impl) return; + if (!impl) return false; const info: IHotkeyEmitInfo = { mode: command.mode, command, @@ -266,18 +265,33 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { impl(info); pending.emitted++; pending.lastEmit = this.excitation?.payload() ?? 0; + return true; } /** - * 获取首个能够触发按键指令的按键组合 + * 获取能够触发按键指令的按键组合,多组候选时仅返回辅助键精确匹配的那一组 * @param command 按键指令 + * @param holding 在按下触发的按键之前,有哪些按键正在被按下 + * @param emit 按下的是哪个按键 */ private findMatchKeys( - command: IHotkeyCommand + command: IHotkeyCommand, + holding: Set, + emit: KeyCode ): [index: number, readonly KeyCode[]] | [] { + const candidates: [index: number, readonly KeyCode[]][] = []; for (const [index, keys] of command.iterateKeyCombinations()) { - const match = keys.every(v => this.holdingSet.has(v)); - if (match) return [index, keys]; + if (keys.at(-1) !== emit) continue; + if (!keys.slice(0, -1).every(v => holding.has(v))) continue; + candidates.push([index, keys]); + } + if (candidates.length === 0) return []; + if (candidates.length === 1) return candidates[0]; + // 多组候选时只认辅助键与当前按住键完全一致的那一组 + for (const candidate of candidates) { + if (candidate[1].length - 1 === holding.size) { + return candidate; + } } return []; } @@ -293,12 +307,15 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { index: number, keys: readonly KeyCode[] ) { + const scope = this.getScope(); + if (!scope) return; const pending: IHotkeyCommandPending = { command, start: this.excitation?.payload() ?? 0, keyIndex: index, keys, context: this.createContext(), + matchedWhen: scope.condition?.() ?? true, lastEmit: -Infinity, emitted: 0 }; @@ -309,7 +326,23 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { } } + /** + * 当前是否应该进行默认行为阻断 + */ + private shouldPrevent(): boolean { + const scope = this.getScope(); + if (!scope) return false; + for (const [command] of this.holdingCmds) { + const impl = scope.implements.get(command); + if (impl) return true; + } + return false; + } + down(key: KeyCode): boolean { + const scope = this.getScope(); + if (!scope) return false; + const beforeHolding = new Set(this.holdingSet); if (this.holdingSet.has(key)) { // 如果有按键可能触发,那么返回 true 来 preventDefault return this.holdingCmds.size > 0 || this.waitTimeout.size > 0; @@ -318,64 +351,46 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { this.holdingSet.add(key); } for (const command of this.commands.values()) { - const match = this.findMatchKeys(command); + const match = this.findMatchKeys(command, beforeHolding, key); if (match.length === 0) continue; const [index, keys] = match; const pending = this.holdingCmds.get(command); - if (pending) { - // 如果之前有过这个按键指令,那么直接修改现有状态 - pending.keyIndex = index; - pending.keys = keys; - pending.context = this.createContext(); - } else { - // 如果之前没有这个按键指令,那么加入执行列表 + if (!pending) { + // 如果之前没有这个按键指令,那么加入执行列表,如果有的话就不管了 this.addPending(command, index, keys); continue; } } // 如果有按键可能触发,那么返回 true 来 preventDefault - return this.holdingCmds.size > 0 || this.waitTimeout.size > 0; + return this.shouldPrevent(); } up(key: KeyCode): boolean { + const scope = this.getScope(); + if (!scope) return false; if (!this.holdingSet.has(key)) return false; this.holdingSet.delete(key); const index = this.holding.indexOf(key); this.holding.splice(index, 1); + const beforeWait = this.waitTimeout.size; + const toEmit: IHotkeyCommandPending[] = []; // 需要快照 for (const [command, pending] of new Map(this.holdingCmds)) { // 对于 KeyUp,如果有可以触发的,那么按规则触发 if (command.mode === HotkeyEmitMode.KeyUp) { - const emit = this.matchKeyUp(pending.keys, key); - if (emit) { - toEmit.push(pending); - this.holdingCmds.delete(command); - } + if (!this.matchKeyUp(pending.keys, key)) continue; + toEmit.push(pending); + this.holdingCmds.delete(command); } else { // 对于其他按键,如果一个按下组合按键不再满足,那么需要删除或加入 timeout - if (!this.matchKeys(pending.keys)) { - // 松开一个按键,那么并不一定会导致按下指令不再触发 - const newMatch = this.findMatchKeys(command); - if (newMatch.length === 0) { - // 没有新的匹配,分两种情况 - if (pending.emitted === 0) { - // 还没触发过,加入 timeout - this.waitTimeout.add(pending); - } else { - // 已经触发过了,直接删除 - this.holdingCmds.delete(command); - } - } else { - // 如果有新的匹配,那么直接修改状态 - const [index, keys] = newMatch; - pending.context = this.createContext(); - pending.keyIndex = index; - pending.keys = keys; - } + if (this.matchKeys(pending.keys)) continue; + this.holdingCmds.delete(command); + if (pending.emitted === 0) { + this.waitTimeout.add(pending); } } } @@ -386,18 +401,24 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { let blocking = -Infinity; for (const pending of toEmit) { if (pending.command.priority < blocking) break; + if (!pending.matchedWhen) continue; + // 没有实现的按键不会进行阻断 + const impl = scope.implements.get(pending.command); + if (!impl) continue; if (pending.command.block) { blocking = pending.command.priority; } if (pending.command.timeout === 0) { this.emit(pending); } else { + // KeyUp 的 timeout 从抬起时刻开始计时 + pending.start = this.excitation?.payload() ?? 0; this.waitTimeout.add(pending); } } // 如果有按键可能触发,那么返回 true 来 preventDefault - return this.holdingCmds.size > 0 || this.waitTimeout.size > 0; + return this.waitTimeout.size > beforeWait || this.shouldPrevent(); } //#endregion @@ -430,22 +451,18 @@ export class HotkeySystem implements IHotkeySystem, IExcitable { if (command.mode === HotkeyEmitMode.KeyUp) continue; // 首次触发 const dt = payload - pending.start; - const dt2 = payload - pending.lastEmit; - if (pending.emitted === 0 && dt < command.timeout) { - this.emit(pending); + if (pending.emitted === 0) { + if (dt >= command.timeout) this.emit(pending); + continue; } - if (command.mode === HotkeyEmitMode.Continuous) { - if (pending.emitted === 1) { - // 先看第二次触发 - if (dt2 >= command.interval) { - this.emit(pending); - } - } else { - // 其他的要看 dt 了 - if (dt2 >= command.dt) { - this.emit(pending); - } - } + if (command.mode !== HotkeyEmitMode.Continuous) continue; + const dt2 = payload - pending.lastEmit; + if (pending.emitted === 1) { + // 先看第二次触发 + if (dt2 >= command.interval) this.emit(pending); + } else { + // 其他的要看 dt 了 + if (dt2 >= command.dt) this.emit(pending); } } } diff --git a/packages/system/src/hotkey/type.ts b/packages/system/src/hotkey/type.ts index 9dcb526..5bce593 100644 --- a/packages/system/src/hotkey/type.ts +++ b/packages/system/src/hotkey/type.ts @@ -18,13 +18,13 @@ export const enum HotkeyEmitMode { export interface IHotkeyEmitContext { /** * 指令触发时,当前所有正在按下的按键,按照按下的顺序排列。如果是抬起事件触发的,那么这里面并不会包含触发事件的那个主要按键。 - * 注意该成员的含义不是当前时刻的按下的按键,对于连续触发以及 `timeout`,依然会包含触发此指令的时刻按下的按键。 + * 注意该成员的含义不是当前时刻的按下的按键,对于连续触发以及 `timeout`,会包含匹配时刻所有按下的按键。 * 也就是说,它并不是当前时刻按下的按键,不要用它获取当前时刻正在按下的按键。参考时间线: * * ```txt * ----------|---------------|----------> * | timeout | - * 指令触发时刻 指令延时触发 + * 指令匹配时刻 指令延时触发 * 记录的是这个 而不是这个 * ``` */ @@ -53,10 +53,17 @@ export interface IHotkeyEmitInfo { } export interface IHotkeyCommandInit { - /** 按键指令优先级,目前仅对抬起触发的按键生效 */ - readonly priority: number; - /** 默认按键组合,可设置多组 */ + /** + * 默认按键组合,可设置多组。每组中,最后一个按键为触发指令的主按键,前面的属于辅助按键, + * 必须先按下这些按键,再触发最后一个按键的按下或抬起才可能触发指令: + * + * - 对于按下事件,先按下主按键,再按下辅助按键不会触发。松开任意一个按键后,如果该指令的按键依然匹配,并不会继续触发。 + * - 对于抬起事件,必须在按下所有的辅助按键和主按键后,松开主按键才可触发,松开辅助按键不会触发。 + * - 如果触发一个主按键后,有多个按键组合对应的指令会被触发,那么只会触发辅助按键精确匹配的那一个。 + */ readonly defaults: KeyCode[][]; + /** 按键指令优先级,目前仅对抬起触发的按键生效,默认为 0 */ + readonly priority?: number; /** 是否阻断优先级精确比此按键指令更低的指令的执行,同优先级的会全部执行,并不会阻断,默认不阻断 */ readonly block?: boolean; /** 按键触发方式,默认为抬起时触发 */ @@ -72,6 +79,9 @@ export interface IHotkeyCommandInit { export interface IHotkeyCommand { /** 按键指令的标识符 */ readonly command: string; + /** 按键指令的名称,用于显示 */ + readonly name: string; + /** 按键指令优先级,越高越先执行,如果同优先级,则无法保证顺序,目前仅对抬起触发的按键生效 */ readonly priority: number; /** 触发后,是否阻断优先级比当前优先级更低的按键的触发 */ @@ -117,13 +127,13 @@ export interface IHotkeyCommand { * 抬起后,在经过 `timeout` 时间后触发一次 * @param timeout 抬起后距离首次触发的时间间隔,单位毫秒,默认为 0 */ - keyUp(timeout?: number): void; + keyUp(timeout?: number): this; /** * 按下后,在经过 `timeout` 时间后只触发一次,不会连续触发 * @param timeout 按下后距离首次触发的时间间隔,单位毫秒,默认为 0 */ - once(timeout?: number): void; + once(timeout?: number): this; /** * 当按下后,在经过 `timeout` 时间后进行一次触发,之后在松开前,会按照激励源的激励触发 @@ -132,7 +142,7 @@ export interface IHotkeyCommand { * @param dt 节流间隔,默认为 0,即每次激励源的激励都会触发 * @param timeout 按下后距离首次触发的时间间隔,单位号码,默认为 0 */ - continuous(interval?: number, dt?: number, timeout?: number): void; + continuous(interval?: number, dt?: number, timeout?: number): this; } export interface IHotkeySystem { @@ -167,9 +177,14 @@ export interface IHotkeySystem { /** * 添加新的按键指令 * @param command 按键指令标识符 + * @param name 按键指令的名称,用于显示 * @param init 指令初始化对象 */ - addCommand(command: string, init: IHotkeyCommandInit): IHotkeyCommand; + addCommand( + command: string, + name: string, + init: IHotkeyCommandInit + ): IHotkeyCommand; /** * 删除指定按键指令 diff --git a/src/client/misc/hotkey.ts b/src/client/misc/hotkey.ts index 695f73d..6428ac2 100644 --- a/src/client/misc/hotkey.ts +++ b/src/client/misc/hotkey.ts @@ -1,20 +1,349 @@ -import { KeyCode } from '@motajs/system'; +import { HotkeyEmitMode, KeyCode } from '@motajs/system'; import { IClientCore } from '@user/client-modules'; export function initializeHotkey(state: IClientCore) { const hotkey = state.hotkey; const system = hotkey.system; + system.excitedBy(state.rafExcitation); + //#region 通用按键 - hotkey.setGroup('@system/common', [ - system.addCommand('ok', { - priority: 0, - defaults: [[KeyCode.Enter], [KeyCode.Space], [KeyCode.KeyC]] + hotkey.setGroup('common', '通用按键', [ + system.addCommand('@common/ok', '确认', { + defaults: [[KeyCode.Enter], [KeyCode.Space], [KeyCode.KeyC]], + priority: 0 }), - system.addCommand('exit', { - priority: 0, - defaults: [[KeyCode.Escape], [KeyCode.KeyX]] + system.addCommand('@common/exit', '退出/返回上一级', { + defaults: [[KeyCode.Escape], [KeyCode.KeyX]], + priority: 0 + }), + system.addCommand('@common/select-up', '上移选择光标', { + defaults: [[KeyCode.UpArrow]], + priority: 0 + }), + system.addCommand('@common/select-down', '下移选择光标', { + defaults: [[KeyCode.DownArrow]], + priority: 0 + }) + ]); + + //#endregion + + //#region 游戏按键 + + hotkey.setGroup('game', '游戏按键', [ + system.addCommand('@game/up', '向上移动', { + defaults: [[KeyCode.UpArrow]], + mode: HotkeyEmitMode.Continuous + }), + system.addCommand('@game/right', '向右移动', { + defaults: [[KeyCode.RightArrow]], + mode: HotkeyEmitMode.Continuous + }), + system.addCommand('@game/down', '向下移动', { + defaults: [[KeyCode.DownArrow]], + mode: HotkeyEmitMode.Continuous + }), + system.addCommand('@game/left', '向左移动', { + defaults: [[KeyCode.LeftArrow]], + mode: HotkeyEmitMode.Continuous + }), + system.addCommand('@game/turn', '勇士转向', { + defaults: [[KeyCode.KeyZ]] + }), + system.addCommand('@game/undo', '回退', { + defaults: [[KeyCode.KeyA]] + }), + system.addCommand('@game/redo', '恢复', { + defaults: [[KeyCode.KeyW]] + }), + system.addCommand('@game/get-next', '轻按', { + defaults: [[KeyCode.Space], [KeyCode.Digit7]] + }), + system.addCommand('@game/quick-equip', '切换/保存套装', { + defaults: [ + [KeyCode.Alt, KeyCode.Digit1], + [KeyCode.Alt, KeyCode.Digit2], + [KeyCode.Alt, KeyCode.Digit3], + [KeyCode.Alt, KeyCode.Digit4], + [KeyCode.Alt, KeyCode.Digit5], + [KeyCode.Alt, KeyCode.Digit6], + [KeyCode.Alt, KeyCode.Digit7], + [KeyCode.Alt, KeyCode.Digit8], + [KeyCode.Alt, KeyCode.Digit9], + [KeyCode.Alt, KeyCode.Digit0] + ] + }) + ]); + + //#endregion + + //#region UI 界面 + + hotkey.setGroup('ui', 'UI 界面', [ + system.addCommand('@ui/book', '怪物手册', { + defaults: [[KeyCode.KeyX]] + }), + system.addCommand('@ui/save', '存档界面', { + defaults: [[KeyCode.KeyS]] + }), + system.addCommand('@ui/load', '读档界面', { + defaults: [[KeyCode.KeyD]] + }), + system.addCommand('@ui/fly', '楼层传送', { + defaults: [[KeyCode.KeyG]] + }), + system.addCommand('@ui/menu', '游戏菜单', { + defaults: [[KeyCode.Escape]] + }), + system.addCommand('@ui/view-map', '浏览地图', { + defaults: [[KeyCode.PageDown], [KeyCode.PageUp]] + }), + system.addCommand('@ui/toolbox', '道具界面', { + defaults: [[KeyCode.KeyT]] + }), + system.addCommand('@ui/command', '装备界面', { + defaults: [[KeyCode.KeyQ]] + }), + system.addCommand('@ui/replay', '回放菜单', { + defaults: [[KeyCode.KeyR]] + }), + system.addCommand('@ui/shop', '快捷商店', { + defaults: [[KeyCode.KeyV]] + }), + system.addCommand('@ui/statistics', '数据统计', { + defaults: [[KeyCode.Ctrl, KeyCode.KeyB]] + }) + ]); + + //#endregion + + //#region 系统按键 + + hotkey.setGroup('system', '系统按键', [ + system.addCommand('@system/restart', '回到开始界面', { + defaults: [[KeyCode.KeyN]] + }), + system.addCommand('@system/comment', '评论区', { + defaults: [[KeyCode.KeyP]] + }), + system.addCommand('@system/debug', '调试模式', { + defaults: [[KeyCode.F8]] + }) + ]); + + //#endregion + + //#region 开始界面 + + hotkey.setGroup('@ui/start', '开始界面', [ + system.addCommand('@ui/start-up', '上移光标', { + defaults: [[KeyCode.UpArrow]], + mode: HotkeyEmitMode.Once + }), + system.addCommand('@ui/start-down', '下移光标', { + defaults: [[KeyCode.DownArrow]], + mode: HotkeyEmitMode.Once + }) + ]); + + //#endregion + + //#region 怪物手册 + + hotkey.setGroup('@ui/book', '怪物手册', [ + system.addCommand('@ui/book-up', '上移光标', { + defaults: [[KeyCode.UpArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/book-down', '下移光标', { + defaults: [[KeyCode.DownArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/book-page-up', '上移五个怪物', { + defaults: [[KeyCode.LeftArrow], [KeyCode.PageUp]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/book-page-down', '下移五个怪物', { + defaults: [[KeyCode.RightArrow], [KeyCode.PageDown]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }) + ]); + + //#endregion + + //#region 常规楼层传送 + + hotkey.setGroup('@ui/fly', '常规楼层传送', [ + system.addCommand('@ui/fly-prev', '上一张地图', { + defaults: [[KeyCode.DownArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/fly-next', '下一张地图', { + defaults: [[KeyCode.UpArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/fly-prev-10', '前十张地图', { + defaults: [[KeyCode.LeftArrow], [KeyCode.PageDown]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/fly-next-10', '后十张地图', { + defaults: [[KeyCode.RightArrow], [KeyCode.PageUp]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }) + ]); + + //#endregion + + //#region 平面楼层传送 + + hotkey.setGroup('@ui/plane-fly', '平面楼层传送', [ + system.addCommand('@ui/plane-fly-up', '上移地图', { + defaults: [[KeyCode.UpArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/plane-fly-right', '右移地图', { + defaults: [[KeyCode.RightArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/plane-fly-down', '下移地图', { + defaults: [[KeyCode.DownArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/plane-fly-left', '左移地图', { + defaults: [[KeyCode.LeftArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/plane-fly-prev', '上一张地图', { + defaults: [[KeyCode.PageDown]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/plane-fly-next', '下一张地图', { + defaults: [[KeyCode.PageUp]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/plane-fly-prev-10', '前十张地图', { + defaults: [ + [KeyCode.Ctrl, KeyCode.PageDown], + [KeyCode.Ctrl, KeyCode.LeftArrow] + ], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/plane-fly-next-10', '后十张地图', { + defaults: [ + [KeyCode.Ctrl, KeyCode.PageUp], + [KeyCode.Ctrl, KeyCode.RightArrow] + ], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }) + ]); + + //#endregion + + //#region 存档界面 + + hotkey.setGroup('@ui/save', '存档界面', [ + system.addCommand('@ui/save-up', '选择框向上', { + defaults: [[KeyCode.UpArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/save-up', '选择框向右', { + defaults: [[KeyCode.RightArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/save-up', '选择框向下', { + defaults: [[KeyCode.DownArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/save-up', '选择框向左', { + defaults: [[KeyCode.LeftArrow]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/save-page-prev', '向前翻页', { + defaults: [[KeyCode.PageUp]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/save-page-next', '向后翻页', { + defaults: [[KeyCode.PageDown]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/save-page-prev', '向前十页', { + defaults: [[KeyCode.Ctrl, KeyCode.PageUp]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/save-page-next', '向后十页', { + defaults: [[KeyCode.Ctrl, KeyCode.PageDown]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }) + ]); + + //#endregion + + //#region 浏览地图 + + hotkey.setGroup('@ui/view-map', '浏览地图', [ + system.addCommand('@ui/view-map-prev', '上一张地图', { + defaults: [[KeyCode.DownArrow], [KeyCode.PageDown]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/view-map-next', '下一张地图', { + defaults: [[KeyCode.UpArrow], [KeyCode.PageUp]], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/view-map-prev-10', '前十张地图', { + defaults: [ + [KeyCode.Ctrl, KeyCode.DownArrow], + [KeyCode.Ctrl, KeyCode.PageDown] + ], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/view-map-next-10', '后十张地图', { + defaults: [ + [KeyCode.Ctrl, KeyCode.UpArrow], + [KeyCode.Ctrl, KeyCode.PageUp] + ], + mode: HotkeyEmitMode.Continuous, + interval: 500 + }), + system.addCommand('@ui/view-map-book', '怪物手册', { + defaults: [[KeyCode.KeyX]], + priority: 10 + }), + system.addCommand('@ui/view-map-fly', '传送至', { + defaults: [[KeyCode.KeyG]], + priority: 10 + }), + system.addCommand('@ui/view-map-reset', '重置视角', { + defaults: [[KeyCode.KeyR]], + priority: 10 }) ]);