HotkeyManager
class exported by @mwillbanks/tuil-hotkeys.
class
Public class exported by @mwillbanks/tuil-hotkeys.
export class HotkeyManager {
readonly #bindings = new Set<HotkeyBinding>();
readonly #observers = new Set<(event: HotkeyEvent) => void>();
readonly #sequence: string[] = [];
#sequenceTimer?: ReturnType<typeof setTimeout>;
#pendingExact?: PendingHotkey;
readonly #deferredErrors: unknown[] = [];
constructor(readonly sequenceTimeout = 750) {}
register(binding: HotkeyBinding): () => void {
if (!binding.keys.trim()) {
throw new Error("Hotkey notation cannot be empty");
}
const registered = Object.freeze({ ...binding });
this.#bindings.add(registered);
return () => {
this.#bindings.delete(registered);
if (this.#pendingExact?.binding === registered) {
this.#pendingExact = undefined;
this.#sequence.length = 0;
if (this.#sequenceTimer) clearTimeout(this.#sequenceTimer);
}
};
}
list(): readonly HotkeyBinding[] {
return [...this.#bindings];
}
conflicts(
platform: NodeJS.Platform = process.platform,
): readonly HotkeyConflict[] {
const groups = new Map<string, HotkeyBinding[]>();
for (const binding of this.#bindings) {
const key = normalizeHotkeyNotation(binding.keys, platform);
const current = groups.get(key) ?? [];
current.push(binding);
groups.set(key, current);
}
const conflicts: HotkeyConflict[] = [];
for (const [keys, bindings] of groups) {
if (bindings.length < 2) continue;
conflicts.push({
keys,
bindings,
resolved: bindings.toSorted(
(left, right) => this.#rank(right) - this.#rank(left),
)[0] as HotkeyBinding,
});
}
return conflicts;
}
observe(observer: (event: HotkeyEvent) => void): () => void {
this.#observers.add(observer);
return () => this.#observers.delete(observer);
}
drainErrors(): readonly unknown[] {
return this.#deferredErrors.splice(0);
}
async dispatch(
input: string,
key: TerminalKey = {},
context: HotkeyDispatchContext = {},
): Promise<HotkeyBinding | undefined> {
const platform = context.platform ?? process.platform;
const chord = normalizeHotkeyNotation(
normalizeTerminalKey(input, key, platform),
platform,
);
this.#sequence.push(chord);
if (this.#sequenceTimer) clearTimeout(this.#sequenceTimer);
const sequence = this.#sequence.join(" ");
const eligible = [...this.#bindings].filter((binding) =>
this.#isEligible(binding, context, platform),
);
const exact = eligible
.filter(
(binding) =>
normalizeHotkeyNotation(binding.keys, platform) === sequence,
)
.sort((left, right) => this.#rank(right) - this.#rank(left));
const hasLongerPrefix = eligible.some((candidate) =>
normalizeHotkeyNotation(candidate.keys, platform).startsWith(
`${sequence} `,
),
);
const binding = exact[0];
if (binding && hasLongerPrefix) {
this.#pendingExact = {
binding,
input,
sequence: [...this.#sequence],
context,
platform,
};
this.#sequenceTimer = setTimeout(() => {
const pending = this.#pendingExact;
this.#pendingExact = undefined;
this.#sequence.length = 0;
if (pending && this.#isPendingEligible(pending)) {
void this.#executeBinding(
pending.binding,
pending.input,
pending.sequence,
pending.platform,
).catch((error) => {
this.#handleDeferredError(error, pending.context);
});
}
}, this.sequenceTimeout);
return undefined;
}
if (!binding && hasLongerPrefix) {
this.#sequenceTimer = setTimeout(() => {
this.#pendingExact = undefined;
this.#sequence.length = 0;
}, this.sequenceTimeout);
return undefined;
}
if (!binding) {
const pending = this.#pendingExact;
this.#pendingExact = undefined;
this.#sequence.length = 0;
if (pending && this.#isPendingEligible(pending)) {
await this.#executeBinding(
pending.binding,
pending.input,
pending.sequence,
pending.platform,
);
return this.dispatch(input, key, context);
}
return undefined;
}
this.#pendingExact = undefined;
if (this.#sequenceTimer) clearTimeout(this.#sequenceTimer);
const pressed = [...this.#sequence];
this.#sequence.length = 0;
await this.#executeBinding(binding, input, pressed, platform);
return binding;
}
async #executeBinding(
binding: HotkeyBinding,
input: string,
sequence: readonly string[],
platform: NodeJS.Platform,
): Promise<void> {
const event: HotkeyEvent = {
keys: normalizeHotkeyNotation(binding.keys, platform),
input,
sequence,
binding,
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
await binding.handler(event);
for (const observer of this.#observers) {
observer(event);
}
}
#isEligible(
binding: HotkeyBinding,
context: HotkeyDispatchContext,
platform: NodeJS.Platform,
): boolean {
if (binding.platforms && !binding.platforms.includes(platform)) {
return false;
}
if (
binding.enabled === false ||
(typeof binding.enabled === "function" && !binding.enabled())
) {
return false;
}
const scope = binding.scope ?? "application";
const activeScopes =
typeof context.activeScopes === "function"
? context.activeScopes()
: context.activeScopes;
const active = activeScopes?.[scope];
return (
(scope === "application" && context.allowApplication !== false) ||
active === true ||
(typeof active === "string" && active === binding.scopeId)
);
}
#rank(binding: HotkeyBinding): number {
return (
scopePriority[binding.scope ?? "application"] * 1000 +
(binding.priority ?? 0)
);
}
#isPendingEligible(pending: PendingHotkey): boolean {
return (
this.#bindings.has(pending.binding) &&
this.#isEligible(pending.binding, pending.context, pending.platform)
);
}
#handleDeferredError(error: unknown, context: HotkeyDispatchContext): void {
if (context.onError) {
try {
context.onError(error);
return;
} catch (reportError) {
this.#deferredErrors.push(
new AggregateError(
[error, reportError],
"Hotkey handler and error reporting failed",
),
);
return;
}
}
this.#deferredErrors.push(error);
}
}Members
| Member | Type | Required | Description | Related types |
|---|---|---|---|---|
#bindings | Set<HotkeyBinding> | Yes | The #bindings member uses the Set<HotkeyBinding> contract. | HotkeyBinding |
#observers | Set<(event: HotkeyEvent) => void> | Yes | The #observers member uses the Set<(event: HotkeyEvent) => void> contract. | HotkeyEvent |
#sequence | string[] | Yes | The #sequence member uses the string[] contract. | — |
#sequenceTimer | NodeJS.Timeout | undefined | No | The #sequenceTimer member uses the NodeJS.Timeout | undefined contract. | — |
#pendingExact | PendingHotkey | undefined | No | The #pendingExact member uses the PendingHotkey | undefined contract. | — |
#deferredErrors | unknown[] | Yes | The #deferredErrors member uses the unknown[] contract. | — |
__constructor | any | Yes | The __constructor member uses the any contract. | — |
register | (binding: HotkeyBinding) => () => void | Yes | The register member uses the (binding: HotkeyBinding) => () => void contract. | HotkeyBinding |
list | () => readonly HotkeyBinding[] | Yes | The list member uses the () => readonly HotkeyBinding[] contract. | HotkeyBinding |
conflicts | (platform?: NodeJS.Platform) => readonly HotkeyConflict[] | Yes | The conflicts member uses the (platform?: NodeJS.Platform) => readonly HotkeyConflict[] contract. | HotkeyConflict |
observe | (observer: (event: HotkeyEvent) => void) => () => void | Yes | The observe member uses the (observer: (event: HotkeyEvent) => void) => () => void contract. | HotkeyEvent |
drainErrors | () => readonly unknown[] | Yes | The drainErrors member uses the () => readonly unknown[] contract. | — |
dispatch | (input: string, key?: TerminalKey, context?: HotkeyDispatchContext) => Promise<HotkeyBinding | undefined> | Yes | The dispatch member uses the (input: string, key?: TerminalKey, context?: HotkeyDispatchContext) => Promise<HotkeyBinding | undefined> contract. | HotkeyBinding, HotkeyDispatchContext, TerminalKey |
#executeBinding | (binding: HotkeyBinding, input: string, sequence: readonly string[], platform: NodeJS.Platform) => Promise<void> | Yes | The #executeBinding member uses the (binding: HotkeyBinding, input: string, sequence: readonly string[], platform: NodeJS.Platform) => Promise<void> contract. | HotkeyBinding |
#isEligible | (binding: HotkeyBinding, context: HotkeyDispatchContext, platform: NodeJS.Platform) => boolean | Yes | The #isEligible member uses the (binding: HotkeyBinding, context: HotkeyDispatchContext, platform: NodeJS.Platform) => boolean contract. | HotkeyBinding, HotkeyDispatchContext |
#rank | (binding: HotkeyBinding) => number | Yes | The #rank member uses the (binding: HotkeyBinding) => number contract. | HotkeyBinding |
#isPendingEligible | (pending: PendingHotkey) => boolean | Yes | The #isPendingEligible member uses the (pending: PendingHotkey) => boolean contract. | — |
#handleDeferredError | (error: unknown, context: HotkeyDispatchContext) => void | Yes | The #handleDeferredError member uses the (error: unknown, context: HotkeyDispatchContext) => void contract. | HotkeyDispatchContext |
Parameters
This declaration has no public members.
Returns
This declaration does not return a value.
Throws
No thrown errors are documented for this declaration.
Related types
Source
View the secondary source reference