diff --git a/example/c-vt-selection-gesture/README.md b/example/c-vt-selection-gesture/README.md new file mode 100644 index 000000000..a64df0e53 --- /dev/null +++ b/example/c-vt-selection-gesture/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Selection Gestures + +This contains a simple example of how to use the `ghostty-vt` selection +gesture API from C. It creates synthetic press, drag, release, and deep-press +events and formats the resulting selection snapshots. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-selection-gesture/build.zig b/example/c-vt-selection-gesture/build.zig new file mode 100644 index 000000000..05f8d1bbc --- /dev/null +++ b/example/c-vt-selection-gesture/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_selection_gesture", + .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-selection-gesture/build.zig.zon b/example/c-vt-selection-gesture/build.zig.zon new file mode 100644 index 000000000..08db85223 --- /dev/null +++ b/example/c-vt-selection-gesture/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_selection_gesture, + .version = "0.0.0", + .fingerprint = 0x5a4e72d27b582404, + .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-selection-gesture/src/main.c b/example/c-vt-selection-gesture/src/main.c new file mode 100644 index 000000000..050e9a3b1 --- /dev/null +++ b/example/c-vt-selection-gesture/src/main.c @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include + +//! [selection-gesture-main] +static void vt_write(GhosttyTerminal terminal, const char *s) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s)); +} + +static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) { + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint point = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = x, .y = y } }, + }; + + GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref); + assert(result == GHOSTTY_SUCCESS); + return ref; +} + +static void print_selection( + GhosttyTerminal terminal, + const char *label, + const GhosttySelection *selection) { + GhosttyTerminalSelectionFormatOptions opts = + GHOSTTY_INIT_SIZED(GhosttyTerminalSelectionFormatOptions); + opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + opts.trim = true; + opts.selection = selection; + + uint8_t *buf = NULL; + size_t len = 0; + GhosttyResult result = ghostty_terminal_selection_format_alloc( + terminal, NULL, opts, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + printf("%s: ", label); + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); +} + +static GhosttySelectionGestureEvent new_event( + GhosttySelectionGestureEventType type) { + GhosttySelectionGestureEvent event = NULL; + GhosttyResult result = ghostty_selection_gesture_event_new(NULL, &event, type); + assert(result == GHOSTTY_SUCCESS); + return event; +} + +int main() { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 20, + .rows = 4, + .max_scrollback = 100, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + vt_write(terminal, "hello world\r\nsecond line"); + + GhosttySelectionGesture gesture = NULL; + result = ghostty_selection_gesture_new(NULL, &gesture); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelectionGestureEvent press = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS); + GhosttySelectionGestureEvent drag = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG); + GhosttySelectionGestureEvent release = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE); + GhosttySelectionGestureEvent deep_press = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS); + + GhosttySelectionGestureGeometry geometry = { + .columns = 20, + .cell_width = 10, + .padding_left = 0, + .screen_height = 40, + }; + + // Press in the first cell. A normal single press records the click anchor but + // doesn't produce a selection yet, so we discard the optional output. + GhosttyGridRef press_ref = ref_at(terminal, 0, 0); + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &press_ref); + assert(result == GHOSTTY_SUCCESS); + + GhosttySurfacePosition press_pos = { .x = 2, .y = 8 }; + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &press_pos); + assert(result == GHOSTTY_SUCCESS); + + result = ghostty_selection_gesture_event( + gesture, terminal, press, NULL); + assert(result == GHOSTTY_NO_VALUE); + + // Drag across "hello". The drag event returns a selection snapshot that the + // embedder can apply to its UI, copy, or format immediately. + GhosttyGridRef drag_ref = ref_at(terminal, 4, 0); + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref); + assert(result == GHOSTTY_SUCCESS); + + GhosttySurfacePosition drag_pos = { .x = 46, .y = 8 }; + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &drag_pos); + assert(result == GHOSTTY_SUCCESS); + + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY, &geometry); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection); + result = ghostty_selection_gesture_event( + gesture, terminal, drag, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "drag", &selection); + + // Release updates gesture state but never produces a selection. + result = ghostty_selection_gesture_event_set( + release, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref); + assert(result == GHOSTTY_SUCCESS); + result = ghostty_selection_gesture_event( + gesture, terminal, release, NULL); + assert(result == GHOSTTY_NO_VALUE); + + bool dragged = false; + result = ghostty_selection_gesture_get( + gesture, terminal, GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED, &dragged); + assert(result == GHOSTTY_SUCCESS); + printf("dragged: %s\n", dragged ? "true" : "false"); + + // Deep press uses the active click anchor to select the surrounding word. + ghostty_selection_gesture_reset(gesture, terminal); + GhosttyGridRef world_ref = ref_at(terminal, 6, 0); + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &world_ref); + assert(result == GHOSTTY_SUCCESS); + result = ghostty_selection_gesture_event( + gesture, terminal, press, NULL); + assert(result == GHOSTTY_NO_VALUE); + + result = ghostty_selection_gesture_event( + gesture, terminal, deep_press, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "deep press", &selection); + + ghostty_selection_gesture_event_free(deep_press); + ghostty_selection_gesture_event_free(release); + ghostty_selection_gesture_event_free(drag); + ghostty_selection_gesture_event_free(press); + ghostty_selection_gesture_free(gesture, terminal); + ghostty_terminal_free(terminal); + return 0; +} +//! [selection-gesture-main] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 7a6a9758a..94a850334 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -104,6 +104,11 @@ * detect when it loses its value, and move it to a new point. */ +/** @example c-vt-selection-gesture/src/main.c + * This example demonstrates how to use synthetic selection gesture events to + * derive drag and deep-press selection snapshots. + */ + /** @example c-vt-kitty-graphics/src/main.c * This example demonstrates how to use the system interface to install a * PNG decoder callback and send a Kitty Graphics Protocol image. diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 142877a97..3b926aab6 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -32,13 +32,44 @@ extern "C" { * for the endpoints and reconstruct a GhosttySelection from fresh snapshots * when needed. * + * Selection gestures provide a reusable state machine for turning UI pointer + * interactions into selection snapshots. A caller creates one + * GhosttySelectionGesture per active gesture stream, reuses typed + * GhosttySelectionGestureEvent objects for synthetic press, drag, release, + * autoscroll tick, and deep-press events, and applies each event with + * ghostty_selection_gesture_event(). The returned GhosttySelection is a + * snapshot; the embedder decides whether to render it, format/copy it, or + * install it as the terminal's active selection. + * * ## Examples * * @snippet c-vt-selection/src/main.c selection-main + * @snippet c-vt-selection-gesture/src/main.c selection-gesture-main * * @{ */ +/** + * Opaque handle to state for interpreting terminal selection gestures. + * + * The gesture owns only the state required to interpret pointer events. Calls + * that use a gesture are not concurrency-safe and must be serialized with + * terminal mutations. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture; + +/** + * Opaque handle to reusable input data for selection gesture operations. + * + * Event options are set with ghostty_selection_gesture_event_set(). Individual + * gesture operations document which options are required or optional. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureEventImpl* GhosttySelectionGestureEvent; + /** * A snapshot selection range defined by two grid references. * @@ -283,6 +314,417 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionAdjust; +/** + * Selection behavior chosen for a gesture's click sequence. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Cell-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_CELL = 0, + + /** Word selection on press and word-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_WORD = 1, + + /** Line selection on press and line-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_LINE = 2, + + /** Semantic command output selection on press and drag. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_OUTPUT = 3, + + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureBehavior; + +/** + * Selection behaviors for single-, double-, and triple-click gestures. + * + * @ingroup selection + */ +typedef struct { + /** Behavior for single-click selection gestures. */ + GhosttySelectionGestureBehavior single_click; + + /** Behavior for double-click selection gestures. */ + GhosttySelectionGestureBehavior double_click; + + /** Behavior for triple-click selection gestures. */ + GhosttySelectionGestureBehavior triple_click; +} GhosttySelectionGestureBehaviors; + +/** + * Display geometry used to interpret selection gesture drag events. + * + * @ingroup selection + */ +typedef struct { + /** Number of columns in the rendered terminal grid. Must be non-zero. */ + uint32_t columns; + + /** Width of one terminal cell in surface pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Left padding before the terminal grid begins in surface pixels. */ + uint32_t padding_left; + + /** Height of the rendered terminal surface in surface pixels. Must be non-zero. */ + uint32_t screen_height; +} GhosttySelectionGestureGeometry; + +/** + * Current autoscroll direction for an active selection drag gesture. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** No selection autoscroll is requested. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_NONE = 0, + + /** Selection dragging should autoscroll the viewport upward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_UP = 1, + + /** Selection dragging should autoscroll the viewport downward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_DOWN = 2, + + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureAutoscroll; + +/** + * Data fields readable from a selection gesture with + * ghostty_selection_gesture_get(). + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Current click count: uint8_t*. 0 means inactive. */ + GHOSTTY_SELECTION_GESTURE_DATA_CLICK_COUNT = 0, + + /** Whether the current/last left-click gesture has dragged: bool*. */ + GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED = 1, + + /** Current autoscroll request: GhosttySelectionGestureAutoscroll*. */ + GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL = 2, + + /** Current gesture behavior: GhosttySelectionGestureBehavior*. */ + GHOSTTY_SELECTION_GESTURE_DATA_BEHAVIOR = 3, + + /** + * Current left-click anchor: GhosttyGridRef*. + * + * Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success, + * writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef + * lifetime rules. + */ + GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR = 4, + + GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureData; + +/** + * Selection gesture event type. + * + * The event type is fixed when the event is created. Each event type documents + * which options are valid and which options are required by gesture operations. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, + + /** Release event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, + + /** Drag event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, + + /** Autoscroll tick event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3, + + /** Deep press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS = 4, + + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventType; + +/** + * Options stored on a reusable selection gesture event. + * + * Passing NULL as the value to ghostty_selection_gesture_event_set() clears the + * corresponding option. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Grid reference under the pointer: GhosttyGridRef*. + * + * Required for PRESS and DRAG events. Optional for RELEASE events; when unset + * or cleared, release records that the pointer did not map to a valid cell. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, + + /** + * Surface-space pointer position: GhosttySurfacePosition*. + * + * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, + + /** Maximum repeat-click distance in pixels: double*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_DISTANCE = 2, + + /** + * Optional monotonic event time in nanoseconds: uint64_t*. + * + * If unset, press treats the event as untimed and only single-click behavior + * is available. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_TIME_NS = 3, + + /** Maximum interval between repeat clicks in nanoseconds: uint64_t*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_INTERVAL_NS = 4, + + /** + * Word-boundary codepoints: GhosttyCodepoints*. + * + * The codepoints are copied into event-owned storage when set. If unset, + * operations that need word boundaries use Ghostty's defaults. + * + * Valid for PRESS, DRAG, AUTOSCROLL_TICK, and DEEP_PRESS. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, + + /** + * Selection behavior table: GhosttySelectionGestureBehaviors*. + * + * If unset, press uses the default behavior table: cell, word, line. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, + + /** Whether a drag or autoscroll tick should produce a rectangular selection: bool*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7, + + /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG and AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8, + + /** Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT = 9, + + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventOption; + +/** + * Create a reusable selection gesture event object. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_event Receives the created event handle + * @param type Event type. This is fixed for the lifetime of the event. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_event is + * NULL or type is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_new( + const GhosttyAllocator* allocator, + GhosttySelectionGestureEvent* out_event, + GhosttySelectionGestureEventType type); + +/** + * Free a selection gesture event object. + * + * Passing NULL is allowed and is a no-op. + * + * @param event Selection gesture event handle to free + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_event_free( + GhosttySelectionGestureEvent event); + +/** + * Set or clear an option on a selection gesture event. + * + * The value type depends on option and is documented by + * GhosttySelectionGestureEventOption. Passing NULL for value clears the option. + * + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option Event option to set or clear + * @param value Pointer to the input value for option, or NULL to clear + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if copying + * event-owned data fails, or GHOSTTY_INVALID_VALUE if event, option, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( + GhosttySelectionGestureEvent event, + GhosttySelectionGestureEventOption option, + const void* value); + +/** + * Apply a selection gesture event and return the resulting selection snapshot. + * + * This dispatches to the gesture operation matching the event's fixed type. + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS, the event must have + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function. + * All other press options use their initialized defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF is valid. It is optional; if unset or + * cleared, release records that the pointer did not map to a valid cell. Release + * events update gesture state but do not produce a selection, so this function + * returns GHOSTTY_NO_VALUE after applying them. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS is valid. It is + * optional and uses initialized defaults when unset or cleared. + * + * The returned selection is not installed as the terminal's current selection. + * It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to interpret and update gesture state + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the resulting selection. May + * be NULL to apply the event and discard the selection result. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the event does not + * currently produce a selection, GHOSTTY_OUT_OF_MEMORY if tracking + * gesture state fails, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * event, or required event data is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureEvent event, + GhosttySelection* out_selection); + +/** + * Create a selection gesture object. + * + * The gesture stores mutable state for terminal text selection gestures. The + * gesture is not bound to a terminal at creation time; terminal-dependent APIs + * take the terminal explicitly. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_gesture Receives the created gesture handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_gesture is + * NULL, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_new( + const GhosttyAllocator* allocator, + GhosttySelectionGesture* out_gesture); + +/** + * Free a selection gesture object. + * + * This releases any tracked terminal references owned by the gesture using the + * provided terminal, then frees the gesture object. Passing NULL for gesture is + * allowed and is a no-op. + * + * If the terminal is still alive, pass the terminal most recently used with the + * gesture so any tracked terminal references can be released correctly. If the + * terminal has already been freed, pass NULL for terminal; the terminal's page + * storage has already released the underlying tracked references, so the + * gesture wrapper can be safely discarded without touching the stale terminal + * state. + * + * @param gesture Selection gesture handle to free + * @param terminal Terminal used to release tracked gesture state, or NULL if + * the terminal has already been freed + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_free( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Reset any active selection gesture state. + * + * This cancels the active click sequence and releases any tracked terminal + * references owned by the gesture without freeing the gesture object. + * Passing NULL is allowed and is a no-op. + * + * @param gesture Selection gesture handle to reset + * @param terminal Terminal used to release tracked gesture state + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_reset( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Read data from a selection gesture. + * + * The type of value depends on data and is documented by + * GhosttySelectionGestureData. For GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR, + * the returned GhosttyGridRef is an untracked snapshot with normal grid-ref + * lifetime rules. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param data Data field to read + * @param value Output pointer whose type depends on data + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + * has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, data, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureData data, + void* value); + +/** + * Read multiple data fields from a selection gesture in a single call. + * + * This is an optimization over calling ghostty_selection_gesture_get() multiple + * times. Each entry in values must point to storage of the type documented by + * the corresponding GhosttySelectionGestureData key. + * + * If any individual read fails, the function returns that error and writes the + * index of the failing key to out_written when out_written is non-NULL. On + * success, out_written receives count when non-NULL. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param count Number of data fields to read + * @param keys Data fields to read (must not be NULL) + * @param values Output pointers corresponding to keys (must not be NULL) + * @param out_written Optional number of fields read, or failing index on error + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a requested data + * field has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * keys, values, or a value pointer is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get_multi( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + size_t count, + const GhosttySelectionGestureData* keys, + void** values, + size_t* out_written); + /** * Derive a word selection snapshot from a terminal grid reference. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index f3874153f..1bec223d6 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -227,6 +227,38 @@ typedef struct { size_t len; } GhosttyString; +/** + * A surface-space position in pixels. + * + * This is not a terminal grid coordinate. It represents an x/y position in the + * rendered surface coordinate space, with (0, 0) at the top-left of the + * surface. + */ +typedef struct { + /** X position in surface pixels. */ + double x; + + /** Y position in surface pixels. */ + double y; +} GhosttySurfacePosition; + +/** + * A borrowed list of Unicode scalar values. + * + * Values are encoded as uint32_t scalar values. The memory is not owned by this + * struct. The pointer is only valid for the lifetime documented by the API that + * consumes or produces it. + * + * APIs may document special handling for NULL + len 0, such as “use defaults”. + */ +typedef struct { + /** Pointer to Unicode scalar values. */ + const uint32_t* ptr; + + /** Number of entries in ptr. */ + size_t len; +} GhosttyCodepoints; + /** * Initialize a sized struct to zero and set its size field. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 71b709135..6d4406e88 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -251,6 +251,15 @@ comptime { @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); + @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); + @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); + @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); + @export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" }); + @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); + @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); + @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); + @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); + @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 4b1edac88..22ba468b9 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -70,6 +70,7 @@ const std = @import("std"); const assert = std.debug.assert; const testing = std.testing; const Allocator = std.mem.Allocator; +const lib = @import("lib.zig"); const PageList = @import("PageList.zig"); const Pin = PageList.Pin; const Screen = @import("Screen.zig"); @@ -118,22 +119,26 @@ left_drag_autoscroll: Autoscroll, /// /// This is used to implement selection above/below the viewport that /// wants to drag the viewport. -pub const Autoscroll = enum { none, up, down }; +pub const Autoscroll = lib.Enum(lib.target, &.{ + "none", + "up", + "down", +}); /// The selection behavior for a click and subsequent drag. -pub const Behavior = enum { - /// Cell-granular drag selection. Press returns null to clear selection. - cell, +pub const Behavior = lib.Enum(lib.target, &.{ + // Cell-granular drag selection. Press returns null to clear selection. + "cell", - /// Word selection on press and word-granular drag selection. - word, + // Word selection on press and word-granular drag selection. + "word", - /// Line selection on press and line-granular drag selection. - line, + // Line selection on press and line-granular drag selection. + "line", - /// Semantic command output selection on press and drag. - output, -}; + // Semantic command output selection on press and drag. + "output", +}); /// Standard terminal selection behavior for single-, double-, and triple-clicks. /// diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 1d78f06bb..648bdbe51 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -31,6 +31,7 @@ pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); pub const render = @import("render.zig"); pub const selection = @import("selection.zig"); +pub const selection_gesture = @import("selection_gesture.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"); @@ -182,6 +183,15 @@ pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; pub const terminal_selection_contains = selection.contains; pub const terminal_selection_equal = selection.equal; +pub const selection_gesture_new = selection_gesture.new; +pub const selection_gesture_free = selection_gesture.free; +pub const selection_gesture_reset = selection_gesture.reset; +pub const selection_gesture_event = selection_gesture.handle_event; +pub const selection_gesture_get = selection_gesture.get; +pub const selection_gesture_get_multi = selection_gesture.get_multi; +pub const selection_gesture_event_new = selection_gesture.event_new; +pub const selection_gesture_event_free = selection_gesture.event_free; +pub const selection_gesture_event_set = selection_gesture.event_set; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; @@ -214,6 +224,7 @@ test { _ = osc; _ = render; _ = selection; + _ = selection_gesture; _ = key_event; _ = key_encode; _ = mouse_event; diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig new file mode 100644 index 000000000..3562447d9 --- /dev/null +++ b/src/terminal/c/selection_gesture.zig @@ -0,0 +1,1482 @@ +const std = @import("std"); +const testing = std.testing; +const builtin = @import("builtin"); +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const SelectionGesture = @import("../SelectionGesture.zig"); +const selection_codepoints = @import("../selection_codepoints.zig"); +const grid_ref = @import("grid_ref.zig"); +const point = @import("../point.zig"); +const selection_c = @import("selection.zig"); +const terminal_c = @import("terminal.zig"); +const types = @import("types.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.selection_gesture_c); + +/// C: GhosttySelectionGesture +pub const Gesture = ?*GestureWrapper; + +/// C: GhosttySelectionGestureEvent +pub const Event = ?*EventWrapper; + +const GestureWrapper = struct { + alloc: std.mem.Allocator, + gesture: SelectionGesture = .init, +}; + +const EventWrapper = struct { + alloc: std.mem.Allocator, + event: union(EventType) { + press: SelectionGesture.Press, + release: SelectionGesture.Release, + drag: SelectionGesture.Drag, + autoscroll_tick: SelectionGesture.AutoscrollTick, + deep_press: SelectionGesture.DeepPress, + }, + + // Validation sidecar for required event fields that don't have safe + // sentinels in the real SelectionGesture payloads. For example, PageList.Pin + // contains a non-null node pointer and Geometry has no meaningful zero + // value. Keep these as one-bit flags so dispatch can reject incomplete C + // events instead of using undefined placeholder data. + event_validation: packed struct { + press_pin_set: bool = false, + drag_pin_set: bool = false, + drag_geometry_set: bool = false, + autoscroll_tick_viewport_set: bool = false, + autoscroll_tick_geometry_set: bool = false, + } = .{}, + + // Backing storage for Press/Drag/AutoscrollTick.word_boundary_codepoints. + // The C API receives codepoints as borrowed uint32_t values, but + // SelectionGesture stores a []const u21 slice. We copy/convert into + // event-owned storage so the real payload can safely point at it until the + // event is changed or freed. + word_boundary_codepoints: ?[]u21 = null, + + // Backing storage for Press.behaviors. The C API sets behaviors as a value + // struct, but SelectionGesture.Press stores a pointer to a [3]Behavior. + // Keep the array on the event wrapper so the Press payload can point at a + // stable location for the lifetime of the event. + behaviors: [3]Behavior = SelectionGesture.default_behaviors, + + fn init(self: *EventWrapper, event_type: EventType) void { + self.event = switch (event_type) { + .press => .{ .press = self.defaultPress() }, + .release => .{ .release = self.defaultRelease() }, + .drag => .{ .drag = self.defaultDrag() }, + .autoscroll_tick => .{ .autoscroll_tick = self.defaultAutoscrollTick() }, + .deep_press => .{ .deep_press = self.defaultDeepPress() }, + }; + } + + fn defaultPress(self: *EventWrapper) SelectionGesture.Press { + return .{ + .time = null, + .pin = undefined, + .xpos = 0, + .ypos = 0, + .max_distance = 0, + .repeat_interval = 0, + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + .behaviors = &self.behaviors, + }; + } + + fn defaultRelease(self: *EventWrapper) SelectionGesture.Release { + _ = self; + return .{ .pin = null }; + } + + fn defaultDrag(self: *EventWrapper) SelectionGesture.Drag { + _ = self; + return .{ + .pin = undefined, + .xpos = 0, + .ypos = 0, + .rectangle = false, + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + .geometry = undefined, + }; + } + + fn defaultAutoscrollTick(self: *EventWrapper) SelectionGesture.AutoscrollTick { + _ = self; + return .{ + .viewport = undefined, + .xpos = 0, + .ypos = 0, + .rectangle = false, + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + .geometry = undefined, + }; + } + + fn defaultDeepPress(self: *EventWrapper) SelectionGesture.DeepPress { + _ = self; + return .{ + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + }; + } + + fn deinit(self: *EventWrapper) void { + if (self.word_boundary_codepoints) |cps| { + if (cps.len > 0) self.alloc.free(cps); + } + } +}; + +/// C: GhosttySelectionGestureBehavior +pub const Behavior = SelectionGesture.Behavior; + +/// C: GhosttySelectionGestureAutoscroll +pub const Autoscroll = SelectionGesture.Autoscroll; + +/// C: GhosttySelectionGestureBehaviors +pub const Behaviors = extern struct { + single_click: Behavior, + double_click: Behavior, + triple_click: Behavior, +}; + +/// C: GhosttySelectionGestureData +pub const Data = enum(c_int) { + click_count = 0, + dragged = 1, + autoscroll = 2, + behavior = 3, + anchor = 4, + + pub fn OutType(comptime self: Data) type { + return switch (self) { + .click_count => u8, + .dragged => bool, + .autoscroll => Autoscroll, + .behavior => Behavior, + .anchor => grid_ref.CGridRef, + }; + } +}; + +/// C: GhosttySelectionGestureEventType +pub const EventType = enum(c_int) { + press = 0, + release = 1, + drag = 2, + autoscroll_tick = 3, + deep_press = 4, +}; + +/// C: GhosttySelectionGestureEventOption +pub const EventOption = enum(c_int) { + ref = 0, + position = 1, + repeat_distance = 2, + time_ns = 3, + repeat_interval_ns = 4, + word_boundary_codepoints = 5, + behaviors = 6, + rectangle = 7, + geometry = 8, + viewport = 9, + + pub fn Type(comptime self: EventOption) type { + return switch (self) { + .ref => grid_ref.CGridRef, + .position => types.SurfacePosition, + .repeat_distance => f64, + .time_ns => u64, + .repeat_interval_ns => u64, + .word_boundary_codepoints => types.Codepoints, + .behaviors => Behaviors, + .rectangle => bool, + .geometry => Geometry, + .viewport => point.Coordinate, + }; + } +}; + +/// C: GhosttySelectionGestureGeometry +pub const Geometry = extern struct { + columns: u32, + cell_width: u32, + padding_left: u32, + screen_height: u32, + + fn toZig(self: Geometry) ?SelectionGesture.Drag.Geometry { + if (self.columns == 0) return null; + if (self.cell_width == 0) return null; + if (self.screen_height == 0) return null; + return .{ + .columns = self.columns, + .cell_width = self.cell_width, + .padding_left = self.padding_left, + .screen_height = self.screen_height, + }; + } +}; + +pub fn new( + alloc_: ?*const CAllocator, + out_gesture: ?*Gesture, +) callconv(lib.calling_conv) Result { + const out = out_gesture orelse return .invalid_value; + + const alloc = lib.alloc.default(alloc_); + const gesture = alloc.create(GestureWrapper) catch { + out.* = null; + return .out_of_memory; + }; + gesture.* = .{ + .alloc = alloc, + }; + out.* = gesture; + return .success; +} + +pub fn event_new( + alloc_: ?*const CAllocator, + out_event: ?*Event, + event_type: EventType, +) callconv(lib.calling_conv) Result { + const out = out_event orelse return .invalid_value; + _ = std.meta.intToEnum(EventType, @intFromEnum(event_type)) catch + return .invalid_value; + + const alloc = lib.alloc.default(alloc_); + const event = alloc.create(EventWrapper) catch { + out.* = null; + return .out_of_memory; + }; + event.* = .{ + .alloc = alloc, + .event = undefined, + }; + event.init(event_type); + out.* = event; + return .success; +} + +pub fn free( + gesture_: Gesture, + terminal: terminal_c.Terminal, +) callconv(lib.calling_conv) void { + const wrapper = gesture_ orelse return; + if (terminal_c.zigTerminal(terminal)) |t| { + wrapper.gesture.deinit(t); + } + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn event_free(event_: Event) callconv(lib.calling_conv) void { + const event = event_ orelse return; + event.deinit(); + const alloc = event.alloc; + alloc.destroy(event); +} + +pub fn reset( + gesture_: Gesture, + terminal: terminal_c.Terminal, +) callconv(lib.calling_conv) void { + const wrapper = gesture_ orelse return; + const t = terminal_c.zigTerminal(terminal) orelse return; + wrapper.gesture.reset(t); +} + +pub fn handle_event( + gesture_: Gesture, + terminal: terminal_c.Terminal, + event_: Event, + out_selection: ?*selection_c.CSelection, +) callconv(lib.calling_conv) Result { + const wrapper = gesture_ orelse return .invalid_value; + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const event_wrapper = event_ orelse return .invalid_value; + + return switch (event_wrapper.event) { + .press => |press| { + if (!event_wrapper.event_validation.press_pin_set) return .invalid_value; + const sel = wrapper.gesture.press(t, press) catch return .out_of_memory; + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, + .release => |release| { + wrapper.gesture.release(t, release); + return .no_value; + }, + .drag => |drag| { + if (!event_wrapper.event_validation.drag_pin_set) return .invalid_value; + if (!event_wrapper.event_validation.drag_geometry_set) return .invalid_value; + const sel = wrapper.gesture.drag(t, drag); + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, + .autoscroll_tick => |tick| { + if (!event_wrapper.event_validation.autoscroll_tick_viewport_set) return .invalid_value; + if (!event_wrapper.event_validation.autoscroll_tick_geometry_set) return .invalid_value; + const sel = wrapper.gesture.autoscrollTick(t, tick); + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, + .deep_press => |deep_press| { + const sel = wrapper.gesture.deepPress(t, deep_press); + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, + }; +} + +pub fn event_set( + event_: Event, + option: EventOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(EventOption, @intFromEnum(option)) catch { + log.warn("selection_gesture_event_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| eventSetTyped( + event_, + comptime_option, + if (value) |ptr| @ptrCast(@alignCast(ptr)) else null, + ), + }; +} + +pub fn get( + gesture_: Gesture, + terminal: terminal_c.Terminal, + data: Data, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Data, @intFromEnum(data)) catch { + log.warn("selection_gesture_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + const out_ptr = out orelse return .invalid_value; + return switch (data) { + inline else => |comptime_data| getTyped( + gesture_, + terminal, + comptime_data, + @ptrCast(@alignCast(out_ptr)), + ), + }; +} + +pub fn get_multi( + gesture_: Gesture, + terminal: terminal_c.Terminal, + count: usize, + keys: ?[*]const Data, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(gesture_, terminal, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + +fn getTyped( + gesture_: Gesture, + terminal: terminal_c.Terminal, + comptime data: Data, + out: *data.OutType(), +) Result { + const wrapper = gesture_ orelse return .invalid_value; + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + + switch (data) { + .click_count => out.* = wrapper.gesture.left_click_count, + .dragged => out.* = wrapper.gesture.left_click_dragged, + .autoscroll => out.* = wrapper.gesture.left_drag_autoscroll, + .behavior => out.* = wrapper.gesture.left_click_behavior, + .anchor => { + const pin = wrapper.gesture.validatedLeftClickPin(&t.screens) orelse + return .no_value; + out.* = .fromPin(pin.*); + }, + } + + return .success; +} + +fn eventSetTyped( + event_: Event, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const event = event_ orelse return .invalid_value; + return switch (event.event) { + .press => |*press| pressSetTyped(event, press, option, value), + .release => |*release| releaseSetTyped(release, option, value), + .drag => |*drag| dragSetTyped(event, drag, option, value), + .autoscroll_tick => |*tick| autoscrollTickSetTyped(event, tick, option, value), + .deep_press => |*deep_press| deepPressSetTyped(event, deep_press, option, value), + }; +} + +fn pressSetTyped( + event: *EventWrapper, + press: *SelectionGesture.Press, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .ref => event.event_validation.press_pin_set = false, + .position => { + press.xpos = 0; + press.ypos = 0; + }, + .repeat_distance => press.max_distance = 0, + .time_ns => press.time = null, + .repeat_interval_ns => press.repeat_interval = 0, + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &press.word_boundary_codepoints, + ), + .behaviors => { + event.behaviors = SelectionGesture.default_behaviors; + press.behaviors = &event.behaviors; + }, + .rectangle, + .geometry, + .viewport, + => return .invalid_value, + } + return .success; + }; + + switch (option) { + .ref => { + press.pin = v.toPin() orelse return .invalid_value; + event.event_validation.press_pin_set = true; + }, + .position => { + press.xpos = v.x; + press.ypos = v.y; + }, + .repeat_distance => press.max_distance = v.*, + .time_ns => press.time = instantFromNs(v.*), + .repeat_interval_ns => press.repeat_interval = v.*, + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &press.word_boundary_codepoints, + v, + ), + .behaviors => { + if (!validBehavior(v.single_click) or + !validBehavior(v.double_click) or + !validBehavior(v.triple_click)) return .invalid_value; + event.behaviors = .{ v.single_click, v.double_click, v.triple_click }; + press.behaviors = &event.behaviors; + }, + .rectangle, + .geometry, + .viewport, + => return .invalid_value, + } + + return .success; +} + +fn releaseSetTyped( + release: *SelectionGesture.Release, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + switch (option) { + .ref => { + const v = value orelse { + release.pin = null; + return .success; + }; + release.pin = v.toPin() orelse return .invalid_value; + }, + + .position, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .word_boundary_codepoints, + .behaviors, + .rectangle, + .geometry, + .viewport, + => return .invalid_value, + } + + return .success; +} + +fn dragSetTyped( + event: *EventWrapper, + drag: *SelectionGesture.Drag, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .ref => event.event_validation.drag_pin_set = false, + .position => { + drag.xpos = 0; + drag.ypos = 0; + }, + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &drag.word_boundary_codepoints, + ), + .rectangle => drag.rectangle = false, + .geometry => event.event_validation.drag_geometry_set = false, + .viewport => return .invalid_value, + + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + return .success; + }; + + switch (option) { + .ref => { + drag.pin = v.toPin() orelse return .invalid_value; + event.event_validation.drag_pin_set = true; + }, + .position => { + drag.xpos = v.x; + drag.ypos = v.y; + }, + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &drag.word_boundary_codepoints, + v, + ), + .rectangle => drag.rectangle = v.*, + .geometry => { + drag.geometry = v.toZig() orelse return .invalid_value; + event.event_validation.drag_geometry_set = true; + }, + .viewport => return .invalid_value, + + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + + return .success; +} + +fn autoscrollTickSetTyped( + event: *EventWrapper, + tick: *SelectionGesture.AutoscrollTick, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .viewport => event.event_validation.autoscroll_tick_viewport_set = false, + .position => { + tick.xpos = 0; + tick.ypos = 0; + }, + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &tick.word_boundary_codepoints, + ), + .rectangle => tick.rectangle = false, + .geometry => event.event_validation.autoscroll_tick_geometry_set = false, + + .ref, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + return .success; + }; + + switch (option) { + .viewport => { + tick.viewport = v.*; + event.event_validation.autoscroll_tick_viewport_set = true; + }, + .position => { + tick.xpos = v.x; + tick.ypos = v.y; + }, + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &tick.word_boundary_codepoints, + v, + ), + .rectangle => tick.rectangle = v.*, + .geometry => { + tick.geometry = v.toZig() orelse return .invalid_value; + event.event_validation.autoscroll_tick_geometry_set = true; + }, + + .ref, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + + return .success; +} + +fn deepPressSetTyped( + event: *EventWrapper, + deep_press: *SelectionGesture.DeepPress, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &deep_press.word_boundary_codepoints, + ), + + .ref, + .position, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + .rectangle, + .geometry, + .viewport, + => return .invalid_value, + } + return .success; + }; + + switch (option) { + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &deep_press.word_boundary_codepoints, + v, + ), + + .ref, + .position, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + .rectangle, + .geometry, + .viewport, + => return .invalid_value, + } + + return .success; +} + +fn trySetWordBoundaryCodepoints( + event: *EventWrapper, + target: *[]const u21, + value: *const types.Codepoints, +) Result { + if (value.len > 0 and value.ptr == null) return .invalid_value; + clearWordBoundaryCodepoints(event, target); + const ptr = value.ptr orelse { + event.word_boundary_codepoints = &.{}; + target.* = event.word_boundary_codepoints.?; + return .success; + }; + const copy = event.alloc.alloc(u21, value.len) catch return .out_of_memory; + errdefer event.alloc.free(copy); + for (copy, ptr[0..value.len]) |*dst, cp| { + dst.* = std.math.cast(u21, cp) orelse return .invalid_value; + } + event.word_boundary_codepoints = copy; + target.* = copy; + return .success; +} + +fn clearWordBoundaryCodepoints(event: *EventWrapper, target: *[]const u21) void { + if (event.word_boundary_codepoints) |cps| { + if (cps.len > 0) event.alloc.free(cps); + } + event.word_boundary_codepoints = null; + target.* = &selection_codepoints.default_word_boundaries; +} + +fn instantFromNs(ns: u64) std.time.Instant { + return switch (builtin.os.tag) { + .windows, .uefi, .wasi => .{ .timestamp = ns }, + else => .{ .timestamp = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + } }, + }; +} + +fn validBehavior(behavior: Behavior) bool { + _ = std.meta.intToEnum(Behavior, @intFromEnum(behavior)) catch return false; + return true; +} + +test "selection gesture lifecycle and get" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var click_count: u8 = 255; + try testing.expectEqual(Result.success, get(gesture, terminal, .click_count, &click_count)); + try testing.expectEqual(@as(u8, 0), click_count); + + var dragged = true; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(!dragged); + + var autoscroll: Autoscroll = .up; + try testing.expectEqual(Result.success, get(gesture, terminal, .autoscroll, &autoscroll)); + try testing.expectEqual(Autoscroll.none, autoscroll); + + var behavior: Behavior = .word; + try testing.expectEqual(Result.success, get(gesture, terminal, .behavior, &behavior)); + try testing.expectEqual(Behavior.cell, behavior); + + var anchor: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.no_value, get(gesture, terminal, .anchor, &anchor)); +} + +test "selection gesture get_multi" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + const keys = [_]Data{ .click_count, .dragged, .autoscroll, .behavior }; + var click_count: u8 = 255; + var dragged = true; + var autoscroll: Autoscroll = .up; + var behavior: Behavior = .word; + var values = [_]?*anyopaque{ + &click_count, + &dragged, + &autoscroll, + &behavior, + }; + var written: usize = 0; + + try testing.expectEqual(Result.success, get_multi( + gesture, + terminal, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(@as(u8, 0), click_count); + try testing.expect(!dragged); + try testing.expectEqual(Autoscroll.none, autoscroll); + try testing.expectEqual(Behavior.cell, behavior); +} + +test "selection gesture get_multi returns first failing index" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + const keys = [_]Data{ .click_count, .anchor, .dragged }; + var click_count: u8 = 255; + var anchor: grid_ref.CGridRef = undefined; + var dragged = true; + var values = [_]?*anyopaque{ &click_count, &anchor, &dragged }; + var written: usize = 0; + + try testing.expectEqual(Result.no_value, get_multi( + gesture, + terminal, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(@as(usize, 1), written); + try testing.expectEqual(@as(u8, 0), click_count); + try testing.expect(dragged); +} + +test "selection gesture event set clear and free" { + var event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &event, + .press, + )); + defer event_free(event); + + const in_pos: types.SurfacePosition = .{ .x = 12.5, .y = -3.25 }; + try testing.expectEqual(Result.success, event_set(event, .position, &in_pos)); + try testing.expectEqual(@as(f64, 12.5), event.?.event.press.xpos); + try testing.expectEqual(@as(f64, -3.25), event.?.event.press.ypos); + + try testing.expectEqual(Result.success, event_set(event, .position, null)); + try testing.expectEqual(@as(f64, 0), event.?.event.press.xpos); + try testing.expectEqual(@as(f64, 0), event.?.event.press.ypos); + + const repeat_distance: f64 = 4.0; + try testing.expectEqual(Result.success, event_set(event, .repeat_distance, &repeat_distance)); + try testing.expectEqual(repeat_distance, event.?.event.press.max_distance); +} + +test "selection gesture event copies clears and frees codepoints" { + var event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &event, + .press, + )); + defer event_free(event); + + var values = [_]u32{ ' ', '\t' }; + const in: types.Codepoints = .{ .ptr = &values, .len = values.len }; + try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, &in)); + + values[0] = 'x'; + + try testing.expectEqual(@as(usize, 2), event.?.event.press.word_boundary_codepoints.len); + try testing.expectEqual(@as(u21, ' '), event.?.event.press.word_boundary_codepoints[0]); + try testing.expectEqual(@as(u21, '\t'), event.?.event.press.word_boundary_codepoints[1]); + + const invalid: types.Codepoints = .{ .ptr = null, .len = 1 }; + try testing.expectEqual(Result.invalid_value, event_set(event, .word_boundary_codepoints, &invalid)); + + try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, null)); + try testing.expectEqual( + selection_codepoints.default_word_boundaries.len, + event.?.event.press.word_boundary_codepoints.len, + ); + + const empty: types.Codepoints = .{ .ptr = null, .len = 0 }; + try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, &empty)); + try testing.expectEqual(@as(usize, 0), event.?.event.press.word_boundary_codepoints.len); +} + +test "selection gesture event behaviors" { + var event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &event, + .press, + )); + defer event_free(event); + + const in: Behaviors = .{ + .single_click = .cell, + .double_click = .word, + .triple_click = .line, + }; + try testing.expectEqual(Result.success, event_set(event, .behaviors, &in)); + try testing.expectEqual(Behavior.cell, event.?.event.press.behaviors[0]); + try testing.expectEqual(Behavior.word, event.?.event.press.behaviors[1]); + try testing.expectEqual(Behavior.line, event.?.event.press.behaviors[2]); +} + +test "selection gesture event applies press" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + terminal_c.vt_write(terminal, "abc", 3); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + const behaviors: Behaviors = .{ + .single_click = .word, + .double_click = .word, + .triple_click = .line, + }; + try testing.expectEqual(Result.success, event_set(press_event, .behaviors, &behaviors)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, press_event, &sel)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x); + + try testing.expectEqual(Result.success, handle_event(gesture, terminal, press_event, null)); +} + +test "selection gesture event press requires ref" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, press_event, &sel)); +} + +test "selection gesture event null output still reports no selection" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); +} + +test "selection gesture event applies release" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var release_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &release_event, + .release, + )); + defer event_free(release_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + try testing.expectEqual(Result.success, event_set(release_event, .ref, &ref)); + + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, release_event, null)); + + var dragged = true; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(!dragged); + + const pos: types.SurfacePosition = .{ .x = 0, .y = 0 }; + try testing.expectEqual(Result.invalid_value, event_set(release_event, .position, &pos)); +} + +test "selection gesture release without ref marks dragged" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var release_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &release_event, + .release, + )); + defer event_free(release_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, release_event, null)); + + var dragged = false; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(dragged); +} + +test "selection gesture event applies drag" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "abcde", 5); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var drag_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &drag_event, + .drag, + )); + defer event_free(drag_event); + + var press_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &press_ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &press_ref)); + + const press_pos: types.SurfacePosition = .{ .x = 10, .y = 10 }; + try testing.expectEqual(Result.success, event_set(press_event, .position, &press_pos)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + + var drag_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &drag_ref)); + try testing.expectEqual(Result.success, event_set(drag_event, .ref, &drag_ref)); + + const drag_pos: types.SurfacePosition = .{ .x = 36, .y = 10 }; + try testing.expectEqual(Result.success, event_set(drag_event, .position, &drag_pos)); + const geometry: Geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 20, + }; + try testing.expectEqual(Result.success, event_set(drag_event, .geometry, &geometry)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, drag_event, &sel)); + try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 3), sel.end.toPin().?.x); + + var dragged = false; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(dragged); +} + +test "selection gesture drag requires ref and geometry" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var drag_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &drag_event, + .drag, + )); + defer event_free(drag_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, drag_event, &sel)); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(drag_event, .ref, &ref)); + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, drag_event, &sel)); + + const invalid_geometry: Geometry = .{ + .columns = 5, + .cell_width = 0, + .padding_left = 0, + .screen_height = 20, + }; + try testing.expectEqual(Result.invalid_value, event_set(drag_event, .geometry, &invalid_geometry)); +} + +test "selection gesture event applies autoscroll tick" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "abcde\r\nfghij", 12); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var drag_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &drag_event, + .drag, + )); + defer event_free(drag_event); + + var tick_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &tick_event, + .autoscroll_tick, + )); + defer event_free(tick_event); + + const geometry: Geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 20, + }; + + var press_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &press_ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &press_ref)); + const press_pos: types.SurfacePosition = .{ .x = 10, .y = 10 }; + try testing.expectEqual(Result.success, event_set(press_event, .position, &press_pos)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + + var drag_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 1 } }, + }, &drag_ref)); + try testing.expectEqual(Result.success, event_set(drag_event, .ref, &drag_ref)); + const drag_pos: types.SurfacePosition = .{ .x = 36, .y = 20 }; + try testing.expectEqual(Result.success, event_set(drag_event, .position, &drag_pos)); + try testing.expectEqual(Result.success, event_set(drag_event, .geometry, &geometry)); + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, drag_event, &sel)); + + var autoscroll: Autoscroll = .none; + try testing.expectEqual(Result.success, get(gesture, terminal, .autoscroll, &autoscroll)); + try testing.expectEqual(Autoscroll.down, autoscroll); + + const viewport: point.Coordinate = .{ .x = 3, .y = 1 }; + try testing.expectEqual(Result.success, event_set(tick_event, .viewport, &viewport)); + try testing.expectEqual(Result.success, event_set(tick_event, .position, &drag_pos)); + try testing.expectEqual(Result.success, event_set(tick_event, .geometry, &geometry)); + + try testing.expectEqual(Result.success, handle_event(gesture, terminal, tick_event, &sel)); +} + +test "selection gesture autoscroll tick requires viewport and geometry" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var tick_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &tick_event, + .autoscroll_tick, + )); + defer event_free(tick_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, tick_event, &sel)); + + const viewport: point.Coordinate = .{ .x = 1, .y = 0 }; + try testing.expectEqual(Result.success, event_set(tick_event, .viewport, &viewport)); + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, tick_event, &sel)); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.invalid_value, event_set(tick_event, .ref, &ref)); +} + +test "selection gesture event applies deep press" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "abcde", 5); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var deep_press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &deep_press_event, + .deep_press, + )); + defer event_free(deep_press_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, deep_press_event, &sel)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 4), sel.end.toPin().?.x); + + var dragged = false; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(dragged); + + const pos: types.SurfacePosition = .{ .x = 0, .y = 0 }; + try testing.expectEqual(Result.invalid_value, event_set(deep_press_event, .position, &pos)); +} + +test "selection gesture deep press without active anchor returns no value" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var deep_press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &deep_press_event, + .deep_press, + )); + defer event_free(deep_press_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, deep_press_event, &sel)); +} + +test "selection gesture free null" { + free(null, null); +} + +test "selection gesture event free null" { + event_free(null); +} diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index d9ece57ee..a44dd1ff5 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -14,15 +14,29 @@ const size_report = @import("size_report.zig"); const terminal = @import("terminal.zig"); const formatter = @import("formatter.zig"); const selection = @import("selection.zig"); +const selection_gesture = @import("selection_gesture.zig"); const render = @import("render.zig"); const style_c = @import("style.zig"); const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); +/// C: GhosttySurfacePosition +pub const SurfacePosition = extern struct { + x: f64, + y: f64, +}; + +/// C: GhosttyCodepoints +pub const Codepoints = extern struct { + ptr: ?[*]const u32 = null, + len: usize = 0, +}; + /// All C API structs and their Ghostty C names. pub const structs: std.StaticStringMap(StructInfo) = structs: { @setEvalBranchQuota(10_000); break :structs .initComptime(.{ + .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, @@ -41,8 +55,11 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, + .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, + .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) }, .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, .{ "GhosttyString", StructInfo.init(lib.String) }, + .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) }, .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, @@ -150,6 +167,11 @@ fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { fn typeName(comptime T: type) []const u8 { return switch (@typeInfo(T)) { .bool => "bool", + .float => |info| switch (info.bits) { + 32 => "f32", + 64 => "f64", + else => @compileError("unsupported float size"), + }, .int => |info| switch (info.signedness) { .signed => switch (info.bits) { 8 => "i8",