libghostty: selection APIs for C (#12794)

Adds libghostty-vt selection APIs read/write, formatting, inspecting,
and rendering selection state from C.

| Introduced type/function | Purpose |
| --- | --- |
| `GhosttyRenderStateRowSelection` | Row-local inclusive selection range
returned by render row queries. |
| `GhosttyTerminalSelectWordOptions` | Options for deriving a word
selection from a grid ref. |
| `GhosttyTerminalSelectWordBetweenOptions` | Options for finding the
nearest selectable word between two refs. |
| `GhosttyTerminalSelectLineOptions` | Options for deriving line
selections, including semantic prompt boundaries. |
| `GhosttyTerminalSelectionFormatOptions` | Options for formatting the
active or caller-provided selection. |
| `GhosttySelectionOrder` | Describes endpoint ordering, including
rectangular mirrored orders. |
| `GhosttySelectionAdjust` | Operations for moving a selection endpoint.
|
| `ghostty_terminal_select_word` | Derive a word selection snapshot. |
| `ghostty_terminal_select_word_between` | Derive the nearest word
selection between two refs. |
| `ghostty_terminal_select_line` | Derive a line selection snapshot. |
| `ghostty_terminal_select_all` | Derive a selection covering all
selectable content. |
| `ghostty_terminal_select_output` | Derive a semantic command-output
selection. |
| `ghostty_terminal_selection_format_buf` | Format a selection into a
caller-provided buffer. |
| `ghostty_terminal_selection_format_alloc` | Format a selection into an
allocated buffer. |
| `ghostty_terminal_selection_adjust` | Mutate a selection snapshot
endpoint. |
| `ghostty_terminal_selection_order` | Query selection endpoint order. |
| `ghostty_terminal_selection_ordered` | Return a selection with
normalized endpoint order. |
| `ghostty_terminal_selection_contains` | Test whether a point is inside
a selection. |
| `ghostty_terminal_selection_equal` | Compare two selection snapshots
using terminal semantics. |
This commit is contained in:
Mitchell Hashimoto
2026-05-24 14:14:54 -07:00
committed by GitHub
23 changed files with 2071 additions and 99 deletions

View File

@@ -2,8 +2,8 @@
This contains an example of how to use the `ghostty-vt` render-state API
to create a render state, update it from terminal content, iterate rows
and cells, read styles and colors, inspect cursor state, and manage dirty
tracking.
and cells, read styles and colors, inspect cursor and row-local selection
state, and manage dirty tracking.
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

View File

@@ -46,6 +46,32 @@ int main(void) {
ghostty_terminal_vt_write(
terminal, (const uint8_t*)content, strlen(content));
// Select "underlined" on the second row. Render state exposes this
// later as a row-local selected cell range.
GhosttyGridRef selection_start = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint selection_start_pt = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 0, .y = 1 } },
};
result = ghostty_terminal_grid_ref(
terminal, selection_start_pt, &selection_start);
assert(result == GHOSTTY_SUCCESS);
GhosttyGridRef selection_end = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint selection_end_pt = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 9, .y = 1 } },
};
result = ghostty_terminal_grid_ref(terminal, selection_end_pt, &selection_end);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
selection.start = selection_start;
selection.end = selection_end;
result = ghostty_terminal_set(
terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_render_state_update(render_state, terminal);
assert(result == GHOSTTY_SUCCESS);
//! [render-state-update]
@@ -154,6 +180,18 @@ int main(void) {
printf("Row %2d [%s]: ", row_index,
row_dirty ? "dirty" : "clean");
// Query the row-local selection range. Rows without a selection return
// GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns.
GhosttyRenderStateRowSelection row_selection =
GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection);
result = ghostty_render_state_row_get(
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION, &row_selection);
assert(result == GHOSTTY_SUCCESS || result == GHOSTTY_NO_VALUE);
if (result == GHOSTTY_SUCCESS) {
printf("selection=%u..%u ",
row_selection.start_x, row_selection.end_x);
}
// Get cells for this row (reuses the same cells handle).
result = ghostty_render_state_row_get(
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &cells);

View File

@@ -0,0 +1,18 @@
# Example: `ghostty-vt` Selection
This contains a simple example of how to use the `ghostty-vt` terminal,
grid reference, selection, and formatter APIs to derive selections such as a
word, semantic command line, command output, and all visible content.
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
```

View File

@@ -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",
.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);
}

View File

@@ -0,0 +1,24 @@
.{
.name = .c_vt_selection,
.version = "0.0.0",
.fingerprint = 0xb2c2f1a828086fef,
.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",
},
}

View File

@@ -0,0 +1,136 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [selection-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) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
opts.trim = true;
opts.selection = selection;
GhosttyFormatter formatter;
GhosttyResult result = ghostty_formatter_terminal_new(
NULL, &formatter, terminal, opts);
assert(result == GHOSTTY_SUCCESS);
uint8_t *buf = NULL;
size_t len = 0;
result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len);
assert(result == GHOSTTY_SUCCESS);
printf("%s: ", label);
fwrite(buf, 1, len, stdout);
printf("\n");
ghostty_free(NULL, buf, len);
ghostty_formatter_free(formatter);
}
int main() {
GhosttyTerminal terminal;
GhosttyTerminalOptions opts = {
.cols = 80,
.rows = 8,
.max_scrollback = 0,
};
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
assert(result == GHOSTTY_SUCCESS);
// A realistic shell transcript with OSC 133 semantic prompt markers.
// Ghostty uses these markers to distinguish prompt/input from command
// output for semantic line and output selections.
vt_write(terminal,
"\033]133;A\007$ " // Prompt starts: "$ "
"\033]133;B\007git status" // Input starts: "git status"
"\033]133;C\007\r\n" // Output starts after Enter
"On branch main\r\n"
"nothing to commit, working tree clean");
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
// Double-click style word selection under the cursor.
GhosttyTerminalSelectWordOptions word = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordOptions);
word.ref = ref_at(terminal, 6, 0); // the "status" in "git status"
result = ghostty_terminal_select_word(terminal, &word, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "word", &selection);
//! [selection-word-between]
// Double-click-and-drag style selection. Suppose the user double-clicks
// "git" and drags to "status". The pointer may pass over whitespace, so
// select the nearest word between the original click and current drag point
// in both directions, then combine the outer word bounds.
GhosttyGridRef click_ref = ref_at(terminal, 2, 0); // the "git" in "git status"
GhosttyGridRef drag_ref = ref_at(terminal, 6, 0); // the "status" in "git status"
GhosttyTerminalSelectWordBetweenOptions start_word_opts =
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions);
start_word_opts.start = click_ref;
start_word_opts.end = drag_ref;
GhosttySelection start_word = GHOSTTY_INIT_SIZED(GhosttySelection);
result = ghostty_terminal_select_word_between(
terminal, &start_word_opts, &start_word);
assert(result == GHOSTTY_SUCCESS);
GhosttyTerminalSelectWordBetweenOptions end_word_opts =
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions);
end_word_opts.start = drag_ref;
end_word_opts.end = click_ref;
GhosttySelection end_word = GHOSTTY_INIT_SIZED(GhosttySelection);
result = ghostty_terminal_select_word_between(
terminal, &end_word_opts, &end_word);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection drag_selection = GHOSTTY_INIT_SIZED(GhosttySelection);
drag_selection.start = start_word.start;
drag_selection.end = end_word.end;
print_selection(terminal, "double-click drag", &drag_selection);
//! [selection-word-between]
// Triple-click style line selection. With semantic prompt boundaries enabled,
// this selects only the input area rather than the leading "$ " prompt.
GhosttyTerminalSelectLineOptions line = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectLineOptions);
line.ref = ref_at(terminal, 2, 0); // the "git status" input area
line.semantic_prompt_boundary = true;
result = ghostty_terminal_select_line(terminal, &line, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "line", &selection);
// Select exactly the command output for the command under the cursor.
result = ghostty_terminal_select_output(
terminal, ref_at(terminal, 0, 1), &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "output", &selection);
// Select all visible content.
result = ghostty_terminal_select_all(terminal, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "all", &selection);
ghostty_terminal_free(terminal);
return 0;
}
//! [selection-main]

View File

@@ -136,6 +136,7 @@ extern "C" {
#include <ghostty/vt/modes.h>
#include <ghostty/vt/mouse.h>
#include <ghostty/vt/paste.h>
#include <ghostty/vt/point.h>
#include <ghostty/vt/screen.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/size_report.h>

View File

@@ -32,23 +32,6 @@ extern "C" {
* @{
*/
/**
* Output format.
*
* @ingroup formatter
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Plain text (no escape sequences). */
GHOSTTY_FORMATTER_FORMAT_PLAIN,
/** VT sequences preserving colors, styles, URLs, etc. */
GHOSTTY_FORMATTER_FORMAT_VT,
/** HTML with inline styles. */
GHOSTTY_FORMATTER_FORMAT_HTML,
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyFormatterFormat;
/**
* Extra screen state to include in styled output.
*

View File

@@ -221,6 +221,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
* valid as long as the underlying render state is not updated.
* It is unsafe to use cell data after updating the render state. */
GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3,
/** Row-local selected cell range (GhosttyRenderStateRowSelection). */
GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4,
GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowData;
@@ -235,6 +238,29 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowOption;
/**
* Row-local selection range.
*
* This struct uses the sized-struct ABI pattern. Initialize with
* GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying
* GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION.
*
* Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE
* if the current row does not intersect the current selection.
*
* @ingroup render
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */
size_t size;
/** Start column of the row-local selection range, inclusive. */
uint16_t start_x;
/** End column of the row-local selection range, inclusive. */
uint16_t end_x;
} GhosttyRenderStateRowSelection;
/**
* Render-state color information.
*

View File

@@ -9,7 +9,11 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/grid_ref.h>
#include <ghostty/vt/point.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
@@ -17,14 +21,38 @@ extern "C" {
/** @defgroup selection Selection
*
* A selection range defined by two grid references that identifies a
* contiguous or rectangular region of terminal content.
* A snapshot selection range defined by two grid references that identifies
* a contiguous or rectangular region of terminal content.
*
* The start and end values are GhosttyGridRef values. They are therefore
* untracked grid references and inherit the same lifetime rules: they are
* only safe to use until the next mutating operation on the terminal that
* produced them, including freeing the terminal. To keep a selection valid
* across terminal mutations, callers must maintain tracked grid references
* for the endpoints and reconstruct a GhosttySelection from fresh snapshots
* when needed.
*
* ## Examples
*
* @snippet c-vt-selection/src/main.c selection-main
*
* @{
*/
/**
* A selection range defined by two grid references.
* A snapshot selection range defined by two grid references.
*
* Both endpoints are inclusive. The endpoints preserve selection direction
* and may be reversed; callers must not assume that start is the top-left
* endpoint or that end is the bottom-right endpoint.
*
* When rectangle is false, the endpoints describe a linear selection. When
* rectangle is true, the same endpoints are interpreted as opposite corners
* of a rectangular/block selection.
*
* The start and end values are untracked GhosttyGridRef snapshots and are
* only valid until the next mutating operation on the terminal that produced
* them unless the selection is reconstructed from tracked references.
*
* This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
*
@@ -34,16 +62,554 @@ typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */
size_t size;
/** Start of the selection range (inclusive). */
/**
* Start of the selection range (inclusive).
*
* This may be after end in terminal order. It is an untracked
* GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules.
*/
GhosttyGridRef start;
/** End of the selection range (inclusive). */
/**
* End of the selection range (inclusive).
*
* This may be before start in terminal order. It is an untracked
* GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules.
*/
GhosttyGridRef end;
/** Whether the selection is rectangular (block) rather than linear. */
/**
* Whether the endpoints are interpreted as a rectangular/block selection
* rather than a linear selection.
*/
bool rectangle;
} GhosttySelection;
/**
* Options for deriving a word selection from a terminal grid reference.
*
* This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
* If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's
* default word-boundary codepoints are used. If boundary_codepoints_len is
* non-zero, boundary_codepoints must not be NULL.
*
* @ingroup selection
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordOptions). */
size_t size;
/** Grid reference under which to derive the word selection. */
GhosttyGridRef ref;
/** Optional word-boundary codepoints as uint32_t scalar values. */
const uint32_t* boundary_codepoints;
/** Number of entries in boundary_codepoints. */
size_t boundary_codepoints_len;
} GhosttyTerminalSelectWordOptions;
/**
* Options for deriving the nearest word selection between two grid references.
*
* This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
* If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's
* default word-boundary codepoints are used. If boundary_codepoints_len is
* non-zero, boundary_codepoints must not be NULL.
*
* @ingroup selection
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordBetweenOptions). */
size_t size;
/** Starting grid reference for the inclusive search range. */
GhosttyGridRef start;
/** Ending grid reference for the inclusive search range. */
GhosttyGridRef end;
/** Optional word-boundary codepoints as uint32_t scalar values. */
const uint32_t* boundary_codepoints;
/** Number of entries in boundary_codepoints. */
size_t boundary_codepoints_len;
} GhosttyTerminalSelectWordBetweenOptions;
/**
* Options for deriving a line selection from a terminal grid reference.
*
* This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
* If whitespace is NULL and whitespace_len is 0, Ghostty's default line-trim
* whitespace codepoints are used. If whitespace_len is non-zero, whitespace
* must not be NULL.
*
* @ingroup selection
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectLineOptions). */
size_t size;
/** Grid reference under which to derive the line selection. */
GhosttyGridRef ref;
/** Optional codepoints to trim from the start and end of the line. */
const uint32_t* whitespace;
/** Number of entries in whitespace. */
size_t whitespace_len;
/** Whether semantic prompt state changes should bound the line selection. */
bool semantic_prompt_boundary;
} GhosttyTerminalSelectLineOptions;
/**
* Options for one-shot formatting of a terminal selection.
*
* This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
*
* If selection is NULL, the terminal's current active selection is used.
* If selection is non-NULL, that caller-provided snapshot selection is used.
*
* The selection is formatted from the terminal's active screen using the same
* formatting semantics as GhosttyFormatter. For copy/clipboard behavior
* matching Ghostty's Screen.selectionString(), use plain output with unwrap
* and trim both set to true.
*
* @ingroup selection
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions). */
size_t size;
/** Output format to emit. */
GhosttyFormatterFormat emit;
/** Whether to unwrap soft-wrapped lines. */
bool unwrap;
/** Whether to trim trailing whitespace on non-blank lines. */
bool trim;
/**
* Optional selection to format.
*
* If NULL, the terminal's current active selection is used. If the terminal
* has no active selection, formatting returns GHOSTTY_NO_VALUE.
*
* If non-NULL, the pointed-to selection must be a valid snapshot selection
* for this terminal and must obey GhosttySelection lifetime rules.
*/
const GhosttySelection *selection;
} GhosttyTerminalSelectionFormatOptions;
/**
* Ordering of a selection's endpoints in terminal coordinates.
*
* Mirrored orders are only produced by rectangular selections whose start
* and end endpoints are on opposite diagonal corners that are not simple
* top-left-to-bottom-right or bottom-right-to-top-left orderings.
*
* @ingroup selection
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Start is before end in top-left to bottom-right order. */
GHOSTTY_SELECTION_ORDER_FORWARD = 0,
/** End is before start in top-left to bottom-right order. */
GHOSTTY_SELECTION_ORDER_REVERSE = 1,
/** Rectangular selection from top-right to bottom-left. */
GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD = 2,
/** Rectangular selection from bottom-left to top-right. */
GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE = 3,
GHOSTTY_SELECTION_ORDER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySelectionOrder;
/**
* Operation used to adjust a selection endpoint.
*
* Adjustment mutates the selection's logical end endpoint, not whichever
* endpoint is visually bottom/right. This preserves keyboard and drag
* behavior for both forward and reversed selections.
*
* @ingroup selection
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Move left to the previous non-empty cell, wrapping upward. */
GHOSTTY_SELECTION_ADJUST_LEFT = 0,
/** Move right to the next non-empty cell, wrapping downward. */
GHOSTTY_SELECTION_ADJUST_RIGHT = 1,
/**
* Move up one row at the current column, or to the beginning of the
* line if already at the top.
*/
GHOSTTY_SELECTION_ADJUST_UP = 2,
/**
* Move down to the next non-blank row at the current column, or to the
* end of the line if none exists.
*/
GHOSTTY_SELECTION_ADJUST_DOWN = 3,
/** Move to the top-left cell of the screen. */
GHOSTTY_SELECTION_ADJUST_HOME = 4,
/** Move to the right edge of the last non-blank row on the screen. */
GHOSTTY_SELECTION_ADJUST_END = 5,
/**
* Move up by one terminal page height, or to home if that would move
* past the top.
*/
GHOSTTY_SELECTION_ADJUST_PAGE_UP = 6,
/**
* Move down by one terminal page height, or to end if that would move
* past the bottom.
*/
GHOSTTY_SELECTION_ADJUST_PAGE_DOWN = 7,
/** Move to the left edge of the current line. */
GHOSTTY_SELECTION_ADJUST_BEGINNING_OF_LINE = 8,
/** Move to the right edge of the current line. */
GHOSTTY_SELECTION_ADJUST_END_OF_LINE = 9,
GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySelectionAdjust;
/**
* Derive a word selection snapshot from a terminal grid reference.
*
* The returned selection is not installed as the terminal's current
* selection. It is a snapshot with the same lifetime rules as GhosttySelection.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param options Word-selection options
* @param[out] out_selection On success, receives the derived selection
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has
* no selectable word content, or GHOSTTY_INVALID_VALUE if the
* terminal, options, ref, codepoint pointer, or output pointer are
* invalid.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_select_word(
GhosttyTerminal terminal,
const GhosttyTerminalSelectWordOptions* options,
GhosttySelection* out_selection);
/**
* Derive the nearest word selection snapshot between two terminal grid refs.
*
* Starting at options->start, this searches toward options->end (inclusive)
* and returns the first selectable word found using Ghostty's word-selection
* rules.
*
* This is useful for implementing double-click-and-drag selection in a UI. If
* a user double-clicks one word and drags across spaces or punctuation toward
* another word, selecting only the word directly under the current pointer can
* flicker or collapse when the pointer is between words. Instead, ask for the
* nearest word between the original click and the drag point, ask again in the
* reverse direction, and combine the two word bounds into the drag selection.
*
* @snippet c-vt-selection/src/main.c selection-word-between
*
* The returned selection is not installed as the terminal's current
* selection. It is a snapshot with the same lifetime rules as GhosttySelection.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param options Word-between-selection options
* @param[out] out_selection On success, receives the derived selection
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no
* selectable word content between the valid refs, or
* GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint
* pointer, or output pointer are invalid.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_select_word_between(
GhosttyTerminal terminal,
const GhosttyTerminalSelectWordBetweenOptions* options,
GhosttySelection* out_selection);
/**
* Derive a line selection snapshot from a terminal grid reference.
*
* The returned selection is not installed as the terminal's current
* selection. It is a snapshot with the same lifetime rules as GhosttySelection.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param options Line-selection options
* @param[out] out_selection On success, receives the derived selection
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has
* no selectable line content, or GHOSTTY_INVALID_VALUE if the
* terminal, options, ref, codepoint pointer, or output pointer are
* invalid.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_select_line(
GhosttyTerminal terminal,
const GhosttyTerminalSelectLineOptions* options,
GhosttySelection* out_selection);
/**
* Derive a selection snapshot covering all selectable terminal content.
*
* The returned selection is not installed as the terminal's current
* selection. It is a snapshot with the same lifetime rules as GhosttySelection.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param[out] out_selection On success, receives the derived selection
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no
* selectable content, or GHOSTTY_INVALID_VALUE if the terminal or
* output pointer is invalid.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_select_all(
GhosttyTerminal terminal,
GhosttySelection* out_selection);
/**
* Derive a command-output selection snapshot from a terminal grid reference.
*
* The returned selection is not installed as the terminal's current
* selection. It is a snapshot with the same lifetime rules as GhosttySelection.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param ref Grid reference within command output to select
* @param[out] out_selection On success, receives the derived selection
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref is
* not selectable command output, or GHOSTTY_INVALID_VALUE if the
* terminal, ref, or output pointer is invalid.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_select_output(
GhosttyTerminal terminal,
GhosttyGridRef ref,
GhosttySelection* out_selection);
/**
* Format a terminal selection into a caller-provided buffer.
*
* This is a one-shot convenience API for formatting either the terminal's
* active selection or a caller-provided GhosttySelection without explicitly
* creating a GhosttyFormatter.
*
* Pass NULL for buf to query the required output size. In that case,
* out_written receives the required size and the function returns
* GHOSTTY_OUT_OF_SPACE.
*
* If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes
* the required size to out_written. The caller can then retry with a larger
* buffer.
*
* If options.selection is NULL and the terminal has no active selection, the
* function returns GHOSTTY_NO_VALUE.
*
* @param terminal The terminal to read from (must not be NULL)
* @param options Selection formatting options
* @param buf Output buffer, or NULL to query required size
* @param buf_len Length of buf in bytes
* @param out_written Number of bytes written, or required size on
* GHOSTTY_OUT_OF_SPACE (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_buf(
GhosttyTerminal terminal,
GhosttyTerminalSelectionFormatOptions options,
uint8_t* buf,
size_t buf_len,
size_t* out_written);
/**
* Format a terminal selection into an allocated buffer.
*
* This is a one-shot convenience API for formatting either the terminal's
* active selection or a caller-provided GhosttySelection without explicitly
* creating a GhosttyFormatter.
*
* The returned buffer is allocated using allocator, or the default allocator
* if NULL is passed. The caller owns the returned buffer and must free it with
* ghostty_free(), passing the same allocator and returned length.
*
* The returned bytes are not NUL-terminated. This supports plain text, VT, and
* HTML uniformly as byte output.
*
* If options.selection is NULL and the terminal has no active selection, the
* function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0.
*
* @param terminal The terminal to read from (must not be NULL)
* @param allocator Allocator used for the returned buffer, or NULL for the default allocator
* @param options Selection formatting options
* @param out_ptr Receives the allocated output buffer (must not be NULL)
* @param out_len Receives the output length in bytes (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_alloc(
GhosttyTerminal terminal,
const GhosttyAllocator* allocator,
GhosttyTerminalSelectionFormatOptions options,
uint8_t** out_ptr,
size_t* out_len);
/**
* Adjust a selection snapshot using terminal selection semantics.
*
* This mutates the caller-provided GhosttySelection in place. The logical end
* endpoint is always moved, regardless of whether the selection is forward or
* reversed visually. The input selection remains a snapshot: after adjustment,
* call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it
* as the terminal-owned selection if desired.
*
* The selection's start and end grid refs must both be valid untracked
* snapshots for the given terminal's currently active screen. In practice,
* they must come from that terminal and screen, and no mutating terminal call
* may have occurred since the refs were produced or reconstructed from
* tracked refs. Passing refs from another terminal, another screen, or stale
* refs violates this precondition.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param selection Selection snapshot to adjust in place
* @param adjustment The adjustment operation to apply
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
* selection, or adjustment are invalid. Selection reference validity
* is a precondition and is not checked.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust(
GhosttyTerminal terminal,
GhosttySelection* selection,
GhosttySelectionAdjust adjustment);
/**
* Get the current endpoint ordering of a selection snapshot.
*
* The selection's start and end grid refs must both be valid untracked
* snapshots for the given terminal's currently active screen. In practice,
* they must come from that terminal and screen, and no mutating terminal call
* may have occurred since the refs were produced or reconstructed from
* tracked refs. Passing refs from another terminal, another screen, or stale
* refs violates this precondition.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param selection Selection snapshot to inspect
* @param[out] out_order On success, receives the selection order
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
* selection, or output pointer are invalid. Selection reference
* validity is a precondition and is not checked.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_order(
GhosttyTerminal terminal,
const GhosttySelection* selection,
GhosttySelectionOrder* out_order);
/**
* Return a selection snapshot with endpoints ordered as requested.
*
* Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds,
* and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds.
* Mirrored desired orders are accepted but normalized the same as forward.
* The output selection is a fresh untracked snapshot and is not installed as
* the terminal's current selection.
*
* The selection's start and end grid refs must both be valid untracked
* snapshots for the given terminal's currently active screen. In practice,
* they must come from that terminal and screen, and no mutating terminal call
* may have occurred since the refs were produced or reconstructed from
* tracked refs. Passing refs from another terminal, another screen, or stale
* refs violates this precondition.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param selection Selection snapshot to order
* @param desired Desired endpoint order
* @param[out] out_selection On success, receives the ordered selection
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
* selection, desired order, or output pointer are invalid. Selection
* reference validity is a precondition and is not checked.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered(
GhosttyTerminal terminal,
const GhosttySelection* selection,
GhosttySelectionOrder desired,
GhosttySelection* out_selection);
/**
* Test whether a terminal point is inside a selection snapshot.
*
* This uses the same selection semantics as the terminal, including
* rectangular/block selections and linear selections spanning multiple rows.
*
* The selection's start and end grid refs must both be valid untracked
* snapshots for the given terminal's currently active screen. In practice,
* they must come from that terminal and screen, and no mutating terminal call
* may have occurred since the refs were produced or reconstructed from
* tracked refs. Passing refs from another terminal, another screen, or stale
* refs violates this precondition.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param selection Selection snapshot to inspect
* @param point Point to test for containment
* @param[out] out_contains On success, receives whether point is inside selection
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
* selection, point, or output pointer are invalid. Selection reference
* validity is a precondition and is not checked.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains(
GhosttyTerminal terminal,
const GhosttySelection* selection,
GhosttyPoint point,
bool* out_contains);
/**
* Test whether two selection snapshots are equal.
*
* Equality uses the terminal's internal selection semantics: both endpoint
* pins must match and both selections must have the same rectangular/block
* state. This avoids requiring callers to compare raw GhosttyGridRef internals.
*
* Both selections' start and end grid refs must be valid untracked snapshots
* for the given terminal's currently active screen. In practice, they must
* come from that terminal and screen, and no mutating terminal call may have
* occurred since the refs were produced or reconstructed from tracked refs.
* Passing refs from another terminal, another screen, or stale refs violates
* this precondition.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param a First selection snapshot to compare
* @param b Second selection snapshot to compare
* @param[out] out_equal On success, receives whether the selections are equal
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
* selections, or output pointer are invalid. Selection reference
* validity is a precondition and is not checked.
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal(
GhosttyTerminal terminal,
const GhosttySelection* a,
const GhosttySelection* b,
bool* out_equal);
/** @} */
#ifdef __cplusplus

View File

@@ -19,6 +19,7 @@
#include <ghostty/vt/kitty_graphics.h>
#include <ghostty/vt/screen.h>
#include <ghostty/vt/point.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/style.h>
#ifdef __cplusplus
@@ -592,6 +593,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20,
/**
* Set the active screen selection.
*
* The value must point to a GhosttySelection whose grid references are
* valid for this terminal's active screen at the time of the call. The
* terminal copies the selection immediately and converts it to
* terminal-owned tracked state, so the GhosttySelection struct and its
* untracked grid references do not need to outlive this call.
*
* Passing NULL clears the active screen selection.
*
* Input type: GhosttySelection*
*/
GHOSTTY_TERMINAL_OPT_SELECTION = 21,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
@@ -868,6 +884,23 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Output type: GhosttyKittyGraphics *
*/
GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30,
/**
* The active screen's current selection.
*
* On success, writes an untracked snapshot of the terminal-owned selection
* to the caller-provided GhosttySelection. The GhosttySelection struct is
* caller-owned and may be kept, but the grid references inside it are
* untracked borrowed references into the active screen. They are only valid
* until the next mutating terminal call, such as ghostty_terminal_set(),
* ghostty_terminal_vt_write(), ghostty_terminal_resize(), or
* ghostty_terminal_reset().
*
* Returns GHOSTTY_NO_VALUE when there is no active selection.
*
* Output type: GhosttySelection *
*/
GHOSTTY_TERMINAL_DATA_SELECTION = 31,
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalData;

View File

@@ -194,6 +194,23 @@ typedef struct GhosttyOscCommandImpl* GhosttyOscCommand;
/* ---- Common value types ---- */
/**
* Terminal content output format.
*
* @ingroup formatter
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Plain text (no escape sequences). */
GHOSTTY_FORMATTER_FORMAT_PLAIN,
/** VT sequences preserving colors, styles, URLs, etc. */
GHOSTTY_FORMATTER_FORMAT_VT,
/** HTML with inline styles. */
GHOSTTY_FORMATTER_FORMAT_HTML,
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyFormatterFormat;
/**
* A borrowed byte string (pointer + length).
*

View File

@@ -49,6 +49,7 @@ const string = @import("string.zig");
const terminal = struct {
const CursorStyle = @import("../terminal/cursor.zig").Style;
const color = @import("../terminal/color.zig");
const selection_codepoints = @import("../terminal/selection_codepoints.zig");
const style = @import("../terminal/style.zig");
const x11_color = @import("../terminal/x11_color.zig");
};
@@ -6149,32 +6150,8 @@ pub const RepeatableString = struct {
pub const SelectionWordChars = struct {
const Self = @This();
/// Default boundary characters: ` \t'"│`|:;,()[]{}<>$`
const default_codepoints = [_]u21{
0, // null
' ', // space
'\t', // tab
'\'', // single quote
'"', // double quote
'│', // U+2502 box drawing
'`', // backtick
'|', // pipe
':', // colon
';', // semicolon
',', // comma
'(', // left paren
')', // right paren
'[', // left bracket
']', // right bracket
'{', // left brace
'}', // right brace
'<', // less than
'>', // greater than
'$', // dollar
};
/// The parsed codepoints. Always includes null (U+0000) at index 0.
codepoints: []const u21 = &default_codepoints,
codepoints: []const u21 = &terminal.selection_codepoints.default_word_boundaries,
pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
const value = input orelse return error.ValueRequired;

View File

@@ -209,6 +209,8 @@ comptime {
@export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" });
@export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" });
@export(&c.formatter_free, .{ .name = "ghostty_formatter_free" });
@export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" });
@export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" });
@export(&c.render_state_new, .{ .name = "ghostty_render_state_new" });
@export(&c.render_state_update, .{ .name = "ghostty_render_state_update" });
@export(&c.render_state_get, .{ .name = "ghostty_render_state_get" });
@@ -239,6 +241,16 @@ comptime {
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
@export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" });
@export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" });
@export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" });
@export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" });
@export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" });
@export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" });
@export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" });
@export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" });
@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.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" });

View File

@@ -658,6 +658,11 @@ pub fn deinit(self: *PageList) void {
pub fn reset(self: *PageList) void {
defer self.assertIntegrity();
// Invalidate all external page refs to the previous list. The reset below
// rebuilds the page list from the pools, so old untracked refs must be
// rejected before any validation attempts to inspect their node pointers.
self.page_serial_min = self.page_serial;
// We need enough pages/nodes to keep our active area. This should
// never fail since we by definition have allocated a page already
// that fits our size but I'm not confident to make that assertion.
@@ -13543,6 +13548,30 @@ test "PageList reset" {
}, s.getTopLeft(.active));
}
test "PageList reset invalidates stale untracked refs even if node memory is reused" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, null);
defer s.deinit();
const old_serial = s.pages.first.?.serial;
try testing.expect(old_serial >= s.page_serial_min);
try testing.expect(old_serial < s.page_serial);
s.reset();
// The important safety property is that stale serials are rejected before
// the node pointer is inspected. Reset rebuilds the page list from the
// pools, so old untracked refs may contain node pointers that are no
// longer safe to dereference.
try testing.expect(old_serial < s.page_serial_min);
const new_serial = s.pages.first.?.serial;
try testing.expect(new_serial >= s.page_serial_min);
try testing.expect(new_serial < s.page_serial);
}
test "PageList reset across two pages" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -13,6 +13,7 @@ const tripwire = @import("../tripwire.zig");
const unicode = @import("../unicode/main.zig");
const Selection = @import("Selection.zig");
const PageList = @import("PageList.zig");
const selection_codepoints = @import("selection_codepoints.zig");
const StringMap = @import("StringMap.zig");
const ScreenFormatter = @import("formatter.zig").ScreenFormatter;
const osc = @import("osc.zig");
@@ -2516,7 +2517,7 @@ pub const SelectLine = struct {
/// These are the codepoints to consider whitespace to trim
/// from the ends of the selection.
whitespace: ?[]const u21 = &.{ 0, ' ', '\t' },
whitespace: ?[]const u21 = &selection_codepoints.default_line_whitespace,
/// If true, line selection will consider semantic prompt
/// state changing a boundary. State changing is ANY state
@@ -2652,10 +2653,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
const this_whitespace = std.mem.indexOfScalar(
u21,
whitespace,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_whitespace) continue;
@@ -2674,10 +2675,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
const this_whitespace = std.mem.indexOfScalar(
u21,
whitespace,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_whitespace) continue;
@@ -2798,10 +2799,10 @@ pub fn selectWord(
if (!start_cell.hasText()) return null;
// Determine if we are a boundary or not to determine what our boundary is.
const expect_boundary = std.mem.indexOfAny(
const expect_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
&[_]u21{start_cell.content.codepoint},
start_cell.content.codepoint,
) != null;
// Go forwards to find our end boundary
@@ -2816,10 +2817,10 @@ pub fn selectWord(
if (!cell.hasText()) break :end prev;
// If we do not match our expected set, we hit a boundary
const this_boundary = std.mem.indexOfAny(
const this_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_boundary != expect_boundary) break :end prev;
@@ -2853,10 +2854,10 @@ pub fn selectWord(
if (!cell.hasText()) break :start prev;
// If we do not match our expected set, we hit a boundary
const this_boundary = std.mem.indexOfAny(
const this_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_boundary != expect_boundary) break :start prev;

View File

@@ -4,6 +4,7 @@ const Selection = @This();
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const lib = @import("lib.zig");
const page = @import("page.zig");
const point = @import("point.zig");
const PageList = @import("PageList.zig");
@@ -195,7 +196,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin {
/// operations only flip the x or y axis, not both. Depending on the y axis
/// direction, this is either mirrored_forward or mirrored_reverse.
///
pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse };
pub const Order = lib.Enum(lib.target, &.{
"forward",
"reverse",
"mirrored_forward",
"mirrored_reverse",
});
pub fn order(self: Selection, s: *const Screen) Order {
const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen;
@@ -389,18 +395,18 @@ pub fn containedRowCached(
}
/// Possible adjustments to the selection.
pub const Adjustment = enum {
left,
right,
up,
down,
home,
end,
page_up,
page_down,
beginning_of_line,
end_of_line,
};
pub const Adjustment = lib.Enum(lib.target, &.{
"left",
"right",
"up",
"down",
"home",
"end",
"page_up",
"page_down",
"beginning_of_line",
"end_of_line",
});
/// Adjust the selection by some given adjustment. An adjustment allows
/// a selection to be expanded slightly left, right, up, down, etc.

View File

@@ -170,6 +170,18 @@ pub const terminal_mode_get = terminal.mode_get;
pub const terminal_mode_set = terminal.mode_set;
pub const terminal_get = terminal.get;
pub const terminal_get_multi = terminal.get_multi;
pub const terminal_select_word = selection.word;
pub const terminal_select_word_between = selection.word_between;
pub const terminal_select_line = selection.line;
pub const terminal_select_all = selection.all;
pub const terminal_select_output = selection.output;
pub const terminal_selection_format_buf = selection.format_buf;
pub const terminal_selection_format_alloc = selection.format_alloc;
pub const terminal_selection_adjust = selection.adjust;
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 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;

View File

@@ -31,6 +31,7 @@ const RowIteratorWrapper = struct {
/// These are the raw pointers into the render state data.
raws: []const page.Row,
cells: []const std.MultiArrayList(renderpkg.RenderState.Cell),
selection: []const ?[2]size.CellCountInt,
dirty: []bool,
/// The color palette from the render state, needed to resolve
@@ -61,6 +62,13 @@ pub const RowCells = ?*RowCellsWrapper;
/// C: GhosttyRenderStateDirty
pub const Dirty = renderpkg.RenderState.Dirty;
/// C: GhosttyRenderStateRowSelection
pub const RowSelection = extern struct {
size: usize = @sizeOf(RowSelection),
start_x: u16 = 0,
end_x: u16 = 0,
};
/// C: GhosttyRenderStateCursorVisualStyle
pub const CursorVisualStyle = enum(c_int) {
bar = 0,
@@ -241,6 +249,7 @@ fn getTyped(
.y = null,
.raws = row_data.items(.raw),
.cells = row_data.items(.cells),
.selection = row_data.items(.selection),
.dirty = row_data.items(.dirty),
.palette = &state.state.colors.palette,
};
@@ -381,6 +390,7 @@ pub fn row_iterator_new(
.y = undefined,
.raws = undefined,
.cells = undefined,
.selection = undefined,
.dirty = undefined,
.palette = undefined,
};
@@ -564,6 +574,7 @@ pub const RowData = enum(c_int) {
dirty = 1,
raw = 2,
cells = 3,
selection = 4,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: RowData) type {
@@ -572,6 +583,7 @@ pub const RowData = enum(c_int) {
.dirty => bool,
.raw => row.CRow,
.cells => RowCells,
.selection => RowSelection,
};
}
};
@@ -654,6 +666,14 @@ fn rowGetTyped(
.palette = it.palette,
};
},
.selection => {
const out_size = out.size;
if (out_size < @sizeOf(RowSelection)) return .invalid_value;
const sel = it.selection[y] orelse return .no_value;
out.start_x = sel[0];
out.end_x = sel[1];
},
}
return .success;
@@ -845,6 +865,7 @@ test "render: row iterator new/free" {
try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y);
try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len);
try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len);
try testing.expectEqual(row_data.items(.selection).len, iterator_ptr.selection.len);
try testing.expectEqual(row_data.items(.dirty).len, iterator_ptr.dirty.len);
}
@@ -1026,6 +1047,60 @@ test "render: row get/set dirty" {
try testing.expect(!dirty);
}
test "render: row get selection" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
.{
.cols = 10,
.rows = 3,
.max_scrollback = 10_000,
},
));
defer terminal_c.free(terminal);
const t = terminal.?.terminal;
const screen = t.screens.active;
try screen.select(.init(
screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
false,
));
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&state,
));
defer free(state);
try testing.expectEqual(Result.success, update(state, terminal));
var it: RowIterator = null;
try testing.expectEqual(Result.success, row_iterator_new(
&lib.alloc.test_allocator,
&it,
));
defer row_iterator_free(it);
try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
var sel: RowSelection = .{};
try testing.expect(row_iterator_next(it));
try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel)));
try testing.expect(row_iterator_next(it));
sel = .{};
try testing.expectEqual(Result.success, row_get(it, .selection, @ptrCast(&sel)));
try testing.expectEqual(@as(u16, 2), sel.start_x);
try testing.expectEqual(@as(u16, 4), sel.end_x);
try testing.expect(row_iterator_next(it));
sel = .{};
try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel)));
}
test "render: row iterator next" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(

View File

@@ -1,5 +1,20 @@
const std = @import("std");
const testing = std.testing;
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const formatterpkg = @import("../formatter.zig");
const grid_ref = @import("grid_ref.zig");
const point = @import("../point.zig");
const selection_codepoints = @import("../selection_codepoints.zig");
const Selection = @import("../Selection.zig");
const Result = @import("result.zig").Result;
const terminal_c = @import("terminal.zig");
const log = std.log.scoped(.selection_c);
pub const Adjustment = Selection.Adjustment;
pub const Order = Selection.Order;
pub const Format = formatterpkg.Format;
/// C: GhosttySelection
pub const CSelection = extern struct {
@@ -13,4 +28,504 @@ pub const CSelection = extern struct {
const end_pin = self.end.toPin() orelse return null;
return Selection.init(start_pin, end_pin, self.rectangle);
}
pub fn fromZig(sel: Selection) CSelection {
return .{
.start = .fromPin(sel.start()),
.end = .fromPin(sel.end()),
.rectangle = sel.rectangle,
};
}
};
/// C: GhosttyTerminalSelectWordOptions
pub const SelectWordOptions = extern struct {
size: usize = @sizeOf(SelectWordOptions),
ref: grid_ref.CGridRef,
boundary_codepoints: ?[*]const u32 = null,
boundary_codepoints_len: usize = 0,
};
/// C: GhosttyTerminalSelectWordBetweenOptions
pub const SelectWordBetweenOptions = extern struct {
size: usize = @sizeOf(SelectWordBetweenOptions),
start: grid_ref.CGridRef,
end: grid_ref.CGridRef,
boundary_codepoints: ?[*]const u32 = null,
boundary_codepoints_len: usize = 0,
};
/// C: GhosttyTerminalSelectLineOptions
pub const SelectLineOptions = extern struct {
size: usize = @sizeOf(SelectLineOptions),
ref: grid_ref.CGridRef,
whitespace: ?[*]const u32 = null,
whitespace_len: usize = 0,
semantic_prompt_boundary: bool = false,
};
/// C: GhosttyTerminalSelectionFormatOptions
pub const FormatOptions = extern struct {
size: usize = @sizeOf(FormatOptions),
emit: Format,
unwrap: bool,
trim: bool,
selection: ?*const CSelection = null,
};
pub fn word(
terminal: terminal_c.Terminal,
options: ?*const SelectWordOptions,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const opts = options orelse return .invalid_value;
if (opts.size < @sizeOf(SelectWordOptions)) return .invalid_value;
const out = out_selection orelse return .invalid_value;
const boundary_codepoints = codepointSlice(
opts.boundary_codepoints,
opts.boundary_codepoints_len,
) catch return .invalid_value;
const screen = t.screens.active;
const pin = opts.ref.toPin() orelse return .invalid_value;
out.* = .fromZig(screen.selectWord(
pin,
boundary_codepoints orelse &selection_codepoints.default_word_boundaries,
) orelse
return .no_value);
return .success;
}
pub fn word_between(
terminal: terminal_c.Terminal,
options: ?*const SelectWordBetweenOptions,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const opts = options orelse return .invalid_value;
if (opts.size < @sizeOf(SelectWordBetweenOptions)) return .invalid_value;
const out = out_selection orelse return .invalid_value;
const boundary_codepoints = codepointSlice(
opts.boundary_codepoints,
opts.boundary_codepoints_len,
) catch return .invalid_value;
const screen = t.screens.active;
const start = opts.start.toPin() orelse return .invalid_value;
const end = opts.end.toPin() orelse return .invalid_value;
out.* = .fromZig(screen.selectWordBetween(
start,
end,
boundary_codepoints orelse &selection_codepoints.default_word_boundaries,
) orelse
return .no_value);
return .success;
}
pub fn line(
terminal: terminal_c.Terminal,
options: ?*const SelectLineOptions,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const opts = options orelse return .invalid_value;
if (opts.size < @sizeOf(SelectLineOptions)) return .invalid_value;
const out = out_selection orelse return .invalid_value;
const whitespace = codepointSlice(
opts.whitespace,
opts.whitespace_len,
) catch return .invalid_value;
const screen = t.screens.active;
const pin = opts.ref.toPin() orelse return .invalid_value;
out.* = .fromZig(screen.selectLine(.{
.pin = pin,
.whitespace = whitespace orelse &selection_codepoints.default_line_whitespace,
.semantic_prompt_boundary = opts.semantic_prompt_boundary,
}) orelse return .no_value);
return .success;
}
pub fn all(
terminal: terminal_c.Terminal,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const out = out_selection orelse return .invalid_value;
out.* = .fromZig(t.screens.active.selectAll() orelse return .no_value);
return .success;
}
pub fn output(
terminal: terminal_c.Terminal,
ref: grid_ref.CGridRef,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const out = out_selection orelse return .invalid_value;
const screen = t.screens.active;
const pin = ref.toPin() orelse return .invalid_value;
out.* = .fromZig(screen.selectOutput(pin) orelse return .no_value);
return .success;
}
pub fn format_buf(
terminal: terminal_c.Terminal,
opts: FormatOptions,
out_: ?[*]u8,
out_len: usize,
out_written: *usize,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
if (out_ == null) {
var discarding: std.Io.Writer.Discarding = .init(&.{});
formatSelection(t, opts, &discarding.writer) catch |err| return switch (err) {
error.InvalidValue => .invalid_value,
error.NoValue => .no_value,
error.WriteFailed => unreachable,
};
out_written.* = @intCast(discarding.count);
return .out_of_space;
}
var writer: std.Io.Writer = .fixed(out_.?[0..out_len]);
formatSelection(t, opts, &writer) catch |err| switch (err) {
error.InvalidValue => return .invalid_value,
error.NoValue => return .no_value,
error.WriteFailed => {
var discarding: std.Io.Writer.Discarding = .init(&.{});
formatSelection(t, opts, &discarding.writer) catch unreachable;
out_written.* = @intCast(discarding.count);
return .out_of_space;
},
};
out_written.* = writer.end;
return .success;
}
pub fn format_alloc(
terminal: terminal_c.Terminal,
alloc_: ?*const CAllocator,
opts: FormatOptions,
out_ptr: *?[*]u8,
out_len: *usize,
) callconv(lib.calling_conv) Result {
out_ptr.* = null;
out_len.* = 0;
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const alloc = lib.alloc.default(alloc_);
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
formatSelection(t, opts, &aw.writer) catch |err| return switch (err) {
error.InvalidValue => .invalid_value,
error.NoValue => .no_value,
error.WriteFailed => .out_of_memory,
};
const buf = aw.toOwnedSlice() catch return .out_of_memory;
out_ptr.* = buf.ptr;
out_len.* = buf.len;
return .success;
}
fn formatSelection(
t: *terminal_c.ZigTerminal,
opts: FormatOptions,
writer: *std.Io.Writer,
) error{ InvalidValue, NoValue, WriteFailed }!void {
var formatter = selectionFormatter(t, opts) catch |err| return err;
try formatter.format(writer);
}
fn selectionFormatter(
t: *terminal_c.ZigTerminal,
opts: FormatOptions,
) error{ InvalidValue, NoValue }!formatterpkg.TerminalFormatter {
if (opts.size < @sizeOf(FormatOptions)) return error.InvalidValue;
_ = std.meta.intToEnum(Format, @intFromEnum(opts.emit)) catch
return error.InvalidValue;
const sel = if (opts.selection) |sel|
sel.toZig() orelse return error.InvalidValue
else
t.screens.active.selection orelse return error.NoValue;
var formatter: formatterpkg.TerminalFormatter = .init(t, .{
.emit = opts.emit,
.unwrap = opts.unwrap,
.trim = opts.trim,
});
formatter.content = .{ .selection = sel };
return formatter;
}
/// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`.
///
/// `NULL + len 0` returns null, which callers treat as “use the API default
/// set.” A non-null pointer with `len 0` returns an empty slice, meaning “use an
/// explicitly empty set.” A non-zero length requires a non-null pointer.
///
/// This is intentionally zero-copy. In the C ABI, codepoints are `uint32_t`,
/// but selection internals use Zig's `u21` to represent valid Unicode scalar
/// values. Zig currently stores `u21` in the same size and alignment as `u32`,
/// so we assert that layout relationship and reinterpret the borrowed slice.
/// If Zig ever changes that representation, these comptime assertions fail
/// loudly rather than silently making this cast wrong.
fn codepointSlice(
ptr: ?[*]const u32,
len: usize,
) error{InvalidValue}!?[]const u21 {
comptime {
std.debug.assert(@sizeOf(u21) == @sizeOf(u32));
std.debug.assert(@alignOf(u21) == @alignOf(u32));
}
if (len == 0) {
const p = ptr orelse return null;
_ = p;
return &.{};
}
const p = ptr orelse return error.InvalidValue;
const cps: [*]const u21 = @ptrCast(p);
return cps[0..len];
}
pub fn adjust(
terminal: terminal_c.Terminal,
selection: ?*CSelection,
adjustment: Selection.Adjustment,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch {
log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)});
return .invalid_value;
};
}
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const sel_ptr = selection orelse return .invalid_value;
var sel = sel_ptr.toZig() orelse return .invalid_value;
sel.adjust(t.screens.active, adjustment);
sel_ptr.* = .fromZig(sel);
return .success;
}
pub fn order(
terminal: terminal_c.Terminal,
selection: ?*const CSelection,
out_order: ?*Selection.Order,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const sel = (selection orelse return .invalid_value).toZig() orelse
return .invalid_value;
const out = out_order orelse return .invalid_value;
out.* = sel.order(t.screens.active);
return .success;
}
pub fn ordered(
terminal: terminal_c.Terminal,
selection: ?*const CSelection,
desired: Selection.Order,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch {
log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)});
return .invalid_value;
};
}
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const sel = (selection orelse return .invalid_value).toZig() orelse
return .invalid_value;
const out = out_selection orelse return .invalid_value;
out.* = .fromZig(sel.ordered(t.screens.active, desired));
return .success;
}
pub fn contains(
terminal: terminal_c.Terminal,
selection: ?*const CSelection,
pt: point.Point.C,
out_contains: ?*bool,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const sel = (selection orelse return .invalid_value).toZig() orelse
return .invalid_value;
const out = out_contains orelse return .invalid_value;
const screen = t.screens.active;
const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value;
out.* = sel.contains(screen, pin);
return .success;
}
pub fn equal(
terminal: terminal_c.Terminal,
a: ?*const CSelection,
b: ?*const CSelection,
out_equal: ?*bool,
) callconv(lib.calling_conv) Result {
_ = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const sel_a = (a orelse return .invalid_value).toZig() orelse
return .invalid_value;
const sel_b = (b orelse return .invalid_value).toZig() orelse
return .invalid_value;
const out = out_equal orelse return .invalid_value;
out.* = sel_a.eql(sel_b);
return .success;
}
test "selection_format_alloc uses active selection" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(t);
terminal_c.vt_write(t, "Hello World", 11);
var start_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 6, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 10, .y = 0 } },
}, &end_ref));
const sel: CSelection = .{
.start = start_ref,
.end = end_ref,
};
try testing.expectEqual(Result.success, terminal_c.set(t, .selection, @ptrCast(&sel)));
const opts: FormatOptions = .{
.emit = .plain,
.unwrap = true,
.trim = true,
};
var required: usize = 0;
try testing.expectEqual(Result.out_of_space, format_buf(
t,
opts,
null,
0,
&required,
));
try testing.expectEqual(@as(usize, 5), required);
var out_ptr: ?[*]u8 = null;
var out_len: usize = 0;
try testing.expectEqual(Result.success, format_alloc(
t,
&lib.alloc.test_allocator,
opts,
&out_ptr,
&out_len,
));
const ptr = out_ptr orelse return error.TestExpectedEqual;
defer lib.alloc.default(&lib.alloc.test_allocator).free(ptr[0..out_len]);
try testing.expectEqualStrings("World", ptr[0..out_len]);
}
test "selection_format_buf uses provided selection" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(t);
terminal_c.vt_write(t, "Hello World", 11);
var start_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 4, .y = 0 } },
}, &end_ref));
const sel: CSelection = .{
.start = start_ref,
.end = end_ref,
};
const opts: FormatOptions = .{
.emit = .plain,
.unwrap = true,
.trim = true,
.selection = &sel,
};
var small: [2]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.out_of_space, format_buf(
t,
opts,
&small,
small.len,
&written,
));
try testing.expectEqual(@as(usize, 5), written);
var buf: [32]u8 = undefined;
try testing.expectEqual(Result.success, format_buf(
t,
opts,
&buf,
buf.len,
&written,
));
try testing.expectEqualStrings("Hello", buf[0..written]);
}
test "selection_format_alloc returns no_value without active selection" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(t);
var out_ptr: ?[*]u8 = @ptrFromInt(1);
var out_len: usize = 123;
try testing.expectEqual(Result.no_value, format_alloc(
t,
&lib.alloc.test_allocator,
.{ .emit = .plain, .unwrap = true, .trim = true },
&out_ptr,
&out_len,
));
try testing.expect(out_ptr == null);
try testing.expectEqual(@as(usize, 0), out_len);
}

View File

@@ -3,7 +3,7 @@ const testing = std.testing;
const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const ZigTerminal = @import("../Terminal.zig");
pub const ZigTerminal = @import("../Terminal.zig");
const Stream = @import("../stream_terminal.zig").Stream;
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
@@ -20,6 +20,7 @@ const cell_c = @import("cell.zig");
const row_c = @import("row.zig");
const grid_ref_c = @import("grid_ref.zig");
const grid_ref_tracked_c = @import("grid_ref_tracked.zig");
const selection_c = @import("selection.zig");
const style_c = @import("style.zig");
const color = @import("../color.zig");
const Result = @import("result.zig").Result;
@@ -209,6 +210,10 @@ const Effects = struct {
/// C: GhosttyTerminal
pub const Terminal = ?*TerminalWrapper;
pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal {
return (terminal_ orelse return null).terminal;
}
/// C: GhosttyTerminalOptions
pub const Options = extern struct {
cols: size.CellCountInt,
@@ -314,6 +319,7 @@ pub const Option = enum(c_int) {
kitty_image_medium_shared_mem = 18,
apc_max_bytes = 19,
apc_max_bytes_kitty = 20,
selection = 21,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -336,6 +342,7 @@ pub const Option = enum(c_int) {
.kitty_image_medium_shared_mem,
=> ?*const bool,
.apc_max_bytes, .apc_max_bytes_kitty => ?*const usize,
.selection => ?*const selection_c.CSelection,
};
}
};
@@ -443,6 +450,14 @@ fn setTyped(
wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty);
}
},
.selection => {
if (value) |ptr| {
const sel = ptr.toZig() orelse return .invalid_value;
wrapper.terminal.screens.active.select(sel) catch return .out_of_memory;
} else {
wrapper.terminal.screens.active.clearSelection();
}
},
}
return .success;
}
@@ -576,6 +591,7 @@ pub const TerminalData = enum(c_int) {
kitty_image_medium_temp_file = 28,
kitty_image_medium_shared_mem = 29,
kitty_graphics = 30,
selection = 31,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: TerminalData) type {
@@ -604,6 +620,7 @@ pub const TerminalData = enum(c_int) {
.kitty_image_medium_shared_mem,
=> bool,
.kitty_graphics => KittyGraphics,
.selection => selection_c.CSelection,
};
}
};
@@ -713,6 +730,9 @@ fn getTyped(
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = &t.screens.active.kitty_images;
},
.selection => out.* = selection_c.CSelection.fromZig(
t.screens.active.selection orelse return .no_value,
),
}
return .success;
@@ -1325,6 +1345,410 @@ test "get invalid" {
try testing.expectEqual(Result.invalid_value, get(t, .invalid, null));
}
test "set and get selection" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
vt_write(t, "Hello", 5);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 4, .y = 0 } },
}, &end_ref));
var out: selection_c.CSelection = undefined;
try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out)));
const sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
try testing.expectEqual(Result.success, set(t, .selection, @ptrCast(&sel)));
try testing.expect(t.?.terminal.screens.active.selection.?.tracked());
try testing.expectEqual(Result.success, get(t, .selection, @ptrCast(&out)));
try testing.expect(out.start.toPin().?.eql(start_ref.toPin().?));
try testing.expect(out.end.toPin().?.eql(end_ref.toPin().?));
try testing.expect(out.rectangle);
try testing.expectEqual(Result.success, set(t, .selection, null));
try testing.expect(t.?.terminal.screens.active.selection == null);
try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out)));
}
test "selection derivation helpers" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
vt_write(t, " Hello \r\nWorld", 16);
var out: selection_c.CSelection = undefined;
var word_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 3, .y = 0 } },
}, &word_ref));
var empty_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 20, .y = 0 } },
}, &empty_ref));
var line_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &line_ref));
var word_opts: selection_c.SelectWordOptions = .{
.ref = word_ref,
};
try testing.expectEqual(Result.success, selection_c.word(t, &word_opts, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
word_opts.ref = empty_ref;
try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out));
var between_start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 20, .y = 1 } },
}, &between_start_ref));
var between_end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 1 } },
}, &between_end_ref));
var word_between_opts: selection_c.SelectWordBetweenOptions = .{
.start = between_start_ref,
.end = between_end_ref,
};
try testing.expectEqual(Result.success, selection_c.word_between(t, &word_between_opts, &out));
try testing.expectEqual(@as(u16, 0), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
var line_opts: selection_c.SelectLineOptions = .{
.ref = line_ref,
};
try testing.expectEqual(Result.success, selection_c.line(t, &line_opts, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
try testing.expectEqual(Result.success, selection_c.all(t, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
try testing.expectEqual(Result.no_value, selection_c.output(t, line_ref, &out));
line_opts.size = @sizeOf(usize) - 1;
try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null));
try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, null, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, &word_between_opts, null));
}
test "selection_adjust mutates snapshot end" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
vt_write(t, "Hello", 5);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 0 } },
}, &end_ref));
var sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right));
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, selection_c.adjust(t, &sel, .left));
try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x);
sel = .{
.start = end_ref,
.end = start_ref,
};
try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right));
try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x);
}
test "selection_order and selection_ordered" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
vt_write(t, "Hello\r\nWorld", 12);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 3, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 1 } },
}, &end_ref));
const sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
var order: selection_c.Order = undefined;
try testing.expectEqual(Result.success, selection_c.order(t, &sel, &order));
try testing.expectEqual(selection_c.Order.mirrored_forward, order);
var out: selection_c.CSelection = undefined;
try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .forward, &out));
try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
try testing.expect(out.rectangle);
try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .reverse, &out));
try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 0), out.end.toPin().?.y);
try testing.expect(out.rectangle);
}
test "selection_contains" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
vt_write(t, "Hello\r\nWorld", 12);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 3, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 1 } },
}, &end_ref));
const linear: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
var contains: bool = undefined;
try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{
.tag = .active,
.value = .{ .active = .{ .x = 4, .y = 0 } },
}, &contains));
try testing.expect(contains);
try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{
.tag = .active,
.value = .{ .active = .{ .x = 2, .y = 0 } },
}, &contains));
try testing.expect(!contains);
const rectangle: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
try testing.expectEqual(Result.success, selection_c.contains(t, &rectangle, .{
.tag = .active,
.value = .{ .active = .{ .x = 2, .y = 0 } },
}, &contains));
try testing.expect(contains);
}
test "selection_equal" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
var other_t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&other_t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(other_t);
vt_write(t, "Hello", 5);
vt_write(other_t, "Hello", 5);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 0 } },
}, &end_ref));
var other_end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 2, .y = 0 } },
}, &other_end_ref));
var cross_terminal_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(other_t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 0 } },
}, &cross_terminal_ref));
const sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
const equal_sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
const different_endpoint: selection_c.CSelection = .{
.start = start_ref,
.end = other_end_ref,
};
const different_rectangle: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
const cross_terminal: selection_c.CSelection = .{
.start = start_ref,
.end = cross_terminal_ref,
};
var equal: bool = undefined;
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &equal_sel, &equal));
try testing.expect(equal);
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_endpoint, &equal));
try testing.expect(!equal);
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_rectangle, &equal));
try testing.expect(!equal);
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &cross_terminal, &equal));
try testing.expect(!equal);
try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &equal_sel, null));
}
test "selection_order invalid values" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
var order: selection_c.Order = undefined;
try testing.expectEqual(Result.invalid_value, selection_c.order(null, null, &order));
try testing.expectEqual(Result.invalid_value, selection_c.order(t, null, &order));
}
test "grid_ref" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -20,30 +20,36 @@ const mouse_encode = @import("mouse_encode.zig");
const grid_ref = @import("grid_ref.zig");
/// All C API structs and their Ghostty C names.
pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
.{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
.{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
.{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) },
.{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
.{ "GhosttySelection", StructInfo.init(selection.CSelection) },
.{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
.{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
.{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
.{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
.{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) },
.{ "GhosttyPoint", StructInfo.init(point.Point.C) },
.{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) },
.{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
.{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) },
.{ "GhosttyString", StructInfo.init(lib.String) },
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
});
pub const structs: std.StaticStringMap(StructInfo) = structs: {
@setEvalBranchQuota(10_000);
break :structs .initComptime(.{
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
.{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
.{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
.{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) },
.{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
.{ "GhosttySelection", StructInfo.init(selection.CSelection) },
.{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) },
.{ "GhosttyTerminalSelectWordBetweenOptions", StructInfo.init(selection.SelectWordBetweenOptions) },
.{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) },
.{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
.{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
.{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
.{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
.{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) },
.{ "GhosttyPoint", StructInfo.init(point.Point.C) },
.{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) },
.{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
.{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) },
.{ "GhosttyString", StructInfo.init(lib.String) },
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
});
};
/// The comptime-generated JSON string of all structs.
pub const json: [:0]const u8 = json: {

View File

@@ -0,0 +1,31 @@
// This file contains various default word boundaries used for
// selection logic. We put it in a separate file so that different
// subsystems can import it without introducing a number of
// dependencies.
/// Default boundary characters for word selection: ` \t'"│`|:;,()[]{}<>$`
pub const default_word_boundaries = [_]u21{
0, // null
' ', // space
'\t', // tab
'\'', // single quote
'"', // double quote
'│', // U+2502 box drawing
'`', // backtick
'|', // pipe
':', // colon
';', // semicolon
',', // comma
'(', // left paren
')', // right paren
'[', // left bracket
']', // right bracket
'{', // left brace
'}', // right brace
'<', // less than
'>', // greater than
'$', // dollar
};
/// Default whitespace characters trimmed from line selections.
pub const default_line_whitespace = [_]u21{ 0, ' ', '\t' };