Skip to content
Closed
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
167 changes: 167 additions & 0 deletions apps/web/src/terminal/ghostty/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyboardEvent> & Pick<KeyboardEvent, "code" | "key">,
): 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<T>(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<GhosttyTerminalCore>();
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|",
);
});
});
});
24 changes: 11 additions & 13 deletions apps/web/src/terminal/ghostty/core.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
type GhosttyKeyboardLayoutMap,
ghosttyConsumedMods,
ghosttyEncoderMods,
ghosttyKeyForCode,
ghosttyUnshiftedCodepoint,
loadGhosttyKeyboardLayoutMap,
Expand Down Expand Up @@ -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);
Expand All @@ -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",
Expand Down
100 changes: 94 additions & 6 deletions apps/web/src/terminal/ghostty/keyCodes.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 });
});
});

Expand Down
Loading
Loading