diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ed070..c194764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable user-facing changes to this package will be documented in this file. ## [Unreleased] +## Added +- Mode-aware mouse encoding through each terminal instance. The binding reads the child-negotiated Ghostty tracking mode and wire format, accepts explicit surface geometry and pressed-button state, and returns the exact bytes to write to the child PTY. + ## [v0.1.0-beta.1](https://github.com/coder/libghostty-vt-node/releases/tag/v0.1.0-beta.1) - 2026-04-24 ## Added diff --git a/README.md b/README.md index f4d779b..6bb28e5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,21 @@ const term = createTerminal({ cols: 80, rows: 24, scrollbackLimit: 1000 }); term.feed("hello\n"); term.feed("\x1b[31mred\x1b[0m"); +// The child enables normal mouse tracking plus SGR encoding. +term.feed("\x1b[?1000h\x1b[?1006h"); +const mouseBytes = term.encodeMouse( + { action: "press", button: "left", x: 25, y: 45 }, + { + geometry: { + screenWidth: 800, + screenHeight: 600, + cellWidth: 10, + cellHeight: 20, + }, + }, +); +// Write mouseBytes to the child PTY. + console.log(getNativeInfo()); console.log(term.getVisibleText()); console.log(term.snapshot({ includeCells: true })); @@ -38,12 +53,32 @@ The public contract is intentionally small: - `createTerminal({ cols, rows, scrollbackLimit })` - `feed(data)`, `resize(cols, rows)`, `snapshot(options)`, `getVisibleText()` +- `encodeMouse(event, options)` for mode-aware terminal mouse bytes - optional debug formatters `formatPlain()` and `formatHtml()` - explicit, idempotent `dispose()` - `getNativeInfo()` for package, Node-API, platform, and Ghostty build metadata All dimensions are validated as positive integers. Using a terminal after `dispose()` throws. +### Mouse encoding + +`encodeMouse` returns a `Buffer` containing the terminal input bytes for one +normalized mouse event. It returns an empty buffer when the child has mouse +tracking disabled or when its negotiated mode suppresses that event. The child +selects X10, UTF-8, SGR, URXVT, or SGR-pixels through the output previously +passed to `feed`; callers do not choose a wire format independently. + +Event coordinates are finite surface-space numbers. Geometry is explicit so +SGR-pixels remains accurate and the other formats can map the same position to +a terminal cell. `anyButtonPressed` supplies the caller-owned aggregate button +state needed for drag events outside the viewport. `trackLastCell` asks Ghostty +to suppress duplicate motion events within one unchanged cell. + +Buttons `four`, `five`, `six`, and `seven` conventionally represent wheel up, +wheel down, wheel left, and wheel right. The binding keeps Ghostty's names at +this low-level API boundary so consumers can provide their own user-facing +aliases. + ## Native Build The addon uses `node-addon-api` over Node-API/N-API and is built with `node-gyp`. Runtime loading uses `node-gyp-build`, so npm packages can ship prebuilt `.node` files. @@ -130,6 +165,7 @@ The native layer currently uses these verified `libghostty-vt` C APIs: - terminal lifecycle and stream processing: `ghostty_terminal_new`, `ghostty_terminal_vt_write`, `ghostty_terminal_resize`, `ghostty_terminal_free` - metadata and state: `ghostty_terminal_get`, `ghostty_build_info` +- mode-aware mouse input: `ghostty_mouse_encoder_*`, `ghostty_mouse_event_*` - plain/HTML debug formatting: `ghostty_formatter_terminal_new`, `ghostty_formatter_format_alloc` - structured snapshots: `ghostty_terminal_grid_ref`, `ghostty_grid_ref_cell`, `ghostty_grid_ref_graphemes`, `ghostty_grid_ref_style`, `ghostty_cell_get` diff --git a/examples/smoke.ts b/examples/smoke.ts index 8e45182..ba967d6 100644 --- a/examples/smoke.ts +++ b/examples/smoke.ts @@ -6,12 +6,27 @@ try { term.feed("hello\n"); term.feed("\x1b[31mred text\x1b[0m\n"); term.feed("\x1b[3;5Hcursor"); + term.feed("\x1b[?1000h\x1b[?1006h"); + + const mouseBytes = term.encodeMouse( + { action: "press", button: "left", x: 4, y: 5 }, + { + geometry: { + screenWidth: 80, + screenHeight: 24, + cellWidth: 1, + cellHeight: 1, + }, + }, + ); const snapshot = term.snapshot({ includeCells: true }); console.log("native info"); console.log(JSON.stringify(getNativeInfo(), null, 2)); console.log("visible text"); console.log(term.getVisibleText()); + console.log("mouse bytes"); + console.log(JSON.stringify([...mouseBytes])); console.log("snapshot summary"); console.log( JSON.stringify( diff --git a/native/terminal.cc b/native/terminal.cc index fc68458..c1a3a21 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -31,11 +32,95 @@ size_t NonNegativeSize(Napi::Env env, const Napi::Value& value, const char* name return static_cast(number); } +uint32_t PositiveUint32(Napi::Env env, const Napi::Value& value, const char* name) { + if (!value.IsNumber()) { + throw Napi::TypeError::New(env, std::string(name) + " must be a positive integer"); + } + const double number = value.As().DoubleValue(); + if (!std::isfinite(number) || number <= 0 || number != std::floor(number) || + number > std::numeric_limits::max()) { + throw Napi::RangeError::New(env, std::string(name) + " must be a positive 32-bit integer"); + } + return static_cast(number); +} + +uint32_t NonNegativeUint32(Napi::Env env, const Napi::Value& value, const char* name) { + if (!value.IsNumber()) { + throw Napi::TypeError::New(env, std::string(name) + " must be a non-negative integer"); + } + const double number = value.As().DoubleValue(); + if (!std::isfinite(number) || number < 0 || number != std::floor(number) || + number > std::numeric_limits::max()) { + throw Napi::RangeError::New(env, std::string(name) + " must be a non-negative 32-bit integer"); + } + return static_cast(number); +} + +float FiniteFloat(Napi::Env env, const Napi::Value& value, const char* name) { + if (!value.IsNumber()) { + throw Napi::TypeError::New(env, std::string(name) + " must be a finite number"); + } + const double number = value.As().DoubleValue(); + if (!std::isfinite(number) || number < -std::numeric_limits::max() || + number > std::numeric_limits::max()) { + throw Napi::RangeError::New(env, std::string(name) + " must be a finite 32-bit float"); + } + return static_cast(number); +} + +GhosttyMouseAction ParseMouseAction(Napi::Env env, const Napi::Value& value) { + if (!value.IsString()) throw Napi::TypeError::New(env, "mouse action must be a string"); + const std::string action = value.As().Utf8Value(); + if (action == "press") return GHOSTTY_MOUSE_ACTION_PRESS; + if (action == "release") return GHOSTTY_MOUSE_ACTION_RELEASE; + if (action == "motion") return GHOSTTY_MOUSE_ACTION_MOTION; + throw Napi::RangeError::New(env, "mouse action is invalid"); +} + +GhosttyMouseButton ParseMouseButton(Napi::Env env, const Napi::Value& value) { + if (!value.IsString()) throw Napi::TypeError::New(env, "mouse button must be a string"); + const std::string button = value.As().Utf8Value(); + if (button == "left") return GHOSTTY_MOUSE_BUTTON_LEFT; + if (button == "right") return GHOSTTY_MOUSE_BUTTON_RIGHT; + if (button == "middle") return GHOSTTY_MOUSE_BUTTON_MIDDLE; + if (button == "four") return GHOSTTY_MOUSE_BUTTON_FOUR; + if (button == "five") return GHOSTTY_MOUSE_BUTTON_FIVE; + if (button == "six") return GHOSTTY_MOUSE_BUTTON_SIX; + if (button == "seven") return GHOSTTY_MOUSE_BUTTON_SEVEN; + if (button == "eight") return GHOSTTY_MOUSE_BUTTON_EIGHT; + if (button == "nine") return GHOSTTY_MOUSE_BUTTON_NINE; + if (button == "ten") return GHOSTTY_MOUSE_BUTTON_TEN; + if (button == "eleven") return GHOSTTY_MOUSE_BUTTON_ELEVEN; + throw Napi::RangeError::New(env, "mouse button is invalid"); +} + bool OptionBool(const Napi::Object& options, const char* name) { const Napi::Value value = options.Get(name); return value.IsBoolean() && value.As().Value(); } +GhosttyMods ParseMouseModifiers(Napi::Env env, const Napi::Value& value) { + if (value.IsUndefined()) return 0; + if (!value.IsObject()) throw Napi::TypeError::New(env, "mouse modifiers must be an object"); + const Napi::Object modifiers = value.As(); + GhosttyMods result = 0; + if (OptionBool(modifiers, "shift")) result |= GHOSTTY_MODS_SHIFT; + if (OptionBool(modifiers, "ctrl")) result |= GHOSTTY_MODS_CTRL; + if (OptionBool(modifiers, "alt")) result |= GHOSTTY_MODS_ALT; + return result; +} + +bool MouseSizeEqual(const GhosttyMouseEncoderSize& left, const GhosttyMouseEncoderSize& right) { + return left.screen_width == right.screen_width && + left.screen_height == right.screen_height && + left.cell_width == right.cell_width && + left.cell_height == right.cell_height && + left.padding_top == right.padding_top && + left.padding_bottom == right.padding_bottom && + left.padding_right == right.padding_right && + left.padding_left == right.padding_left; +} + void ThrowResult(Napi::Env env, const char* operation, GhosttyResult result) { throw Napi::Error::New(env, ResultMessage(operation, result)); } @@ -51,6 +136,7 @@ void TerminalWrap::Init(Napi::Env env, Napi::Object exports) { { InstanceMethod("feed", &TerminalWrap::Feed), InstanceMethod("resize", &TerminalWrap::Resize), + InstanceMethod("encodeMouse", &TerminalWrap::EncodeMouse), InstanceMethod("snapshot", &TerminalWrap::Snapshot), InstanceMethod("getVisibleText", &TerminalWrap::GetVisibleText), InstanceMethod("formatPlain", &TerminalWrap::FormatPlain), @@ -94,14 +180,29 @@ TerminalWrap::TerminalWrap(const Napi::CallbackInfo& info) } assert(created != nullptr); terminal_ = created; + + GhosttyMouseEncoder mouse_encoder = nullptr; + const GhosttyResult mouse_result = ghostty_mouse_encoder_new(nullptr, &mouse_encoder); + if (mouse_result != GHOSTTY_SUCCESS) { + ghostty_terminal_free(terminal_); + terminal_ = nullptr; + ThrowResult(env, "ghostty_mouse_encoder_new", mouse_result); + } + assert(mouse_encoder != nullptr); + mouse_encoder_ = mouse_encoder; } TerminalWrap::~TerminalWrap() { DisposeNative(); } void TerminalWrap::DisposeNative() { - if (terminal_ == nullptr) return; - ghostty_terminal_free(terminal_); - terminal_ = nullptr; + if (mouse_encoder_ != nullptr) { + ghostty_mouse_encoder_free(mouse_encoder_); + mouse_encoder_ = nullptr; + } + if (terminal_ != nullptr) { + ghostty_terminal_free(terminal_); + terminal_ = nullptr; + } } GhosttyTerminal TerminalWrap::RequireTerminal(Napi::Env env) { @@ -111,6 +212,13 @@ GhosttyTerminal TerminalWrap::RequireTerminal(Napi::Env env) { return terminal_; } +GhosttyMouseEncoder TerminalWrap::RequireMouseEncoder(Napi::Env env) { + if (mouse_encoder_ == nullptr) { + throw Napi::Error::New(env, "GhosttyVtTerminal has been disposed"); + } + return mouse_encoder_; +} + Napi::Value TerminalWrap::Feed(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); GhosttyTerminal terminal = RequireTerminal(env); @@ -147,6 +255,7 @@ Napi::Value TerminalWrap::Feed(const Napi::CallbackInfo& info) { if (len == 0) return env.Undefined(); assert(data != nullptr); ghostty_terminal_vt_write(terminal, data, len); + mouse_modes_dirty_ = true; return env.Undefined(); } @@ -167,6 +276,103 @@ Napi::Value TerminalWrap::Resize(const Napi::CallbackInfo& info) { return env.Undefined(); } +// Encode against the child-negotiated terminal state while caching geometry so +// Ghostty's same-cell motion deduplication survives unchanged input dimensions. +Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + GhosttyTerminal terminal = RequireTerminal(env); + GhosttyMouseEncoder encoder = RequireMouseEncoder(env); + if (env.IsExceptionPending()) return env.Undefined(); + if (info.Length() != 2 || !info[0].IsObject() || !info[1].IsObject()) { + throw Napi::TypeError::New(env, "encodeMouse expects event and options objects"); + } + + const Napi::Object input = info[0].As(); + const Napi::Object options = info[1].As(); + const Napi::Value geometry_value = options.Get("geometry"); + if (!geometry_value.IsObject()) { + throw Napi::TypeError::New(env, "mouse geometry must be an object"); + } + const Napi::Object geometry = geometry_value.As(); + + GhosttyMouseEncoderSize size = {}; + size.size = sizeof(GhosttyMouseEncoderSize); + size.screen_width = PositiveUint32(env, geometry.Get("screenWidth"), "screenWidth"); + size.screen_height = PositiveUint32(env, geometry.Get("screenHeight"), "screenHeight"); + size.cell_width = PositiveUint32(env, geometry.Get("cellWidth"), "cellWidth"); + size.cell_height = PositiveUint32(env, geometry.Get("cellHeight"), "cellHeight"); + size.padding_top = NonNegativeUint32(env, geometry.Get("paddingTop"), "paddingTop"); + size.padding_bottom = NonNegativeUint32(env, geometry.Get("paddingBottom"), "paddingBottom"); + size.padding_right = NonNegativeUint32(env, geometry.Get("paddingRight"), "paddingRight"); + size.padding_left = NonNegativeUint32(env, geometry.Get("paddingLeft"), "paddingLeft"); + + if (mouse_modes_dirty_) { + ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); + mouse_modes_dirty_ = false; + } + if (!mouse_size_configured_ || !MouseSizeEqual(size, mouse_size_)) { + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_SIZE, &size); + mouse_size_ = size; + mouse_size_configured_ = true; + } + const bool any_button_pressed = OptionBool(options, "anyButtonPressed"); + ghostty_mouse_encoder_setopt( + encoder, GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED, &any_button_pressed); + const bool track_last_cell = OptionBool(options, "trackLastCell"); + ghostty_mouse_encoder_setopt( + encoder, GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL, &track_last_cell); + + GhosttyMouseEvent event = nullptr; + GhosttyResult result = ghostty_mouse_event_new(nullptr, &event); + if (result != GHOSTTY_SUCCESS) ThrowResult(env, "ghostty_mouse_event_new", result); + assert(event != nullptr); + + try { + ghostty_mouse_event_set_action(event, ParseMouseAction(env, input.Get("action"))); + const Napi::Value button = input.Get("button"); + if (button.IsUndefined()) { + ghostty_mouse_event_clear_button(event); + } else { + ghostty_mouse_event_set_button(event, ParseMouseButton(env, button)); + } + ghostty_mouse_event_set_mods(event, ParseMouseModifiers(env, input.Get("modifiers"))); + ghostty_mouse_event_set_position( + event, + GhosttyMousePosition{ + FiniteFloat(env, input.Get("x"), "mouse x"), + FiniteFloat(env, input.Get("y"), "mouse y"), + }); + + size_t required = 0; + result = ghostty_mouse_encoder_encode(encoder, event, nullptr, 0, &required); + if (result == GHOSTTY_SUCCESS) { + assert(required == 0); + ghostty_mouse_event_free(event); + return Napi::Buffer::New(env, 0); + } + if (result != GHOSTTY_OUT_OF_SPACE) { + ThrowResult(env, "ghostty_mouse_encoder_encode(size)", result); + } + if (required == 0) { + throw Napi::Error::New(env, "ghostty_mouse_encoder_encode returned an empty size"); + } + + std::vector output(required); + size_t written = 0; + result = ghostty_mouse_encoder_encode( + encoder, event, reinterpret_cast(output.data()), output.size(), &written); + if (result != GHOSTTY_SUCCESS) { + ThrowResult(env, "ghostty_mouse_encoder_encode", result); + } + assert(written <= output.size()); + ghostty_mouse_event_free(event); + return Napi::Buffer::Copy(env, output.data(), written); + } catch (...) { + ghostty_mouse_event_free(event); + throw; + } +} + Napi::Value TerminalWrap::Snapshot(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); RequireTerminal(env); diff --git a/native/terminal.hh b/native/terminal.hh index bb74a3f..650ea1a 100644 --- a/native/terminal.hh +++ b/native/terminal.hh @@ -24,8 +24,10 @@ class TerminalWrap final : public Napi::ObjectWrap { void DisposeNative(); GhosttyTerminal RequireTerminal(Napi::Env env); + GhosttyMouseEncoder RequireMouseEncoder(Napi::Env env); Napi::Value Feed(const Napi::CallbackInfo& info); Napi::Value Resize(const Napi::CallbackInfo& info); + Napi::Value EncodeMouse(const Napi::CallbackInfo& info); Napi::Value Snapshot(const Napi::CallbackInfo& info); Napi::Value GetVisibleText(const Napi::CallbackInfo& info); Napi::Value FormatPlain(const Napi::CallbackInfo& info); @@ -41,6 +43,10 @@ class TerminalWrap final : public Napi::ObjectWrap { std::string ResolveStyleColor(const GhosttyStyleColor& color); GhosttyTerminal terminal_ = nullptr; + GhosttyMouseEncoder mouse_encoder_ = nullptr; + bool mouse_modes_dirty_ = true; + bool mouse_size_configured_ = false; + GhosttyMouseEncoderSize mouse_size_ = {}; }; Napi::Value CreateTerminal(const Napi::CallbackInfo& info); diff --git a/src/index.ts b/src/index.ts index 7e4d69e..9c2fa92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,12 @@ import { decorateNativeInfo, loadNative } from "./loader.js"; import type { CreateTerminalOptions, GhosttyVtTerminal, + MouseAction, + MouseButton, + MouseEncoderOptions, + MouseGeometry, + MouseInputEvent, + MouseModifiers, NativeInfo, NativeTerminal, SnapshotOptions, @@ -12,6 +18,12 @@ import type { export type { CreateTerminalOptions, GhosttyVtTerminal, + MouseAction, + MouseButton, + MouseEncoderOptions, + MouseGeometry, + MouseInputEvent, + MouseModifiers, NativeInfo, SnapshotCell, SnapshotOptions, @@ -19,6 +31,9 @@ export type { VisibleLine, } from "./types.js"; +/** Import-time capability marker for consumers which must avoid native allocation. */ +export const supportsMouseInput = true; + function assertPositiveInteger(name: string, value: unknown): asserts value is number { if (!Number.isInteger(value) || (value as number) <= 0) { throw new TypeError(`${name} must be a positive integer`); @@ -66,6 +81,102 @@ function assertFeedData(data: Uint8Array | Buffer | string): void { throw new TypeError("feed data must be a string, Buffer, or Uint8Array"); } +const mouseActions = new Set(["press", "release", "motion"]); +const mouseButtons = new Set([ + "left", + "right", + "middle", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", +]); + +function assertObject(name: string, value: unknown): asserts value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${name} must be an object`); + } +} + +function assertFiniteNumber(name: string, value: unknown): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new TypeError(`${name} must be a finite number`); + } +} + +function optionalBoolean(name: string, value: unknown): boolean { + if (value === undefined) return false; + if (typeof value !== "boolean") throw new TypeError(`${name} must be a boolean`); + return value; +} + +// Reject ambiguous input before native allocation and retain only the fields +// that Ghostty's mouse event model can represent. +function normalizeMouseEvent(event: MouseInputEvent): 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)) { + throw new TypeError("mouse button is invalid"); + } + if (event.action !== "motion" && event.button === undefined) { + throw new TypeError(`${event.action} mouse event requires a button`); + } + assertFiniteNumber("mouse x", event.x); + assertFiniteNumber("mouse y", event.y); + + 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), + }; + } + + return { + action: event.action, + ...(event.button === undefined ? {} : { button: event.button }), + x: event.x, + y: event.y, + ...(modifiers === undefined ? {} : { modifiers }), + }; +} + +// Fill stable defaults while preserving explicit renderer geometry for both +// 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]; + if (value !== undefined) assertNonNegativeInteger(`mouse geometry.${key}`, value); + normalizedGeometry[key] = value ?? 0; + } + + return { + geometry: normalizedGeometry, + anyButtonPressed: optionalBoolean("anyButtonPressed", options.anyButtonPressed), + trackLastCell: optionalBoolean("trackLastCell", options.trackLastCell), + }; +} + class Terminal implements GhosttyVtTerminal { readonly #native: NativeTerminal; #disposed = false; @@ -89,6 +200,11 @@ class Terminal implements GhosttyVtTerminal { this.#native.resize(cols, rows); } + encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer { + this.#assertUsable(); + return this.#native.encodeMouse(normalizeMouseEvent(event), normalizeMouseOptions(options)); + } + snapshot(options?: SnapshotOptions): TerminalSnapshot { this.#assertUsable(); return this.#native.snapshot(normalizeSnapshotOptions(options)); diff --git a/src/types.ts b/src/types.ts index f203faf..a28c0aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,52 @@ export interface SnapshotOptions { includeCells?: boolean; } +export type MouseAction = "press" | "release" | "motion"; + +export type MouseButton = + | "left" + | "right" + | "middle" + | "four" + | "five" + | "six" + | "seven" + | "eight" + | "nine" + | "ten" + | "eleven"; + +export interface MouseModifiers { + shift?: boolean; + ctrl?: boolean; + alt?: boolean; +} + +export interface MouseInputEvent { + action: MouseAction; + button?: MouseButton; + x: number; + y: number; + modifiers?: MouseModifiers; +} + +export interface MouseGeometry { + screenWidth: number; + screenHeight: number; + cellWidth: number; + cellHeight: number; + paddingTop?: number; + paddingBottom?: number; + paddingRight?: number; + paddingLeft?: number; +} + +export interface MouseEncoderOptions { + geometry: MouseGeometry; + anyButtonPressed?: boolean; + trackLastCell?: boolean; +} + export interface VisibleLine { row: number; text: string; @@ -40,6 +86,7 @@ export interface TerminalSnapshot { export interface GhosttyVtTerminal { feed(data: Uint8Array | Buffer | string): void; resize(cols: number, rows: number): void; + encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer; snapshot(options?: SnapshotOptions): TerminalSnapshot; getVisibleText(): string; formatPlain?(): string; @@ -59,6 +106,7 @@ export interface NativeInfo { export interface NativeTerminal { feed(data: Uint8Array | Buffer | string): void; resize(cols: number, rows: number): void; + encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer; snapshot(options?: SnapshotOptions): TerminalSnapshot; getVisibleText(): string; formatPlain(): string; diff --git a/test/terminal.test.ts b/test/terminal.test.ts index c690fa2..a1561f1 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -1,11 +1,22 @@ import { describe, expect, it } from "vitest"; -import { createTerminal } from "../src/index.js"; +import { createTerminal, supportsMouseInput } from "../src/index.js"; import { nativeSupport } from "./native-support.js"; const support = nativeSupport(); const describeIfNative = support.available ? describe : describe.skip; +const unitGeometry = { + screenWidth: 80, + screenHeight: 24, + cellWidth: 1, + cellHeight: 1, +}; + describe("createTerminal validation", () => { + it("advertises mouse-input support without allocating a terminal", () => { + expect(supportsMouseInput).toBe(true); + }); + it("validates positive dimensions before loading native state", () => { expect(() => createTerminal({ cols: 0, rows: 24 })).toThrow(/cols/); expect(() => createTerminal({ cols: 80, rows: 0 })).toThrow(/rows/); @@ -50,11 +61,265 @@ describeIfNative("GhosttyVtTerminal", () => { } }); + it("encodes press, release, motion, modifiers, and wheel buttons using negotiated SGR mode", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1003h\x1b[?1006h"); + + const cases = [ + { + event: { action: "press", button: "left", x: 4, y: 5 } as const, + expected: "\x1b[<0;5;6M", + }, + { + event: { action: "release", button: "right", x: 4, y: 5 } as const, + expected: "\x1b[<2;5;6m", + }, + { + event: { action: "motion", x: 1, y: 2 } as const, + expected: "\x1b[<35;2;3M", + }, + { + event: { + action: "press", + button: "left", + x: 2, + y: 3, + modifiers: { shift: true, alt: true, ctrl: true }, + } as const, + expected: "\x1b[<28;3;4M", + }, + ...(["four", "five", "six", "seven"] as const).map((button, index) => ({ + event: { action: "press" as const, button, x: 0, y: 0 }, + expected: `\x1b[<${64 + index};1;1M`, + })), + ...(["eight", "nine"] as const).map((button, index) => ({ + event: { action: "press" as const, button, x: 0, y: 0 }, + expected: `\x1b[<${128 + index};1;1M`, + })), + ...(["ten", "eleven"] as const).map((button) => ({ + event: { action: "press" as const, button, x: 0, y: 0 }, + expected: "", + })), + ]; + + for (const { event, expected } of cases) { + expect(term.encodeMouse(event, { geometry: unitGeometry })).toEqual(Buffer.from(expected)); + } + } finally { + term.dispose(); + } + }); + + it("encodes the same event across every Ghostty mouse wire format", () => { + const cases = [ + { + mode: "\x1b[?9h", + expected: Buffer.from([0x1b, 0x5b, 0x4d, 0x20, 0x23, 0x24]), + }, + { + mode: "\x1b[?1000h", + expected: Buffer.from([0x1b, 0x5b, 0x4d, 0x3c, 0x23, 0x24]), + }, + { + mode: "\x1b[?1000h\x1b[?1005h", + expected: Buffer.from([0x1b, 0x5b, 0x4d, 0x3c, 0x23, 0x24]), + }, + { mode: "\x1b[?1000h\x1b[?1015h", expected: Buffer.from("\x1b[60;3;4M") }, + { mode: "\x1b[?1000h\x1b[?1006h", expected: Buffer.from("\x1b[<28;3;4M") }, + { mode: "\x1b[?1000h\x1b[?1016h", expected: Buffer.from("\x1b[<28;2;3M") }, + ]; + + for (const testCase of cases) { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed(testCase.mode); + expect( + term.encodeMouse( + { + action: "press", + button: "left", + x: 2, + y: 3, + modifiers: { shift: true, alt: true, ctrl: true }, + }, + { geometry: unitGeometry }, + ), + ).toEqual(testCase.expected); + } finally { + term.dispose(); + } + } + }); + + it("refreshes mouse mode after terminal output changes state", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + const event = { action: "press", button: "left", x: 4, y: 5 } as const; + expect(term.encodeMouse(event, { geometry: unitGeometry })).toHaveLength(0); + + term.feed("\x1b[?1000h\x1b[?1006h"); + expect(term.encodeMouse(event, { geometry: unitGeometry })).toEqual( + Buffer.from("\x1b[<0;5;6M"), + ); + + term.feed("\x1b[?1000l"); + expect(term.encodeMouse(event, { geometry: unitGeometry })).toHaveLength(0); + } finally { + term.dispose(); + } + }); + + it("applies tracking-mode, pressed-button, viewport, and motion-dedup classifiers", () => { + const cases = [ + { + name: "normal mode suppresses motion", + mode: "\x1b[?1000h\x1b[?1006h", + event: { action: "motion", button: "left", x: 2, y: 3 } as const, + options: { geometry: unitGeometry }, + expected: [""], + }, + { + name: "any mode deduplicates motion in one cell", + mode: "\x1b[?1003h\x1b[?1006h", + event: { action: "motion", x: 2, y: 3 } as const, + options: { geometry: unitGeometry, trackLastCell: true }, + expected: ["\x1b[<35;3;4M", ""], + }, + { + name: "out-of-viewport motion requires a pressed button", + mode: "\x1b[?1003h\x1b[?1006h", + event: { action: "motion", button: "left", x: 100, y: 30 } as const, + options: { geometry: unitGeometry, anyButtonPressed: false }, + expected: [""], + }, + { + name: "pressed drag outside viewport clamps to the final cell", + mode: "\x1b[?1003h\x1b[?1006h", + event: { action: "motion", button: "left", x: 100, y: 30 } as const, + options: { geometry: unitGeometry, anyButtonPressed: true }, + expected: ["\x1b[<32;80;24M"], + }, + ]; + + for (const testCase of cases) { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed(testCase.mode); + const actual = testCase.expected.map(() => + term.encodeMouse(testCase.event, testCase.options), + ); + expect(actual, testCase.name).toEqual(testCase.expected.map((value) => Buffer.from(value))); + } finally { + term.dispose(); + } + } + }); + + it("keeps pixel geometry explicit when the child negotiates SGR pixels", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1000h\x1b[?1016h"); + const bytes = term.encodeMouse( + { action: "press", button: "left", x: 50, y: 40 }, + { + geometry: { + screenWidth: 800, + screenHeight: 600, + cellWidth: 10, + cellHeight: 20, + }, + }, + ); + expect(bytes).toEqual(Buffer.from("\x1b[<0;50;40M")); + } finally { + term.dispose(); + } + }); + + it("rejects invalid mouse events and geometry as sets", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + expect(typeof term.encodeMouse).toBe("function"); + const invalidCalls = [ + () => + term.encodeMouse( + { action: "click" as never, button: "left", x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "primary" as never, x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + () => term.encodeMouse({ action: "press", x: 0, y: 0 }, { geometry: unitGeometry }), + () => + term.encodeMouse( + { action: "release", x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: Number.NaN, y: 0 }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: Number.POSITIVE_INFINITY }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { + action: "press", + button: "left", + x: 0, + y: 0, + modifiers: { shift: "yes" as never }, + }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: { ...unitGeometry, cellWidth: 0 } }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: { ...unitGeometry, screenWidth: 2 ** 32 } }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: { ...unitGeometry, paddingLeft: -1 } }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: unitGeometry, anyButtonPressed: "yes" as never }, + ), + ]; + + for (const invalidCall of invalidCalls) { + expect(invalidCall).toThrow(); + } + } finally { + term.dispose(); + } + }); + it("makes dispose idempotent and rejects use after dispose", () => { const term = createTerminal({ cols: 80, rows: 24 }); term.dispose(); expect(() => term.dispose()).not.toThrow(); expect(() => term.feed("after")).toThrow(/disposed/); expect(() => term.snapshot()).toThrow(/disposed/); + expect(() => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + ).toThrow(/disposed/); }); });