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/terminal.h b/include/ghostty/vt/terminal.h index 19b27b7a6..ddfcb9c0d 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -901,6 +901,16 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: GhosttySelection * */ GHOSTTY_TERMINAL_DATA_SELECTION = 31, + + /** + * Whether the viewport is currently pinned to the active area. + * + * This is true when the viewport is following the active terminal area, + * and false when the user has scrolled into history. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; 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/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bcccef8f9..ee6312ab9 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1410,9 +1410,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We also want to get notified of certain changes to update our appearance. focusedSurface.$derivedConfig + .dropFirst() .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) focusedSurface.$backgroundColor + .dropFirst() .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 887482b30..c0013ec78 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -725,7 +725,19 @@ extension Ghostty { // Update our derived config DispatchQueue.main.async { [weak self] in - self?.derivedConfig = DerivedConfig(config) + guard let self else { return } + self.derivedConfig = DerivedConfig(config) + + // If the cached OSC 11 background color disagrees with the new + // config-derived background, drop it so window chrome follows + // the new config (e.g., on light/dark theme auto-switch). The + // cached value is restored next time the terminal emits a + // color_change. + if let cached = self.backgroundColor, + cached != self.derivedConfig.backgroundColor + { + self.backgroundColor = nil + } } } diff --git a/src/Surface.zig b/src/Surface.zig index 525e73a9e..410f717b0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -224,23 +224,8 @@ const Mouse = struct { /// pressed or release. mods: input.Mods = .{}, - /// The point at which the left mouse click happened. This is in screen - /// coordinates so that scrolling preserves the location. - left_click_pin: ?*terminal.Pin = null, - left_click_screen: terminal.ScreenSet.Key = .primary, - left_click_screen_generation: usize = 0, - - /// The starting xpos/ypos of the left click. Note that if scrolling occurs, - /// these will point to different "cells", but the xpos/ypos will stay - /// stable during scrolling relative to the surface. - left_click_xpos: f64 = 0, - left_click_ypos: f64 = 0, - - /// The count of clicks to count double and triple clicks and so on. - /// The left click time was the last time the left click was done. This - /// is always set on the first left click. - left_click_count: u8 = 0, - left_click_time: std.time.Instant = undefined, + /// Gesture state for text selection. + selection_gesture: terminal.SelectionGesture = .init, /// The last x/y sent for mouse reports. event_point: ?terminal.point.Coordinate = null, @@ -263,20 +248,9 @@ const Mouse = struct { /// only process link hover events when the mouse actually moves cells. link_point: ?terminal.point.Coordinate = null, - /// Return the PageList that owns the left-click pin, or null if the screen - /// has been removed/reinitialized since the pin was tracked. - fn leftClickPageList(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.PageList { - if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; - const screen = screens.get(self.left_click_screen) orelse return null; - return &screen.pages; - } - /// Return the left-click pin only if it still belongs to the active screen. fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin { - const pin = self.left_click_pin orelse return null; - if (self.left_click_screen != screens.active_key) return null; - _ = self.leftClickPageList(screens) orelse return null; - return pin; + return self.selection_gesture.validatedLeftClickPin(screens); } }; @@ -839,6 +813,7 @@ pub fn deinit(self: *Surface) void { self.renderer_thread.deinit(); self.renderer.deinit(); self.io_thread.deinit(); + self.mouse.selection_gesture.deinit(&self.io.terminal); self.io.deinit(); if (self.inspector) |v| { @@ -1196,46 +1171,53 @@ fn selectionScrollTick(self: *Surface) !void { // If we're no longer active then we don't do anything. if (!self.selection_scroll_active) return; - // If we don't have a left mouse button down then we - // don't do anything. - if (self.mouse.left_click_count == 0) return; + // If our gesture doesn't want autoscrolling then disable it. + const was_autoscrolling = self.mouse.selection_gesture.left_drag_autoscroll != .none; + if (!was_autoscrolling) { + self.queueIo( + .{ .selection_scroll = false }, + .unlocked, + ); + return; + } const pos = try self.rt_surface.getCursorPos(); const pos_vp = self.posToViewport(pos.x, pos.y); - const delta: isize = if (pos.y < 0) -1 else 1; // We need our locked state for the remainder self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - // If our left-click pin no longer belongs to the active screen, we stop - // our selection scroll. - if (self.mouse.activeLeftClickPin(&t.screens) == null) { + const selection = self.mouse.selection_gesture.autoscrollTick(t, .{ + .viewport = pos_vp, + .xpos = pos.x, + .ypos = pos.y, + .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .word_boundary_codepoints = self.config.selection_word_chars, + .geometry = .{ + .columns = @intCast(self.size.grid().columns), + .cell_width = self.size.cell.width, + .padding_left = self.size.padding.left, + .screen_height = self.size.screen.height, + }, + }); + + // If we're no longer autoscrolling for whatever reason, disable it. + if (self.mouse.selection_gesture.left_drag_autoscroll == .none) { self.queueIo( .{ .selection_scroll = false }, .locked, ); - return; } - // Scroll the viewport as required - t.scrollViewport(.{ .delta = delta }); - - // Next, trigger our drag behavior - const pin = t.screens.active.pages.pin(.{ - .viewport = .{ - .x = pos_vp.x, - .y = pos_vp.y, - }, - }) orelse { - if (comptime std.debug.runtime_safety) unreachable; - return; - }; - try self.dragLeftClickSingle(pin, pos.x); + // If our left click was invalidated, ignore the result. This isn't + // strictly necessary but its a nice to have. + if (self.mouse.selection_gesture.left_click_count == 0) return; // We modified our viewport and selection so we need to queue // a render. + try self.io.terminal.screens.active.select(selection); try self.queueRender(); } @@ -3781,7 +3763,7 @@ pub fn mouseButtonCallback( // We could do all the conditionals in one but I find it more // readable as a human to break this one up. if (mods.shift and - self.mouse.left_click_count > 0 and + self.mouse.selection_gesture.left_click_count > 0 and !shift_capture) extend_selection: { // We split this conditional out on its own because this is the @@ -3792,7 +3774,9 @@ pub fn mouseButtonCallback( // If we are within the interval that the click would register // an increment then we do not extend the selection. if (std.time.Instant.now()) |now| { - const since = now.since(self.mouse.left_click_time); + const click_time = self.mouse.selection_gesture.left_click_time orelse + break :extend_selection; + const since = now.since(click_time); if (since <= self.config.mouse_interval) { // Click interval very short, we may be increasing // click counts so we don't extend the selection. @@ -3814,12 +3798,39 @@ pub fn mouseButtonCallback( } if (button == .left and action == .release) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // The selection gesture tracks whether a press became a drag by + // comparing the release cell to the original press cell. Resolve the + // release position and pin before notifying the gesture so later + // release handling can query that state. + const release_pos: ?apprt.CursorPos = self.rt_surface.getCursorPos() catch |err| pos: { + log.warn("error reading cursor position for mouse release err={}", .{err}); + break :pos null; + }; + + // If we can't map the release position to a cell, pass null so the + // gesture can conservatively treat the release as having moved away + // from the pressed cell. + const release_pin: ?terminal.Pin = if (release_pos) |pos| pin: { + const release_vp = self.posToViewport(pos.x, pos.y); + break :pin self.io.terminal.screens.active.pages.pin(.{ .viewport = .{ + .x = release_vp.x, + .y = release_vp.y, + } }); + } else null; + self.mouse.selection_gesture.release( + self.renderer_state.terminal, + .{ .pin = release_pin }, + ); + // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { self.queueIo( .{ .selection_scroll = false }, - .unlocked, + .locked, ); } @@ -3827,8 +3838,6 @@ pub fn mouseButtonCallback( // the left button is released. This is to avoid the clipboard // being updated on every mouse move which would be noisy. if (self.config.copy_on_select != .false) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelection(terminal.Selection.init( @@ -3842,10 +3851,10 @@ pub fn mouseButtonCallback( // Handle link clicking. We want to do this before we do mouse // reporting or any other mouse handling because a successfully // clicked link will swallow the event. - if (self.mouse.over_link) { - const pos = try self.rt_surface.getCursorPos(); - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + if (self.mouse.over_link and !self.mouse.selection_gesture.left_click_dragged) { + // We are holding the renderer lock, but this should just be + // a cached value. + const pos = release_pos orelse try self.rt_surface.getCursorPos(); if (self.processLinks(pos)) |processed| { if (processed) return true; } else |err| { @@ -3880,7 +3889,7 @@ pub fn mouseButtonCallback( // We also set the left click count to 0 so that if mouse reporting // is disabled in the middle of press (before release) we don't // suddenly start selecting text. - self.mouse.left_click_count = 0; + self.mouse.selection_gesture.reset(self.renderer_state.terminal); const pos = try self.rt_surface.getCursorPos(); @@ -3927,108 +3936,67 @@ pub fn mouseButtonCallback( break :click; }; - break :pin try screen.pages.trackPin(pin); + break :pin pin; }; - errdefer screen.pages.untrackPin(pin); - // If we move our cursor too much between clicks then we reset - // the multi-click state. - if (self.mouse.left_click_count > 0) { - const max_distance: f64 = @floatFromInt(self.size.cell.width); - const distance = @sqrt( - std.math.pow(f64, pos.x - self.mouse.left_click_xpos, 2) + - std.math.pow(f64, pos.y - self.mouse.left_click_ypos, 2), - ); - - if (distance > max_distance) self.mouse.left_click_count = 0; - } - - if (self.mouse.left_click_pin) |prev| { - if (self.mouse.leftClickPageList(&t.screens)) |pages| pages.untrackPin(prev); - self.mouse.left_click_pin = null; - } - - // Store it - self.mouse.left_click_pin = pin; - self.mouse.left_click_screen = t.screens.active_key; - self.mouse.left_click_screen_generation = t.screens.generation(t.screens.active_key); - self.mouse.left_click_xpos = pos.x; - self.mouse.left_click_ypos = pos.y; - - // Setup our click counter and timer - if (std.time.Instant.now()) |now| { - // If we have mouse clicks, then we check if the time elapsed - // is less than and our interval and if so, increase the count. - if (self.mouse.left_click_count > 0) { - const since = now.since(self.mouse.left_click_time); - if (since > self.config.mouse_interval) { - self.mouse.left_click_count = 0; - } - } - - self.mouse.left_click_time = now; - self.mouse.left_click_count += 1; - - // We only support up to triple-clicks. - if (self.mouse.left_click_count > 3) self.mouse.left_click_count = 1; - } else |err| { - self.mouse.left_click_count = 1; + const time = std.time.Instant.now() catch |err| time: { log.err("error reading time, mouse multi-click won't work err={}", .{err}); - } - - // In all cases below, we set the selection directly rather than use - // `setSelection` because we want to avoid copying the selection - // to the selection clipboard. For left mouse clicks we only set - // the clipboard on release. - switch (self.mouse.left_click_count) { - // Single click - 1 => { - // If we have a selection, clear it. This always happens. - if (self.io.terminal.screens.active.selection != null) { - try self.io.terminal.screens.active.select(null); - try self.queueRender(); - } + break :time null; + }; + var press_selection = try self.mouse.selection_gesture.press(t, .{ + .time = time, + .pin = pin, + .xpos = pos.x, + .ypos = pos.y, + .max_distance = @floatFromInt(self.size.cell.width), + .repeat_interval = self.config.mouse_interval, + .word_boundary_codepoints = self.config.selection_word_chars, + .behaviors = &.{ + .cell, + .word, + if (mods.ctrlOrSuper()) .output else .line, }, + }); - // Double click, select the word under our mouse. - // First try to detect if we're clicking on a URL to select the entire URL. + // The gesture owns the standard single/double/triple-click selection + // behavior. Surface keeps terminal-surface-specific overrides here. + switch (self.mouse.selection_gesture.left_click_count) { + 1 => {}, + + // Double click on a URL selects the entire URL instead of the + // standard word selection returned by the gesture. 2 => { - const sel_ = sel: { - // Try link detection without requiring modifier keys - if (self.linkAtPin( - pin.*, - null, - )) |result_| { - if (result_) |result| { - break :sel result.selection; - } - } else |_| { - // Ignore any errors, likely regex errors. + // Try link detection without requiring modifier keys. + if (self.linkAtPin( + pin, + null, + )) |result_| { + if (result_) |result| { + press_selection = result.selection; } - - break :sel self.io.terminal.screens.active.selectWord(pin.*, self.config.selection_word_chars); - }; - if (sel_) |sel| { - try self.io.terminal.screens.active.select(sel); - try self.queueRender(); + } else |_| { + // Ignore any errors, likely regex errors. } }, - // Triple click, select the line under our mouse - 3 => { - const sel_ = if (mods.ctrlOrSuper()) - self.io.terminal.screens.active.selectOutput(pin.*) - else - self.io.terminal.screens.active.selectLine(.{ .pin = pin.* }); - if (sel_) |sel| { - try self.io.terminal.screens.active.select(sel); - try self.queueRender(); - } - }, + 3 => {}, // We should be bounded by 1 to 3 else => unreachable, } + + // We set the selection directly rather than use `setSelection` because + // we want to avoid copying the selection to the selection clipboard. + // For left mouse clicks we only set the clipboard on release. + if (press_selection) |selection| { + try self.io.terminal.screens.active.select(selection); + try self.queueRender(); + } else if (self.mouse.selection_gesture.left_click_count == 1 and + self.io.terminal.screens.active.selection != null) + { + try self.io.terminal.screens.active.select(null); + try self.queueRender(); + } } // Middle-click paste source follows copy-on-select: when copy-on-select @@ -4156,9 +4124,8 @@ pub fn mouseButtonCallback( return false; } +/// Requires the renderer state mutex is held. fn maybePromptClick(self: *Surface) !bool { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; const screen: *terminal.Screen = t.screens.active; @@ -4173,11 +4140,12 @@ fn maybePromptClick(self: *Surface) !bool { // prompt clicks because we can't move if we're not in a prompt! if (!t.cursorIsAtPrompt()) return false; - // If we have a selection currently, then releasing the mouse - // completes the selection and we don't do prompt moving. I don't - // love this logic, I think it should be generalized to "if the - // mouse release was on a different cell than the mouse press" but - // our mouse state at the time of writing this doesn't support that. + // If the left click moved away from its pressed cell then releasing the + // mouse completes the drag gesture and we don't do prompt moving. + if (self.mouse.selection_gesture.left_click_dragged) return false; + + // If we have a selection currently, then releasing the mouse completes + // the selection and we don't do prompt moving. if (screen.selection != null) return false; // Get the pin for our mouse click. @@ -4470,9 +4438,11 @@ pub fn mousePressureCallback( // Update our pressure stage. self.mouse.pressure_stage = stage; - // If our left mouse button is pressed and we're entering a deep - // click then we want to start a selection. We treat this as a - // word selection since that is typical macOS behavior. + // A deep press is pressure-sensitive pointer input, such as macOS force + // click / deep click on a trackpad, that occurs while the left mouse + // button is already down. Treat it as the platform text-selection + // affordance: select the pressed word, then consume the active gesture so + // further cursor motion doesn't drag the selection. const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press and stage == .deep) @@ -4480,14 +4450,21 @@ pub fn mousePressureCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - // This should always be set in this state but we don't want - // to handle state inconsistency here. - const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :select; - const sel = self.io.terminal.screens.active.selectWord( - pin.*, - self.config.selection_word_chars, - ) orelse break :select; - try self.io.terminal.screens.active.select(sel); + const sel = self.mouse.selection_gesture.deepPress( + self.renderer_state.terminal, + .{ .word_boundary_codepoints = self.config.selection_word_chars }, + ); + + // Deep press consumes the active drag gesture, so stop any pending + // selection autoscroll timer that may have been started by the drag. + if (self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + } + + try self.io.terminal.screens.active.select(sel orelse break :select); try self.queueRender(); } } @@ -4566,15 +4543,6 @@ pub fn cursorPosCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - // Stop selection scrolling when inside the viewport within a 1px buffer - // for fullscreen windows, but only when selection scrolling is active. - if (pos.y >= 1 and self.selection_scroll_active) { - self.queueIo( - .{ .selection_scroll = false }, - .locked, - ); - } - // Update our mouse state. We set this to null initially because we only // want to set it when we're not selecting or doing any other mouse // event. @@ -4645,7 +4613,7 @@ pub fn cursorPosCallback( // In this scenario, we mark the click state because we need that to // properly make some mouse reports, but we don't keep track of the // count because we don't want to handle selection. - if (self.mouse.left_click_count == 0) break :select; + if (self.mouse.selection_gesture.left_click_count == 0) break :select; // If our left-click pin no longer belongs to the active screen then we // don't process this. We don't invalidate our pin or mouse state @@ -4657,25 +4625,6 @@ pub fn cursorPosCallback( // All roads lead to requiring a re-render at this point. try self.queueRender(); - // If our y is negative, we're above the window. In this case, we scroll - // up. The amount we scroll up is dependent on how negative we are. - // We allow for a 1 pixel buffer at the top and bottom to detect - // scroll even in full screen windows. - // Note: one day, we can change this from distance to time based if we want. - //log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen }); - const max_y: f32 = @floatFromInt(self.size.screen.height); - - // If the mouse is outside the viewport and we have the left - // mouse button pressed then we need to start the scroll timer. - if ((pos.y <= 1 or pos.y > max_y - 1) and - !self.selection_scroll_active) - { - self.queueIo( - .{ .selection_scroll = true }, - .locked, - ); - } - // Convert to points const screen: *terminal.Screen = t.screens.active; const pin = screen.pages.pin(.{ @@ -4688,261 +4637,42 @@ pub fn cursorPosCallback( return; }; - // Handle dragging depending on click count - switch (self.mouse.left_click_count) { - 1 => try self.dragLeftClickSingle(pin, pos.x), - 2 => try self.dragLeftClickDouble(pin), - 3 => try self.dragLeftClickTriple(pin), - 0 => unreachable, // handled above - else => unreachable, + // Perform our drag behavior in our gesture handler. + const drag_selection = self.mouse.selection_gesture.drag(t, .{ + .pin = pin, + .xpos = pos.x, + .ypos = pos.y, + .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .word_boundary_codepoints = self.config.selection_word_chars, + .geometry = .{ + .columns = @intCast(self.size.grid().columns), + .cell_width = self.size.cell.width, + .padding_left = self.size.padding.left, + .screen_height = self.size.screen.height, + }, + }); + + // Update our autoscroll timer based on the gesture state + switch (self.mouse.selection_gesture.left_drag_autoscroll) { + .none => if (self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + }, + .up, .down => if (!self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = true }, + .locked, + ); + }, } - return; + // Update our selection based on the gesture state + try self.io.terminal.screens.active.select(drag_selection); } } -/// Double-click dragging moves the selection one "word" at a time. -fn dragLeftClickDouble( - self: *Surface, - drag_pin: terminal.Pin, -) !void { - const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin = (self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse return).*; - - // Get the word closest to our starting click. - const word_start = screen.selectWordBetween( - click_pin, - drag_pin, - self.config.selection_word_chars, - ) orelse { - try self.setSelection(null); - return; - }; - - // Get the word closest to our current point. - const word_current = screen.selectWordBetween( - drag_pin, - click_pin, - self.config.selection_word_chars, - ) orelse { - try self.setSelection(null); - return; - }; - - // If our current mouse position is before the starting position, - // then the selection start is the word nearest our current position. - if (drag_pin.before(click_pin)) { - try self.io.terminal.screens.active.select(.init( - word_current.start(), - word_start.end(), - false, - )); - } else { - try self.io.terminal.screens.active.select(.init( - word_start.start(), - word_current.end(), - false, - )); - } -} - -/// Triple-click dragging moves the selection one "line" at a time. -fn dragLeftClickTriple( - self: *Surface, - drag_pin: terminal.Pin, -) !void { - const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin: terminal.Pin = pin: { - const set: *terminal.ScreenSet = &self.io.terminal.screens; - const tracked = self.mouse.activeLeftClickPin(set) orelse return; - break :pin tracked.*; - }; - - // Get the line selection under our current drag point. If there isn't a - // line, do nothing. - const line = screen.selectLine(.{ .pin = drag_pin }) orelse return; - - // Get the selection under our click point. We first try to trim - // whitespace if we've selected a word. But if no word exists then - // we select the blank line. - const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse - screen.selectLine(.{ .pin = click_pin, .whitespace = null }); - - var sel = sel_ orelse return; - if (drag_pin.before(click_pin)) { - sel.startPtr().* = line.start(); - } else { - sel.endPtr().* = line.end(); - } - try self.io.terminal.screens.active.select(sel); -} - -fn dragLeftClickSingle( - self: *Surface, - drag_pin: terminal.Pin, - drag_x: f64, -) !void { - // This logic is in a separate function so that it can be unit tested. - const click_pin: terminal.Pin = pin: { - const set: *terminal.ScreenSet = &self.io.terminal.screens; - const tracked = self.mouse.activeLeftClickPin(set) orelse return; - break :pin tracked.*; - }; - try self.io.terminal.screens.active.select(mouseSelection( - click_pin, - drag_pin, - @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), - @intFromFloat(@max(0.0, drag_x)), - self.mouse.mods, - self.size, - )); -} - -/// Calculates the appropriate selection given pins and pixel x positions for -/// the click point and the drag point, as well as mouse mods and screen size. -fn mouseSelection( - click_pin: terminal.Pin, - drag_pin: terminal.Pin, - click_x: u32, - drag_x: u32, - mods: input.Mods, - size: rendererpkg.Size, -) ?terminal.Selection { - // Explanation: - // - // # Normal selections - // - // ## Left-to-right selections - // - The clicked cell is included if it was clicked to the left of its - // threshold point and the drag location is right of the threshold point. - // - The cell under the cursor (the "drag cell") is included if the drag - // location is right of its threshold point. - // - // ## Right-to-left selections - // - The clicked cell is included if it was clicked to the right of its - // threshold point and the drag location is left of the threshold point. - // - The cell under the cursor (the "drag cell") is included if the drag - // location is left of its threshold point. - // - // # Rectangular selections - // - // Rectangular selections are handled similarly, except that - // entire columns are considered rather than individual cells. - - // We only include cells in the selection if the threshold point lies - // between the start and end points of the selection. A threshold of - // 60% of the cell width was chosen empirically because it felt good. - const threshold_point: u32 = @intFromFloat(@round( - @as(f64, @floatFromInt(size.cell.width)) * 0.6, - )); - - // We use this to clamp the pixel positions below. - const max_x = size.grid().columns * size.cell.width - 1; - - // We need to know how far across in the cell the drag pos is, so - // we subtract the padding and then take it modulo the cell width. - const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width; - - // We figure out the fractional part of the click x position similarly. - const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width; - - // Whether or not this is a rectangular selection. - const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods); - - // Whether the click pin and drag pin are equal. - const same_pin = drag_pin.eql(click_pin); - - // Whether or not the end point of our selection is before the start point. - const end_before_start = ebs: { - if (same_pin) { - break :ebs drag_x_frac < click_x_frac; - } - - // Special handling for rectangular selections, we only use x position. - if (rectangle_selection) { - break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { - .eq => drag_x_frac < click_x_frac, - .lt => true, - .gt => false, - }; - } - - break :ebs drag_pin.before(click_pin); - }; - - // Whether or not the click pin cell - // should be included in the selection. - const include_click_cell = if (end_before_start) - click_x_frac >= threshold_point - else - click_x_frac < threshold_point; - - // Whether or not the drag pin cell - // should be included in the selection. - const include_drag_cell = if (end_before_start) - drag_x_frac < threshold_point - else - drag_x_frac >= threshold_point; - - // If the click cell should be included in the selection then it's the - // start, otherwise we get the previous or next cell to it depending on - // the type and direction of the selection. - const start_pin = - if (include_click_cell) - click_pin - else if (end_before_start) - if (rectangle_selection) - click_pin.leftClamp(1) - else - click_pin.leftWrap(1) orelse click_pin - else if (rectangle_selection) - click_pin.rightClamp(1) - else - click_pin.rightWrap(1) orelse click_pin; - - // Likewise for the end pin with the drag cell. - const end_pin = - if (include_drag_cell) - drag_pin - else if (end_before_start) - if (rectangle_selection) - drag_pin.rightClamp(1) - else - drag_pin.rightWrap(1) orelse drag_pin - else if (rectangle_selection) - drag_pin.leftClamp(1) - else - drag_pin.leftWrap(1) orelse drag_pin; - - // If the click cell is the same as the drag cell and the click cell - // shouldn't be included, or if the cells are adjacent such that the - // start or end pin becomes the other cell, and that cell should not - // be included, then we have no selection, so we set it to null. - // - // If in rectangular selection mode, we compare columns as well. - // - // TODO(qwerasd): this can/should probably be refactored, it's a bit - // repetitive and does excess work in rectangle mode. - if ((!include_click_cell and same_pin) or - (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or - (!include_click_cell and end_pin.eql(click_pin)) or - (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or - (!include_drag_cell and start_pin.eql(drag_pin)) or - (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) - { - return null; - } - - // TODO: Clamp selection to the screen area, don't - // let it extend past the last written row. - - return .init( - start_pin, - end_pin, - rectangle_selection, - ); -} - /// Call to notify Ghostty that the color scheme for the terminal has /// changed. pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { @@ -6271,436 +6001,9 @@ fn presentSurface(self: *Surface) !void { ); } -/// Utility function for the unit tests for mouse selection logic. -/// -/// Tests a click and drag on a 10x5 cell grid, x positions are given in -/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. -/// -/// NOTE: The size tested with has 10px wide cells, meaning only one digit -/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. -/// -/// The provided start_x/y and end_x/y are the expected start and end points -/// of the resulting selection. -fn testMouseSelection( - click_x: f64, - click_y: u32, - drag_x: f64, - drag_y: u32, - start_x: terminal.size.CellCountInt, - start_y: u32, - end_x: terminal.size.CellCountInt, - end_y: u32, - rect: bool, -) !void { - assert(builtin.is_test); - - // Our screen size is 10x5 cells that are - // 10x20 px, with 5px padding on all sides. - const size: rendererpkg.Size = .{ - .cell = .{ .width = 10, .height = 20 }, - .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, - .screen = .{ .width = 110, .height = 110 }, - }; - var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - defer screen.deinit(); - - // We hold both ctrl and alt for rectangular - // select so that this test is platform agnostic. - const mods: input.Mods = .{ - .ctrl = rect, - .alt = rect, - }; - - try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); - - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, - }) orelse unreachable; - - const cell_width_f64: f64 = @floatFromInt(size.cell.width); - const click_x_pos: u32 = - @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + - size.padding.left; - const drag_x_pos: u32 = - @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + - size.padding.left; - - const start_pin = screen.pages.pin(.{ - .viewport = .{ .x = start_x, .y = start_y }, - }) orelse unreachable; - const end_pin = screen.pages.pin(.{ - .viewport = .{ .x = end_x, .y = end_y }, - }) orelse unreachable; - - try std.testing.expectEqualDeep(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = rect, - }, mouseSelection( - click_pin, - drag_pin, - click_x_pos, - drag_x_pos, - mods, - size, - )); -} - -/// Like `testMouseSelection` but checks that the resulting selection is null. -/// -/// See `testMouseSelection` for more details. -fn testMouseSelectionIsNull( - click_x: f64, - click_y: u32, - drag_x: f64, - drag_y: u32, - rect: bool, -) !void { - assert(builtin.is_test); - - // Our screen size is 10x5 cells that are - // 10x20 px, with 5px padding on all sides. - const size: rendererpkg.Size = .{ - .cell = .{ .width = 10, .height = 20 }, - .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, - .screen = .{ .width = 110, .height = 110 }, - }; - var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - defer screen.deinit(); - - // We hold both ctrl and alt for rectangular - // select so that this test is platform agnostic. - const mods: input.Mods = .{ - .ctrl = rect, - .alt = rect, - }; - - try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); - - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, - }) orelse unreachable; - - const cell_width_f64: f64 = @floatFromInt(size.cell.width); - const click_x_pos: u32 = - @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + - size.padding.left; - const drag_x_pos: u32 = - @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + - size.padding.left; - - try std.testing.expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x_pos, - drag_x_pos, - mods, - size, - ), - ); -} - /// Get information about the process(es) running within the surface. Returns /// `null` if there was an error getting the information or the information is /// not available on a particular platform. pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { return self.io.getProcessInfo(info); } - -test "Surface: selection logic" { - // We disable format to make these easier to - // read by pairing sets of coordinates per line. - // zig fmt: off - - // -- LTR - // single cell selection - try testMouseSelection( - 3.0, 3, // click - 3.9, 3, // drag - 3, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click and drag pin cells - try testMouseSelection( - 3.0, 3, // click - 5.9, 3, // drag - 3, 3, // expected start - 5, 3, // expected end - false, // regular selection - ); - // including click pin cell but not drag pin cell - try testMouseSelection( - 3.0, 3, // click - 5.0, 3, // drag - 3, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // including drag pin cell but not click pin cell - try testMouseSelection( - 3.9, 3, // click - 5.9, 3, // drag - 4, 3, // expected start - 5, 3, // expected end - false, // regular selection - ); - // including neither click nor drag pin cells - try testMouseSelection( - 3.9, 3, // click - 5.0, 3, // drag - 4, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // empty selection (single cell on only left half) - try testMouseSelectionIsNull( - 3.0, 3, // click - 3.1, 3, // drag - false, // regular selection - ); - // empty selection (single cell on only right half) - try testMouseSelectionIsNull( - 3.8, 3, // click - 3.9, 3, // drag - false, // regular selection - ); - // empty selection (between two cells, not crossing threshold) - try testMouseSelectionIsNull( - 3.9, 3, // click - 4.0, 3, // drag - false, // regular selection - ); - - // -- RTL - // single cell selection - try testMouseSelection( - 3.9, 3, // click - 3.0, 3, // drag - 3, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click and drag pin cells - try testMouseSelection( - 5.9, 3, // click - 3.0, 3, // drag - 5, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click pin cell but not drag pin cell - try testMouseSelection( - 5.9, 3, // click - 3.9, 3, // drag - 5, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // including drag pin cell but not click pin cell - try testMouseSelection( - 5.0, 3, // click - 3.0, 3, // drag - 4, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including neither click nor drag pin cells - try testMouseSelection( - 5.0, 3, // click - 3.9, 3, // drag - 4, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // empty selection (single cell on only left half) - try testMouseSelectionIsNull( - 3.1, 3, // click - 3.0, 3, // drag - false, // regular selection - ); - // empty selection (single cell on only right half) - try testMouseSelectionIsNull( - 3.9, 3, // click - 3.8, 3, // drag - false, // regular selection - ); - // empty selection (between two cells, not crossing threshold) - try testMouseSelectionIsNull( - 4.0, 3, // click - 3.9, 3, // drag - false, // regular selection - ); - - // -- Wrapping - // LTR, wrap excluded cells - try testMouseSelection( - 9.9, 2, // click - 0.0, 4, // drag - 0, 3, // expected start - 9, 3, // expected end - false, // regular selection - ); - // RTL, wrap excluded cells - try testMouseSelection( - 0.0, 4, // click - 9.9, 2, // drag - 9, 3, // expected start - 0, 3, // expected end - false, // regular selection - ); -} - -test "Surface: rectangle selection logic" { - // We disable format to make these easier to - // read by pairing sets of coordinates per line. - // zig fmt: off - - // -- LTR - // single column selection - try testMouseSelection( - 3.0, 2, // click - 3.9, 4, // drag - 3, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click and drag pin columns - try testMouseSelection( - 3.0, 2, // click - 5.9, 4, // drag - 3, 2, // expected start - 5, 4, // expected end - true, //rectangle selection - ); - // including click pin column but not drag pin column - try testMouseSelection( - 3.0, 2, // click - 5.0, 4, // drag - 3, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // including drag pin column but not click pin column - try testMouseSelection( - 3.9, 2, // click - 5.9, 4, // drag - 4, 2, // expected start - 5, 4, // expected end - true, //rectangle selection - ); - // including neither click nor drag pin columns - try testMouseSelection( - 3.9, 2, // click - 5.0, 4, // drag - 4, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // empty selection (single column on only left half) - try testMouseSelectionIsNull( - 3.0, 2, // click - 3.1, 4, // drag - true, //rectangle selection - ); - // empty selection (single column on only right half) - try testMouseSelectionIsNull( - 3.8, 2, // click - 3.9, 4, // drag - true, //rectangle selection - ); - // empty selection (between two columns, not crossing threshold) - try testMouseSelectionIsNull( - 3.9, 2, // click - 4.0, 4, // drag - true, //rectangle selection - ); - - // -- RTL - // single column selection - try testMouseSelection( - 3.9, 2, // click - 3.0, 4, // drag - 3, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click and drag pin columns - try testMouseSelection( - 5.9, 2, // click - 3.0, 4, // drag - 5, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click pin column but not drag pin column - try testMouseSelection( - 5.9, 2, // click - 3.9, 4, // drag - 5, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // including drag pin column but not click pin column - try testMouseSelection( - 5.0, 2, // click - 3.0, 4, // drag - 4, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including neither click nor drag pin columns - try testMouseSelection( - 5.0, 2, // click - 3.9, 4, // drag - 4, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // empty selection (single column on only left half) - try testMouseSelectionIsNull( - 3.1, 2, // click - 3.0, 4, // drag - true, //rectangle selection - ); - // empty selection (single column on only right half) - try testMouseSelectionIsNull( - 3.9, 2, // click - 3.8, 4, // drag - true, //rectangle selection - ); - // empty selection (between two columns, not crossing threshold) - try testMouseSelectionIsNull( - 4.0, 2, // click - 3.9, 4, // drag - true, //rectangle selection - ); - - // -- Wrapping - // LTR, do not wrap - try testMouseSelection( - 9.9, 2, // click - 0.0, 4, // drag - 9, 2, // expected start - 0, 4, // expected end - true, //rectangle selection - ); - // RTL, do not wrap - try testMouseSelection( - 0.0, 4, // click - 9.9, 2, // drag - 0, 4, // expected start - 9, 2, // expected end - true, //rectangle selection - ); -} diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index d9232bea8..bb11e74fb 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024; /// Path to a file where the cache is stored. path: []const u8, -pub const DefaultPathError = Allocator.Error || error{ - /// The general error that is returned for any filesystem error - /// that may have resulted in the XDG lookup failing. - XdgLookupFailed, -}; - -pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; - /// Returns the default path for the cache for a given program. /// /// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`. @@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; pub fn defaultPath( alloc: Allocator, program: []const u8, -) DefaultPathError![]const u8 { +) ![]const u8 { const state_dir: []const u8 = xdg.state( alloc, .{ .subdir = program }, @@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void { }; } -pub const AddResult = enum { added, updated }; - -pub const AddError = std.fs.Dir.MakeError || - std.fs.Dir.StatFileError || - std.fs.File.OpenError || - std.fs.File.ChmodError || - std.io.Reader.LimitedAllocError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Add or update a hostname entry in the cache. -/// Returns AddResult.added for new entries or AddResult.updated for existing ones. +/// Add or update an entry in the cache, recording `timestamp` (Unix seconds). /// The cache file is created if it doesn't exist with secure permissions (0600). pub fn add( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) AddError!AddResult { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, + timestamp: i64, +) !void { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Create cache directory if needed if (std.fs.path.dirname(self.path)) |dir| { @@ -107,58 +87,49 @@ pub fn add( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - // Add or update entry - const gop = try entries.getOrPut(hostname); - const result: AddResult = if (!gop.found_existing) add: { - const hostname_copy = try alloc.dupe(u8, hostname); - errdefer alloc.free(hostname_copy); + // Update the timestamp of an existing entry, or insert a new one. For a + // new entry, dupe both strings up front so a failed allocation never + // leaves a half-built slot (borrowed key, undefined value) for the + // `deinitEntries` defer to walk. + if (entries.getPtr(key)) |existing| { + existing.timestamp = timestamp; + } else { + const key_copy = try alloc.dupe(u8, key); + errdefer alloc.free(key_copy); const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); errdefer alloc.free(terminfo_copy); - gop.key_ptr.* = hostname_copy; - gop.value_ptr.* = .{ - .hostname = gop.key_ptr.*, - .timestamp = std.time.timestamp(), + try entries.put(key_copy, .{ + .hostname = key_copy, + .timestamp = timestamp, .terminfo_version = terminfo_copy, - }; - break :add .added; - } else update: { - // Update timestamp for existing entry - gop.value_ptr.timestamp = std.time.timestamp(); - break :update .updated; - }; + }); + } - try self.writeCacheFile(entries, null); - return result; + try self.writeCacheFile(entries); } -pub const RemoveError = std.fs.File.OpenError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Remove a hostname entry from the cache. -/// No error is returned if the hostname doesn't exist or the cache file is missing. +/// Remove an entry from the cache. Returns true if an entry was removed, +/// false if the key wasn't present (or the cache file is missing). pub fn remove( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) RemoveError!void { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( self.path, .{ .mode = .read_write }, ) catch |err| switch (err) { - error.FileNotFound => return, + error.FileNotFound => return false, else => return err, }; defer file.close(); @@ -167,7 +138,7 @@ pub fn remove( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); // Read existing entries @@ -175,27 +146,73 @@ pub fn remove( defer deinitEntries(alloc, &entries); // Remove the entry if it exists and ensure we free the memory - if (entries.fetchRemove(hostname)) |kv| { + const removed = if (entries.fetchRemove(key)) |kv| removed: { + assert(kv.key.ptr == kv.value.hostname.ptr); + alloc.free(kv.value.hostname); + alloc.free(kv.value.terminfo_version); + break :removed true; + } else false; + + try self.writeCacheFile(entries); + return removed; +} + +/// Remove all entries older than `max_age_s` seconds and return how many +/// were pruned. Returns zero (and nothing written) if the cache file is +/// missing. +pub fn prune( + self: DiskCache, + alloc: Allocator, + max_age_s: u64, +) !usize { + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return 0, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Lock + // Causes a compile failure in the Zig std library on Windows, see: + // https://github.com/ziglang/zig/issues/18430 + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; + defer if (comptime builtin.os.tag != .windows) file.unlock(); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Drop expired entries from the map, then persist what remains. + const now = std.time.timestamp(); + var expired: std.ArrayList([]const u8) = .empty; + defer expired.deinit(alloc); + var iter = entries.iterator(); + while (iter.next()) |kv| { + const age_s = now -| kv.value_ptr.timestamp; + if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*); + } + for (expired.items) |key| { + const kv = entries.fetchRemove(key).?; assert(kv.key.ptr == kv.value.hostname.ptr); alloc.free(kv.value.hostname); alloc.free(kv.value.terminfo_version); } - try self.writeCacheFile(entries, null); + try self.writeCacheFile(entries); + return expired.items.len; } -pub const ContainsError = std.fs.File.OpenError || - ReadEntriesError || - error{HostnameIsInvalid}; - -/// Check if a hostname exists in the cache. +/// Check if a key exists in the cache. /// Returns false if the cache file doesn't exist. pub fn contains( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) ContainsError!bool { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( @@ -211,12 +228,10 @@ pub fn contains( var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - return entries.contains(hostname); + return entries.contains(key); } -pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError); - -fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { +fn fixupPermissions(file: std.fs.File) !void { // Windows does not support chmod if (comptime builtin.os.tag == .windows) return; @@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { } } -pub const WriteCacheFileError = std.fs.Dir.OpenError || - std.fs.AtomicFile.InitError || - std.fs.AtomicFile.FlushError || - std.fs.AtomicFile.FinishError || - Entry.FormatError || - error{InvalidCachePath}; - fn writeCacheFile( self: DiskCache, entries: std.StringHashMap(Entry), - expire_days: ?u32, -) WriteCacheFileError!void { +) !void { const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath; const cache_basename = std.fs.path.basename(self.path); @@ -255,8 +262,6 @@ fn writeCacheFile( var iter = entries.iterator(); while (iter.next()) |kv| { - // Only write non-expired entries - if (kv.value_ptr.isExpired(expire_days)) continue; try kv.value_ptr.format(&atomic_file.file_writer.interface); } @@ -299,12 +304,10 @@ pub fn deinitEntries( entries.deinit(); } -pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError; - fn readEntries( alloc: Allocator, file: std.fs.File, -) ReadEntriesError!std.StringHashMap(Entry) { +) !std.StringHashMap(Entry) { var reader = file.reader(&.{}); const content = try reader.interface.allocRemaining( alloc, @@ -365,7 +368,7 @@ fn readEntries( } // Supports both standalone hostnames and user@hostname format -fn isValidCacheKey(key: []const u8) bool { +pub fn isValidCacheKey(key: []const u8) bool { if (key.len == 0) return false; // Check for user@hostname format @@ -463,33 +466,23 @@ test "disk cache operations" { const path = try tmp.dir.realpathAlloc(alloc, "cache"); defer alloc.free(path); - // Setup our cache + // Setup our cache. Adding the same key twice exercises both the new + // and existing-entry paths. const cache: DiskCache = .{ .path = path }; - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); - try testing.expectEqual( - AddResult.updated, - try cache.add(alloc, "example.com"), - ); - try testing.expect( - try cache.contains(alloc, "example.com"), - ); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.com", std.time.timestamp()); + try testing.expect(try cache.contains(alloc, "example.com")); // List var entries = try cache.list(alloc); deinitEntries(alloc, &entries); - // Remove - try cache.remove(alloc, "example.com"); - try testing.expect( - !(try cache.contains(alloc, "example.com")), - ); - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); + // Remove reports that it removed the entry, and a second remove of the + // same key reports nothing to remove. + try testing.expect(try cache.remove(alloc, "example.com")); + try testing.expect(!try cache.remove(alloc, "example.com")); + try testing.expect(!(try cache.contains(alloc, "example.com"))); + try cache.add(alloc, "example.com", std.time.timestamp()); } test "disk cache cleans up temp files" { @@ -505,8 +498,8 @@ test "disk cache cleans up temp files" { defer alloc.free(cache_path); const cache: DiskCache = .{ .path = cache_path }; - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com")); - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org")); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.org", std.time.timestamp()); // Verify only the cache file exists and no temp files left behind var count: usize = 0; @@ -518,6 +511,55 @@ test "disk cache cleans up temp files" { try testing.expectEqual(1, count); } +test "disk cache prune" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + + // Back-date one entry an hour old and one 100 days old. + const day = std.time.s_per_day; + const hour = std.time.s_per_hour; + const now = std.time.timestamp(); + try cache.add(alloc, "recent.com", now - hour); + try cache.add(alloc, "old.com", now - 100 * day); + + // Prune entries older than 90 days: only old.com goes. + try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day)); + try testing.expect(try cache.contains(alloc, "recent.com")); + try testing.expect(!try cache.contains(alloc, "old.com")); + + // Pruning again removes nothing. + try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day)); + + // Sub-day granularity: a 30-minute max age prunes the hour-old entry. + try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min)); + try testing.expect(!try cache.contains(alloc, "recent.com")); +} + +test "disk cache prune missing file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30)); +} + test "disk cache reads duplicate keys" { const testing = std.testing; const alloc = testing.allocator; @@ -600,6 +642,39 @@ test "disk cache reads survive allocation failure" { } } +test "disk cache add survives allocation failure" { + const testing = std.testing; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(tmp_path); + const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" }); + defer testing.allocator.free(path); + + const cache: DiskCache = .{ .path = path }; + + // Fail the Nth allocation for every N until add completes. A failed add + // must not leak or leave a half-built map entry. The FailingAllocator + // is backed by testing.allocator to catch either. Each iteration starts + // from a clean cache file. + var fail_index: usize = 0; + while (true) : (fail_index += 1) { + std.fs.cwd().deleteFile(path) catch {}; + var failing = std.testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + const alloc = failing.allocator(); + + if (cache.add(alloc, "user@example.com", 100)) |_| { + if (!failing.has_induced_failure) break; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + } +} + test isValidHost { const testing = std.testing; diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig index b586161f2..158694f9a 100644 --- a/src/cli/ssh-cache/Entry.zig +++ b/src/cli/ssh-cache/Entry.zig @@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void { ); } -pub fn isExpired(self: Entry, expire_days_: ?u32) bool { - const expire_days = expire_days_ orelse return false; - const now = std.time.timestamp(); - const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day); - return age_days > expire_days; -} - -test "cache entry expiration" { - const testing = std.testing; - const now = std.time.timestamp(); - - const fresh_entry: Entry = .{ - .hostname = "test.com", - .timestamp = now - std.time.s_per_day, // 1 day old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!fresh_entry.isExpired(90)); - - const old_entry: Entry = .{ - .hostname = "old.com", - .timestamp = now - (std.time.s_per_day * 100), // 100 days old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(old_entry.isExpired(90)); - - // Test never-expire case - try testing.expect(!old_entry.isExpired(null)); -} - -test "cache entry expiration exact boundary" { - const testing = std.testing; - const now = std.time.timestamp(); - - // Exactly at expiration boundary - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now - (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); - try testing.expect(boundary_entry.isExpired(29)); -} - -test "cache entry expiration large timestamp" { - const testing = std.testing; - const now = std.time.timestamp(); - - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now + (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); -} - test "cache entry parsing valid formats" { const testing = std.testing; diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig index 7f808a6cd..76bfb10ee 100644 --- a/src/cli/ssh.zig +++ b/src/cli/ssh.zig @@ -302,7 +302,7 @@ fn runInner( // Attempt to cache (if needed) on a successful ssh execution. if (exit_code == 0) if (session.to_cache) |entry| { - if (entry.cache.add(alloc, entry.dest)) |_| { + if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| { verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); } else |err| { log.debug("cache add failed for '{s}': {}", .{ entry.dest, err }); diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index d3ee658af..83031e8e7 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -3,6 +3,7 @@ const fs = std.fs; const Allocator = std.mem.Allocator; const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; +const Duration = @import("../config.zig").Config.Duration; pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); @@ -10,8 +11,7 @@ pub const Options = struct { clear: bool = false, add: ?[]const u8 = null, remove: ?[]const u8 = null, - host: ?[]const u8 = null, - @"expire-days": ?u32 = null, + prune: ?Duration = null, pub fn deinit(self: *Options) void { _ = self; @@ -25,27 +25,36 @@ pub const Options = struct { /// Manage the SSH terminfo cache for automatic remote host setup. /// -/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, -/// Ghostty automatically installs its terminfo on remote hosts. This command -/// manages the cache of successful installations to avoid redundant uploads. +/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records +/// each success in this cache so it doesn't re-upload on later connections. +/// (`+ssh` runs automatically from the shell integration when +/// `shell-integration-features` includes `ssh-terminfo`.) This command +/// inspects and maintains that cache. /// -/// The cache stores hostnames (or user@hostname combinations) along with timestamps. -/// Entries older than the expiration period are automatically removed during cache -/// operations. By default, entries never expire. +/// The cache stores destinations (a hostname or user@hostname) along with +/// timestamps. /// -/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. -/// If multiple are specified, one of the actions will be executed but -/// it isn't guaranteed which one. This is entirely unsafe so you should split -/// multiple actions into separate commands. +/// A positional destination queries the cache: `user@hostname` shows that +/// exact entry, while a bare `hostname` shows every cached entry for that +/// host regardless of user. With no destination and no action, the entire +/// cache is listed. A query that matches nothing exits 1. +/// +/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be +/// specified, and not together with a positional destination; combining them +/// is an error. +/// +/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`, +/// `y`) and removes every entry older than it, e.g. `--prune=30d`, +/// `--prune=6h`, `--prune=1y`. /// /// Examples: -/// ghostty +ssh-cache # List all cached hosts -/// ghostty +ssh-cache --host=example.com # Check if host is cached -/// ghostty +ssh-cache --add=example.com # Manually add host to cache -/// ghostty +ssh-cache --add=user@example.com # Add user@host combination -/// ghostty +ssh-cache --remove=example.com # Remove host from cache -/// ghostty +ssh-cache --clear # Clear entire cache -/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period +/// ghostty +ssh-cache # List all cached destinations +/// ghostty +ssh-cache user@example.com # Show that destination +/// ghostty +ssh-cache example.com # Show all users on that host +/// ghostty +ssh-cache --add=user@example.com # Manually add a destination +/// ghostty +ssh-cache --remove=user@example.com # Remove a destination +/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days +/// ghostty +ssh-cache --clear # Clear entire cache pub fn run(alloc_gpa: Allocator) !u8 { var arena = std.heap.ArenaAllocator.init(alloc_gpa); defer arena.deinit(); @@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); - { - var iter = try args.argsIterator(alloc_gpa); - defer iter.deinit(); - try args.parse(Options, alloc_gpa, &opts, &iter); - } - var stdout_buffer: [1024]u8 = undefined; var stdout_file: std.fs.File = .stdout(); var stdout_writer = stdout_file.writer(&stdout_buffer); @@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 { var stderr_writer = stderr_file.writer(&stderr_buffer); const stderr = &stderr_writer.interface; - const result = runInner(alloc, opts, stdout, stderr); + // The cache is queried by a positional destination (`user@host` or a + // bare `host`). `args.parse` rejects non-`--` tokens, so we lift the + // positional out here and parse only the remaining flags. `--host=X` + // is accepted as a deprecated spelling of the positional (it was the + // original shipped flag name). + var query: ?[]const u8 = null; + var flags: std.ArrayList([]const u8) = .empty; + { + var iter = try args.argsIterator(alloc_gpa); + defer iter.deinit(); + while (iter.next()) |arg| { + const is_host_flag = std.mem.startsWith(u8, arg, "--host="); + if (is_host_flag) { + try stderr.print( + "Warning: --host is deprecated; pass the destination " ++ + "directly, e.g. `ghostty +ssh-cache {s}`.\n", + .{arg["--host=".len..]}, + ); + } + const dest: ?[]const u8 = if (is_host_flag) + arg["--host=".len..] + else if (!std.mem.startsWith(u8, arg, "-")) + arg + else + null; + + if (dest) |d| { + if (query != null) { + try stderr.print( + "Error: only one destination may be specified.\n", + .{}, + ); + stderr.flush() catch {}; + return 2; + } + query = try alloc.dupe(u8, d); + } else { + try flags.append(alloc, try alloc.dupe(u8, arg)); + } + } + } + + { + var iter = args.sliceIterator(flags.items); + args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) { + error.InvalidField => { + try stderr.print("Error: unknown flag.\n", .{}); + stderr.flush() catch {}; + return 2; + }, + error.InvalidValue, error.ValueRequired => { + try stderr.print("Error: invalid flag value.\n", .{}); + stderr.flush() catch {}; + return 2; + }, + else => return err, + }; + } + + const result = runInner(alloc, opts, query, stdout, stderr); // Flushing *shouldn't* fail but... stdout.flush() catch {}; @@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 { pub fn runInner( alloc: Allocator, opts: Options, + query: ?[]const u8, stdout: *std.Io.Writer, stderr: *std.Io.Writer, ) !u8 { + // At most one action may be specified, and a query (positional + // destination) is itself an action. + const action_count = + @as(usize, @intFromBool(opts.clear)) + + @intFromBool(opts.add != null) + + @intFromBool(opts.remove != null) + + @intFromBool(opts.prune != null) + + @intFromBool(query != null); + if (action_count > 1) { + try stderr.print( + "Error: only one of a destination, --clear, --add, --remove, " ++ + "or --prune may be specified.\n", + .{}, + ); + return 2; + } + // Setup our disk cache to the standard location const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); const cache: DiskCache = .{ .path = cache_path }; if (opts.clear) { try cache.clear(); - try stdout.print("Cache cleared.\n", .{}); return 0; } - if (opts.add) |host| { - const result = cache.add(alloc, host) catch |err| switch (err) { - DiskCache.Error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - DiskCache.Error.CacheIsLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; + if (opts.add) |dest| { + cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) { + error.InvalidCacheKey => { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{dest}, + ); + return 2; }, else => { try stderr.print( "Error: Unable to add '{s}' to cache. Error: {}\n", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - - switch (result) { - .added => try stdout.print("Added '{s}' to cache.\n", .{host}), - .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}), - } return 0; } - if (opts.remove) |host| { - cache.remove(alloc, host) catch |err| switch (err) { - DiskCache.Error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - DiskCache.Error.CacheIsLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; + if (opts.remove) |dest| { + const removed = cache.remove(alloc, dest) catch |err| switch (err) { + error.InvalidCacheKey => { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{dest}, + ); + return 2; }, else => { try stderr.print( "Error: Unable to remove '{s}' from cache. Error: {}\n", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - try stdout.print("Removed '{s}' from cache.\n", .{host}); + // Silence on success; a no-op removal is an error (exit 1). + if (!removed) { + try stderr.print("Error: '{s}' is not in the cache.\n", .{dest}); + return 1; + } return 0; } - if (opts.host) |host| { - const cached = cache.contains(alloc, host) catch |err| switch (err) { - error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - else => { - try stderr.print( - "Error: Unable to check host '{s}' in cache. Error: {}\n", - .{ host, err }, - ); - return 1; - }, - }; - - if (cached) { - try stdout.print( - "'{s}' has Ghostty terminfo installed.\n", - .{host}, + if (opts.prune) |max_age| { + const max_age_s = max_age.duration / std.time.ns_per_s; + if (max_age_s == 0) { + try stderr.print( + "Error: --prune requires a duration of at least one second.\n", + .{}, ); - return 0; - } else { - try stdout.print( - "'{s}' does not have Ghostty terminfo installed.\n", - .{host}, - ); - return 1; + return 2; } + const pruned = cache.prune(alloc, max_age_s) catch |err| { + try stderr.print("Error: Unable to prune cache. Error: {}\n", .{err}); + return 1; + }; + try stdout.print("Pruned cache entries: {d}\n", .{pruned}); + return 0; } - // Default action: list all hosts var entries = try cache.list(alloc); defer DiskCache.deinitEntries(alloc, &entries); + + // A positional query filters the listing: an exact `user@host` match, + // or every entry on a bare `host`. + if (query) |q| { + if (!DiskCache.isValidCacheKey(q)) { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{q}, + ); + return 2; + } + + var matches: std.StringHashMap(Entry) = .init(alloc); + defer matches.deinit(); + var iter = entries.iterator(); + while (iter.next()) |kv| { + const key = kv.key_ptr.*; + if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*); + } + + if (matches.count() == 0) return 1; + try listEntries(alloc, &matches, stdout); + return 0; + } + + // List all destinations by default. try listEntries(alloc, &entries, stdout); return 0; } @@ -187,10 +272,7 @@ fn listEntries( entries: *const std.StringHashMap(Entry), writer: *std.Io.Writer, ) !void { - if (entries.count() == 0) { - try writer.print("No hosts in cache.\n", .{}); - return; - } + if (entries.count() == 0) return; // Sort entries by hostname for consistent output var items: std.ArrayList(Entry) = .empty; @@ -207,22 +289,200 @@ fn listEntries( } }.lessThan); - try writer.print("Cached hosts ({d}):\n", .{items.items.len}); - const now = std.time.timestamp(); - + // Align the timestamp column by padding destinations to the widest. + var widest: usize = 0; for (items.items) |entry| { - const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); - if (age_days == 0) { - try writer.print(" {s} (today)\n", .{entry.hostname}); - } else if (age_days == 1) { - try writer.print(" {s} (yesterday)\n", .{entry.hostname}); - } else { - try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); - } + widest = @max(widest, entry.hostname.len); } + + const now = std.time.timestamp(); + for (items.items) |entry| { + try writer.print("{s}", .{entry.hostname}); + try writer.splatByteAll(' ', widest - entry.hostname.len + 2); + + var iso_buf: [20]u8 = undefined; + var age_buf: [32]u8 = undefined; + try writer.print("{s} ({s})\n", .{ + formatTimestamp(&iso_buf, entry.timestamp), + relativeAge(&age_buf, now, entry.timestamp), + }); + } +} + +/// Whether a cache `key` matches a positional `query`. A `user@host` query +/// (containing `@`) matches one exact key; a bare `host` query matches every +/// key on that host regardless of user, comparing against the key's host +/// component (everything after its first `@`, or the whole key if userless). +fn matchesQuery(key: []const u8, query: []const u8) bool { + if (std.mem.indexOfScalar(u8, query, '@') != null) { + return std.mem.eql(u8, key, query); + } + + const at = std.mem.indexOfScalar(u8, key, '@'); + const host = if (at) |i| key[i + 1 ..] else key; + return std.mem.eql(u8, host, query); +} + +test matchesQuery { + const testing = std.testing; + + // Exact user@host: only the identical key. + try testing.expect(matchesQuery("user@example.com", "user@example.com")); + try testing.expect(!matchesQuery("root@example.com", "user@example.com")); + try testing.expect(!matchesQuery("example.com", "user@example.com")); + + // Bare host: every key on that host, plus a keyless entry for it. + try testing.expect(matchesQuery("user@example.com", "example.com")); + try testing.expect(matchesQuery("root@example.com", "example.com")); + try testing.expect(matchesQuery("example.com", "example.com")); + try testing.expect(!matchesQuery("user@other.com", "example.com")); +} + +/// Format a Unix timestamp as an ISO-8601 UTC string +/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes. +/// Out-of-range input is clamped so this can't crash on a garbage cache line. +fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 { + // Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch` + // accumulates the year in a `u16` (panics beyond that), and the buffer + // only fits a 4-digit year. + const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799)); + + const epoch = std.time.epoch; + const epoch_secs: epoch.EpochSeconds = .{ .secs = secs }; + const day = epoch_secs.getEpochDay(); + const year_day = day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + const ds = epoch_secs.getDaySeconds(); + return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ + year_day.year, + month_day.month.numeric(), + month_day.day_index + 1, + ds.getHoursIntoDay(), + ds.getMinutesIntoHour(), + ds.getSecondsIntoMinute(), + }) catch unreachable; +} + +test formatTimestamp { + const testing = std.testing; + var buf: [20]u8 = undefined; + + try testing.expectEqualStrings( + "2026-05-05T22:49:33Z", + formatTimestamp(&buf, 1778021373), + ); + + // Epoch. + try testing.expectEqualStrings( + "1970-01-01T00:00:00Z", + formatTimestamp(&buf, 0), + ); + + // Out-of-range inputs clamp instead of overflowing the [20]u8 / + // panicking inside std: negatives floor at the epoch, huge values cap + // at the last second of year 9999. + try testing.expectEqualStrings( + "1970-01-01T00:00:00Z", + formatTimestamp(&buf, -5), + ); + try testing.expectEqualStrings( + "9999-12-31T23:59:59Z", + formatTimestamp(&buf, std.math.maxInt(i64)), + ); +} + +/// Format the age of `timestamp` (relative to `now`, both Unix seconds) +/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s +/// unit vocabulary but keeps only the single largest unit for scannability. +/// A non-positive age (timestamp at or after `now`) is "now". +fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 { + // Saturating so a garbage timestamp can't overflow; clamp at 0 so a + // future timestamp becomes a zero age rather than going negative. + const age: u64 = @intCast(@max(0, now -| timestamp)); + if (age == 0) return "now"; + + // Round down to the largest unit that fits, so Duration.format emits + // only that unit (e.g. 19d -> 2w, 90m -> 1h). + const units = [_]u64{ + 365 * std.time.s_per_day, // y + std.time.s_per_week, // w + std.time.s_per_day, // d + std.time.s_per_hour, // h + std.time.s_per_min, // m + 1, // s + }; + const unit = for (units) |u| { + if (age >= u) break u; + } else 1; + + // Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g. + // hugely negative, timestamp otherwise yields an age near i64-max). + const max_age = std.math.maxInt(u64) / std.time.ns_per_s; + const rounded = @min(age, max_age) / unit * unit; + const d: Duration = .{ .duration = rounded * std.time.ns_per_s }; + return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable; +} + +test relativeAge { + const testing = std.testing; + var buf: [32]u8 = undefined; + const now: i64 = 2_000_000_000; // fixed reference + const min = std.time.s_per_min; + const hour = std.time.s_per_hour; + const day = std.time.s_per_day; + + // Out-of-range timestamps don't crash: a huge future one saturates to + // a non-positive age ("now"); a negative one is a large but real age. + try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64))); + try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100)); + + // A huge age (garbage timestamp) saturates the ns conversion instead of + // overflowing; it must not crash and must fit the buffer. + try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago")); + + // Future timestamp (clock skew) and same-instant read "now". + try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100)); + try testing.expectEqualStrings("now", relativeAge(&buf, now, now)); + + // Only the single largest unit is kept (smaller units rounded away). + try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30)); + try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min)); + try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m + try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour)); + try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h + try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day)); + try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w } test { _ = DiskCache; _ = Entry; } + +test "runInner rejects multiple actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var stdout: std.Io.Writer.Allocating = .init(alloc); + defer stdout.deinit(); + var stderr: std.Io.Writer.Allocating = .init(alloc); + defer stderr.deinit(); + + // The check runs before any cache access, so it never touches disk. + const code = try runInner(alloc, .{ + .add = "example.com", + .remove = "other.com", + }, null, &stdout.writer, &stderr.writer); + + try testing.expectEqual(@as(u8, 2), code); + try testing.expectEqualStrings("", stdout.written()); + try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null); + + // A positional query is itself an action: query + a flag conflicts. + stderr.clearRetainingCapacity(); + const code2 = try runInner(alloc, .{ + .clear = true, + }, "example.com", &stdout.writer, &stderr.writer); + try testing.expectEqual(@as(u8, 2), code2); + try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null); +} diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index d73e784ce..a630bad87 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -462,7 +462,8 @@ fn mouseTable( { const left_click_point: terminal.point.Coordinate = pt: { - const p = surface_mouse.left_click_pin orelse break :pt .{}; + const p = surface_mouse.selection_gesture.validatedLeftClickPin(&t.screens) orelse + break :pt .{}; const pt = t.screens.active.pages.pointFromPin( .active, p.*, @@ -495,8 +496,8 @@ fn mouseTable( _ = cimgui.c.ImGui_TableSetColumnIndex(1); cimgui.c.ImGui_Text( "(%dpx, %dpx)", - @as(u32, @intFromFloat(surface_mouse.left_click_xpos)), - @as(u32, @intFromFloat(surface_mouse.left_click_ypos)), + @as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_xpos)), + @as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_ypos)), ); } } 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 new file mode 100644 index 000000000..22ba468b9 --- /dev/null +++ b/src/terminal/SelectionGesture.zig @@ -0,0 +1,2008 @@ +/// SelectionGesture manages gesture-based terminal text selection for one +/// pointer stream: press, drag, release, autoscroll, and pressure/deep-press +/// selection. +/// +/// This type owns only the state required to interpret a gesture. It does not +/// modify the terminal selection directly, except for scrolling the viewport +/// during `autoscrollTick`. The caller feeds platform events into this type and +/// applies the returned `Selection` to the active screen when appropriate. +/// +/// A typical single-click drag flow looks like this: +/// +/// ```zig +/// const selection = try gesture.press(terminal, .{ ... }); +/// try terminal.screens.active.select(selection); +/// if (gesture.drag(terminal, .{ ... })) |selection| { +/// try terminal.screens.active.select(selection); +/// } +/// gesture.release(terminal, .{ ... }); +/// ``` +/// +/// Double- and triple-click gestures use the same event flow. Repeated presses +/// inside `Press.repeat_interval` and within `Press.max_distance` increment the +/// internal click count up to three. `Press.behaviors` maps single-, double-, +/// and triple-clicks to behavior. By default, a single press returns null to +/// clear any existing selection, a double-click returns a word selection, and a +/// triple-click returns a line selection. Drags use the behavior selected by the +/// corresponding press. A new press that is too late, too far away, or on +/// another active screen starts a new single-click gesture. +/// +/// # Resetting and lifetime +/// +/// `release` ends the active drag/autoscroll phase but intentionally preserves +/// enough state for a subsequent press to become a double- or triple-click. +/// Call `reset` when the gesture is cancelled rather than released normally, or +/// when another subsystem takes ownership of pointer input. Examples include +/// enabling mouse reporting for an application, losing pointer/button state, +/// destroying the surface, switching to a mode that must not continue text +/// selection, or otherwise abandoning the current click sequence. Call `deinit` +/// once before discarding the gesture object so any tracked click pin is +/// released. +/// +/// # Terminal and screen changes +/// +/// The initial press pin is tracked in the active screen's `PageList`, so normal +/// terminal output and viewport scrolling can move rows without making the +/// gesture immediately stale. Selection results are computed against the current +/// terminal contents at the time of each call. For example, a double-click drag +/// selects word boundaries from the screen as it exists during `drag`, not from a +/// snapshot captured at `press`. +/// +/// The tracked pin is tied to both a `ScreenSet.Key` and that screen's +/// generation. If the active screen changes, or a screen is removed/recycled, +/// `validatedLeftClickPin` returns null and drag-style operations stop producing +/// selections. `autoscrollTick` treats this as cancellation and calls `reset` so +/// callers can stop their timers. This avoids exposing pins from inactive or +/// freed screens, but it does not make a historical snapshot of terminal data. +/// +/// # Concurrency +/// +/// SelectionGesture is not concurrency safe. It has mutable gesture state and +/// mutates/tracks pins inside the terminal page list without taking locks. The +/// caller must serialize all calls that touch the same gesture and terminal, +/// typically by holding the same terminal/renderer mutex used for other screen +/// mutations. Do not call `press`, `drag`, `release`, `reset`, `deinit`, or +/// `autoscrollTick` concurrently with each other or with unrelated terminal +/// mutations unless the caller provides that synchronization. +const SelectionGesture = @This(); + +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"); +const ScreenSet = @import("ScreenSet.zig"); +const Selection = @import("Selection.zig"); +const Terminal = @import("Terminal.zig"); +const point = @import("point.zig"); + +/// The tracked pin of the initial left click along with the screen +/// that the pin is part of. +left_click_pin: ?*Pin, +left_click_screen: ScreenSet.Key, +left_click_screen_generation: usize, + +/// The count of clicks to count double and triple clicks and so on. +/// The left click time was the last time the left click was done, if the +/// caller could provide one. If this is null then we only support single clicks. +left_click_count: u3, +left_click_time: ?std.time.Instant, + +/// The selection behavior chosen for the active left-click gesture. +left_click_behavior: Behavior, + +/// The starting xpos/ypos of the left click. Note that if scrolling occurs, +/// these will point to different cells, but the xpos/ypos will stay +/// stable during scrolling relative to the surface. +left_click_xpos: f64, +left_click_ypos: f64, + +/// True once the active left-click gesture has moved away from the initially +/// pressed cell. This is reset on every press that starts or continues a +/// multi-click sequence, and is left available for callers to inspect while +/// handling the corresponding release. +left_click_dragged: bool, + +/// The current autoscroll state for the active left-click drag gesture. +left_drag_autoscroll: Autoscroll, + +/// The direction that selection dragging should autoscroll the viewport. +/// This is derived from the most recent drag position relative to the +/// surface bounds and reset whenever there is no active drag gesture. +/// +/// When autoscroll is non-none, the caller should setup a timer +/// to periodically call autoscrollTick. The timer interval is up to the +/// caller but reasonable defaults are approximately every 15 milliseconds. +/// +/// This is used to implement selection above/below the viewport that +/// wants to drag the viewport. +pub const Autoscroll = lib.Enum(lib.target, &.{ + "none", + "up", + "down", +}); + +/// The selection behavior for a click and subsequent drag. +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", + + // Line selection on press and line-granular drag selection. + "line", + + // Semantic command output selection on press and drag. + "output", +}); + +/// Standard terminal selection behavior for single-, double-, and triple-clicks. +/// +/// A single click uses cell behavior, which returns null on press so callers can +/// clear any existing selection and then drag by cell. A double-click selects and +/// drags by word. A triple-click selects and drags by line. +pub const default_behaviors: [3]Behavior = .{ .cell, .word, .line }; + +/// Distance from the top or bottom surface edge, in pixels, where dragging +/// should request autoscroll. This preserves the historical 1px buffer used +/// so fullscreen-edge drags can still trigger autoscroll. +const autoscroll_buffer: f64 = 1; + +pub const init: SelectionGesture = .{ + .left_click_pin = null, + .left_click_count = 0, + .left_click_time = null, + .left_click_behavior = .cell, + .left_click_screen = .primary, + .left_click_screen_generation = 0, + .left_click_xpos = 0, + .left_click_ypos = 0, + .left_click_dragged = false, + .left_drag_autoscroll = .none, +}; + +pub fn deinit(self: *SelectionGesture, t: *Terminal) void { + // Grab our pagelist that is associated with the pin. If it doesn't + // exist anymore then our tracked pin is already free. + const pin = self.left_click_pin orelse return; + if (t.screens.generation(self.left_click_screen) != self.left_click_screen_generation) return; + const screen = t.screens.get(self.left_click_screen) orelse return; + screen.pages.untrackPin(pin); +} + +/// Reset any active gesture state and untrack the tracked click pin. +/// +/// Use this for cancellation/abandonment, not for the ordinary left-button +/// release path. `release` deliberately keeps the last press time/count so a +/// following press can become a double- or triple-click; `reset` clears that +/// sequence and makes the next press a fresh single click. +/// +/// Examples of reset-worthy events are: mouse reporting taking over, pointer +/// capture being lost, a surface/window being torn down, or another interaction +/// mode deciding that text selection must stop immediately. If the active screen +/// was already removed or recycled, this safely drops the stale reference without +/// trying to untrack a pin from the wrong screen generation. +pub fn reset(self: *SelectionGesture, t: *Terminal) void { + self.left_click_count = 0; + self.left_click_time = null; + self.left_click_behavior = .cell; + self.left_click_dragged = false; + self.left_drag_autoscroll = .none; + self.untrackPin(t); +} + +/// Return the tracked left-click pin only if it still belongs to the current +/// active screen instance. +/// +/// This validates both the screen key and generation so a pin from a removed, +/// recycled, or inactive screen is never exposed to callers. A null result means +/// callers should treat the in-progress gesture as temporarily or permanently +/// unable to produce a selection. For a normal drag this usually means "do +/// nothing for this event"; for autoscroll it is treated as cancellation because +/// a timer should not continue firing for a gesture that no longer has a valid +/// anchor. +pub fn validatedLeftClickPin( + self: *const SelectionGesture, + screens: *const ScreenSet, +) ?*Pin { + const pin = self.left_click_pin orelse return null; + if (self.left_click_screen != screens.active_key) return null; + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; + _ = screens.get(self.left_click_screen) orelse return null; + return pin; +} + +pub const Press = struct { + /// The time when the press event occurred. Use a monotonic timer. + /// This can be null if you're on a system that doesn't support + /// time for some reason. In that case, we only support single clicks. + time: ?std.time.Instant, + + /// The cell where the click was. + /// + /// `press` stores a tracked copy of this pin. The caller does not need to + /// keep `p.pin` alive after the call returns, but the pin must belong to the + /// terminal's active screen when passed in. + pin: Pin, + + /// The x/y value of the click relative to the surface with (0,0) being + /// top-left. This is used for distance detection for multi-clicks so + /// double/triple clicks too far away from each other will reset the click + /// count as well more accurate drag behaviors. + xpos: f64, + ypos: f64, + + /// Maximum distance a click can be from the original click to register + /// as a repeat. If uncertain, set this to cell width. + max_distance: f64, + + /// The maximum interval in nanoseconds that a press is considered + /// a repeat e.g. to record double/triple clicks. + repeat_interval: u64, + + /// The codepoints that delimit words for double-click selection. + word_boundary_codepoints: []const u21, + + /// Selection behaviors for single-, double-, and triple-clicks. + behaviors: *const [3]Behavior = &default_behaviors, +}; + +/// Record a press event and return the standard selection for this click. +/// +/// If this press continues the existing click sequence, the click count is +/// incremented up to three and the original anchor pin is kept. Otherwise, the +/// previous gesture state is cleared and this press becomes the new anchor. +/// The returned selection is untracked and represents the standard terminal +/// click behavior for the resulting click count. The caller is responsible for +/// applying it to the screen, usually with `Screen.select`, and for arranging +/// any copy-on-select behavior. +/// +/// Examples: +/// +/// * first press: `left_click_count == 1`, defaults to cell behavior; +/// * second nearby press within the repeat interval: `left_click_count == 2`, +/// defaults to word behavior; +/// * third nearby press within the repeat interval: `left_click_count == 3`, +/// defaults to line behavior; +/// * press after the interval, too far away, or after a screen generation +/// change: starts over at `left_click_count == 1` and returns null. +pub fn press( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) Allocator.Error!?Selection { + if (self.left_click_count > 0) { + if (self.pressRepeat(t, p)) { + // Successful repeat. + return self.pressSelection(t.screens.active, p); + } else |err| switch (err) { + error.PressRequiresReset => {}, + } + } + + // Initial click or the repeat failed for some reason such as + // the subsequent click being too far away. + try self.pressInitial(t, p); + return self.pressSelection(t.screens.active, p); +} + +pub const Drag = struct { + /// The cell where the current drag position is. This is used + /// synchronously to calculate the selection and is not tracked. + pin: Pin, + + /// The x/y value of the drag relative to the surface with (0,0) being + /// top-left. + xpos: f64, + ypos: f64, + + /// True if the current drag should produce a rectangular selection. + rectangle: bool, + + /// The codepoints that delimit words for double-click drag selection. + word_boundary_codepoints: []const u21, + + /// Geometry required for selection threshold and autoscroll calculations. + geometry: Geometry, + + /// Display geometry needed to translate surface-relative pointer positions + /// into selection behavior. + pub const Geometry = struct { + /// The number of columns in the rendered terminal grid. + columns: u32, + + /// The width of one terminal cell in surface pixels. + cell_width: u32, + + /// The left padding before the terminal grid begins, in surface pixels. + padding_left: u32, + + /// The height of the rendered terminal surface in surface pixels. + screen_height: u32, + }; +}; + +/// Record a drag event and return the current untracked drag selection. +/// +/// The returned selection is untracked and represents the best selection for the +/// terminal contents at the time of this call. The caller is responsible for +/// applying it to the screen, usually with `Screen.select`, and for arranging any +/// copy-on-select behavior. A null result means either there is no active +/// selection gesture, the original press is no longer valid for the active +/// screen, or the drag has not crossed the threshold required to select a cell. +/// +/// This method also updates `left_click_dragged` and `left_drag_autoscroll`. +/// If `left_drag_autoscroll` becomes `.up` or `.down`, the caller should start or +/// keep a timer that calls `autoscrollTick` while the button remains pressed. If +/// it becomes `.none`, the caller should stop that timer. +/// +/// Normal terminal output and viewport movement between drag events are allowed: +/// the tracked press pin follows the page list, and the drag pin is used only +/// synchronously. Content-sensitive selections such as word and line selection +/// are recalculated from the current active screen every time. +pub fn drag( + self: *SelectionGesture, + t: *Terminal, + d: Drag, +) ?Selection { + // If we aren't currently clicked then we don't do any dragging + // behavior. + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return null; + } + + // Get our click pin. We get a validated pin because if our + // screen changed out from under us then we aren't actually + // clicking anymore. + const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; + if (!d.pin.eql(click_pin.*)) self.left_click_dragged = true; + + // Determine if we should autoscroll. If our drag position is above + // the top, we go up. If its below the bottom we go down. Easy. + const max_y: f64 = @floatFromInt(d.geometry.screen_height); + self.left_drag_autoscroll = if (d.ypos <= autoscroll_buffer) + .up + else if (d.ypos > max_y - autoscroll_buffer) + .down + else + .none; + + const selection = switch (self.left_click_behavior) { + .cell => dragSelection( + click_pin.*, + d.pin, + @intFromFloat(@max(0, self.left_click_xpos)), + @intFromFloat(@max(0, d.xpos)), + d.rectangle, + d.geometry, + ), + + .word => dragSelectionWord( + t.screens.active, + click_pin.*, + d.pin, + d.word_boundary_codepoints, + ), + + .line => dragSelectionLine( + t.screens.active, + click_pin.*, + d.pin, + ), + + .output => dragSelectionOutput( + t.screens.active, + click_pin.*, + d.pin, + ), + }; + + // Same-cell cell selections can still become real selections when the drag + // crosses the within-cell threshold. Treat those as drags so callers don't + // also process click-only actions such as opening links. + if (self.left_click_behavior == .cell and selection != null) { + self.left_click_dragged = true; + } + + return selection; +} + +pub const AutoscrollTick = struct { + /// The viewport cell where the current drag position is. This is resolved + /// after the viewport is scrolled so the selection tracks the newly visible + /// row under the pointer. + viewport: point.Coordinate, + + /// The x/y value of the drag relative to the surface with (0,0) being + /// top-left. + xpos: f64, + ypos: f64, + + /// True if the current drag should produce a rectangular selection. + rectangle: bool, + + /// The codepoints that delimit words for double-click drag selection. + word_boundary_codepoints: []const u21, + + /// Geometry required for selection threshold and autoscroll calculations. + geometry: Drag.Geometry, +}; + +/// Record a selection autoscroll tick for the active left-click drag gesture. +/// +/// This scrolls the viewport in the active autoscroll direction and then +/// continues the drag at the provided viewport position. The viewport position +/// is resolved to a pin after scrolling so the drag applies to the row now under +/// the pointer. +/// +/// This always scrolls the viewport by exactly one row in the current +/// autoscroll direction. If you want to scroll by more, increase your +/// tick rate. +/// +/// If the original press pin no longer belongs to the active screen, this calls +/// `reset` and returns null. That is a signal for the caller to stop its +/// autoscroll timer and leave any existing terminal selection alone unless some +/// other event says otherwise. +pub fn autoscrollTick( + self: *SelectionGesture, + t: *Terminal, + tick: AutoscrollTick, +) ?Selection { + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return null; + } + + const delta: isize = switch (self.left_drag_autoscroll) { + .none => return null, + .up => -1, + .down => 1, + }; + + // If our click pin no longer belongs to the active screen, the gesture is + // no longer valid. Stop it so callers can stop their autoscroll timer + // without clearing the current selection as if this were a real drag. + _ = self.validatedLeftClickPin(&t.screens) orelse { + self.reset(t); + return null; + }; + + t.scrollViewport(.{ .delta = delta }); + + const pin = t.screens.active.pages.pin(.{ .viewport = tick.viewport }) orelse return null; + return self.drag(t, .{ + .pin = pin, + .xpos = tick.xpos, + .ypos = tick.ypos, + .rectangle = tick.rectangle, + .word_boundary_codepoints = tick.word_boundary_codepoints, + .geometry = tick.geometry, + }); +} + +/// A pressure-based activation during an existing left-click gesture. +/// +/// This is the terminal gesture model for platform features such as macOS +/// force click / deep click on pressure-sensitive trackpads. It is not a +/// distinct mouse button and it is not part of the normal single/double/triple +/// click count sequence; it can only occur after a left press is already +/// active. +pub const DeepPress = struct { + /// The codepoints that delimit words for the word selection produced by + /// the deep press. + word_boundary_codepoints: []const u21, +}; + +/// Record a deep press event for the active left-click gesture. +/// +/// A deep press is a force/pressure activation while the primary pointer is +/// already down. Ghostty treats it like the platform text-selection affordance: +/// select the word under the original press, then consume the gesture so +/// further cursor movement while the button remains pressed does not drag or +/// autoscroll the selection. +/// +/// After a successful deep press, the click sequence is cleared and the tracked +/// pin is untracked. The returned selection should be applied by the caller. A +/// null result means there was no valid active left-click anchor, commonly +/// because the screen changed or the gesture had already been cancelled. +pub fn deepPress( + self: *SelectionGesture, + t: *Terminal, + p: DeepPress, +) ?Selection { + const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; + const sel = t.screens.active.selectWord( + click_pin.*, + p.word_boundary_codepoints, + ); + + self.left_click_count = 0; + self.left_click_time = null; + self.left_click_behavior = .cell; + self.left_click_dragged = true; + self.left_drag_autoscroll = .none; + self.untrackPin(t); + + return sel; +} + +pub const Release = struct { + /// The cell where the release occurred, if the release position mapped to + /// a valid cell. This is used synchronously to update gesture state and is + /// not tracked. + pin: ?Pin, +}; + +/// Record a release event for the active left-click gesture. +/// +/// This stops autoscroll and updates `left_click_dragged`, but it does not clear +/// the click count or time. Keeping that state is what lets the next nearby press +/// become a double- or triple-click. Call `reset` instead if the release should +/// cancel the click sequence entirely. +/// +/// Pass the release pin when the pointer position maps to a valid terminal cell. +/// If it does not, pass null; the gesture then conservatively records that the +/// pointer moved away from the original pressed cell. This is useful for callers +/// that use `left_click_dragged` after release to decide whether a click should +/// activate links or other hit targets. +pub fn release( + self: *SelectionGesture, + t: *Terminal, + r: Release, +) void { + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return; + } + + if (r.pin) |release_pin| { + if (self.validatedLeftClickPin(&t.screens)) |click_pin| { + if (!release_pin.eql(click_pin.*)) self.left_click_dragged = true; + } else { + // If the original anchor is no longer valid, conservatively treat + // this as a drag/cancelled click so callers don't perform click-only + // actions on a different or recycled screen. + self.left_click_dragged = true; + } + } else { + self.left_click_dragged = true; + } + self.left_drag_autoscroll = .none; +} + +fn pressInitial( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) Allocator.Error!void { + // Setup our pin first, reusing our existing pin if we can. + if (self.left_click_pin) |pin| { + if (comptime std.debug.runtime_safety) { + assert(self.left_click_screen == t.screens.active_key); + assert(self.left_click_screen_generation == t.screens.generation(t.screens.active_key)); + } + pin.* = p.pin; + } else { + const screens: *const ScreenSet = &t.screens; + self.left_click_pin = try screens.active.pages.trackPin(p.pin); + errdefer comptime unreachable; + self.left_click_screen = screens.active_key; + self.left_click_screen_generation = screens.generation(screens.active_key); + } + errdefer comptime unreachable; + self.left_click_count = 1; + self.left_click_behavior = p.behaviors[0]; + self.left_click_xpos = p.xpos; + self.left_click_ypos = p.ypos; + self.left_click_time = p.time; + self.left_click_dragged = false; + self.left_drag_autoscroll = .none; +} + +fn pressRepeat( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) error{PressRequiresReset}!void { + errdefer { + self.left_click_count = 0; + self.left_click_behavior = .cell; + self.untrackPin(t); + } + + // If too much time has passed then we always reset. + const time = p.time orelse return error.PressRequiresReset; + { + const prev_time = self.left_click_time orelse return error.PressRequiresReset; + const since = time.since(prev_time); + if (since > p.repeat_interval) return error.PressRequiresReset; + } + + // If the click is too far away from the initial click we can't continue. + const distance = @sqrt( + std.math.pow(f64, p.xpos - self.left_click_xpos, 2) + + std.math.pow(f64, p.ypos - self.left_click_ypos, 2), + ); + if (distance > p.max_distance) return error.PressRequiresReset; + + // If our prior click was on another screen then free and reset. "Another screen" + // doesn't just mean alt vs primary, it could mean an alt screen that was + // recycled since we free tracked pins on recycle. + const screens: *const ScreenSet = &t.screens; + if (self.left_click_screen != screens.active_key or + screens.generation(self.left_click_screen) != + self.left_click_screen_generation) + { + // The error return will trigger the top-level errdefer which + // will reset our pin. + return error.PressRequiresReset; + } + + self.left_click_time = time; + self.left_click_dragged = false; + self.left_drag_autoscroll = .none; + self.left_click_count = @min( + self.left_click_count + 1, + 3, // We only support triple clicks max + ); + self.left_click_behavior = p.behaviors[self.left_click_count - 1]; +} + +fn pressSelection( + self: *const SelectionGesture, + screen: *Screen, + p: Press, +) ?Selection { + return switch (self.left_click_behavior) { + .cell => null, + .word => screen.selectWord(p.pin, p.word_boundary_codepoints), + .line => screen.selectLine(.{ .pin = p.pin }), + .output => screen.selectOutput(p.pin), + }; +} + +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as selection mode and geometry. +fn dragSelection( + click_pin: Pin, + drag_pin: Pin, + click_x: u32, + drag_x: u32, + rectangle_selection: bool, + geometry: Drag.Geometry, +) ?Selection { + // Explanation: + // + // # Normal selections + // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that + // entire columns are considered rather than individual cells. + + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of + // 60% of the cell width was chosen empirically because it felt good. + const threshold_point: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(geometry.cell_width)) * 0.6, + )); + + // We use this to clamp the pixel positions below. + const max_x = geometry.columns * geometry.cell_width - 1; + + // We need to know how far across in the cell the drag pos is, so + // we subtract the padding and then take it modulo the cell width. + const drag_x_frac = @min(max_x, drag_x -| geometry.padding_left) % geometry.cell_width; + + // We figure out the fractional part of the click x position similarly. + const click_x_frac = @min(max_x, click_x -| geometry.padding_left) % geometry.cell_width; + + // Whether the click pin and drag pin are equal. + const same_pin = drag_pin.eql(click_pin); + + // Whether or not the end point of our selection is before the start point. + const end_before_start = ebs: { + if (same_pin) { + break :ebs drag_x_frac < click_x_frac; + } + + // Special handling for rectangular selections, we only use x position. + if (rectangle_selection) { + break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { + .eq => drag_x_frac < click_x_frac, + .lt => true, + .gt => false, + }; + } + + break :ebs drag_pin.before(click_pin); + }; + + // Whether or not the click pin cell + // should be included in the selection. + const include_click_cell = if (end_before_start) + click_x_frac >= threshold_point + else + click_x_frac < threshold_point; + + // Whether or not the drag pin cell + // should be included in the selection. + const include_drag_cell = if (end_before_start) + drag_x_frac < threshold_point + else + drag_x_frac >= threshold_point; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + const start_pin = + if (include_click_cell) + click_pin + else if (end_before_start) + if (rectangle_selection) + click_pin.leftClamp(1) + else + click_pin.leftWrap(1) orelse click_pin + else if (rectangle_selection) + click_pin.rightClamp(1) + else + click_pin.rightWrap(1) orelse click_pin; + + // Likewise for the end pin with the drag cell. + const end_pin = + if (include_drag_cell) + drag_pin + else if (end_before_start) + if (rectangle_selection) + drag_pin.rightClamp(1) + else + drag_pin.rightWrap(1) orelse drag_pin + else if (rectangle_selection) + drag_pin.leftClamp(1) + else + drag_pin.leftWrap(1) orelse drag_pin; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not + // be included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + // + // TODO(qwerasd): this can/should probably be refactored, it's a bit + // repetitive and does excess work in rectangle mode. + if ((!include_click_cell and same_pin) or + (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or + (!include_click_cell and end_pin.eql(click_pin)) or + (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or + (!include_drag_cell and start_pin.eql(drag_pin)) or + (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) + { + return null; + } + + // TODO: Clamp selection to the screen area, don't + // let it extend past the last written row. + + return .init( + start_pin, + end_pin, + rectangle_selection, + ); +} + +/// Calculates the appropriate word-wise selection for a double-click drag. +fn dragSelectionWord( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, + boundary_codepoints: []const u21, +) ?Selection { + // Get the word closest to our starting click. + const word_start = screen.selectWordBetween( + click_pin, + drag_pin, + boundary_codepoints, + ) orelse return null; + + // Get the word closest to our current point. + const word_current = screen.selectWordBetween( + drag_pin, + click_pin, + boundary_codepoints, + ) orelse return null; + + // If our current mouse position is before the starting position, + // then the selection start is the word nearest our current position. + return if (drag_pin.before(click_pin)) + .init( + word_current.start(), + word_start.end(), + false, + ) + else + .init( + word_start.start(), + word_current.end(), + false, + ); +} + +/// Calculates the appropriate line-wise selection for a triple-click drag. +fn dragSelectionLine( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, +) ?Selection { + // Get the line selection under our current drag point. If there isn't a + // line, do nothing. + const line = screen.selectLine(.{ .pin = drag_pin }) orelse return null; + + // Get the selection under our click point. We first try to trim + // whitespace if we've selected a word. But if no word exists then + // we select the blank line. + const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse + screen.selectLine(.{ .pin = click_pin, .whitespace = null }); + + var sel = sel_ orelse return null; + if (drag_pin.before(click_pin)) { + sel.startPtr().* = line.start(); + } else { + sel.endPtr().* = line.end(); + } + return sel; +} + +/// Calculates the appropriate semantic-output-wise selection for an output +/// drag. This expands from the output block under the click point to the output +/// block under the current drag point. If the drag point is not output, keep the +/// original output selection. +fn dragSelectionOutput( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, +) ?Selection { + var sel = screen.selectOutput(click_pin) orelse return null; + const current = screen.selectOutput(drag_pin) orelse return sel; + + if (drag_pin.before(click_pin)) { + sel.startPtr().* = current.start(); + } else { + sel.endPtr().* = current.end(); + } + return sel; +} + +fn untrackPin(self: *SelectionGesture, t: *Terminal) void { + // Can't untrack unless we have a pin. + const pin = self.left_click_pin orelse return; + self.left_click_pin = null; + + // If the generation changed our pin is already invalid. + const screens: *const ScreenSet = &t.screens; + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return; + + // If we can't get a screen then its already freed. + const screen = screens.get(self.left_click_screen) orelse return; + screen.pages.untrackPin(pin); +} + +fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press { + return .{ + .time = time, + .pin = t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?, + .xpos = @floatFromInt(x), + .ypos = @floatFromInt(y), + .max_distance = 1, + .repeat_interval = std.math.maxInt(u64), + .word_boundary_codepoints = &.{}, + }; +} + +fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { + return .{ + .pin = t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?, + .xpos = xpos, + .ypos = ypos, + .rectangle = false, + .word_boundary_codepoints = &.{}, + .geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 100, + }, + }; +} + +fn testAutoscrollTick( + viewport: point.Coordinate, + xpos: f64, + ypos: f64, +) AutoscrollTick { + return .{ + .viewport = viewport, + .xpos = xpos, + .ypos = ypos, + .rectangle = false, + .word_boundary_codepoints = &.{}, + .geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 100, + }, + }; +} + +fn testPin(t: *Terminal, x: u16, y: u32) Pin { + return t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?; +} + +/// Utility function for the unit tests for drag selection logic. +/// +/// Tests a click and drag on a 10x5 cell grid, x positions are given in +/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. +/// +/// NOTE: The geometry tested with has 10px wide cells, meaning only one digit +/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. +/// +/// The provided start_x/y and end_x/y are the expected start and end points +/// of the resulting selection. +fn testDragSelection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: u16, + start_y: u32, + end_x: u16, + end_y: u32, + rect: bool, +) !void { + assert(@import("builtin").is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const geometry: Drag.Geometry = .{ + .columns = 10, + .cell_width = 10, + .padding_left = 5, + .screen_height = 110, + }; + var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(geometry.cell_width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + geometry.padding_left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + geometry.padding_left; + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = start_x, .y = start_y }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = end_x, .y = end_y }, + }) orelse unreachable; + + try testing.expectEqualDeep(Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }, dragSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + rect, + geometry, + )); +} + +/// Like `testDragSelection` but checks that the resulting selection is null. +/// +/// See `testDragSelection` for more details. +fn testDragSelectionIsNull( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, +) !void { + assert(@import("builtin").is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const geometry: Drag.Geometry = .{ + .columns = 10, + .cell_width = 10, + .padding_left = 5, + .screen_height = 110, + }; + var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(geometry.cell_width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + geometry.padding_left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + geometry.padding_left; + + try testing.expectEqual( + null, + dragSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + rect, + geometry, + ), + ); +} + +test "SelectionGesture drag selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single cell selection + try testDragSelection( + 3.0, 3, // click + 3.9, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testDragSelection( + 3.0, 3, // click + 5.9, 3, // drag + 3, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testDragSelection( + 3.0, 3, // click + 5.0, 3, // drag + 3, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testDragSelection( + 3.9, 3, // click + 5.9, 3, // drag + 4, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testDragSelection( + 3.9, 3, // click + 5.0, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testDragSelectionIsNull( + 3.0, 3, // click + 3.1, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testDragSelectionIsNull( + 3.8, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testDragSelectionIsNull( + 3.9, 3, // click + 4.0, 3, // drag + false, // regular selection + ); + + // -- RTL + // single cell selection + try testDragSelection( + 3.9, 3, // click + 3.0, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testDragSelection( + 5.9, 3, // click + 3.0, 3, // drag + 5, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testDragSelection( + 5.9, 3, // click + 3.9, 3, // drag + 5, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testDragSelection( + 5.0, 3, // click + 3.0, 3, // drag + 4, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testDragSelection( + 5.0, 3, // click + 3.9, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testDragSelectionIsNull( + 3.1, 3, // click + 3.0, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testDragSelectionIsNull( + 3.9, 3, // click + 3.8, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testDragSelectionIsNull( + 4.0, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + + // -- Wrapping + // LTR, wrap excluded cells + try testDragSelection( + 9.9, 2, // click + 0.0, 4, // drag + 0, 3, // expected start + 9, 3, // expected end + false, // regular selection + ); + // RTL, wrap excluded cells + try testDragSelection( + 0.0, 4, // click + 9.9, 2, // drag + 9, 3, // expected start + 0, 3, // expected end + false, // regular selection + ); +} + +test "SelectionGesture rectangle drag selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single column selection + try testDragSelection( + 3.0, 2, // click + 3.9, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testDragSelection( + 3.0, 2, // click + 5.9, 4, // drag + 3, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testDragSelection( + 3.0, 2, // click + 5.0, 4, // drag + 3, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testDragSelection( + 3.9, 2, // click + 5.9, 4, // drag + 4, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testDragSelection( + 3.9, 2, // click + 5.0, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testDragSelectionIsNull( + 3.0, 2, // click + 3.1, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testDragSelectionIsNull( + 3.8, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testDragSelectionIsNull( + 3.9, 2, // click + 4.0, 4, // drag + true, //rectangle selection + ); + + // -- RTL + // single column selection + try testDragSelection( + 3.9, 2, // click + 3.0, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testDragSelection( + 5.9, 2, // click + 3.0, 4, // drag + 5, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testDragSelection( + 5.9, 2, // click + 3.9, 4, // drag + 5, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testDragSelection( + 5.0, 2, // click + 3.0, 4, // drag + 4, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testDragSelection( + 5.0, 2, // click + 3.9, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testDragSelectionIsNull( + 3.1, 2, // click + 3.0, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testDragSelectionIsNull( + 3.9, 2, // click + 3.8, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testDragSelectionIsNull( + 4.0, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + + // -- Wrapping + // LTR, do not wrap + try testDragSelection( + 9.9, 2, // click + 0.0, 4, // drag + 9, 2, // expected start + 0, 4, // expected end + true, //rectangle selection + ); + // RTL, do not wrap + try testDragSelection( + 0.0, 4, // click + 9.9, 2, // drag + 0, 4, // expected start + 9, 2, // expected end + true, //rectangle selection + ); +} + +test "SelectionGesture press records initial click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 2, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(time, gesture.left_click_time.?); + try testing.expectEqual(@as(f64, 1), gesture.left_click_xpos); + try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos); + try testing.expectEqual(false, gesture.left_click_dragged); +} + +test "SelectionGesture press returns standard click selections" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + var event = testPress(&t, 1, 0, time); + event.word_boundary_codepoints = &.{ ' ' }; + + try testing.expectEqual(null, try gesture.press(&t, event)); + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 4, 0), + false, + ), (try gesture.press(&t, event)).?); + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), (try gesture.press(&t, event)).?); +} + +test "SelectionGesture press behaviors choose press and drag behavior" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + var event = testPress(&t, 1, 0, time); + event.behaviors = &.{ .cell, .line, .word }; + event.word_boundary_codepoints = &.{ ' ' }; + + _ = try gesture.press(&t, event); + try testing.expectEqual(.cell, gesture.left_click_behavior); + + const double_click = (try gesture.press(&t, event)).?; + try testing.expectEqual(.line, gesture.left_click_behavior); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), double_click); + + const line_drag = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?; + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), line_drag); +} + +test "SelectionGesture output behavior selects and drags semantic output" { + var t = try Terminal.init(testing.allocator, .{ .cols = 10, .rows = 6 }); + defer t.deinit(testing.allocator); + + const screen = t.screens.active; + screen.cursorSetSemanticContent(.output); + try screen.testWriteString("out1\n"); + screen.cursorSetSemanticContent(.{ .prompt = .initial }); + try screen.testWriteString("$ "); + screen.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try screen.testWriteString("cmd\n"); + screen.cursorSetSemanticContent(.output); + try screen.testWriteString("out2"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var event = testPress(&t, 1, 0, try std.time.Instant.now()); + event.behaviors = &.{ .output, .word, .line }; + + const press_selection = (try gesture.press(&t, event)).?; + try testing.expectEqual(.output, gesture.left_click_behavior); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 3, 0), + false, + ), press_selection); + + const output_drag = gesture.drag(&t, testDrag(&t, 1, 2, 10, 50)).?; + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 3, 2), + false, + ), output_drag); +} + +test "SelectionGesture drag returns selection and records autoscroll" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + const sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?; + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); + + try testing.expectEqualDeep(Selection.init( + t.screens.active.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?, + false, + ), sel); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); +} + +test "SelectionGesture release clears autoscroll and records drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try testing.expectEqual(false, gesture.left_click_dragged); + + _ = gesture.drag(&t, testDrag(&t, 1, 1, 10, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + try testing.expectEqual(false, gesture.left_click_dragged); + + gesture.release(&t, .{ + .pin = testPin(&t, 2, 1), + }); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); +} + +test "SelectionGesture release with invalidated click records drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try testing.expectEqual(false, gesture.left_click_dragged); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + gesture.release(&t, .{ .pin = testPin(&t, 1, 1) }); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); +} + +test "SelectionGesture same-cell threshold selection records drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + try testing.expectEqual(false, gesture.left_click_dragged); + + const sel = gesture.drag(&t, testDrag(&t, 1, 1, 19, 50)).?; + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + testPin(&t, 1, 1), + false, + ), sel); +} + +test "SelectionGesture drag without press returns null" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 1, 1, 10, 50))); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); +} + +test "SelectionGesture drag autoscroll edge boundaries" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1.1)); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99)); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99.1)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); +} + +test "SelectionGesture autoscroll tick scrolls and continues drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + + const sel = gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 3, .y = 2 }, 39, 100)).?; + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + testPin(&t, 3, 2), + false, + ), sel); +} + +test "SelectionGesture autoscroll tick resolves drag pin after scrolling" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); + defer t.deinit(testing.allocator); + try t.printString("1111\n2222\n3333\n4444\n5555"); + t.scrollViewport(.{ .delta = -2 }); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 3, 2, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + + const viewport: point.Coordinate = .{ .x = 3, .y = 2 }; + const pre_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?; + const sel = gesture.autoscrollTick(&t, testAutoscrollTick(viewport, 39, 100)).?; + const post_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?; + + try testing.expect(!pre_scroll_pin.eql(post_scroll_pin)); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + post_scroll_pin, + false, + ), sel); +} + +test "SelectionGesture autoscroll tick stops with invalidated click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + try testing.expectEqual(null, gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 2, .y = 1 }, 20, 1))); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(@as(u3, 0), gesture.left_click_count); +} + +test "SelectionGesture deep press selects word and consumes drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 0, try std.time.Instant.now())); + _ = gesture.drag(&t, testDrag(&t, 1, 0, 10, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + const sel = gesture.deepPress(&t, .{ + .word_boundary_codepoints = &.{ ' ' }, + }).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 4, 0), + false, + ), sel); + try testing.expectEqual(@as(u3, 0), gesture.left_click_count); + try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expect(gesture.left_click_pin == null); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 7, 0, 70, 50))); + gesture.release(&t, .{ .pin = testPin(&t, 7, 0) }); + try testing.expectEqual(true, gesture.left_click_dragged); +} + +test "SelectionGesture drag with invalidated click returns null" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 2, 1, 20, 50))); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); +} + +test "SelectionGesture double-click drag selects by word" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta gamma"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + + var drag_event = testDrag(&t, 7, 0, 70, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture double-click drag selects by word backwards" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta gamma"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 7, 0, time)); + _ = try gesture.press(&t, testPress(&t, 7, 0, time)); + + var drag_event = testDrag(&t, 1, 0, 10, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture double-click drag on empty cell selects nearest word" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + + var drag_event = testDrag(&t, 15, 0, 150, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture triple-click drag selects by line" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + + const sel = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), sel); +} + +test "SelectionGesture triple-click drag selects by line backwards" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + + const sel = gesture.drag(&t, testDrag(&t, 1, 0, 10, 50)).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), sel); +} + +test "SelectionGesture repeat increments click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 2), gesture.left_click_count); +} + +test "SelectionGesture repeat clamps at triple click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + for (0..4) |_| _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 3), gesture.left_click_count); +} + +test "SelectionGesture null initial time stays single click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, null)); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expect(gesture.left_click_time != null); +} + +test "SelectionGesture null repeat time stays single click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 1, null)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time); +} + +test "SelectionGesture distant press resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + _ = try gesture.press(&t, testPress(&t, 4, 1, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(@as(f64, 4), gesture.left_click_xpos); +} + +test "SelectionGesture expired repeat resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var event = testPress(&t, 1, 1, try std.time.Instant.now()); + event.repeat_interval = 0; + _ = try gesture.press(&t, event); + + std.Thread.sleep(std.time.ns_per_ms); + event.time = try std.time.Instant.now(); + _ = try gesture.press(&t, event); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); +} + +test "SelectionGesture screen switch resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + const primary_tracked = t.screens.active.pages.countTrackedPins(); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.alternate, gesture.left_click_screen); + try testing.expectEqual(primary_tracked, t.screens.get(.primary).?.pages.countTrackedPins()); +} + +test "SelectionGesture removed screen resets without untracking stale pin" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + t.screens.switchTo(.primary); + t.screens.remove(testing.allocator, .alternate); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.primary, gesture.left_click_screen); +} + +test "SelectionGesture deinit untracks pin" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + const tracked = t.screens.active.pages.countTrackedPins(); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + gesture.deinit(&t); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} 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/terminal.zig b/src/terminal/c/terminal.zig index 8deb4c95c..302fb77a6 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -593,13 +593,14 @@ pub const TerminalData = enum(c_int) { kitty_image_medium_shared_mem = 29, kitty_graphics = 30, selection = 31, + viewport_active = 32, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { return switch (self) { .invalid => void, .cols, .rows, .cursor_x, .cursor_y => size.CellCountInt, - .cursor_pending_wrap, .cursor_visible, .mouse_tracking => bool, + .cursor_pending_wrap, .cursor_visible, .mouse_tracking, .viewport_active => bool, .active_screen => TerminalScreen, .kitty_keyboard_flags => u8, .scrollbar => TerminalScrollbar, @@ -734,6 +735,7 @@ fn getTyped( .selection => out.* = selection_c.CSelection.fromZig( t.screens.active.selection orelse return .no_value, ), + .viewport_active => out.* = t.screens.active.pages.viewport == .active, } return .success; @@ -883,6 +885,10 @@ test "scroll_viewport" { const zt = t.?.terminal; + var viewport_active: bool = false; + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + // Write "hello" on the first line vt_write(t, "hello", 5); @@ -897,6 +903,8 @@ test "scroll_viewport" { // Scroll to top: "hello" should be visible again scroll_viewport(t, .{ .tag = .top, .value = undefined }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(!viewport_active); { const str = try zt.plainString(testing.allocator); defer testing.allocator.free(str); @@ -905,6 +913,8 @@ test "scroll_viewport" { // Scroll to bottom: viewport should be empty again scroll_viewport(t, .{ .tag = .bottom, .value = undefined }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); { const str = try zt.plainString(testing.allocator); defer testing.allocator.free(str); @@ -913,6 +923,8 @@ test "scroll_viewport" { // Scroll up by delta to bring "hello" back into view scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(!viewport_active); { const str = try zt.plainString(testing.allocator); defer testing.allocator.free(str); 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", diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 87a9aded9..53491a009 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -49,6 +49,7 @@ pub const Screen = @import("Screen.zig"); pub const ScreenSet = @import("ScreenSet.zig"); pub const Scrollbar = PageList.Scrollbar; pub const Selection = @import("Selection.zig"); +pub const SelectionGesture = @import("SelectionGesture.zig"); pub const SizeReportStyle = csi.SizeReportStyle; pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style;