init claude-code
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
/**
|
||||
* Mouse click event. Fired on left-button release without drag, only when
|
||||
* mouse tracking is enabled (i.e. inside <AlternateScreen>).
|
||||
*
|
||||
* Bubbles from the deepest hit node up through parentNode. Call
|
||||
* stopImmediatePropagation() to prevent ancestors' onClick from firing.
|
||||
*/
|
||||
export class ClickEvent extends Event {
|
||||
/** 0-indexed screen column of the click */
|
||||
readonly col: number
|
||||
/** 0-indexed screen row of the click */
|
||||
readonly row: number
|
||||
/**
|
||||
* Click column relative to the current handler's Box (col - box.x).
|
||||
* Recomputed by dispatchClick before each handler fires, so an onClick
|
||||
* on a container sees coords relative to that container, not to any
|
||||
* child the click landed on.
|
||||
*/
|
||||
localCol = 0
|
||||
/** Click row relative to the current handler's Box (row - box.y). */
|
||||
localRow = 0
|
||||
/**
|
||||
* True if the clicked cell has no visible content (unwritten in the
|
||||
* screen buffer — both packed words are 0). Handlers can check this to
|
||||
* ignore clicks on blank space to the right of text, so accidental
|
||||
* clicks on empty terminal space don't toggle state.
|
||||
*/
|
||||
readonly cellIsBlank: boolean
|
||||
|
||||
constructor(col: number, row: number, cellIsBlank: boolean) {
|
||||
super()
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.cellIsBlank = cellIsBlank
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EventEmitter as NodeEventEmitter } from 'events'
|
||||
import { Event } from './event.js'
|
||||
|
||||
// Similar to node's builtin EventEmitter, but is also aware of our `Event`
|
||||
// class, and so `emit` respects `stopImmediatePropagation()`.
|
||||
export class EventEmitter extends NodeEventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
// Disable the default maxListeners warning. In React, many components
|
||||
// can legitimately listen to the same event (e.g., useInput hooks).
|
||||
// The default limit of 10 causes spurious warnings.
|
||||
this.setMaxListeners(0)
|
||||
}
|
||||
|
||||
override emit(type: string | symbol, ...args: unknown[]): boolean {
|
||||
// Delegate to node for `error`, since it's not treated like a normal event
|
||||
if (type === 'error') {
|
||||
return super.emit(type, ...args)
|
||||
}
|
||||
|
||||
const listeners = this.rawListeners(type)
|
||||
|
||||
if (listeners.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ccEvent = args[0] instanceof Event ? args[0] : null
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener.apply(this, args)
|
||||
|
||||
if (ccEvent?.didStopImmediatePropagation()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ClickEvent } from './click-event.js'
|
||||
import type { FocusEvent } from './focus-event.js'
|
||||
import type { KeyboardEvent } from './keyboard-event.js'
|
||||
import type { PasteEvent } from './paste-event.js'
|
||||
import type { ResizeEvent } from './resize-event.js'
|
||||
|
||||
type KeyboardEventHandler = (event: KeyboardEvent) => void
|
||||
type FocusEventHandler = (event: FocusEvent) => void
|
||||
type PasteEventHandler = (event: PasteEvent) => void
|
||||
type ResizeEventHandler = (event: ResizeEvent) => void
|
||||
type ClickEventHandler = (event: ClickEvent) => void
|
||||
type HoverEventHandler = () => void
|
||||
|
||||
/**
|
||||
* Props for event handlers on Box and other host components.
|
||||
*
|
||||
* Follows the React/DOM naming convention:
|
||||
* - onEventName: handler for bubble phase
|
||||
* - onEventNameCapture: handler for capture phase
|
||||
*/
|
||||
export type EventHandlerProps = {
|
||||
onKeyDown?: KeyboardEventHandler
|
||||
onKeyDownCapture?: KeyboardEventHandler
|
||||
|
||||
onFocus?: FocusEventHandler
|
||||
onFocusCapture?: FocusEventHandler
|
||||
onBlur?: FocusEventHandler
|
||||
onBlurCapture?: FocusEventHandler
|
||||
|
||||
onPaste?: PasteEventHandler
|
||||
onPasteCapture?: PasteEventHandler
|
||||
|
||||
onResize?: ResizeEventHandler
|
||||
|
||||
onClick?: ClickEventHandler
|
||||
onMouseEnter?: HoverEventHandler
|
||||
onMouseLeave?: HoverEventHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup: event type string → handler prop names.
|
||||
* Used by the dispatcher for O(1) handler lookup per node.
|
||||
*/
|
||||
export const HANDLER_FOR_EVENT: Record<
|
||||
string,
|
||||
{ bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
|
||||
> = {
|
||||
keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
|
||||
focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
|
||||
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
|
||||
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||
resize: { bubble: 'onResize' },
|
||||
click: { bubble: 'onClick' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of all event handler prop names, for the reconciler to detect
|
||||
* event props and store them in _eventHandlers instead of attributes.
|
||||
*/
|
||||
export const EVENT_HANDLER_PROPS = new Set<string>([
|
||||
'onKeyDown',
|
||||
'onKeyDownCapture',
|
||||
'onFocus',
|
||||
'onFocusCapture',
|
||||
'onBlur',
|
||||
'onBlurCapture',
|
||||
'onPaste',
|
||||
'onPasteCapture',
|
||||
'onResize',
|
||||
'onClick',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave',
|
||||
])
|
||||
@@ -0,0 +1,11 @@
|
||||
export class Event {
|
||||
private _didStopImmediatePropagation = false
|
||||
|
||||
didStopImmediatePropagation(): boolean {
|
||||
return this._didStopImmediatePropagation
|
||||
}
|
||||
|
||||
stopImmediatePropagation(): void {
|
||||
this._didStopImmediatePropagation = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type EventTarget, TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Focus event for component focus changes.
|
||||
*
|
||||
* Dispatched when focus moves between elements. 'focus' fires on the
|
||||
* newly focused element, 'blur' fires on the previously focused one.
|
||||
* Both bubble, matching react-dom's use of focusin/focusout semantics
|
||||
* so parent components can observe descendant focus changes.
|
||||
*/
|
||||
export class FocusEvent extends TerminalEvent {
|
||||
readonly relatedTarget: EventTarget | null
|
||||
|
||||
constructor(
|
||||
type: 'focus' | 'blur',
|
||||
relatedTarget: EventTarget | null = null,
|
||||
) {
|
||||
super(type, { bubbles: true, cancelable: false })
|
||||
this.relatedTarget = relatedTarget
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
|
||||
import { Event } from './event.js'
|
||||
|
||||
export type Key = {
|
||||
upArrow: boolean
|
||||
downArrow: boolean
|
||||
leftArrow: boolean
|
||||
rightArrow: boolean
|
||||
pageDown: boolean
|
||||
pageUp: boolean
|
||||
wheelUp: boolean
|
||||
wheelDown: boolean
|
||||
home: boolean
|
||||
end: boolean
|
||||
return: boolean
|
||||
escape: boolean
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
fn: boolean
|
||||
tab: boolean
|
||||
backspace: boolean
|
||||
delete: boolean
|
||||
meta: boolean
|
||||
super: boolean
|
||||
}
|
||||
|
||||
function parseKey(keypress: ParsedKey): [Key, string] {
|
||||
const key: Key = {
|
||||
upArrow: keypress.name === 'up',
|
||||
downArrow: keypress.name === 'down',
|
||||
leftArrow: keypress.name === 'left',
|
||||
rightArrow: keypress.name === 'right',
|
||||
pageDown: keypress.name === 'pagedown',
|
||||
pageUp: keypress.name === 'pageup',
|
||||
wheelUp: keypress.name === 'wheelup',
|
||||
wheelDown: keypress.name === 'wheeldown',
|
||||
home: keypress.name === 'home',
|
||||
end: keypress.name === 'end',
|
||||
return: keypress.name === 'return',
|
||||
escape: keypress.name === 'escape',
|
||||
fn: keypress.fn,
|
||||
ctrl: keypress.ctrl,
|
||||
shift: keypress.shift,
|
||||
tab: keypress.name === 'tab',
|
||||
backspace: keypress.name === 'backspace',
|
||||
delete: keypress.name === 'delete',
|
||||
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
|
||||
// but with option = true, so we need to take this into account here
|
||||
// to avoid breaking changes in Ink.
|
||||
// TODO(vadimdemedes): consider removing this in the next major version.
|
||||
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
|
||||
// Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
|
||||
// protocol CSI u sequences. Distinct from meta (Alt/Option) so
|
||||
// bindings like cmd+c can be expressed separately from opt+c.
|
||||
super: keypress.super,
|
||||
}
|
||||
|
||||
let input = keypress.ctrl ? keypress.name : keypress.sequence
|
||||
|
||||
// Handle undefined input case
|
||||
if (input === undefined) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// When ctrl is set, keypress.name for space is the literal word "space".
|
||||
// Convert to actual space character for consistency with the CSI u branch
|
||||
// (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
|
||||
// word "space" into text input.
|
||||
if (keypress.ctrl && input === 'space') {
|
||||
input = ' '
|
||||
}
|
||||
|
||||
// Suppress unrecognized escape sequences that were parsed as function keys
|
||||
// (matched by FN_KEY_RE) but have no name in the keyName map.
|
||||
// Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
|
||||
// Without this, the ESC prefix is stripped below and the remainder (e.g.,
|
||||
// "[25~") leaks into the input as literal text.
|
||||
if (keypress.code && !keypress.name) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks
|
||||
// the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across
|
||||
// stdin chunks gets its buffered ESC flushed as a lone Escape key, and the
|
||||
// continuation arrives as a text token with name='' — which falls through
|
||||
// all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys
|
||||
// clear below (name is falsy). The fragment then leaks into the prompt as
|
||||
// literal `[<64;74;16M`. This is the same defensive sink as the F13 guard
|
||||
// above; the underlying tokenizer-flush race is upstream of this layer.
|
||||
if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Strip meta if it's still remaining after `parseKeypress`
|
||||
// TODO(vadimdemedes): remove this in the next major version.
|
||||
if (input.startsWith('\u001B')) {
|
||||
input = input.slice(1)
|
||||
}
|
||||
|
||||
// Track whether we've already processed this as a special sequence
|
||||
// that converted input to the key name (CSI u or application keypad mode).
|
||||
// For these, we don't want to clear input with nonAlphanumericKeys check.
|
||||
let processedAsSpecialSequence = false
|
||||
|
||||
// Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
|
||||
// we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
|
||||
// Use the parsed key name instead for input handling. Require a digit
|
||||
// after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
|
||||
// false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
|
||||
// literal text "mouse" into the prompt via processedAsSpecialSequence.
|
||||
if (/^\[\d/.test(input) && input.endsWith('u')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
|
||||
// bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
|
||||
// so the raw "[57358u" doesn't leak into the prompt. See #38781.
|
||||
input = ''
|
||||
} else {
|
||||
// 'space' → ' '; 'escape' → '' (key.escape carries it;
|
||||
// processedAsSpecialSequence bypasses the nonAlphanumericKeys
|
||||
// clear below, so we must handle it explicitly here);
|
||||
// otherwise use key name.
|
||||
input =
|
||||
keypress.name === 'space'
|
||||
? ' '
|
||||
: keypress.name === 'escape'
|
||||
? ''
|
||||
: keypress.name
|
||||
}
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
|
||||
// with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
|
||||
// extraction as CSI u — without this, printable-char keycodes (single-letter
|
||||
// names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
|
||||
if (input.startsWith('[27;') && input.endsWith('~')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped modifyOtherKeys keycode — swallow for consistency with
|
||||
// the CSI u handler above. Practically untriggerable today (xterm
|
||||
// modifyOtherKeys only sends ASCII keycodes, all mapped), but
|
||||
// guards against future terminal behavior.
|
||||
input = ''
|
||||
} else {
|
||||
input =
|
||||
keypress.name === 'space'
|
||||
? ' '
|
||||
: keypress.name === 'escape'
|
||||
? ''
|
||||
: keypress.name
|
||||
}
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle application keypad mode sequences: after stripping ESC,
|
||||
// we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
|
||||
// Use the parsed key name (the digit character) for input handling.
|
||||
if (
|
||||
input.startsWith('O') &&
|
||||
input.length === 2 &&
|
||||
keypress.name &&
|
||||
keypress.name.length === 1
|
||||
) {
|
||||
input = keypress.name
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Clear input for non-alphanumeric keys (arrows, function keys, etc.)
|
||||
// Skip this for CSI u and application keypad mode sequences since
|
||||
// those were already converted to their proper input characters.
|
||||
if (
|
||||
!processedAsSpecialSequence &&
|
||||
keypress.name &&
|
||||
nonAlphanumericKeys.includes(keypress.name)
|
||||
) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Set shift=true for uppercase letters (A-Z)
|
||||
// Must check it's actually a letter, not just any char unchanged by toUpperCase
|
||||
if (
|
||||
input.length === 1 &&
|
||||
typeof input[0] === 'string' &&
|
||||
input[0] >= 'A' &&
|
||||
input[0] <= 'Z'
|
||||
) {
|
||||
key.shift = true
|
||||
}
|
||||
|
||||
return [key, input]
|
||||
}
|
||||
|
||||
export class InputEvent extends Event {
|
||||
readonly keypress: ParsedKey
|
||||
readonly key: Key
|
||||
readonly input: string
|
||||
|
||||
constructor(keypress: ParsedKey) {
|
||||
super()
|
||||
const [key, input] = parseKey(keypress)
|
||||
|
||||
this.keypress = keypress
|
||||
this.key = key
|
||||
this.input = input
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ParsedKey } from '../parse-keypress.js'
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Keyboard event dispatched through the DOM tree via capture/bubble.
|
||||
*
|
||||
* Follows browser KeyboardEvent semantics: `key` is the literal character
|
||||
* for printable keys ('a', '3', ' ', '/') and a multi-char name for
|
||||
* special keys ('down', 'return', 'escape', 'f1'). The idiomatic
|
||||
* printable-char check is `e.key.length === 1`.
|
||||
*/
|
||||
export class KeyboardEvent extends TerminalEvent {
|
||||
readonly key: string
|
||||
readonly ctrl: boolean
|
||||
readonly shift: boolean
|
||||
readonly meta: boolean
|
||||
readonly superKey: boolean
|
||||
readonly fn: boolean
|
||||
|
||||
constructor(parsedKey: ParsedKey) {
|
||||
super('keydown', { bubbles: true, cancelable: true })
|
||||
|
||||
this.key = keyFromParsed(parsedKey)
|
||||
this.ctrl = parsedKey.ctrl
|
||||
this.shift = parsedKey.shift
|
||||
this.meta = parsedKey.meta || parsedKey.option
|
||||
this.superKey = parsedKey.super
|
||||
this.fn = parsedKey.fn
|
||||
}
|
||||
}
|
||||
|
||||
function keyFromParsed(parsed: ParsedKey): string {
|
||||
const seq = parsed.sequence ?? ''
|
||||
const name = parsed.name ?? ''
|
||||
|
||||
// Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the
|
||||
// letter. Browsers report e.key === 'c' with e.ctrlKey === true.
|
||||
if (parsed.ctrl) return name
|
||||
|
||||
// Single printable char (space through ~, plus anything above ASCII):
|
||||
// use the literal char. Browsers report e.key === '3', not 'Digit3'.
|
||||
if (seq.length === 1) {
|
||||
const code = seq.charCodeAt(0)
|
||||
if (code >= 0x20 && code !== 0x7f) return seq
|
||||
}
|
||||
|
||||
// Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is
|
||||
// either an escape sequence (\x1b[B) or a control byte (\r, \t), so use
|
||||
// the parsed name. Browsers report e.key === 'ArrowDown'.
|
||||
return name || seq
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
|
||||
|
||||
type TerminalEventInit = {
|
||||
bubbles?: boolean
|
||||
cancelable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all terminal events with DOM-style propagation.
|
||||
*
|
||||
* Extends Event so existing event types (ClickEvent, InputEvent,
|
||||
* TerminalFocusEvent) share a common ancestor and can migrate later.
|
||||
*
|
||||
* Mirrors the browser's Event API: target, currentTarget, eventPhase,
|
||||
* stopPropagation(), preventDefault(), timeStamp.
|
||||
*/
|
||||
export class TerminalEvent extends Event {
|
||||
readonly type: string
|
||||
readonly timeStamp: number
|
||||
readonly bubbles: boolean
|
||||
readonly cancelable: boolean
|
||||
|
||||
private _target: EventTarget | null = null
|
||||
private _currentTarget: EventTarget | null = null
|
||||
private _eventPhase: EventPhase = 'none'
|
||||
private _propagationStopped = false
|
||||
private _defaultPrevented = false
|
||||
|
||||
constructor(type: string, init?: TerminalEventInit) {
|
||||
super()
|
||||
this.type = type
|
||||
this.timeStamp = performance.now()
|
||||
this.bubbles = init?.bubbles ?? true
|
||||
this.cancelable = init?.cancelable ?? true
|
||||
}
|
||||
|
||||
get target(): EventTarget | null {
|
||||
return this._target
|
||||
}
|
||||
|
||||
get currentTarget(): EventTarget | null {
|
||||
return this._currentTarget
|
||||
}
|
||||
|
||||
get eventPhase(): EventPhase {
|
||||
return this._eventPhase
|
||||
}
|
||||
|
||||
get defaultPrevented(): boolean {
|
||||
return this._defaultPrevented
|
||||
}
|
||||
|
||||
stopPropagation(): void {
|
||||
this._propagationStopped = true
|
||||
}
|
||||
|
||||
override stopImmediatePropagation(): void {
|
||||
super.stopImmediatePropagation()
|
||||
this._propagationStopped = true
|
||||
}
|
||||
|
||||
preventDefault(): void {
|
||||
if (this.cancelable) {
|
||||
this._defaultPrevented = true
|
||||
}
|
||||
}
|
||||
|
||||
// -- Internal setters used by the Dispatcher
|
||||
|
||||
/** @internal */
|
||||
_setTarget(target: EventTarget): void {
|
||||
this._target = target
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_setCurrentTarget(target: EventTarget | null): void {
|
||||
this._currentTarget = target
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_setEventPhase(phase: EventPhase): void {
|
||||
this._eventPhase = phase
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isPropagationStopped(): boolean {
|
||||
return this._propagationStopped
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isImmediatePropagationStopped(): boolean {
|
||||
return this.didStopImmediatePropagation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subclasses to do per-node setup before each handler fires.
|
||||
* Default is a no-op.
|
||||
*/
|
||||
_prepareForTarget(_target: EventTarget): void {}
|
||||
}
|
||||
|
||||
export type EventTarget = {
|
||||
parentNode: EventTarget | undefined
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
|
||||
|
||||
/**
|
||||
* Event fired when the terminal window gains or loses focus.
|
||||
*
|
||||
* Uses DECSET 1004 focus reporting - the terminal sends:
|
||||
* - CSI I (\x1b[I) when the terminal gains focus
|
||||
* - CSI O (\x1b[O) when the terminal loses focus
|
||||
*/
|
||||
export class TerminalFocusEvent extends Event {
|
||||
readonly type: TerminalFocusEventType
|
||||
|
||||
constructor(type: TerminalFocusEventType) {
|
||||
super()
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user