tuil
ReferencePackages@mwillbanks/tuil-hotkeysAPI

HotkeyManager

class exported by @mwillbanks/tuil-hotkeys.

View rawEdit

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

MemberTypeRequiredDescriptionRelated types
#bindingsSet<HotkeyBinding>YesThe #bindings member uses the Set<HotkeyBinding> contract.HotkeyBinding
#observersSet<(event: HotkeyEvent) => void>YesThe #observers member uses the Set<(event: HotkeyEvent) => void> contract.HotkeyEvent
#sequencestring[]YesThe #sequence member uses the string[] contract.
#sequenceTimerNodeJS.Timeout | undefinedNoThe #sequenceTimer member uses the NodeJS.Timeout | undefined contract.
#pendingExactPendingHotkey | undefinedNoThe #pendingExact member uses the PendingHotkey | undefined contract.
#deferredErrorsunknown[]YesThe #deferredErrors member uses the unknown[] contract.
__constructoranyYesThe __constructor member uses the any contract.
register(binding: HotkeyBinding) => () => voidYesThe register member uses the (binding: HotkeyBinding) => () => void contract.HotkeyBinding
list() => readonly HotkeyBinding[]YesThe list member uses the () => readonly HotkeyBinding[] contract.HotkeyBinding
conflicts(platform?: NodeJS.Platform) => readonly HotkeyConflict[]YesThe conflicts member uses the (platform?: NodeJS.Platform) => readonly HotkeyConflict[] contract.HotkeyConflict
observe(observer: (event: HotkeyEvent) => void) => () => voidYesThe observe member uses the (observer: (event: HotkeyEvent) => void) => () => void contract.HotkeyEvent
drainErrors() => readonly unknown[]YesThe drainErrors member uses the () => readonly unknown[] contract.
dispatch(input: string, key?: TerminalKey, context?: HotkeyDispatchContext) => Promise<HotkeyBinding | undefined>YesThe 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>YesThe #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) => booleanYesThe #isEligible member uses the (binding: HotkeyBinding, context: HotkeyDispatchContext, platform: NodeJS.Platform) => boolean contract.HotkeyBinding, HotkeyDispatchContext
#rank(binding: HotkeyBinding) => numberYesThe #rank member uses the (binding: HotkeyBinding) => number contract.HotkeyBinding
#isPendingEligible(pending: PendingHotkey) => booleanYesThe #isPendingEligible member uses the (pending: PendingHotkey) => boolean contract.
#handleDeferredError(error: unknown, context: HotkeyDispatchContext) => voidYesThe #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.

Source

View the secondary source reference

Package

@mwillbanks/tuil-hotkeys

On this page