Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Internal (not exported by the package):
- `encodeMouse` checks the options, then the event. `options`, `geometry`, the event, and `modifiers` (when defined) must be non-array objects (`TypeError("<name> must be an object")`). The four geometry sizes must be positive integers and the paddings non-negative integers, with the `TypeError` messages of `assertPositiveInteger` and `assertNonNegativeInteger` and the name prefix `mouse geometry.`. A field above 2^32-1 throws `RangeError("mouse geometry.<key> exceeds the 32-bit limit")` [src/index.ts:normalizeMouseOptions].
- `encodeMouse` repeats the native padding, cell-count, and coordinate-range limits with the same three messages: `"mouse geometry padding exceeds screenWidth"`, `"mouse geometry.screenWidth exceeds the 65535-cell limit"`, and `"mouse x is outside Ghostty's cell or pixel coordinate range"` (all `RangeError`) [src/index.ts:assertMouseAxis] [src/index.ts:assertMouseCoordinate]. `src/` has no f32-range check: for `x: 1e39` it throws the coordinate-range error, where the addon throws `"mouse x must be a finite 32-bit float"`. The native layer checks every limit again, and adds the UTF-8 check, which needs the terminal's modes [native/terminal.cc:TerminalWrap::EncodeMouse].
- `encodeMouse` event: an unknown `action` or `button` throws `TypeError("mouse action is invalid")` or `"mouse button is invalid"`, a `press` or `release` without `button` throws `TypeError("<action> mouse event requires a button")`, a non-finite `x` or `y` throws `TypeError("mouse x must be a finite number")`, and a non-boolean modifier, `anyButtonPressed`, or `trackLastCell` throws `TypeError("<name> must be a boolean")` [src/index.ts:normalizeMouseEvent] [src/index.ts:optionalBoolean].
- The addon receives new objects: the event with `action`, `x`, `y`, `button` only when given, and `modifiers` only when given (with all three booleans), and the options with all eight geometry fields (missing paddings as 0), `anyButtonPressed`, and `trackLastCell` [src/index.ts:normalizeMouseEvent] [src/index.ts:normalizeMouseOptions].
- The addon receives new objects: the event with `action`, `x`, `y`, `button` only when given, and `modifiers` only when given (with all three booleans), and the options with all eight geometry fields (missing paddings as 0), `anyButtonPressed`, and `trackLastCell`. Each caller field is read once, so a getter cannot pass the checks with one value and send the addon another [src/index.ts:normalizeMouseEvent] [src/index.ts:normalizeMouseOptions].
- `feed` accepts a string or a `Uint8Array` (a `Buffer` is one). Any other value throws `TypeError("feed data must be a string, Buffer, or Uint8Array")` [src/index.ts:assertFeedData].
- The `Terminal` constructor asserts that the addon returned a non-null object (`"native terminal must be an object"`, `"native terminal must not be null"`) [src/index.ts:Terminal].
- After `dispose()`, `feed`, `resize`, `encodeMouse`, `snapshot`, `getVisibleText`, `formatPlain`, and `formatHtml` throw `Error("GhosttyVtTerminal has been disposed")`. The disposed check runs before the argument checks [src/index.ts:Terminal].
Expand Down
94 changes: 51 additions & 43 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,34 +118,36 @@ function optionalBoolean(name: string, value: unknown): boolean {
// that Ghostty's mouse event model can represent.
function normalizeMouseEvent(event: MouseInputEvent, geometry: MouseGeometry): MouseInputEvent {
assertObject("mouse event", event);
if (!mouseActions.has(event.action)) throw new TypeError("mouse action is invalid");
if (event.button !== undefined && !mouseButtons.has(event.button)) {
const { action, button, x, y, modifiers } = event;
if (!mouseActions.has(action)) throw new TypeError("mouse action is invalid");
if (button !== undefined && !mouseButtons.has(button)) {
throw new TypeError("mouse button is invalid");
}
if (event.action !== "motion" && event.button === undefined) {
throw new TypeError(`${event.action} mouse event requires a button`);
if (action !== "motion" && button === undefined) {
throw new TypeError(`${action} mouse event requires a button`);
}
assertFiniteNumber("mouse x", event.x);
assertFiniteNumber("mouse y", event.y);
assertMouseCoordinate("mouse x", event.x, geometry.paddingLeft ?? 0, geometry.cellWidth);
assertMouseCoordinate("mouse y", event.y, geometry.paddingTop ?? 0, geometry.cellHeight);

let modifiers: MouseModifiers | undefined;
if (event.modifiers !== undefined) {
assertObject("mouse modifiers", event.modifiers);
modifiers = {
shift: optionalBoolean("mouse modifiers.shift", event.modifiers.shift),
ctrl: optionalBoolean("mouse modifiers.ctrl", event.modifiers.ctrl),
alt: optionalBoolean("mouse modifiers.alt", event.modifiers.alt),
assertFiniteNumber("mouse x", x);
assertFiniteNumber("mouse y", y);
assertMouseCoordinate("mouse x", x, geometry.paddingLeft ?? 0, geometry.cellWidth);
assertMouseCoordinate("mouse y", y, geometry.paddingTop ?? 0, geometry.cellHeight);

let normalizedModifiers: MouseModifiers | undefined;
if (modifiers !== undefined) {
assertObject("mouse modifiers", modifiers);
const { shift, ctrl, alt } = modifiers;
normalizedModifiers = {
shift: optionalBoolean("mouse modifiers.shift", shift),
ctrl: optionalBoolean("mouse modifiers.ctrl", ctrl),
alt: optionalBoolean("mouse modifiers.alt", alt),
};
}

return {
action: event.action,
...(event.button === undefined ? {} : { button: event.button }),
x: event.x,
y: event.y,
...(modifiers === undefined ? {} : { modifiers }),
action,
...(button === undefined ? {} : { button }),
x,
y,
...(normalizedModifiers === undefined ? {} : { modifiers: normalizedModifiers }),
};
}

Expand All @@ -168,36 +170,42 @@ function assertMouseAxis(name: string, screen: number, cell: number, padding: nu
// cell-based and pixel-based terminal protocols.
function normalizeMouseOptions(options: MouseEncoderOptions): MouseEncoderOptions {
assertObject("mouse encoder options", options);
assertObject("mouse geometry", options.geometry);
const geometry = options.geometry;
assertPositiveInteger("mouse geometry.screenWidth", geometry.screenWidth);
assertPositiveInteger("mouse geometry.screenHeight", geometry.screenHeight);
assertPositiveInteger("mouse geometry.cellWidth", geometry.cellWidth);
assertPositiveInteger("mouse geometry.cellHeight", geometry.cellHeight);

const normalizedGeometry: MouseGeometry = {
screenWidth: geometry.screenWidth,
screenHeight: geometry.screenHeight,
cellWidth: geometry.cellWidth,
cellHeight: geometry.cellHeight,
};
for (const key of ["paddingTop", "paddingBottom", "paddingRight", "paddingLeft"] as const) {
const value = geometry[key];
const { geometry, anyButtonPressed, trackLastCell } = options;
assertObject("mouse geometry", geometry);
const {
screenWidth, screenHeight, cellWidth, cellHeight,
paddingTop, paddingBottom, paddingRight, paddingLeft,
} = geometry;
assertPositiveInteger("mouse geometry.screenWidth", screenWidth);
assertPositiveInteger("mouse geometry.screenHeight", screenHeight);
assertPositiveInteger("mouse geometry.cellWidth", cellWidth);
assertPositiveInteger("mouse geometry.cellHeight", cellHeight);
for (const [key, value] of Object.entries({ paddingTop, paddingBottom, paddingRight, paddingLeft })) {
if (value !== undefined) assertNonNegativeInteger(`mouse geometry.${key}`, value);
normalizedGeometry[key] = value ?? 0;
}

const normalizedGeometry = {
screenWidth,
screenHeight,
cellWidth,
cellHeight,
paddingTop: paddingTop ?? 0,
paddingBottom: paddingBottom ?? 0,
paddingRight: paddingRight ?? 0,
paddingLeft: paddingLeft ?? 0,
};
for (const [key, value] of Object.entries(normalizedGeometry)) {
if (value > 0xffffffff) throw new RangeError(`mouse geometry.${key} exceeds the 32-bit limit`);
}
assertMouseAxis("screenWidth", geometry.screenWidth, geometry.cellWidth,
normalizedGeometry.paddingLeft! + normalizedGeometry.paddingRight!);
assertMouseAxis("screenHeight", geometry.screenHeight, geometry.cellHeight,
normalizedGeometry.paddingTop! + normalizedGeometry.paddingBottom!);
assertMouseAxis("screenWidth", screenWidth, cellWidth,
normalizedGeometry.paddingLeft + normalizedGeometry.paddingRight);
assertMouseAxis("screenHeight", screenHeight, cellHeight,
normalizedGeometry.paddingTop + normalizedGeometry.paddingBottom);

return {
geometry: normalizedGeometry,
anyButtonPressed: optionalBoolean("anyButtonPressed", options.anyButtonPressed),
trackLastCell: optionalBoolean("trackLastCell", options.trackLastCell),
anyButtonPressed: optionalBoolean("anyButtonPressed", anyButtonPressed),
trackLastCell: optionalBoolean("trackLastCell", trackLastCell),
};
}

Expand Down
32 changes: 32 additions & 0 deletions test/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,38 @@ describeIfNative("GhosttyVtTerminal", () => {
}
});

it("encodes the event and geometry values it validated when getters change", () => {
const term = createTerminal({ cols: 80, rows: 24 });
try {
term.feed("\x1b[?1000h\x1b[?1006h");
const expected = Buffer.from("\x1b[<0;4;4M");

let xReads = 0;
const event = {
action: "press",
button: "left",
get x() {
return xReads++ === 0 ? 3 : 1e30;
},
y: 3,
} as const;
expect(term.encodeMouse(event, { geometry: unitGeometry })).toEqual(expected);

let widthReads = 0;
const geometry = {
...unitGeometry,
get screenWidth() {
return widthReads++ === 0 ? 80 : 0;
},
};
expect(
term.encodeMouse({ action: "press", button: "left", x: 3, y: 3 }, { geometry }),
).toEqual(expected);
} finally {
term.dispose();
}
});

it("covers button tracking, legacy releases, X10 limits, and UTF-8 coordinates", () => {
const cases = [
{ mode: "\x1b[?1002h\x1b[?1006h", action: "motion", button: "left", x: 2, expected: Buffer.from("\x1b[<32;3;4M") },
Expand Down
Loading