-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add mode-aware mouse encoding #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| #include <algorithm> | ||
| #include <cassert> | ||
| #include <cmath> | ||
| #include <limits> | ||
| #include <stdexcept> | ||
|
|
||
|
|
@@ -31,11 +32,95 @@ size_t NonNegativeSize(Napi::Env env, const Napi::Value& value, const char* name | |
| return static_cast<size_t>(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<Napi::Number>().DoubleValue(); | ||
| if (!std::isfinite(number) || number <= 0 || number != std::floor(number) || | ||
| number > std::numeric_limits<uint32_t>::max()) { | ||
| throw Napi::RangeError::New(env, std::string(name) + " must be a positive 32-bit integer"); | ||
| } | ||
| return static_cast<uint32_t>(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<Napi::Number>().DoubleValue(); | ||
| if (!std::isfinite(number) || number < 0 || number != std::floor(number) || | ||
| number > std::numeric_limits<uint32_t>::max()) { | ||
| throw Napi::RangeError::New(env, std::string(name) + " must be a non-negative 32-bit integer"); | ||
| } | ||
| return static_cast<uint32_t>(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<Napi::Number>().DoubleValue(); | ||
| if (!std::isfinite(number) || number < -std::numeric_limits<float>::max() || | ||
| number > std::numeric_limits<float>::max()) { | ||
| throw Napi::RangeError::New(env, std::string(name) + " must be a finite 32-bit float"); | ||
| } | ||
| return static_cast<float>(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<Napi::String>().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<Napi::String>().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<Napi::Boolean>().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<Napi::Object>(); | ||
| 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<Napi::Object>(); | ||
| const Napi::Object options = info[1].As<Napi::Object>(); | ||
| 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<Napi::Object>(); | ||
|
|
||
| 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"); | ||
|
Comment on lines
+300
to
+307
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking (see review item 1). Each field is bounded only to u32. Ghostty then computes |
||
|
|
||
| if (mouse_modes_dirty_) { | ||
| ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: |
||
| 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"), | ||
|
Comment on lines
+342
to
+343
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking (see review item 1). Any finite f32 passes. Ghostty's |
||
| }); | ||
|
|
||
| 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<uint8_t>::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<uint8_t> output(required); | ||
| size_t written = 0; | ||
| result = ghostty_mouse_encoder_encode( | ||
| encoder, event, reinterpret_cast<char*>(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<uint8_t>::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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking: two things worth documenting here. First,
tenandelevenare accepted but always return an empty buffer, because Ghostty'sbuttonCodehas no mapping for them (src/input/mouse_encode.zig:225). Second, wheel buttons should only be sent aspress, because legacy formats encode any release as button 3.