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); + }); });