From 4aeae6d733ea231907c40833cf02473cb0d3b4a2 Mon Sep 17 00:00:00 2001 From: maco Date: Fri, 2 Oct 2026 06:30:23 +0000 Subject: [PATCH] fix(web): German Option characters reach the terminal On macOS, Option+L is @. The terminal forwarded Option as Alt into a non-Darwin libghostty-vt build, so DEC 1036 sent ESC @. Marking Alt consumed still left the raw Alt bit set, and modifyOtherKeys mode 2 encoded that as an Alt sequence. Consume a lone macOS Option that produced a character, and clear those consumed bits from the raw modifier mask before encoding. Ctrl, Meta, Option+arrow, and a lone Shift stay intact. There is no Option-as-Meta setting; this matches Terminal.app with that option off. Tests that stub navigator.platform restore the previous own descriptor, and delete the temporary own property when platform was inherited, including when the stubbed call throws. --- apps/web/src/terminal/ghostty/core.test.ts | 167 +++++++++++++ apps/web/src/terminal/ghostty/core.ts | 24 +- .../web/src/terminal/ghostty/keyCodes.test.ts | 100 +++++++- apps/web/src/terminal/ghostty/keyCodes.ts | 101 +++++++- .../src/terminal/ghostty/runtimeAbi.test.ts | 222 +++++++++++++++++- 5 files changed, 589 insertions(+), 25 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 48cc4256de61..ca82a8dd8093 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -378,3 +378,170 @@ describe("GhosttyTerminalCore snapshots", () => { expect(core.snapshot()).toEqual(reference.snapshot()); }); }); + +/** Keyboard event stub. Unset modifiers are false and locks are reported off. */ +function terminalKey( + partial: Partial & Pick, +): KeyboardEvent { + return { + altKey: false, + ctrlKey: false, + isComposing: false, + metaKey: false, + repeat: false, + shiftKey: false, + getModifierState: () => false, + ...partial, + } as KeyboardEvent; +} + +/** + * Put `navigator.platform` back after a test stub. + * + * Node exposes `platform` on the prototype, so `getOwnPropertyDescriptor` is + * empty. Restoring only a saved own descriptor leaves the temporary own + * property in place and later tests keep the stub. + */ +function restoreNavigatorPlatform(descriptor: PropertyDescriptor | undefined) { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + return; + } + Reflect.deleteProperty(navigator, "platform"); +} + +/** + * Run `run` while `navigator.platform` is `platform`, then restore the previous + * platform. `encodeKey` reads the platform when deciding whether Option + * produced a character. The previous value is restored when `run` throws. + */ +function withPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { configurable: true, value: platform }); + try { + return run(); + } finally { + restoreNavigatorPlatform(descriptor); + } +} + +describe("GhosttyTerminalCore.encodeKey", () => { + const cores = new Set(); + const initialPlatform = navigator.platform; + const initialPlatformDescriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + + /** Small terminal used to assert the bytes `encodeKey` writes. */ + async function createCore() { + const core = await GhosttyTerminalCore.create( + 12, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + cores.add(core); + return core; + } + + afterEach(() => { + try { + expect(navigator.platform).toBe(initialPlatform); + expect(Object.getOwnPropertyDescriptor(navigator, "platform")).toEqual( + initialPlatformDescriptor, + ); + } finally { + restoreNavigatorPlatform(initialPlatformDescriptor); + for (const core of cores) core.dispose(); + cores.clear(); + } + }); + + it("restores an inherited navigator.platform after Option encoding", () => { + expect(initialPlatformDescriptor).toBeUndefined(); + expect(() => + withPlatform("MacIntel", () => { + expect(navigator.platform).toBe("MacIntel"); + expect(Object.getOwnPropertyDescriptor(navigator, "platform")?.value).toBe("MacIntel"); + throw new Error("encoder failed"); + }), + ).toThrow("encoder failed"); + expect(Object.getOwnPropertyDescriptor(navigator, "platform")).toBeUndefined(); + expect(navigator.platform).toBe(initialPlatform); + }); + + it("restores an own navigator.platform descriptor", () => { + const saved = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + enumerable: true, + value: "TestPlatform", + writable: true, + }); + try { + withPlatform("Linux", () => { + expect(navigator.platform).toBe("Linux"); + }); + expect(navigator.platform).toBe("TestPlatform"); + expect(Object.getOwnPropertyDescriptor(navigator, "platform")).toMatchObject({ + configurable: true, + value: "TestPlatform", + writable: true, + }); + } finally { + restoreNavigatorPlatform(saved); + } + }); + + it("types macOS Option characters through the WASM encoder", async () => { + const core = await createCore(); + const optionAt = terminalKey({ altKey: true, code: "KeyL", key: "@" }); + withPlatform("MacIntel", () => { + expect(core.encodeKey(optionAt)).toBe("@"); + expect(core.encodeKey(terminalKey({ altKey: true, code: "KeyE", key: "€" }))).toBe("€"); + expect(core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "|" }))).toBe("|"); + expect( + core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "\\", shiftKey: true })), + ).toBe("\\"); + expect(core.encodeKey(terminalKey({ code: "KeyC", ctrlKey: true, key: "c" }))).toBe("\u0003"); + expect( + core.encodeKey(terminalKey({ altKey: true, code: "ArrowLeft", key: "ArrowLeft" })), + ).toBe("\u001b[1;3D"); + + core.write("\u001b[>4;2m"); + expect(core.encodeKey(optionAt)).toBe("@"); + expect( + core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "\\", shiftKey: true })), + ).toBe("\\"); + expect(core.encodeKey(terminalKey({ code: "KeyA", key: "A", shiftKey: true }))).toBe( + "\u001b[27;2;65~", + ); + + core.write("\u001b[>1u"); + expect(core.encodeKey(optionAt)).toBe("@"); + expect(core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "|" }))).toBe("|"); + expect(core.encodeKey(terminalKey({ code: "KeyC", ctrlKey: true, key: "c" }))).toBe( + "\u001b[99;5u", + ); + + core.write("\u001b[>31u"); + expect(core.encodeKey(optionAt)).toBe("\u001b[64::108;;64u"); + }); + }); + + it("keeps Alt as a modifier off macOS", async () => { + const core = await createCore(); + withPlatform("Linux", () => { + expect(core.encodeKey(terminalKey({ altKey: true, code: "KeyL", key: "@" }))).toBe("\u001b@"); + }); + withPlatform("Win32", () => { + expect(core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "|" }))).toBe( + "\u001b|", + ); + }); + }); +}); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index d01e20529d45..9f85913ac0f7 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -1,6 +1,6 @@ import { type GhosttyKeyboardLayoutMap, - ghosttyConsumedMods, + ghosttyEncoderMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint, loadGhosttyKeyboardLayoutMap, @@ -472,6 +472,14 @@ export class GhosttyTerminalCore { ); } + /** + * Bytes libghostty-vt emits for one key event. + * + * On macOS, a lone Option that produced a character (German Option+L is `@`) + * is consumed and cleared from the raw modifier mask before encoding, so the + * character is written instead of an Alt sequence. Ctrl, Meta, Option+arrow, + * and a lone Shift stay intact. + */ encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string { this.ensureActive(); this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal); @@ -481,19 +489,9 @@ export class GhosttyTerminalCore { action === "release" ? 0 : event.repeat ? 2 : 1, ); this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code)); - const mods = - (event.shiftKey ? 1 : 0) | - (event.ctrlKey ? 1 << 1 : 0) | - (event.altKey ? 1 << 2 : 0) | - (event.metaKey ? 1 << 3 : 0) | - (event.getModifierState("CapsLock") ? 1 << 4 : 0) | - (event.getModifierState("NumLock") ? 1 << 5 : 0); + const { mods, consumedMods } = ghosttyEncoderMods(event); this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods); - this.runtime.call( - "ghostty_key_event_set_consumed_mods", - this.keyEvent, - ghosttyConsumedMods(event), - ); + this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, consumedMods); this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0); this.runtime.call( "ghostty_key_event_set_unshifted_codepoint", diff --git a/apps/web/src/terminal/ghostty/keyCodes.test.ts b/apps/web/src/terminal/ghostty/keyCodes.test.ts index ef4b623fdfc8..f3f4024aedcd 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.test.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { ghosttyConsumedMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; +import { + ghosttyConsumedMods, + ghosttyEncoderMods, + ghosttyKeyForCode, + ghosttyUnshiftedCodepoint, +} from "./keyCodes"; describe("ghosttyKeyForCode", () => { it("keeps the tail of the pinned Ghostty key enum in order", () => { @@ -14,12 +19,95 @@ describe("ghosttyKeyForCode", () => { describe("ghosttyConsumedMods", () => { const shifted = { altKey: false, ctrlKey: false, key: "@", metaKey: false, shiftKey: true }; - it("only consumes a lone Shift producing a character", () => { - expect(ghosttyConsumedMods(shifted)).toBe(1); - expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true })).toBe(0); - expect(ghosttyConsumedMods({ ...shifted, key: "Tab" })).toBe(0); + it("consumes a lone Shift producing a character", () => { + expect(ghosttyConsumedMods(shifted, "Linux")).toBe(1); + expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...shifted, key: "Tab" }, "MacIntel")).toBe(0); // Deliberate: Shift+Space collapses to Space so it still types one. - expect(ghosttyConsumedMods({ ...shifted, key: " " })).toBe(1); + expect(ghosttyConsumedMods({ ...shifted, key: " " }, "Linux")).toBe(1); + }); + + it("consumes a lone macOS Option that produced a character", () => { + const option = { altKey: true, ctrlKey: false, key: "@", metaKey: false, shiftKey: false }; + expect(ghosttyConsumedMods(option, "MacIntel")).toBe(1 << 2); + expect(ghosttyConsumedMods(option, "iPhone")).toBe(1 << 2); + expect(ghosttyConsumedMods({ ...option, key: "€" }, "MacIntel")).toBe(1 << 2); + expect(ghosttyConsumedMods({ ...option, key: "\\", shiftKey: true }, "MacIntel")).toBe( + (1 << 2) | 1, + ); + expect(ghosttyConsumedMods({ ...option, ctrlKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, metaKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, key: "ArrowLeft" }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, key: "Dead" }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods(option, "Linux")).toBe(0); + expect(ghosttyConsumedMods(option, "Win32")).toBe(0); + expect(ghosttyConsumedMods({ ...option, shiftKey: true }, "Linux")).toBe(0); + }); +}); + +describe("ghosttyEncoderMods", () => { + const locksOff = { getModifierState: () => false }; + + it("drops consumed Option from the raw mask and keeps a lone Shift", () => { + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: false, key: "@", metaKey: false, shiftKey: false }, + "MacIntel", + ), + ).toEqual({ mods: 0, consumedMods: 1 << 2 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: false, key: "\\", metaKey: false, shiftKey: true }, + "MacIntel", + ), + ).toEqual({ mods: 0, consumedMods: (1 << 2) | 1 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: false, ctrlKey: false, key: "A", metaKey: false, shiftKey: true }, + "MacIntel", + ), + ).toEqual({ mods: 1, consumedMods: 1 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: true, key: "a", metaKey: false, shiftKey: false }, + "MacIntel", + ), + ).toEqual({ mods: (1 << 1) | (1 << 2), consumedMods: 0 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: false, key: "@", metaKey: false, shiftKey: false }, + "Linux", + ), + ).toEqual({ mods: 1 << 2, consumedMods: 0 }); + expect( + ghosttyEncoderMods( + { + ...locksOff, + altKey: true, + ctrlKey: false, + key: "ArrowLeft", + metaKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toEqual({ mods: 1 << 2, consumedMods: 0 }); + }); + + it("keeps lock modifiers that did not compose the character", () => { + expect( + ghosttyEncoderMods( + { + altKey: true, + ctrlKey: false, + key: "@", + metaKey: false, + shiftKey: false, + getModifierState: (key) => key === "CapsLock", + }, + "MacIntel", + ), + ).toEqual({ mods: 1 << 4, consumedMods: 1 << 2 }); }); }); diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts index a643827d6f2d..aacb7bafa6b5 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -218,6 +218,11 @@ const shiftedToUnshiftedCharacter = new Map([ let keyboardLayoutMapPromise: Promise | undefined; +/** + * Browser keyboard layout, cached for the page. Used to recover the unshifted + * base key of an Option-composed character such as German Option+L (`@`). + * Resolves to `undefined` when the Keyboard API is missing or rejects. + */ export function loadGhosttyKeyboardLayoutMap(): Promise { if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise; const browserNavigator = globalThis.navigator as @@ -233,15 +238,101 @@ export function loadGhosttyKeyboardLayoutMap(): Promise, + event: Pick, + platform = ghosttyHostPlatform(), ): number { - if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0; - return [...event.key].length === 1 ? 1 : 0; + if ([...event.key].length !== 1 || event.ctrlKey || event.metaKey) return 0; + const shift = event.shiftKey ? GHOSTTY_MOD_SHIFT : 0; + if (event.altKey && isGhosttyMacPlatform(platform)) return shift | GHOSTTY_MOD_ALT; + if (!event.shiftKey || event.altKey) return 0; + return GHOSTTY_MOD_SHIFT; +} + +export interface GhosttyEncoderMods { + readonly mods: number; + readonly consumedMods: number; +} + +/** + * Raw mods plus the consumed mask passed to libghostty-vt. + * + * Consuming Option is not enough on this build. modifyOtherKeys mode 2 and + * Kitty report-all read the raw Alt bit and still emit an Alt sequence when + * that bit is set, even if Alt is also consumed. Clear every modifier that + * composed the Option character (Option, and Shift when it participated). + * A lone Shift stays in the raw mask so modifyOtherKeys can still report + * Shift+letter. + */ +export function ghosttyEncoderMods( + event: GhosttyKeyModState, + platform = ghosttyHostPlatform(), +): GhosttyEncoderMods { + const consumedMods = ghosttyConsumedMods(event, platform); + let mods = + (event.shiftKey ? GHOSTTY_MOD_SHIFT : 0) | + (event.ctrlKey ? GHOSTTY_MOD_CTRL : 0) | + (event.altKey ? GHOSTTY_MOD_ALT : 0) | + (event.metaKey ? GHOSTTY_MOD_SUPER : 0) | + (event.getModifierState?.("CapsLock") ? 1 << 4 : 0) | + (event.getModifierState?.("NumLock") ? 1 << 5 : 0); + if ((consumedMods & GHOSTTY_MOD_ALT) !== 0) { + mods &= ~consumedMods; + } + return { mods, consumedMods }; } +/** + * Unshifted codepoint for Kitty alternate-key encoding. + * + * Prefers the active layout map, then US letter and symbol pairs, then + * lowercasing. Returns 0 when the unshifted form cannot be known. Reporting + * the shifted character as unshifted corrupts Kitty alternate keys. + * Option-composed characters such as `@` still report their layout base key. + */ export function ghosttyUnshiftedCodepoint( event: Pick, layoutMap?: GhosttyKeyboardLayoutMap, diff --git a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts index 7d4782b4b409..7893fb1e1ce5 100644 --- a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts +++ b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import wasmDataUrl from "./vendor/ghostty-vt.wasm?inline"; import writePtyWasmDataUrl from "./vendor/ghostty-write-pty.wasm?inline"; import pinnedVersion from "../../../../../native/libghostty-vt/VERSION?raw"; -import { ghosttyKeyForCode } from "./keyCodes"; +import { ghosttyEncoderMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; type WasmFunction = (...args: number[]) => number; @@ -714,4 +714,224 @@ describe("vendored libghostty-vt WebAssembly", () => { call("ghostty_wasm_free_opaque", terminalSlot); free(terminalOptions, 8); }); + + it("encodes macOS Option text only when consumed Alt is absent from raw mods", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const free = (pointer: number, size: number) => + call("ghostty_wasm_free_u8_array", pointer, size); + + const terminalOptions = alloc(8); + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); + terminalOptionsView.setUint16(0, 80, true); + terminalOptionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const encoderSlot = call("ghostty_wasm_alloc_opaque"); + const eventSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_key_encoder_new", 0, encoderSlot)).toBe(0); + expect(call("ghostty_key_event_new", 0, eventSlot)).toBe(0); + const keyEncoder = new DataView(memory.buffer).getUint32(encoderSlot, true); + const keyEvent = new DataView(memory.buffer).getUint32(eventSlot, true); + + const writeTerminal = (data: string) => { + const bytes = new TextEncoder().encode(data); + const pointer = alloc(bytes.length); + new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes); + call("ghostty_terminal_vt_write", terminal, pointer, bytes.length); + free(pointer, bytes.length); + }; + + const encode = (input: { + readonly code: string; + readonly key: string; + readonly mods: number; + readonly consumedMods: number; + readonly unshifted: number; + }) => { + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + call("ghostty_key_event_set_action", keyEvent, 1); + call("ghostty_key_event_set_key", keyEvent, ghosttyKeyForCode(input.code)); + call("ghostty_key_event_set_mods", keyEvent, input.mods); + call("ghostty_key_event_set_consumed_mods", keyEvent, input.consumedMods); + call("ghostty_key_event_set_composing", keyEvent, 0); + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, input.unshifted); + const text = new TextEncoder().encode(input.key); + const textPointer = text.length === 0 ? 0 : alloc(text.length); + if (textPointer !== 0) new Uint8Array(memory.buffer, textPointer, text.length).set(text); + call("ghostty_key_event_set_utf8", keyEvent, textPointer, text.length); + const written = call("ghostty_wasm_alloc_usize"); + const query = call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written); + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + let encoded = ""; + if (outputSize > 0) { + expect(query).toBe(-3); + const output = alloc(outputSize); + expect( + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, output, outputSize, written), + ).toBe(0); + const outputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + encoded = new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputLength)); + free(output, outputSize); + } else { + expect(query).toBe(0); + } + call("ghostty_wasm_free_usize", written); + if (textPointer !== 0) free(textPointer, text.length); + return encoded; + }; + + const alt = 1 << 2; + const optionAt = { + code: "KeyL", + key: "@", + shiftKey: false, + }; + const produced = ghosttyEncoderMods( + { + altKey: true, + ctrlKey: false, + getModifierState: () => false, + key: optionAt.key, + metaKey: false, + shiftKey: optionAt.shiftKey, + }, + "MacIntel", + ); + const unshiftedAt = ghosttyUnshiftedCodepoint(optionAt); + const layoutUnshiftedAt = ghosttyUnshiftedCodepoint(optionAt, new Map([["KeyL", "l"]])); + expect(produced).toEqual({ mods: 0, consumedMods: alt }); + expect(unshiftedAt).toBe("@".codePointAt(0)); + expect(layoutUnshiftedAt).toBe("l".codePointAt(0)); + + // Raw Alt, whether or not it is also consumed, is an Alt chord on this + // non-Darwin build. The mask encodeKey sends (Alt consumed and cleared) + // is the one that writes the character. + expect(encode({ ...optionAt, mods: alt, consumedMods: 0, unshifted: unshiftedAt })).toBe( + "\u001b@", + ); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe("@"); + expect( + encode({ + ...optionAt, + mods: produced.mods, + consumedMods: produced.consumedMods, + unshifted: unshiftedAt, + }), + ).toBe("@"); + + writeTerminal("\u001b[>4;2m"); + expect(encode({ ...optionAt, mods: alt, consumedMods: 0, unshifted: unshiftedAt })).toBe( + "\u001b[27;3;64~", + ); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe( + "\u001b[27;3;64~", + ); + expect( + encode({ + ...optionAt, + mods: produced.mods, + consumedMods: produced.consumedMods, + unshifted: unshiftedAt, + }), + ).toBe("@"); + expect( + encode({ + ...optionAt, + mods: produced.mods, + consumedMods: produced.consumedMods, + unshifted: layoutUnshiftedAt, + }), + ).toBe("@"); + + const optionBackslash = ghosttyEncoderMods( + { + altKey: true, + ctrlKey: false, + getModifierState: () => false, + key: "\\", + metaKey: false, + shiftKey: true, + }, + "MacIntel", + ); + const backslash = { + code: "Digit7", + key: "\\", + unshifted: ghosttyUnshiftedCodepoint({ code: "Digit7", key: "\\", shiftKey: true }), + }; + const layoutBackslash = ghosttyUnshiftedCodepoint( + { code: "Digit7", key: "\\", shiftKey: true }, + new Map([["Digit7", "7"]]), + ); + expect(optionBackslash).toEqual({ mods: 0, consumedMods: alt | 1 }); + expect(backslash.unshifted).toBe(0); + // Leaving the composing Shift in the raw mask still encodes modifyOtherKeys. + expect( + encode({ + ...backslash, + mods: 1, + consumedMods: optionBackslash.consumedMods, + }), + ).toBe("\u001b[27;2;92~"); + expect(encode({ ...backslash, ...optionBackslash })).toBe("\\"); + expect(encode({ ...backslash, ...optionBackslash, unshifted: layoutBackslash })).toBe("\\"); + + const shiftA = ghosttyEncoderMods( + { + altKey: false, + ctrlKey: false, + getModifierState: () => false, + key: "A", + metaKey: false, + shiftKey: true, + }, + "MacIntel", + ); + expect(shiftA).toEqual({ mods: 1, consumedMods: 1 }); + expect( + encode({ + code: "KeyA", + key: "A", + mods: shiftA.mods, + consumedMods: shiftA.consumedMods, + unshifted: "a".codePointAt(0)!, + }), + ).toBe("\u001b[27;2;65~"); + + writeTerminal("\u001b[>1u"); + expect(encode({ ...optionAt, mods: alt, consumedMods: 0, unshifted: unshiftedAt })).toBe( + "\u001b[64;3u", + ); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe("@"); + expect(encode({ ...optionAt, ...produced, unshifted: unshiftedAt })).toBe("@"); + expect(encode({ ...optionAt, ...produced, unshifted: layoutUnshiftedAt })).toBe("@"); + + writeTerminal("\u001b[>31u"); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe( + "\u001b[64::108;3u", + ); + expect(encode({ ...optionAt, ...produced, unshifted: unshiftedAt })).toBe( + "\u001b[64::108;;64u", + ); + expect(encode({ ...optionAt, ...produced, unshifted: layoutUnshiftedAt })).toBe( + "\u001b[108;;64u", + ); + + call("ghostty_key_event_free", keyEvent); + call("ghostty_key_encoder_free", keyEncoder); + call("ghostty_wasm_free_opaque", eventSlot); + call("ghostty_wasm_free_opaque", encoderSlot); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + free(terminalOptions, 8); + }); });