From f9f92f2e0f590ece488e06718ccbbeb4bd6fc601 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Mar 2026 15:48:09 -0700 Subject: [PATCH 1/6] terminal: consolidate mouse types into mouse.zig Move MouseEvent and MouseFormat out of Terminal.zig and MouseShape out of mouse_shape.zig into a new mouse.zig file. The types are named without the Mouse prefix inside the module (Event, Format, Shape) and re-exported with the prefix from terminal/main.zig for external use. Update all call sites (mouse_encode.zig, surface_mouse.zig, stream.zig) to import through terminal/main.zig or directly from mouse.zig. Remove the now-unused mouse_shape.zig. --- src/input/mouse_encode.zig | 7 ++-- src/surface_mouse.zig | 4 +-- src/terminal/Terminal.zig | 33 +++--------------- src/terminal/main.zig | 5 ++- src/terminal/{mouse_shape.zig => mouse.zig} | 37 +++++++++++++++++---- src/terminal/stream.zig | 2 +- 6 files changed, 46 insertions(+), 42 deletions(-) rename src/terminal/{mouse_shape.zig => mouse.zig} (77%) diff --git a/src/input/mouse_encode.zig b/src/input/mouse_encode.zig index 2dfe084fb..9dceb0ab3 100644 --- a/src/input/mouse_encode.zig +++ b/src/input/mouse_encode.zig @@ -1,6 +1,7 @@ const std = @import("std"); const testing = std.testing; -const Terminal = @import("../terminal/Terminal.zig"); +const terminal = @import("../terminal/main.zig"); +const Terminal = terminal.Terminal; const renderer_size = @import("../renderer/size.zig"); const point = @import("../terminal/point.zig"); const key = @import("key.zig"); @@ -11,10 +12,10 @@ const log = std.log.scoped(.mouse_encode); /// Options that affect mouse encoding behavior and provide runtime context. pub const Options = struct { /// Terminal mouse reporting mode (X10, normal, button, any). - event: Terminal.MouseEvent = .none, + event: terminal.MouseEvent = .none, /// Terminal mouse reporting format. - format: Terminal.MouseFormat = .x10, + format: terminal.MouseFormat = .x10, /// Full renderer size used to convert surface-space pixel positions /// into grid cell coordinates (for most formats) and terminal-space diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index 6d3c11394..8fa53d240 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -9,14 +9,14 @@ const std = @import("std"); const builtin = @import("builtin"); const input = @import("input.zig"); const terminal = @import("terminal/main.zig"); -const MouseShape = @import("terminal/mouse_shape.zig").MouseShape; +const MouseShape = terminal.MouseShape; /// For processing key events; the key that was physically pressed on the /// keyboard. physical_key: input.Key, /// The mouse event tracking mode, if any. -mouse_event: terminal.Terminal.MouseEvent, +mouse_event: terminal.MouseEvent, /// The current terminal's mouse shape. mouse_shape: MouseShape, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 9e21ba97a..636a8f2ee 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -23,7 +23,7 @@ const point = @import("point.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); -const mouse_shape_pkg = @import("mouse_shape.zig"); +const mouse = @import("mouse.zig"); const ReadonlyHandler = @import("stream_readonly.zig").Handler; const ReadonlyStream = @import("stream_readonly.zig").Stream; @@ -79,7 +79,7 @@ previous_char: ?u21 = null, modes: modespkg.ModeState = .{}, /// The most recently set mouse shape for the terminal. -mouse_shape: mouse_shape_pkg.MouseShape = .text, +mouse_shape: mouse.Shape = .text, /// These are just a packed set of flags we may set on the terminal. flags: packed struct { @@ -96,8 +96,8 @@ flags: packed struct { /// set mode in modes. You can't get the right event/format to use /// based on modes alone because modes don't show you what order /// this was called so we have to track it separately. - mouse_event: MouseEvent = .none, - mouse_format: MouseFormat = .x10, + mouse_event: mouse.Event = .none, + mouse_format: mouse.Format = .x10, /// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1) /// then we want to capture the shift key for the mouse protocol @@ -167,31 +167,6 @@ pub const Dirty = packed struct { preedit: bool = false, }; -/// The event types that can be reported for mouse-related activities. -/// These are all mutually exclusive (hence in a single enum). -pub const MouseEvent = enum(u3) { - none = 0, - x10 = 1, // 9 - normal = 2, // 1000 - button = 3, // 1002 - any = 4, // 1003 - - /// Returns true if this event sends motion events. - pub fn motion(self: MouseEvent) bool { - return self == .button or self == .any; - } -}; - -/// The format of mouse events when enabled. -/// These are all mutually exclusive (hence in a single enum). -pub const MouseFormat = enum(u3) { - x10 = 0, - utf8 = 1, // 1005 - sgr = 2, // 1006 - urxvt = 3, // 1015 - sgr_pixels = 4, // 1016 -}; - /// Scrolling region is the area of the screen designated where scrolling /// occurs. When scrolling the screen, only this viewport is scrolled. pub const ScrollingRegion = struct { diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 06c930014..a87d5ec87 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -31,7 +31,10 @@ pub const Cell = page.Cell; pub const Coordinate = point.Coordinate; pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; -pub const MouseShape = @import("mouse_shape.zig").MouseShape; +pub const mouse = @import("mouse.zig"); +pub const MouseEvent = mouse.Event; +pub const MouseFormat = mouse.Format; +pub const MouseShape = mouse.Shape; pub const Page = page.Page; pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); diff --git a/src/terminal/mouse_shape.zig b/src/terminal/mouse.zig similarity index 77% rename from src/terminal/mouse_shape.zig rename to src/terminal/mouse.zig index b5c6ac4d1..758369ae9 100644 --- a/src/terminal/mouse_shape.zig +++ b/src/terminal/mouse.zig @@ -2,12 +2,37 @@ const std = @import("std"); const build_options = @import("terminal_options"); const lib = @import("../lib/main.zig"); +/// The event types that can be reported for mouse-related activities. +/// These are all mutually exclusive (hence in a single enum). +pub const Event = enum(u3) { + none = 0, + x10 = 1, // 9 + normal = 2, // 1000 + button = 3, // 1002 + any = 4, // 1003 + + /// Returns true if this event sends motion events. + pub fn motion(self: Event) bool { + return self == .button or self == .any; + } +}; + +/// The format of mouse events when enabled. +/// These are all mutually exclusive (hence in a single enum). +pub const Format = enum(u3) { + x10 = 0, + utf8 = 1, // 1005 + sgr = 2, // 1006 + urxvt = 3, // 1015 + sgr_pixels = 4, // 1016 +}; + /// The possible cursor shapes. Not all app runtimes support these shapes. /// The shapes are always based on the W3C supported cursor styles so we /// can have a cross platform list. // // Must be kept in sync with ghostty_cursor_shape_e -pub const MouseShape = enum(c_int) { +pub const Shape = enum(c_int) { default, context_menu, help, @@ -44,7 +69,7 @@ pub const MouseShape = enum(c_int) { zoom_out, /// Build cursor shape from string or null if its unknown. - pub fn fromString(v: []const u8) ?MouseShape { + pub fn fromString(v: []const u8) ?Shape { return string_map.get(v); } @@ -57,7 +82,7 @@ pub const MouseShape = enum(c_int) { break :gtk switch (@import("../build_config.zig").app_runtime) { .gtk => @import("gobject").ext.defineEnum( - MouseShape, + Shape, .{ .name = "GhosttyMouseShape" }, ), @@ -66,11 +91,11 @@ pub const MouseShape = enum(c_int) { }; test "ghostty.h MouseShape" { - try lib.checkGhosttyHEnum(MouseShape, "GHOSTTY_MOUSE_SHAPE_"); + try lib.checkGhosttyHEnum(Shape, "GHOSTTY_MOUSE_SHAPE_"); } }; -const string_map = std.StaticStringMap(MouseShape).initComptime(.{ +const string_map = std.StaticStringMap(Shape).initComptime(.{ // W3C .{ "default", .default }, .{ "context-menu", .context_menu }, @@ -134,7 +159,7 @@ const string_map = std.StaticStringMap(MouseShape).initComptime(.{ test "cursor shape from string" { const testing = std.testing; - try testing.expectEqual(MouseShape.default, MouseShape.fromString("default").?); + try testing.expectEqual(Shape.default, Shape.fromString("default").?); } test { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 5f83128a9..f78bcc6f5 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -16,7 +16,7 @@ const modes = @import("modes.zig"); const osc = @import("osc.zig"); const sgr = @import("sgr.zig"); const UTF8Decoder = @import("UTF8Decoder.zig"); -const MouseShape = @import("mouse_shape.zig").MouseShape; +const MouseShape = @import("mouse.zig").Shape; const log = std.log.scoped(.stream); From 37efac99b0c777a89d28503e7cace78cb394b4d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Mar 2026 16:32:03 -0700 Subject: [PATCH 2/6] terminal/mouse: convert Event and Format to lib.Enum Convert the Event and Format enums from fixed-size Zig enums to lib.Enum so they are C ABI compatible when targeting C. The motion method on Event becomes a free function eventIsMotion since lib.Enum types cannot have declarations. --- src/input/mouse_encode.zig | 2 +- src/terminal/mouse.zig | 37 +++++++++++++++++++------------------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/input/mouse_encode.zig b/src/input/mouse_encode.zig index 9dceb0ab3..0cf431303 100644 --- a/src/input/mouse_encode.zig +++ b/src/input/mouse_encode.zig @@ -93,7 +93,7 @@ pub fn encode( // If we don't have a motion-tracking event mode, do nothing, // because events outside the viewport are never reported in // such cases. - if (!opts.event.motion()) return; + if (!terminal.mouse.eventSendsMotion(opts.event)) return; // For motion modes, we only report if a button is currently pressed. // This lets a TUI detect a click over the surface + drag out diff --git a/src/terminal/mouse.zig b/src/terminal/mouse.zig index 758369ae9..e72e166f7 100644 --- a/src/terminal/mouse.zig +++ b/src/terminal/mouse.zig @@ -1,31 +1,32 @@ const std = @import("std"); const build_options = @import("terminal_options"); const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; /// The event types that can be reported for mouse-related activities. /// These are all mutually exclusive (hence in a single enum). -pub const Event = enum(u3) { - none = 0, - x10 = 1, // 9 - normal = 2, // 1000 - button = 3, // 1002 - any = 4, // 1003 +pub const Event = lib.Enum(lib_target, &.{ + "none", + "x10", // 9 + "normal", // 1000 + "button", // 1002 + "any", // 1003 +}); - /// Returns true if this event sends motion events. - pub fn motion(self: Event) bool { - return self == .button or self == .any; - } -}; +/// Returns true if this event sends motion events. +pub fn eventSendsMotion(event: Event) bool { + return event == .button or event == .any; +} /// The format of mouse events when enabled. /// These are all mutually exclusive (hence in a single enum). -pub const Format = enum(u3) { - x10 = 0, - utf8 = 1, // 1005 - sgr = 2, // 1006 - urxvt = 3, // 1015 - sgr_pixels = 4, // 1016 -}; +pub const Format = lib.Enum(lib_target, &.{ + "x10", + "utf8", // 1005 + "sgr", // 1006 + "urxvt", // 1015 + "sgr_pixels", // 1016 +}); /// The possible cursor shapes. Not all app runtimes support these shapes. /// The shapes are always based on the W3C supported cursor styles so we From 79e023b65eebcf56ee0a17ff3fca2609463b1bc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Mar 2026 19:38:09 -0700 Subject: [PATCH 3/6] terminal,renderer: convert structs to extern Convert Coordinate in terminal/point.zig and CellSize, ScreenSize, GridSize, and Padding in renderer/size.zig to extern structs. All fields are already extern-compatible types, so this gives them a guaranteed C ABI layout with no functional change. --- src/input/mouse_encode.zig | 2 +- src/renderer/size.zig | 8 ++++---- src/terminal/point.zig | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/input/mouse_encode.zig b/src/input/mouse_encode.zig index 0cf431303..13a8bd462 100644 --- a/src/input/mouse_encode.zig +++ b/src/input/mouse_encode.zig @@ -69,7 +69,7 @@ pub const Event = struct { pos: Pos = .{}, /// Mouse position in surface-space pixels. - pub const Pos = struct { + pub const Pos = extern struct { x: f32 = 0, y: f32 = 0, }; diff --git a/src/renderer/size.zig b/src/renderer/size.zig index e9754d1f3..565e95e2c 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -172,7 +172,7 @@ pub const Coordinate = union(enum) { /// /// The units for the width and height are in world space. They have to /// be normalized for any renderer implementation. -pub const CellSize = struct { +pub const CellSize = extern struct { width: u32, height: u32, }; @@ -180,7 +180,7 @@ pub const CellSize = struct { /// The dimensions of the screen that the grid is rendered to. This is the /// terminal screen, so it is likely a subset of the window size. The dimensions /// should be in pixels. -pub const ScreenSize = struct { +pub const ScreenSize = extern struct { width: u32, height: u32, @@ -224,7 +224,7 @@ pub const ScreenSize = struct { }; /// The dimensions of the grid itself, in rows/columns units. -pub const GridSize = struct { +pub const GridSize = extern struct { pub const Unit = terminal.size.CellCountInt; columns: Unit = 0, @@ -257,7 +257,7 @@ pub const GridSize = struct { }; /// The padding to add to a screen. -pub const Padding = struct { +pub const Padding = extern struct { top: u32 = 0, bottom: u32 = 0, right: u32 = 0, diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 5a3d4a6f8..c5983fcbc 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -66,7 +66,7 @@ pub const Point = union(Tag) { } }; -pub const Coordinate = struct { +pub const Coordinate = extern struct { /// x can use size.CellCountInt because the number of columns /// can't ever be more than a valid number of columns in a Page. x: size.CellCountInt = 0, From 9b35c2bb65ef900c9eab5ab6920d582cb5333035 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Mar 2026 20:02:39 -0700 Subject: [PATCH 4/6] vt: add mouse encoding C API Expose the internal mouse encoding functionality through the C API, following the same pattern as the existing key encoding API. This allows external consumers of libvt to encode mouse events into terminal escape sequences (X10, UTF-8, SGR, URxvt, SGR-Pixels). The API is split into two opaque handle types: GhosttyMouseEvent for building normalized mouse events (action, button, modifiers, position) and GhosttyMouseEncoder for converting those events into escape sequences. The encoder is configured via a setopt interface supporting tracking mode, output format, renderer geometry, button state, and optional motion deduplication by last cell. Encoder state can also be bulk-configured from a terminal handle via ghostty_mouse_encoder_setopt_from_terminal. Failed encodes due to insufficient buffer space report the required size without mutating deduplication state. --- include/ghostty/vt.h | 2 + include/ghostty/vt/mouse.h | 35 ++ include/ghostty/vt/mouse/encoder.h | 211 ++++++++++++ include/ghostty/vt/mouse/event.h | 193 +++++++++++ src/input/mouse.zig | 2 +- src/lib_vt.zig | 17 + src/terminal/c/main.zig | 23 ++ src/terminal/c/mouse_encode.zig | 529 +++++++++++++++++++++++++++++ src/terminal/c/mouse_event.zig | 155 +++++++++ 9 files changed, 1166 insertions(+), 1 deletion(-) create mode 100644 include/ghostty/vt/mouse.h create mode 100644 include/ghostty/vt/mouse/encoder.h create mode 100644 include/ghostty/vt/mouse/event.h create mode 100644 src/terminal/c/mouse_encode.zig create mode 100644 src/terminal/c/mouse_event.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index dd5eda989..f3958ccc3 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -31,6 +31,7 @@ * - @ref terminal "Terminal" - Complete terminal emulator state and rendering * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML * - @ref key "Key Encoding" - Encode key events into terminal sequences + * - @ref mouse "Mouse Encoding" - Encode mouse events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste Utilities" - Validate paste data safety @@ -88,6 +89,7 @@ extern "C" { #include #include #include +#include #include #include diff --git a/include/ghostty/vt/mouse.h b/include/ghostty/vt/mouse.h new file mode 100644 index 000000000..4e44e15b5 --- /dev/null +++ b/include/ghostty/vt/mouse.h @@ -0,0 +1,35 @@ +/** + * @file mouse.h + * + * Mouse encoding module - encode mouse events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_H +#define GHOSTTY_VT_MOUSE_H + +/** @defgroup mouse Mouse Encoding + * + * Utilities for encoding mouse events into terminal escape sequences, + * supporting X10, UTF-8, SGR, URxvt, and SGR-Pixels mouse protocols. + * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_mouse_encoder_new(). + * 2. Configure encoder options with ghostty_mouse_encoder_setopt() or + * ghostty_mouse_encoder_setopt_from_terminal(). + * 3. For each mouse event: + * - Create a mouse event with ghostty_mouse_event_new(). + * - Set event properties (action, button, modifiers, position). + * - Encode with ghostty_mouse_encoder_encode(). + * - Free the event with ghostty_mouse_event_free() or reuse it. + * 4. Free the encoder with ghostty_mouse_encoder_free() when done. + * + * @{ + */ + +#include +#include + +/** @} */ + +#endif /* GHOSTTY_VT_MOUSE_H */ diff --git a/include/ghostty/vt/mouse/encoder.h b/include/ghostty/vt/mouse/encoder.h new file mode 100644 index 000000000..c418ed8ec --- /dev/null +++ b/include/ghostty/vt/mouse/encoder.h @@ -0,0 +1,211 @@ +/** + * @file encoder.h + * + * Mouse event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_ENCODER_H +#define GHOSTTY_VT_MOUSE_ENCODER_H + +#include +#include +#include +#include +#include +#include +#include + +/** + * Opaque handle to a mouse encoder instance. + * + * This handle represents a mouse encoder that converts normalized + * mouse events into terminal escape sequences. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEncoder *GhosttyMouseEncoder; + +/** + * Mouse tracking mode. + * + * @ingroup mouse + */ +typedef enum { + /** Mouse reporting disabled. */ + GHOSTTY_MOUSE_TRACKING_NONE = 0, + + /** X10 mouse mode. */ + GHOSTTY_MOUSE_TRACKING_X10 = 1, + + /** Normal mouse mode (button press/release only). */ + GHOSTTY_MOUSE_TRACKING_NORMAL = 2, + + /** Button-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_BUTTON = 3, + + /** Any-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_ANY = 4, +} GhosttyMouseTrackingMode; + +/** + * Mouse output format. + * + * @ingroup mouse + */ +typedef enum { + GHOSTTY_MOUSE_FORMAT_X10 = 0, + GHOSTTY_MOUSE_FORMAT_UTF8 = 1, + GHOSTTY_MOUSE_FORMAT_SGR = 2, + GHOSTTY_MOUSE_FORMAT_URXVT = 3, + GHOSTTY_MOUSE_FORMAT_SGR_PIXELS = 4, +} GhosttyMouseFormat; + +/** + * Mouse encoder size and geometry context. + * + * This describes the rendered terminal geometry used to convert + * surface-space positions into encoded coordinates. + * + * @ingroup mouse + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyMouseEncoderSize). */ + size_t size; + + /** Full screen width in pixels. */ + uint32_t screen_width; + + /** Full screen height in pixels. */ + uint32_t screen_height; + + /** Cell width in pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Cell height in pixels. Must be non-zero. */ + uint32_t cell_height; + + /** Top padding in pixels. */ + uint32_t padding_top; + + /** Bottom padding in pixels. */ + uint32_t padding_bottom; + + /** Right padding in pixels. */ + uint32_t padding_right; + + /** Left padding in pixels. */ + uint32_t padding_left; +} GhosttyMouseEncoderSize; + +/** + * Mouse encoder option identifiers. + * + * These values are used with ghostty_mouse_encoder_setopt() to configure + * the behavior of the mouse encoder. + * + * @ingroup mouse + */ +typedef enum { + /** Mouse tracking mode (value: GhosttyMouseTrackingMode). */ + GHOSTTY_MOUSE_ENCODER_OPT_EVENT = 0, + + /** Mouse output format (value: GhosttyMouseFormat). */ + GHOSTTY_MOUSE_ENCODER_OPT_FORMAT = 1, + + /** Renderer size context (value: GhosttyMouseEncoderSize). */ + GHOSTTY_MOUSE_ENCODER_OPT_SIZE = 2, + + /** Whether any mouse button is currently pressed (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED = 3, + + /** Whether to enable motion deduplication by last cell (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL = 4, +} GhosttyMouseEncoderOption; + +/** + * Create a new mouse encoder instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param encoder Pointer to store the created encoder handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup mouse + */ +GhosttyResult ghostty_mouse_encoder_new(const GhosttyAllocator *allocator, + GhosttyMouseEncoder *encoder); + +/** + * Free a mouse encoder instance. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_free(GhosttyMouseEncoder encoder); + +/** + * Set an option on the mouse encoder. + * + * A null pointer value does nothing. It does not reset to defaults. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to option value (type depends on option) + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_setopt(GhosttyMouseEncoder encoder, + GhosttyMouseEncoderOption option, + const void *value); + +/** + * Set encoder options from a terminal's current state. + * + * This sets tracking mode and output format from terminal state. + * It does not modify size or any-button state. + * + * @param encoder The encoder handle, must not be NULL + * @param terminal The terminal handle, must not be NULL + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_setopt_from_terminal(GhosttyMouseEncoder encoder, + GhosttyTerminal terminal); + +/** + * Reset internal encoder state. + * + * This clears motion deduplication state (last tracked cell). + * + * @param encoder The encoder handle (may be NULL) + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_reset(GhosttyMouseEncoder encoder); + +/** + * Encode a mouse event into a terminal escape sequence. + * + * Not all mouse events produce output. In such cases this returns + * GHOSTTY_SUCCESS with out_len set to 0. + * + * If the output buffer is too small, this returns GHOSTTY_OUT_OF_MEMORY + * and out_len contains the required size. + * + * @param encoder The encoder handle, must not be NULL + * @param event The mouse event to encode, must not be NULL + * @param out_buf Buffer to write encoded bytes to, or NULL to query required size + * @param out_buf_size Size of out_buf in bytes + * @param out_len Pointer to store bytes written (or required bytes on failure) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer is too small, + * or another error code + * + * @ingroup mouse + */ +GhosttyResult ghostty_mouse_encoder_encode(GhosttyMouseEncoder encoder, + GhosttyMouseEvent event, + char *out_buf, + size_t out_buf_size, + size_t *out_len); + +#endif /* GHOSTTY_VT_MOUSE_ENCODER_H */ diff --git a/include/ghostty/vt/mouse/event.h b/include/ghostty/vt/mouse/event.h new file mode 100644 index 000000000..44132097c --- /dev/null +++ b/include/ghostty/vt/mouse/event.h @@ -0,0 +1,193 @@ +/** + * @file event.h + * + * Mouse event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_MOUSE_EVENT_H +#define GHOSTTY_VT_MOUSE_EVENT_H + +#include +#include +#include +#include + +/** + * Opaque handle to a mouse event. + * + * This handle represents a normalized mouse input event containing + * action, button, modifiers, and surface-space position. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEvent *GhosttyMouseEvent; + +/** + * Mouse event action type. + * + * @ingroup mouse + */ +typedef enum { + /** Mouse button was pressed. */ + GHOSTTY_MOUSE_ACTION_PRESS = 0, + + /** Mouse button was released. */ + GHOSTTY_MOUSE_ACTION_RELEASE = 1, + + /** Mouse moved. */ + GHOSTTY_MOUSE_ACTION_MOTION = 2, +} GhosttyMouseAction; + +/** + * Mouse button identity. + * + * @ingroup mouse + */ +typedef enum { + GHOSTTY_MOUSE_BUTTON_UNKNOWN = 0, + GHOSTTY_MOUSE_BUTTON_LEFT = 1, + GHOSTTY_MOUSE_BUTTON_RIGHT = 2, + GHOSTTY_MOUSE_BUTTON_MIDDLE = 3, + GHOSTTY_MOUSE_BUTTON_FOUR = 4, + GHOSTTY_MOUSE_BUTTON_FIVE = 5, + GHOSTTY_MOUSE_BUTTON_SIX = 6, + GHOSTTY_MOUSE_BUTTON_SEVEN = 7, + GHOSTTY_MOUSE_BUTTON_EIGHT = 8, + GHOSTTY_MOUSE_BUTTON_NINE = 9, + GHOSTTY_MOUSE_BUTTON_TEN = 10, + GHOSTTY_MOUSE_BUTTON_ELEVEN = 11, +} GhosttyMouseButton; + +/** + * Mouse position in surface-space pixels. + * + * @ingroup mouse + */ +typedef struct { + float x; + float y; +} GhosttyMousePosition; + +/** + * Create a new mouse event instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param event Pointer to store the created event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup mouse + */ +GhosttyResult ghostty_mouse_event_new(const GhosttyAllocator *allocator, + GhosttyMouseEvent *event); + +/** + * Free a mouse event instance. + * + * @param event The mouse event handle to free (may be NULL) + * + * @ingroup mouse + */ +void ghostty_mouse_event_free(GhosttyMouseEvent event); + +/** + * Set the event action. + * + * @param event The event handle, must not be NULL + * @param action The action to set + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_action(GhosttyMouseEvent event, + GhosttyMouseAction action); + +/** + * Get the event action. + * + * @param event The event handle, must not be NULL + * @return The event action + * + * @ingroup mouse + */ +GhosttyMouseAction ghostty_mouse_event_get_action(GhosttyMouseEvent event); + +/** + * Set the event button. + * + * This sets a concrete button identity for the event. + * To represent "no button" (for motion events), use + * ghostty_mouse_event_clear_button(). + * + * @param event The event handle, must not be NULL + * @param button The button to set + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_button(GhosttyMouseEvent event, + GhosttyMouseButton button); + +/** + * Clear the event button. + * + * This sets the event button to "none". + * + * @param event The event handle, must not be NULL + * + * @ingroup mouse + */ +void ghostty_mouse_event_clear_button(GhosttyMouseEvent event); + +/** + * Get the event button. + * + * @param event The event handle, must not be NULL + * @param out_button Output pointer for the button value (may be NULL) + * @return true if a button is set, false if no button is set + * + * @ingroup mouse + */ +bool ghostty_mouse_event_get_button(GhosttyMouseEvent event, + GhosttyMouseButton *out_button); + +/** + * Set keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @param mods Modifier bitmask + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_mods(GhosttyMouseEvent event, + GhosttyMods mods); + +/** + * Get keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @return Modifier bitmask + * + * @ingroup mouse + */ +GhosttyMods ghostty_mouse_event_get_mods(GhosttyMouseEvent event); + +/** + * Set the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @param position The position to set + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_position(GhosttyMouseEvent event, + GhosttyMousePosition position); + +/** + * Get the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @return The current event position + * + * @ingroup mouse + */ +GhosttyMousePosition ghostty_mouse_event_get_position(GhosttyMouseEvent event); + +#endif /* GHOSTTY_VT_MOUSE_EVENT_H */ diff --git a/src/input/mouse.zig b/src/input/mouse.zig index 8a769557f..fad3ed67a 100644 --- a/src/input/mouse.zig +++ b/src/input/mouse.zig @@ -4,7 +4,7 @@ const std = @import("std"); /// from ButtonState because button state is simply the current state /// of a mouse button but an action is something that triggers via /// an GUI event and supports more. -pub const Action = enum { press, release, motion }; +pub const Action = enum(c_int) { press, release, motion }; /// The state of a mouse button. /// diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 0fa808b59..32fd14dc5 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -126,6 +126,23 @@ comptime { @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); @export(&c.key_encoder_setopt_from_terminal, .{ .name = "ghostty_key_encoder_setopt_from_terminal" }); @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); + @export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" }); + @export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" }); + @export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" }); + @export(&c.mouse_event_get_action, .{ .name = "ghostty_mouse_event_get_action" }); + @export(&c.mouse_event_set_button, .{ .name = "ghostty_mouse_event_set_button" }); + @export(&c.mouse_event_clear_button, .{ .name = "ghostty_mouse_event_clear_button" }); + @export(&c.mouse_event_get_button, .{ .name = "ghostty_mouse_event_get_button" }); + @export(&c.mouse_event_set_mods, .{ .name = "ghostty_mouse_event_set_mods" }); + @export(&c.mouse_event_get_mods, .{ .name = "ghostty_mouse_event_get_mods" }); + @export(&c.mouse_event_set_position, .{ .name = "ghostty_mouse_event_set_position" }); + @export(&c.mouse_event_get_position, .{ .name = "ghostty_mouse_event_get_position" }); + @export(&c.mouse_encoder_new, .{ .name = "ghostty_mouse_encoder_new" }); + @export(&c.mouse_encoder_free, .{ .name = "ghostty_mouse_encoder_free" }); + @export(&c.mouse_encoder_setopt, .{ .name = "ghostty_mouse_encoder_setopt" }); + @export(&c.mouse_encoder_setopt_from_terminal, .{ .name = "ghostty_mouse_encoder_setopt_from_terminal" }); + @export(&c.mouse_encoder_reset, .{ .name = "ghostty_mouse_encoder_reset" }); + @export(&c.mouse_encoder_encode, .{ .name = "ghostty_mouse_encoder_encode" }); @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 7842866dc..5adc00251 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -3,6 +3,8 @@ pub const formatter = @import("formatter.zig"); pub const osc = @import("osc.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); +pub const mouse_event = @import("mouse_event.zig"); +pub const mouse_encode = @import("mouse_encode.zig"); pub const paste = @import("paste.zig"); pub const sgr = @import("sgr.zig"); pub const terminal = @import("terminal.zig"); @@ -58,6 +60,25 @@ pub const key_encoder_setopt = key_encode.setopt; pub const key_encoder_setopt_from_terminal = key_encode.setopt_from_terminal; pub const key_encoder_encode = key_encode.encode; +pub const mouse_event_new = mouse_event.new; +pub const mouse_event_free = mouse_event.free; +pub const mouse_event_set_action = mouse_event.set_action; +pub const mouse_event_get_action = mouse_event.get_action; +pub const mouse_event_set_button = mouse_event.set_button; +pub const mouse_event_clear_button = mouse_event.clear_button; +pub const mouse_event_get_button = mouse_event.get_button; +pub const mouse_event_set_mods = mouse_event.set_mods; +pub const mouse_event_get_mods = mouse_event.get_mods; +pub const mouse_event_set_position = mouse_event.set_position; +pub const mouse_event_get_position = mouse_event.get_position; + +pub const mouse_encoder_new = mouse_encode.new; +pub const mouse_encoder_free = mouse_encode.free; +pub const mouse_encoder_setopt = mouse_encode.setopt; +pub const mouse_encoder_setopt_from_terminal = mouse_encode.setopt_from_terminal; +pub const mouse_encoder_reset = mouse_encode.reset; +pub const mouse_encoder_encode = mouse_encode.encode; + pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; @@ -73,6 +94,8 @@ test { _ = osc; _ = key_event; _ = key_encode; + _ = mouse_event; + _ = mouse_encode; _ = paste; _ = sgr; _ = terminal; diff --git a/src/terminal/c/mouse_encode.zig b/src/terminal/c/mouse_encode.zig new file mode 100644 index 000000000..1a19b0511 --- /dev/null +++ b/src/terminal/c/mouse_encode.zig @@ -0,0 +1,529 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const input_mouse_encode = @import("../../input/mouse_encode.zig"); +const renderer_size = @import("../../renderer/size.zig"); +const point = @import("../point.zig"); +const terminal_mouse = @import("../mouse.zig"); +const mouse_event = @import("mouse_event.zig"); +const Result = @import("result.zig").Result; +const Event = mouse_event.Event; +const Terminal = @import("terminal.zig").Terminal; + +const log = std.log.scoped(.mouse_encode); + +/// Wrapper around mouse encoding options that tracks the allocator for C API usage. +const MouseEncoderWrapper = struct { + opts: input_mouse_encode.Options, + track_last_cell: bool = false, + last_cell: ?point.Coordinate = null, + alloc: Allocator, +}; + +/// C: GhosttyMouseEncoder +pub const Encoder = ?*MouseEncoderWrapper; + +/// C: GhosttyMouseTrackingMode +pub const TrackingMode = terminal_mouse.Event; + +/// C: GhosttyMouseFormat +pub const Format = terminal_mouse.Format; + +/// C: GhosttyMouseEncoderSize +pub const Size = extern struct { + size: usize = @sizeOf(Size), + screen_width: u32, + screen_height: u32, + cell_width: u32, + cell_height: u32, + padding_top: u32, + padding_bottom: u32, + padding_right: u32, + padding_left: u32, + + fn toRenderer(self: Size) ?renderer_size.Size { + if (self.cell_width == 0 or self.cell_height == 0) return null; + return .{ + .screen = .{ + .width = self.screen_width, + .height = self.screen_height, + }, + .cell = .{ + .width = self.cell_width, + .height = self.cell_height, + }, + .padding = .{ + .top = self.padding_top, + .bottom = self.padding_bottom, + .right = self.padding_right, + .left = self.padding_left, + }, + }; + } +}; + +/// C: GhosttyMouseEncoderOption +pub const Option = enum(c_int) { + event = 0, + format = 1, + size = 2, + any_button_pressed = 3, + track_last_cell = 4, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .event => TrackingMode, + .format => Format, + .size => Size, + .any_button_pressed, + .track_last_cell, + => bool, + }; + } +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Encoder, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(MouseEncoderWrapper) catch + return .out_of_memory; + ptr.* = .{ + .opts = .{ .size = defaultSize() }, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(encoder_: Encoder) callconv(.c) void { + const wrapper = encoder_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn setopt( + encoder_: Encoder, + option: Option, + value: ?*const anyopaque, +) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setoptTyped( + encoder_, + comptime_option, + @ptrCast(@alignCast(value orelse return)), + ), + }; +} + +fn setoptTyped( + encoder_: Encoder, + comptime option: Option, + value: *const option.InType(), +) void { + const wrapper = encoder_.?; + switch (option) { + .event => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(TrackingMode, @intFromEnum(value.*)) catch { + log.warn("setopt invalid TrackingMode value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + + if (wrapper.opts.event != value.*) wrapper.last_cell = null; + wrapper.opts.event = value.*; + }, + + .format => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Format, @intFromEnum(value.*)) catch { + log.warn("setopt invalid Format value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + + if (wrapper.opts.format != value.*) wrapper.last_cell = null; + wrapper.opts.format = value.*; + }, + + .size => { + if (value.size < @sizeOf(Size)) { + log.warn("setopt size struct too small size={d} expected>={d}", .{ + value.size, + @sizeOf(Size), + }); + return; + } + + wrapper.opts.size = value.toRenderer() orelse { + log.warn("setopt invalid size values (cell width and height must be non-zero)", .{}); + return; + }; + wrapper.last_cell = null; + }, + + .any_button_pressed => wrapper.opts.any_button_pressed = value.*, + + .track_last_cell => { + wrapper.track_last_cell = value.*; + if (!value.*) wrapper.last_cell = null; + }, + } +} + +pub fn setopt_from_terminal( + encoder_: Encoder, + terminal_: Terminal, +) callconv(.c) void { + const wrapper = encoder_ orelse return; + const t = terminal_ orelse return; + wrapper.opts.event = t.flags.mouse_event; + wrapper.opts.format = t.flags.mouse_format; + wrapper.last_cell = null; +} + +pub fn reset(encoder_: Encoder) callconv(.c) void { + const wrapper = encoder_ orelse return; + wrapper.last_cell = null; +} + +pub fn encode( + encoder_: Encoder, + event_: Event, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(.c) Result { + const wrapper = encoder_ orelse return .invalid_value; + const event = event_ orelse return .invalid_value; + + const prev_last_cell = wrapper.last_cell; + + var opts = wrapper.opts; + opts.last_cell = if (wrapper.track_last_cell) &wrapper.last_cell else null; + + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + input_mouse_encode.encode( + &writer, + event.event, + opts, + ) catch |err| switch (err) { + error.WriteFailed => { + // Failed writes should not mutate motion dedupe state because no + // complete sequence was produced. + wrapper.last_cell = prev_last_cell; + + // Use a discarding writer to count how much space we would have needed. + var count_last_cell = prev_last_cell; + var count_opts = wrapper.opts; + count_opts.last_cell = if (wrapper.track_last_cell) &count_last_cell else null; + + var discarding: std.Io.Writer.Discarding = .init(&.{}); + input_mouse_encode.encode( + &discarding.writer, + event.event, + count_opts, + ) catch unreachable; + + // Discarding always uses a u64. If we're on 32-bit systems + // we cast down. We should make this safer in the future. + out_written.* = @intCast(discarding.count); + return .out_of_memory; + }, + }; + + out_written.* = writer.end; + return .success; +} + +fn defaultSize() renderer_size.Size { + return .{ + .screen = .{ .width = 1, .height = 1 }, + .cell = .{ .width = 1, .height = 1 }, + .padding = .{}, + }; +} + +fn testSize() Size { + return .{ + .size = @sizeOf(Size), + .screen_width = 1_000, + .screen_height = 1_000, + .cell_width = 1, + .cell_height = 1, + .padding_top = 0, + .padding_bottom = 0, + .padding_right = 0, + .padding_left = 0, + }; +} + +test "alloc" { + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + free(e); +} + +test "setopt" { + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + const event_mode: TrackingMode = .any; + setopt(e, .event, &event_mode); + try testing.expectEqual(TrackingMode.any, e.?.opts.event); + + const format_mode: Format = .sgr; + setopt(e, .format, &format_mode); + try testing.expectEqual(Format.sgr, e.?.opts.format); + + const size = testSize(); + setopt(e, .size, &size); + try testing.expectEqual(size.screen_width, e.?.opts.size.screen.width); + try testing.expectEqual(size.screen_height, e.?.opts.size.screen.height); + + const any_button_pressed = true; + setopt(e, .any_button_pressed, &any_button_pressed); + try testing.expect(e.?.opts.any_button_pressed); + + const track_last_cell = true; + setopt(e, .track_last_cell, &track_last_cell); + try testing.expect(e.?.track_last_cell); +} + +test "setopt_from_terminal" { + const terminal_c = @import("terminal.zig"); + + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + const event_mode: TrackingMode = .any; + setopt(e, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(e, .format, &format_mode); + + setopt_from_terminal(e, t); + try testing.expectEqual(TrackingMode.none, e.?.opts.event); + try testing.expectEqual(Format.x10, e.?.opts.format); +} + +test "setopt_from_terminal null" { + setopt_from_terminal(null, null); + + const terminal_c = @import("terminal.zig"); + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + setopt_from_terminal(null, t); + + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + setopt_from_terminal(e, null); +} + +test "encode: sgr press left" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib_alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .press); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 0, .y = 0 }); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_memory, encode( + encoder, + event, + null, + 0, + &required, + )); + try testing.expectEqual(@as(usize, 9), required); + + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(required, written); + try testing.expectEqualStrings("\x1b[<0;1;1M", buf[0..written]); +} + +test "encode: motion dedupe and reset" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + const track_last_cell = true; + setopt(encoder, .track_last_cell, &track_last_cell); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib_alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .motion); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 5, .y = 6 }); + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); + } + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(@as(usize, 0), written); + } + + reset(encoder); + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); + } +} + +test "encode: querying required size doesn't update dedupe state" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + const track_last_cell = true; + setopt(encoder, .track_last_cell, &track_last_cell); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib_alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .motion); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 5, .y = 6 }); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_memory, encode( + encoder, + event, + null, + 0, + &required, + )); + try testing.expect(required > 0); + + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); +} diff --git a/src/terminal/c/mouse_event.zig b/src/terminal/c/mouse_event.zig new file mode 100644 index 000000000..91f4e2126 --- /dev/null +++ b/src/terminal/c/mouse_event.zig @@ -0,0 +1,155 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const key = @import("../../input/key.zig"); +const mouse = @import("../../input/mouse.zig"); +const mouse_encode = @import("../../input/mouse_encode.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.mouse_event); + +/// Wrapper around mouse event that tracks the allocator for C API usage. +const MouseEventWrapper = struct { + event: mouse_encode.Event = .{}, + alloc: Allocator, +}; + +/// C: GhosttyMouseEvent +pub const Event = ?*MouseEventWrapper; + +/// C: GhosttyMouseAction +pub const Action = mouse.Action; + +/// C: GhosttyMouseButton +pub const Button = mouse.Button; + +/// C: GhosttyMousePosition +pub const Position = mouse_encode.Event.Pos; + +/// C: GhosttyMods +pub const Mods = key.Mods; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Event, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(MouseEventWrapper) catch + return .out_of_memory; + ptr.* = .{ .alloc = alloc }; + result.* = ptr; + return .success; +} + +pub fn free(event_: Event) callconv(.c) void { + const wrapper = event_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn set_action(event_: Event, action: Action) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Action, @intFromEnum(action)) catch { + log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); + return; + }; + } + + event_.?.event.action = action; +} + +pub fn get_action(event_: Event) callconv(.c) Action { + return event_.?.event.action; +} + +pub fn set_button(event_: Event, button: Button) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Button, @intFromEnum(button)) catch { + log.warn("set_button invalid button value={d}", .{@intFromEnum(button)}); + return; + }; + } + + event_.?.event.button = button; +} + +pub fn clear_button(event_: Event) callconv(.c) void { + event_.?.event.button = null; +} + +pub fn get_button(event_: Event, out: ?*Button) callconv(.c) bool { + if (event_.?.event.button) |button| { + if (out) |ptr| ptr.* = button; + return true; + } + + return false; +} + +pub fn set_mods(event_: Event, mods: Mods) callconv(.c) void { + event_.?.event.mods = mods; +} + +pub fn get_mods(event_: Event) callconv(.c) Mods { + return event_.?.event.mods; +} + +pub fn set_position(event_: Event, pos: Position) callconv(.c) void { + event_.?.event.pos = pos; +} + +pub fn get_position(event_: Event) callconv(.c) Position { + return event_.?.event.pos; +} + +test "alloc" { + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + free(e); +} + +test "free null" { + free(null); +} + +test "set/get" { + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Action + set_action(e, .motion); + try testing.expectEqual(Action.motion, get_action(e)); + + // Button + set_button(e, .left); + var button: Button = .unknown; + try testing.expect(get_button(e, &button)); + try testing.expectEqual(Button.left, button); + try testing.expect(get_button(e, null)); + + clear_button(e); + try testing.expect(!get_button(e, &button)); + + // Mods + const mods: Mods = .{ .shift = true, .ctrl = true }; + set_mods(e, mods); + const got_mods = get_mods(e); + try testing.expect(got_mods.shift); + try testing.expect(got_mods.ctrl); + try testing.expect(!got_mods.alt); + + // Position + set_position(e, .{ .x = 12.5, .y = -4.0 }); + const pos = get_position(e); + try testing.expectEqual(@as(f32, 12.5), pos.x); + try testing.expectEqual(@as(f32, -4.0), pos.y); +} From 33b05b9876ab8940e7ad2f98d3cf5ede277cf4d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Mar 2026 20:12:05 -0700 Subject: [PATCH 5/6] example: add C mouse encoding example Add a new c-vt-mouse-encode example that demonstrates how to use the mouse encoder C API. The example creates a mouse encoder configured with SGR format and normal tracking mode, sets up terminal geometry for pixel-to-cell coordinate mapping, and encodes a left button press event into a terminal escape sequence. Mirrors the structure of the existing c-vt-key-encode example. Also adds the corresponding @example doxygen reference in vt.h. --- example/c-vt-mouse-encode/README.md | 23 +++++++ example/c-vt-mouse-encode/build.zig | 42 +++++++++++++ example/c-vt-mouse-encode/build.zig.zon | 24 +++++++ example/c-vt-mouse-encode/src/main.c | 78 +++++++++++++++++++++++ include/ghostty/vt.h | 6 ++ include/ghostty/vt/mouse.h | 84 +++++++++++++++++++++++++ 6 files changed, 257 insertions(+) create mode 100644 example/c-vt-mouse-encode/README.md create mode 100644 example/c-vt-mouse-encode/build.zig create mode 100644 example/c-vt-mouse-encode/build.zig.zon create mode 100644 example/c-vt-mouse-encode/src/main.c diff --git a/example/c-vt-mouse-encode/README.md b/example/c-vt-mouse-encode/README.md new file mode 100644 index 000000000..754e09805 --- /dev/null +++ b/example/c-vt-mouse-encode/README.md @@ -0,0 +1,23 @@ +# Example: `ghostty-vt` C Mouse Encoding + +This example demonstrates how to use the `ghostty-vt` C library to encode mouse +events into terminal escape sequences. + +This example specifically shows how to: + +1. Create a mouse encoder with the C API +2. Configure tracking mode and output format (this example uses SGR) +3. Set terminal geometry for pixel-to-cell coordinate mapping +4. Create and configure a mouse event +5. Encode the mouse event into a terminal escape sequence + +The example encodes a left button press at pixel position (50, 40) using SGR +format, producing an escape sequence like `\x1b[<0;6;3M`. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-mouse-encode/build.zig b/example/c-vt-mouse-encode/build.zig new file mode 100644 index 000000000..350d0a89e --- /dev/null +++ b/example/c-vt-mouse-encode/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_mouse_encode", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-mouse-encode/build.zig.zon b/example/c-vt-mouse-encode/build.zig.zon new file mode 100644 index 000000000..1ab5da284 --- /dev/null +++ b/example/c-vt-mouse-encode/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt, + .version = "0.0.0", + .fingerprint = 0x413a8529a6dd3c51, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-mouse-encode/src/main.c b/example/c-vt-mouse-encode/src/main.c new file mode 100644 index 000000000..55f867252 --- /dev/null +++ b/example/c-vt-mouse-encode/src/main.c @@ -0,0 +1,78 @@ +#include +#include +#include +#include +#include + +int main() { + GhosttyMouseEncoder encoder; + GhosttyResult result = ghostty_mouse_encoder_new(NULL, &encoder); + assert(result == GHOSTTY_SUCCESS); + + // Set tracking mode to normal (button press/release) + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_EVENT, + &(GhosttyMouseTrackingMode){GHOSTTY_MOUSE_TRACKING_NORMAL}); + + // Set output format to SGR + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_FORMAT, + &(GhosttyMouseFormat){GHOSTTY_MOUSE_FORMAT_SGR}); + + // Set terminal geometry so the encoder can map pixel positions to cells + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_SIZE, + &(GhosttyMouseEncoderSize){ + .size = sizeof(GhosttyMouseEncoderSize), + .screen_width = 800, + .screen_height = 600, + .cell_width = 10, + .cell_height = 20, + .padding_top = 0, + .padding_bottom = 0, + .padding_right = 0, + .padding_left = 0, + }); + + // Create mouse event: left button press at pixel position (50, 40) + GhosttyMouseEvent event; + result = ghostty_mouse_event_new(NULL, &event); + assert(result == GHOSTTY_SUCCESS); + ghostty_mouse_event_set_action(event, GHOSTTY_MOUSE_ACTION_PRESS); + ghostty_mouse_event_set_button(event, GHOSTTY_MOUSE_BUTTON_LEFT); + ghostty_mouse_event_set_position(event, (GhosttyMousePosition){.x = 50.0f, .y = 40.0f}); + printf("Encoding event: left button press at (50, 40) in SGR format\n"); + + // Optionally, encode with null buffer to get required size. You can + // skip this step and provide a sufficiently large buffer directly. + // If there isn't enough space, the function will return an out of memory + // error. + size_t required = 0; + result = ghostty_mouse_encoder_encode(encoder, event, NULL, 0, &required); + assert(result == GHOSTTY_OUT_OF_MEMORY); + printf("Required buffer size: %zu bytes\n", required); + + // Encode the mouse event. We don't use our required size above because + // that was just an example; we know 128 bytes is enough. + char buf[128]; + size_t written = 0; + result = ghostty_mouse_encoder_encode(encoder, event, buf, sizeof(buf), &written); + assert(result == GHOSTTY_SUCCESS); + printf("Encoded %zu bytes\n", written); + + // Print the encoded sequence (hex and string) + printf("Hex: "); + for (size_t i = 0; i < written; i++) printf("%02x ", (unsigned char)buf[i]); + printf("\n"); + + printf("String: "); + for (size_t i = 0; i < written; i++) { + if (buf[i] == 0x1b) { + printf("\\x1b"); + } else { + printf("%c", buf[i]); + } + } + printf("\n"); + + ghostty_mouse_event_free(event); + ghostty_mouse_encoder_free(encoder); + return 0; +} diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index f3958ccc3..9d22b647b 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -43,6 +43,7 @@ * Complete working examples: * - @ref c-vt/src/main.c - OSC parser example * - @ref c-vt-key-encode/src/main.c - Key encoding example + * - @ref c-vt-mouse-encode/src/main.c - Mouse encoding example * - @ref c-vt-paste/src/main.c - Paste safety check example * - @ref c-vt-sgr/src/main.c - SGR parser example * - @ref c-vt-formatter/src/main.c - Terminal formatter example @@ -59,6 +60,11 @@ * into terminal escape sequences using the Kitty keyboard protocol. */ +/** @example c-vt-mouse-encode/src/main.c + * This example demonstrates how to use the mouse encoder to convert mouse events + * into terminal escape sequences using the SGR mouse format. + */ + /** @example c-vt-paste/src/main.c * This example demonstrates how to use the paste utilities to check if * paste data is safe before sending it to the terminal. diff --git a/include/ghostty/vt/mouse.h b/include/ghostty/vt/mouse.h index 4e44e15b5..f5f37ce7b 100644 --- a/include/ghostty/vt/mouse.h +++ b/include/ghostty/vt/mouse.h @@ -24,6 +24,90 @@ * - Free the event with ghostty_mouse_event_free() or reuse it. * 4. Free the encoder with ghostty_mouse_encoder_free() when done. * + * For a complete working example, see example/c-vt-mouse-encode in the + * repository. + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * // Create encoder + * GhosttyMouseEncoder encoder; + * GhosttyResult result = ghostty_mouse_encoder_new(NULL, &encoder); + * assert(result == GHOSTTY_SUCCESS); + * + * // Configure SGR format with normal tracking + * ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_EVENT, + * &(GhosttyMouseTrackingMode){GHOSTTY_MOUSE_TRACKING_NORMAL}); + * ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_FORMAT, + * &(GhosttyMouseFormat){GHOSTTY_MOUSE_FORMAT_SGR}); + * + * // Set terminal geometry for coordinate mapping + * ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_SIZE, + * &(GhosttyMouseEncoderSize){ + * .size = sizeof(GhosttyMouseEncoderSize), + * .screen_width = 800, .screen_height = 600, + * .cell_width = 10, .cell_height = 20, + * }); + * + * // Create and configure a left button press event + * GhosttyMouseEvent event; + * result = ghostty_mouse_event_new(NULL, &event); + * assert(result == GHOSTTY_SUCCESS); + * ghostty_mouse_event_set_action(event, GHOSTTY_MOUSE_ACTION_PRESS); + * ghostty_mouse_event_set_button(event, GHOSTTY_MOUSE_BUTTON_LEFT); + * ghostty_mouse_event_set_position(event, + * (GhosttyMousePosition){.x = 50.0f, .y = 40.0f}); + * + * // Encode the mouse event + * char buf[128]; + * size_t written = 0; + * result = ghostty_mouse_encoder_encode(encoder, event, + * buf, sizeof(buf), &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence (e.g., write to terminal) + * fwrite(buf, 1, written, stdout); + * + * // Cleanup + * ghostty_mouse_event_free(event); + * ghostty_mouse_encoder_free(encoder); + * return 0; + * } + * @endcode + * + * ## Example: Encoding with Terminal State + * + * When you have a GhosttyTerminal, you can sync its tracking mode and + * output format into the encoder automatically: + * + * @code{.c} + * // Create a terminal and feed it some VT data that enables mouse tracking + * GhosttyTerminal terminal; + * ghostty_terminal_new(NULL, &terminal, + * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0}); + * + * // Application might write data that enables mouse reporting, etc. + * ghostty_terminal_vt_write(terminal, vt_data, vt_len); + * + * // Create an encoder and sync its options from the terminal + * GhosttyMouseEncoder encoder; + * ghostty_mouse_encoder_new(NULL, &encoder); + * ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); + * + * // Encode a mouse event using the terminal-derived options + * char buf[128]; + * size_t written = 0; + * ghostty_mouse_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * ghostty_mouse_encoder_free(encoder); + * ghostty_terminal_free(terminal); + * @endcode + * * @{ */ From de87456a9b662065e7dbdc6433d1ff283bbf13af Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Mar 2026 20:15:51 -0700 Subject: [PATCH 6/6] lib/vt: export mouse encoding API Export mouse_encode types and functions through the lib_vt public input API, mirroring the existing key encoding exports. This adds MouseAction, MouseButton, MouseEncodeOptions, MouseEncodeEvent, and encodeMouse so that consumers of the Zig module can encode mouse events without reaching into internal packages. --- src/lib_vt.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 32fd14dc5..44ffe7a3e 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -84,6 +84,7 @@ pub const input = struct { const paste = @import("input/paste.zig"); const key = @import("input/key.zig"); const key_encode = @import("input/key_encode.zig"); + const mouse_encode = @import("input/mouse_encode.zig"); // Paste-related APIs pub const PasteError = paste.Error; @@ -98,6 +99,13 @@ pub const input = struct { pub const KeyMods = key.Mods; pub const KeyEncodeOptions = key_encode.Options; pub const encodeKey = key_encode.encode; + + // Mouse encoding + pub const MouseAction = @import("input/mouse.zig").Action; + pub const MouseButton = @import("input/mouse.zig").Button; + pub const MouseEncodeOptions = mouse_encode.Options; + pub const MouseEncodeEvent = mouse_encode.Event; + pub const encodeMouse = mouse_encode.encode; }; comptime {