Back to Pünktlich!
SOURCE / PINNED RELEASE

Made of little things.

Pünktlich!

Release
9dc6c97948c0…
Author-recorded commit
aebcea49a666…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-bf1ae466031

Archive hash verified: 7705a83d7bdb3f9b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/keyboard.ts
import { KEYS_DOMAIN, keys } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';

// Keyboard controls as NAP-KEYS actions. The shell owns the bindings (and may
// let the player rebind them); the runtime's keys shim turns a bound keydown
// into the action. Without the keys domain, or for an action the shell left
// unbound, the listed fallback codes are handled locally instead.

export type KeyAction = {
  /** Stable `feature.action` id so the shell can remember rebinds. */
  id: string;
  label: string;
  defaultKey: string;
  /** KeyboardEvent.code values handled locally when the shell has no binding. */
  fallbackCodes: string[];
  /** Fallback key names for hints, e.g. "→ / D". */
  fallbackLabel: string;
  /** `repeat` is true for auto-repeat while a key is held. */
  onPress: (repeat: boolean) => void;
  /** Hold actions get a release when the key that started them goes up. */
  onRelease?: () => void;
};

// Actions pushed by the shell carry no key-up; a held shell key repeats the
// action, so a hold lasts this long past the latest press.
const SHELL_HOLD_MS = 280;

const shellBindings = new Map<string, string>();
let currentKeydown: KeyboardEvent | null = null;

export function bindKeyboard(actions: KeyAction[], onBindingsChange: () => void): void {
  const heldBy = new Map<string, string>();
  const shellHoldTimers = new Map<string, number>();

  const press = (action: KeyAction) => {
    const event = currentKeydown;
    if (event) {
      action.onPress(event.repeat);
      if (action.onRelease) heldBy.set(action.id, event.code);
      return;
    }
    action.onPress(false);
    if (action.onRelease) {
      window.clearTimeout(shellHoldTimers.get(action.id));
      shellHoldTimers.set(action.id, window.setTimeout(action.onRelease, SHELL_HOLD_MS));
    }
  };

  // Window capture runs before the shim's document-capture listener, so a
  // locally triggered action can see the keydown that caused it.
  window.addEventListener('keydown', (event) => { currentKeydown = event; }, true);

  window.addEventListener('keydown', (event) => {
    currentKeydown = null;
    if (event.defaultPrevented) return; // Already consumed by a shell binding.
    const action = actions.find((a) => !shellBindings.has(a.id) && a.fallbackCodes.includes(event.code));
    if (!action) return;
    event.preventDefault();
    action.onPress(event.repeat);
    if (action.onRelease) heldBy.set(action.id, event.code);
  });

  window.addEventListener('keyup', (event) => {
    for (const [id, code] of heldBy) {
      if (code !== event.code) continue;
      heldBy.delete(id);
      actions.find((a) => a.id === id)?.onRelease?.();
    }
  });

  window.addEventListener('blur', () => {
    for (const id of heldBy.keys()) actions.find((a) => a.id === id)?.onRelease?.();
    heldBy.clear();
  });

  if (!runtimeHasDomain(KEYS_DOMAIN)) return;
  for (const action of actions) {
    keys
      .register({ id: action.id, label: action.label, defaultKey: action.defaultKey }, () => press(action))
      .then((result) => {
        if (!result.binding) return;
        shellBindings.set(action.id, result.binding);
        onBindingsChange();
        window.addEventListener('pagehide', () => result.close(), { once: true });
      })
      .catch(() => {
        // Keys is optional; this action keeps its local fallback keys.
      });
  }
}

/** Hint text for an action: the shell's binding, or the local fallback keys. */
export function keyLabel(action: KeyAction): string {
  const binding = shellBindings.get(action.id);
  if (!binding) return action.fallbackLabel;
  return binding === ' ' ? 'Leertaste' : binding;
}