diff --git a/src/README.md b/src/README.md index 5bc3498..9b5ce87 100644 --- a/src/README.md +++ b/src/README.md @@ -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(" 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. 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(" 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(" 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]. diff --git a/src/index.ts b/src/index.ts index 6060a0f..bcfe1b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 }), }; } @@ -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), }; } diff --git a/test/terminal.test.ts b/test/terminal.test.ts index e28215a..8c8f4b8 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -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") },