diff --git a/README.md b/README.md index 06b9216..79fe3c2 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,9 @@ throws `RangeError` before reaching Ghostty, including for suppressed events. Ordinary negative and off-screen positions remain supported. When UTF-8 mouse mode (1005) is set, the binding also rejects clamped cell -coordinates whose encoded value would be a Unicode surrogate, or whose row -would overflow the pinned encoder's 16-bit arithmetic. This conservative guard -applies while 1005 remains set even if another mouse format was selected later. +coordinates whose encoded value would be a Unicode surrogate. This conservative +guard applies while 1005 remains set even if another mouse format was selected +later. With the pinned Ghostty version, any non-empty `feed()` resets motion deduplication when the next mouse event refreshes negotiated modes. Geometry diff --git a/native/README.md b/native/README.md index 5236fa9..49fdf20 100644 --- a/native/README.md +++ b/native/README.md @@ -46,7 +46,7 @@ C++ declarations: - `encodeMouse` throws `TypeError("encodeMouse expects event and options objects")` unless it gets exactly two objects, and `TypeError("mouse geometry must be an object")` for a non-object `geometry` [native/terminal.cc:TerminalWrap::EncodeMouse]. `screenWidth`, `screenHeight`, `cellWidth`, and `cellHeight` must be integers from 1 to 2^32-1, and the four paddings integers from 0 to 2^32-1: a non-number throws `TypeError(" must be a positive integer")` or `"... non-negative integer"`, and any other bad value `RangeError(" must be a positive 32-bit integer")` or `"... non-negative 32-bit integer"` [native/terminal.cc:PositiveUint32] [native/terminal.cc:NonNegativeUint32]. - `encodeMouse` geometry limits, the same calculation that Ghostty makes before its unchecked casts: the padding sum of an axis, added in 64 bits, must not exceed the screen size (`RangeError("mouse geometry padding exceeds screenWidth")`, or `screenHeight`), and `f32(screen - padding) / f32(cell)` must be below 65536 (`RangeError("mouse geometry.screenWidth exceeds the 65535-cell limit")`, or `screenHeight`) [native/terminal.cc:ValidateMouseAxis]. - `encodeMouse` coordinates: A non-number `x` or `y` throws `TypeError("mouse x must be a finite number")`, and a value that is not finite or is outside the f32 range throws `RangeError("mouse x must be a finite 32-bit float")`. After the f32 rounding and the padding subtraction, the value must fit a signed 32-bit integer, and the value divided by the cell size must be below 65536. Otherwise it throws `RangeError("mouse x is outside Ghostty's cell or pixel coordinate range")`. The check runs for every event, also one that the mode drops [native/terminal.cc:MouseCoordinate]. -- `encodeMouse` with mode 1005 (UTF-8) set: it computes the clamped cell as Ghostty does and throws `RangeError("mouse x is unsafe for Ghostty's UTF-8 mouse encoding")` when `cell + 33` is a UTF-16 surrogate (0xD800 to 0xDFFF), or for `y` also above 65535. It checks while 1005 is set, even when a later mode selected another format [native/terminal.cc:ValidateUtf8MouseCoordinate]. +- `encodeMouse` with mode 1005 (UTF-8) set: it computes the clamped cell as Ghostty does and throws `RangeError("mouse x is unsafe for Ghostty's UTF-8 mouse encoding")` when `cell + 33` is a UTF-16 surrogate (0xD800 to 0xDFFF). It checks while 1005 is set, even when a later mode selected another format [native/terminal.cc:ValidateUtf8MouseCoordinate]. - `encodeMouse` `action` must be `press`, `release`, or `motion`, and `button` (when not `undefined`) one of `left`, `right`, `middle`, `four` to `eleven`. A non-string throws `TypeError("mouse action must be a string")` or `"mouse button must be a string"`, an unknown string `RangeError("mouse action is invalid")` or `"mouse button is invalid"`. `modifiers`, when defined, must be an object (`TypeError("mouse modifiers must be an object")`), and `shift`, `ctrl`, `alt`, `anyButtonPressed`, and `trackLastCell` count only when they are boolean `true` [native/terminal.cc:ParseMouseAction] [native/terminal.cc:ParseMouseButton] [native/terminal.cc:ParseMouseModifiers]. - Mouse mode cache: a non-empty `feed` sets `mouse_modes_dirty_`. The next `encodeMouse` that passes its checks then reloads the tracking mode and format with `ghostty_mouse_encoder_setopt_from_terminal`. The pinned Ghostty also resets its same-cell motion deduplication there (see "Mouse encoding" in the root README). A call that throws leaves `mouse_modes_dirty_` set. `encodeMouse` sets the size option only when the geometry differs from the last one [native/terminal.cc:TerminalWrap::Feed] [native/terminal.cc:TerminalWrap::EncodeMouse]. - `feed` throws `TypeError` for an argument count other than 1 (`"feed expects exactly one argument"`), for a typed array that is not a `Uint8Array` (`"feed typed array must be Uint8Array"`), and for any other value (`"feed data must be a string, Buffer, or Uint8Array"`). Zero-length input returns without a libghostty-vt call [native/terminal.cc:TerminalWrap::Feed]. diff --git a/native/terminal.cc b/native/terminal.cc index fe1fcd9..baa90ad 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -106,17 +106,16 @@ float MouseCoordinate(Napi::Env env, const Napi::Value& value, const char* name, void ValidateUtf8MouseCoordinate(Napi::Env env, float position, uint32_t screen, uint32_t cell, uint32_t before, uint32_t after, - const char* name, bool is_row) { + const char* name) { // Match Ghostty's f32 grid size and f64 position conversion, including - // viewport clamping. The pinned UTF-8 encoder assumes Unicode scalars and - // adds 33 to rows in u16 arithmetic before widening. + // viewport clamping. The pinned UTF-8 encoder treats a surrogate code point + // as unreachable. const auto cells = std::max(1, static_cast( static_cast(screen - before - after) / static_cast(cell))); const auto index = static_cast( std::max(0.0, static_cast(position) - before) / cell); const auto codepoint = std::min(index, cells - 1) + 33; - if ((codepoint >= 0xd800 && codepoint <= 0xdfff) || - (is_row && codepoint > 65535)) { + if (codepoint >= 0xd800 && codepoint <= 0xdfff) { throw Napi::RangeError::New(env, std::string(name) + " is unsafe for Ghostty's UTF-8 mouse encoding"); } } @@ -395,9 +394,9 @@ Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { // these exceptional cells whenever 1005 is set, even if another format was // selected later. Ordinary geometry and SGR-only large grids are unchanged. ValidateUtf8MouseCoordinate(env, position.x, size.screen_width, size.cell_width, - size.padding_left, size.padding_right, "mouse x", false); + size.padding_left, size.padding_right, "mouse x"); ValidateUtf8MouseCoordinate(env, position.y, size.screen_height, size.cell_height, - size.padding_top, size.padding_bottom, "mouse y", true); + size.padding_top, size.padding_bottom, "mouse y"); } if (mouse_modes_dirty_) { diff --git a/test/mouse-safety.test.ts b/test/mouse-safety.test.ts index 05d4760..86ee985 100644 --- a/test/mouse-safety.test.ts +++ b/test/mouse-safety.test.ts @@ -29,9 +29,13 @@ describe.each([ const term = createTerminal({ cols: 80, rows: 24 }); term.feed("\\x1b[?1000h\\x1b[?1005h"); const options = { geometry: ${JSON.stringify({ ...geometry, screenWidth: 65535, screenHeight: 65535 })} }; - for (const [x, y] of [[55263, 0], [0, 55263], [57310, 0], [0, 65503]]) { + for (const [x, y] of [[55263, 0], [0, 55263], [57310, 0]]) { assert.throws(() => term.encodeMouse({ action: "press", button: "left", x, y }, options), RangeError); } + for (const [y, row] of [[65503, "\\xf0\\x90\\x80\\x80"], [65534, "\\xf0\\x90\\x80\\x9f"]]) { + assert.deepEqual(term.encodeMouse({ action: "press", button: "left", x: 0, y }, options), + Buffer.from("\\x1b[M\\x20\\x21" + row, "latin1")); + } // Clamping happens before UTF-8 conversion: a distant release on a small // viewport is safe even when the original coordinate was problematic. const bytes = term.encodeMouse({ action: "release", button: "left", x: 55263, y: 65503 },