πŸ“„ File detail

ink/events/dispatcher.ts

🧩 .tsπŸ“ 234 linesπŸ’Ύ 6,004 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œink/”, which covers Ink terminal UI (layouts, TTY IO, keyboard, renderer components). On the API surface it exposes Dispatcher β€” mainly types, interfaces, or factory objects. Dependencies touch react-reconciler. It composes internal code from utils, event-handlers, and terminal-event (relative imports).

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

import { ContinuousEventPriority, DefaultEventPriority, DiscreteEventPriority, NoEventPriority,

πŸ“€ Exports (heuristic)

  • Dispatcher

πŸ“š External import roots

Package roots from from "…" (relative paths omitted).

  • react-reconciler

πŸ–₯️ Source preview

import {
  ContinuousEventPriority,
  DefaultEventPriority,
  DiscreteEventPriority,
  NoEventPriority,
} from 'react-reconciler/constants.js'
import { logError } from '../../utils/log.js'
import { HANDLER_FOR_EVENT } from './event-handlers.js'
import type { EventTarget, TerminalEvent } from './terminal-event.js'

// --

type DispatchListener = {
  node: EventTarget
  handler: (event: TerminalEvent) => void
  phase: 'capturing' | 'at_target' | 'bubbling'
}

function getHandler(
  node: EventTarget,
  eventType: string,
  capture: boolean,
): ((event: TerminalEvent) => void) | undefined {
  const handlers = node._eventHandlers
  if (!handlers) return undefined

  const mapping = HANDLER_FOR_EVENT[eventType]
  if (!mapping) return undefined

  const propName = capture ? mapping.capture : mapping.bubble
  if (!propName) return undefined

  return handlers[propName] as ((event: TerminalEvent) => void) | undefined
}

/**
 * Collect all listeners for an event in dispatch order.
 *
 * Uses react-dom's two-phase accumulation pattern:
 * - Walk from target to root
 * - Capture handlers are prepended (unshift) β†’ root-first
 * - Bubble handlers are appended (push) β†’ target-first
 *
 * Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
 */
function collectListeners(
  target: EventTarget,
  event: TerminalEvent,
): DispatchListener[] {
  const listeners: DispatchListener[] = []

  let node: EventTarget | undefined = target
  while (node) {
    const isTarget = node === target

    const captureHandler = getHandler(node, event.type, true)
    const bubbleHandler = getHandler(node, event.type, false)

    if (captureHandler) {
      listeners.unshift({
        node,
        handler: captureHandler,
        phase: isTarget ? 'at_target' : 'capturing',
      })
    }

    if (bubbleHandler && (event.bubbles || isTarget)) {
      listeners.push({
        node,
        handler: bubbleHandler,
        phase: isTarget ? 'at_target' : 'bubbling',
      })
    }

    node = node.parentNode
  }

  return listeners
}

/**
 * Execute collected listeners with propagation control.
 *
 * Before each handler, calls event._prepareForTarget(node) so event
 * subclasses can do per-node setup.
 */
function processDispatchQueue(
  listeners: DispatchListener[],
  event: TerminalEvent,
): void {
  let previousNode: EventTarget | undefined

  for (const { node, handler, phase } of listeners) {
    if (event._isImmediatePropagationStopped()) {
      break
    }

    if (event._isPropagationStopped() && node !== previousNode) {
      break
    }

    event._setEventPhase(phase)
    event._setCurrentTarget(node)
    event._prepareForTarget(node)

    try {
      handler(event)
    } catch (error) {
      logError(error)
    }

    previousNode = node
  }
}

// --

/**
 * Map terminal event types to React scheduling priorities.
 * Mirrors react-dom's getEventPriority() switch.
 */
function getEventPriority(eventType: string): number {
  switch (eventType) {
    case 'keydown':
    case 'keyup':
    case 'click':
    case 'focus':
    case 'blur':
    case 'paste':
      return DiscreteEventPriority as number
    case 'resize':
    case 'scroll':
    case 'mousemove':
      return ContinuousEventPriority as number
    default:
      return DefaultEventPriority as number
  }
}

// --

type DiscreteUpdates = <A, B>(
  fn: (a: A, b: B) => boolean,
  a: A,
  b: B,
  c: undefined,
  d: undefined,
) => boolean

/**
 * Owns event dispatch state and the capture/bubble dispatch loop.
 *
 * The reconciler host config reads currentEvent and currentUpdatePriority
 * to implement resolveUpdatePriority, resolveEventType, and
 * resolveEventTimeStamp β€” mirroring how react-dom's host config reads
 * ReactDOMSharedInternals and window.event.
 *
 * discreteUpdates is injected after construction (by InkReconciler)
 * to break the import cycle.
 */
export class Dispatcher {
  currentEvent: TerminalEvent | null = null
  currentUpdatePriority: number = DefaultEventPriority as number
  discreteUpdates: DiscreteUpdates | null = null

  /**
   * Infer event priority from the currently-dispatching event.
   * Called by the reconciler host config's resolveUpdatePriority
   * when no explicit priority has been set.
   */
  resolveEventPriority(): number {
    if (this.currentUpdatePriority !== (NoEventPriority as number)) {
      return this.currentUpdatePriority
    }
    if (this.currentEvent) {
      return getEventPriority(this.currentEvent.type)
    }
    return DefaultEventPriority as number
  }

  /**
   * Dispatch an event through capture and bubble phases.
   * Returns true if preventDefault() was NOT called.
   */
  dispatch(target: EventTarget, event: TerminalEvent): boolean {
    const previousEvent = this.currentEvent
    this.currentEvent = event
    try {
      event._setTarget(target)

      const listeners = collectListeners(target, event)
      processDispatchQueue(listeners, event)

      event._setEventPhase('none')
      event._setCurrentTarget(null)

      return !event.defaultPrevented
    } finally {
      this.currentEvent = previousEvent
    }
  }

  /**
   * Dispatch with discrete (sync) priority.
   * For user-initiated events: keyboard, click, focus, paste.
   */
  dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
    if (!this.discreteUpdates) {
      return this.dispatch(target, event)
    }
    return this.discreteUpdates(
      (t, e) => this.dispatch(t, e),
      target,
      event,
      undefined,
      undefined,
    )
  }

  /**
   * Dispatch with continuous priority.
   * For high-frequency events: resize, scroll, mouse move.
   */
  dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
    const previousPriority = this.currentUpdatePriority
    try {
      this.currentUpdatePriority = ContinuousEventPriority as number
      return this.dispatch(target, event)
    } finally {
      this.currentUpdatePriority = previousPriority
    }
  }
}