Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions packages/core/src/lib/KeyHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,3 +651,160 @@ test("KeyHandler - error in one event type does not prevent other event types fr
expect(keypressCalled).toBe(true)
expect(pasteCalled).toBe(true)
})

test("KeyHandler - Kitty emoji reassembly: basic emoji", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[128512u") // 😀 U+1F600
await new Promise((r) => setTimeout(r, 10))

expect(events).toHaveLength(1)
expect(events[0].name).toBe("😀")
expect(events[0].sequence).toBe("😀")
expect(events[0].raw).toBe("\x1b[128512u")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: ZWJ family", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[128104u") // 👨 U+1F468
handler.processInput("\x1b[8205u") // ZWJ U+200D
handler.processInput("\x1b[128105u") // 👩 U+1F469
handler.processInput("\x1b[8205u") // ZWJ
handler.processInput("\x1b[128103u") // 👧 U+1F467
await new Promise((r) => setTimeout(r, 10))

expect(events).toHaveLength(1)
expect(events[0].name).toBe("👨‍👩‍👧")
expect(events[0].sequence).toBe("👨‍👩‍👧")
expect(events[0].raw).toBe("\x1b[128104u\x1b[8205u\x1b[128105u\x1b[8205u\x1b[128103u")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: flag emoji", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[127482u") // 🇺 U+1F1FA
handler.processInput("\x1b[127480u") // 🇸 U+1F1F8
await new Promise((r) => setTimeout(r, 10))

expect(events).toHaveLength(1)
expect(events[0].name).toBe("🇺🇸")
expect(events[0].sequence).toBe("🇺🇸")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: skin tone modifier", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[128075u") // 👋 U+1F44B
handler.processInput("\x1b[127995u") // 🏻 U+1F3FB (light skin tone)
await new Promise((r) => setTimeout(r, 10))

expect(events).toHaveLength(1)
expect(events[0].name).toBe("👋🏻")
expect(events[0].sequence).toBe("👋🏻")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: keycap", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[35u") // # U+0023
handler.processInput("\x1b[65039u") // VS16 U+FE0F
handler.processInput("\x1b[8419u") // Keycap U+20E3
await new Promise((r) => setTimeout(r, 10))

expect(events).toHaveLength(1)
expect(events[0].name).toBe("#️⃣")
expect(events[0].sequence).toBe("#️⃣")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: flush on non-emoji input", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[128075u") // 👋
handler.processInput("\x1b[97u") // 'a' - not an extender, should flush

expect(events).toHaveLength(2)
expect(events[0].name).toBe("👋")
expect(events[1].name).toBe("a")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: non-emoji Kitty sequences pass through", () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[97u") // 'a'
handler.processInput("\x1b[98u") // 'b'

expect(events).toHaveLength(2)
expect(events[0].name).toBe("a")
expect(events[1].name).toBe("b")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: multiple flags split correctly", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[127482u") // 🇺 U+1F1FA
handler.processInput("\x1b[127480u") // 🇸 U+1F1F8
handler.processInput("\x1b[127471u") // 🇯 U+1F1EF
handler.processInput("\x1b[127477u") // 🇵 U+1F1F5
await new Promise((r) => setTimeout(r, 10))

expect(events).toHaveLength(2)
expect(events[0].name).toBe("🇺🇸")
expect(events[1].name).toBe("🇯🇵")
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: modifiers prevent buffering", () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[128512;5u") // Ctrl+😀

expect(events).toHaveLength(1)
expect(events[0].name).toBe("😀")
expect(events[0].ctrl).toBe(true)
handler.destroy()
})

test("KeyHandler - Kitty emoji reassembly: subdivision flag", async () => {
const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 })
const events: KeyEvent[] = []
handler.on("keypress", (key: KeyEvent) => events.push(key))

handler.processInput("\x1b[127988u") // 🏴 U+1F3F4
handler.processInput("\x1b[917607u") // Tag g U+E0067
handler.processInput("\x1b[917602u") // Tag b U+E0062
handler.processInput("\x1b[917605u") // Tag e U+E0065
handler.processInput("\x1b[917614u") // Tag n U+E006E
handler.processInput("\x1b[917607u") // Tag g U+E0067
handler.processInput("\x1b[917631u") // Tag cancel U+E007F
await new Promise((r) => setTimeout(r, 10))

expect(events).toHaveLength(1)
expect(events[0].name).toBe("🏴󠁧󠁢󠁥󠁮󠁧󠁿")
handler.destroy()
})
176 changes: 165 additions & 11 deletions packages/core/src/lib/KeyHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,38 @@
import { EventEmitter } from "events"
import { parseKeypress, type KeyEventType, type ParsedKey } from "./parse.keypress"
import { ANSI } from "../ansi"
import { getGraphemeSegmenter } from "./grapheme-segmenter"

// Grapheme cluster extenders per UAX #29 (https://unicode.org/reports/tr29/)
// and emoji sequences per UTS #51 (https://unicode.org/reports/tr51/)
function isGraphemeExtender(codepoint: number): boolean {
return (
codepoint === 0x200d || // ZWJ (Grapheme_Cluster_Break=ZWJ)
(codepoint >= 0xfe00 && codepoint <= 0xfe0f) || // Variation Selectors (Grapheme_Cluster_Break=Extend)
(codepoint >= 0x1f3fb && codepoint <= 0x1f3ff) || // Emoji Modifiers (Emoji_Modifier=Yes → Extend)
(codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators (form RI pairs)
codepoint === 0x20e3 || // Combining Enclosing Keycap (emoji_keycap_sequence)
(codepoint >= 0xe0020 && codepoint <= 0xe007f) // Tag Characters (Grapheme_Cluster_Break=Extend)
)
}

// Codepoints that can start multi-codepoint emoji sequences per UTS #51
function canStartGraphemeCluster(codepoint: number): boolean {
return (
(codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators (emoji_flag_sequence)
(codepoint >= 0x1f300 && codepoint <= 0x1faff) || // Emoji ranges (Emoji=Yes)
codepoint === 0x1f3f4 || // Black Flag (emoji_tag_sequence base)
codepoint === 0x23 || // # (emoji_keycap_sequence)
codepoint === 0x2a || // * (emoji_keycap_sequence)
(codepoint >= 0x30 && codepoint <= 0x39) || // 0-9 (emoji_keycap_sequence)
(codepoint >= 0x2600 && codepoint <= 0x27bf) // Misc Symbols & Dingbats (Emoji=Yes)
)
}

type EmojiBuffer = {
codepoints: number[]
rawSequences: string[]
baseParsedKey: ParsedKey
}

export class KeyEvent implements ParsedKey {
name: string
Expand Down Expand Up @@ -93,12 +125,120 @@ export type KeyHandlerEventMap = {
paste: [PasteEvent]
}

export type KeyHandlerOptions = {
useKittyKeyboard?: boolean
emojiBufferTimeout?: number
}

export class KeyHandler extends EventEmitter<KeyHandlerEventMap> {
protected useKittyKeyboard: boolean
private emojiBuffer: EmojiBuffer | null = null
private emojiTimeout: Timer | null = null
private readonly emojiBufferTimeoutMs: number

constructor(useKittyKeyboard: boolean = false) {
constructor(options: KeyHandlerOptions | boolean = false) {
super()
this.useKittyKeyboard = useKittyKeyboard
if (typeof options === "boolean") {
this.useKittyKeyboard = options
this.emojiBufferTimeoutMs = 10
} else {
this.useKittyKeyboard = options.useKittyKeyboard ?? false
this.emojiBufferTimeoutMs = options.emojiBufferTimeout ?? 10
}
}

private getCodepointFromKittyKey(parsedKey: ParsedKey): number | null {
if (parsedKey.source !== "kitty") return null
if (parsedKey.name.length === 0) return null

const codepoint = parsedKey.name.codePointAt(0)
if (codepoint === undefined) return null
if (parsedKey.name.length !== String.fromCodePoint(codepoint).length) return null

return codepoint
}

private shouldBufferForEmoji(parsedKey: ParsedKey): boolean {
if (parsedKey.source !== "kitty") return false
if (parsedKey.eventType !== "press") return false
if (parsedKey.ctrl || parsedKey.meta || parsedKey.super || parsedKey.hyper) return false

const codepoint = this.getCodepointFromKittyKey(parsedKey)
if (codepoint === null) return false

if (this.emojiBuffer !== null) {
if (isGraphemeExtender(codepoint)) return true
const lastCp = this.emojiBuffer.codepoints[this.emojiBuffer.codepoints.length - 1]!
const ZWJ = 0x200d
if (lastCp === ZWJ) return true
return false
}

return canStartGraphemeCluster(codepoint)
}

private bufferEmojiCodepoint(parsedKey: ParsedKey, rawSequence: string): void {
const codepoint = this.getCodepointFromKittyKey(parsedKey)!

if (this.emojiBuffer === null) {
this.emojiBuffer = {
codepoints: [codepoint],
rawSequences: [rawSequence],
baseParsedKey: parsedKey,
}
} else {
this.emojiBuffer.codepoints.push(codepoint)
this.emojiBuffer.rawSequences.push(rawSequence)
}

this.scheduleEmojiFlush()
}

private scheduleEmojiFlush(): void {
if (this.emojiTimeout) {
clearTimeout(this.emojiTimeout)
}
this.emojiTimeout = setTimeout(() => {
this.flushEmojiBuffer()
}, this.emojiBufferTimeoutMs)
}

private assembleGraphemes(codepoints: number[]): string[] {
const text = String.fromCodePoint(...codepoints)
return [...getGraphemeSegmenter().segment(text)].map((seg) => seg.segment)
}

public flushEmojiBuffer(): void {
if (this.emojiTimeout) {
clearTimeout(this.emojiTimeout)
this.emojiTimeout = null
}

if (this.emojiBuffer === null) return

const { codepoints, rawSequences, baseParsedKey } = this.emojiBuffer
this.emojiBuffer = null

const graphemes = this.assembleGraphemes(codepoints)

for (const grapheme of graphemes) {
const keyEvent: ParsedKey = {
...baseParsedKey,
name: grapheme,
sequence: grapheme,
raw: rawSequences.join(""),
}

try {
if (keyEvent.eventType === "press") {
this.emit("keypress", new KeyEvent(keyEvent))
} else {
this.emit("keyrelease", new KeyEvent(keyEvent))
}
} catch (error) {
console.error(`[KeyHandler] Error emitting buffered emoji:`, error)
}
}
}

public processInput(data: string): boolean {
Expand All @@ -108,6 +248,13 @@ export class KeyHandler extends EventEmitter<KeyHandlerEventMap> {
return false
}

if (this.shouldBufferForEmoji(parsedKey)) {
this.bufferEmojiCodepoint(parsedKey, data)
return true
}

this.flushEmojiBuffer()

try {
switch (parsedKey.eventType) {
case "press":
Expand Down Expand Up @@ -136,17 +283,21 @@ export class KeyHandler extends EventEmitter<KeyHandlerEventMap> {
console.error(`[KeyHandler] Error processing paste:`, error)
}
}

public destroy(): void {
if (this.emojiTimeout) {
clearTimeout(this.emojiTimeout)
this.emojiTimeout = null
}
this.emojiBuffer = null
}
}

/**
* This class is used internally by the renderer to ensure global handlers
* can preventDefault before renderable handlers process events.
*/
export class InternalKeyHandler extends KeyHandler {
private renderableHandlers: Map<keyof KeyHandlerEventMap, Set<Function>> = new Map()

constructor(useKittyKeyboard: boolean = false) {
super(useKittyKeyboard)
constructor(options: KeyHandlerOptions | boolean = false) {
super(options)
}

public emit<K extends keyof KeyHandlerEventMap>(event: K, ...args: KeyHandlerEventMap[K]): boolean {
Expand Down Expand Up @@ -181,8 +332,6 @@ export class InternalKeyHandler extends KeyHandler {
}

const renderableSet = this.renderableHandlers.get(event)
// Snapshot the handler list so listeners added during dispatch (e.g., via focus changes)
// do not receive the in-flight key event.
const renderableHandlers = renderableSet && renderableSet.size > 0 ? [...renderableSet] : []
let hasRenderableListeners = false

Expand Down Expand Up @@ -234,4 +383,9 @@ export class InternalKeyHandler extends KeyHandler {
handlers.delete(handler)
}
}

public override destroy(): void {
super.destroy()
this.renderableHandlers.clear()
}
}
Loading
Loading