Merge remote-tracking branch 'upstream/main'
commit
3f7d8b6aff
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
//! [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]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1049
src/Surface.zig
1049
src/Surface.zig
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue