diff --git a/example/c-vt-render/README.md b/example/c-vt-render/README.md index 3725ed46f..b56cd8384 100644 --- a/example/c-vt-render/README.md +++ b/example/c-vt-render/README.md @@ -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 diff --git a/example/c-vt-render/src/main.c b/example/c-vt-render/src/main.c index 0714d4160..feb3628d4 100644 --- a/example/c-vt-render/src/main.c +++ b/example/c-vt-render/src/main.c @@ -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); diff --git a/example/c-vt-selection/README.md b/example/c-vt-selection/README.md new file mode 100644 index 000000000..c88f7a11d --- /dev/null +++ b/example/c-vt-selection/README.md @@ -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 +``` diff --git a/example/c-vt-selection/build.zig b/example/c-vt-selection/build.zig new file mode 100644 index 000000000..49f7c8cb3 --- /dev/null +++ b/example/c-vt-selection/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_selection", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-selection/build.zig.zon b/example/c-vt-selection/build.zig.zon new file mode 100644 index 000000000..d09800a51 --- /dev/null +++ b/example/c-vt-selection/build.zig.zon @@ -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", + }, +} diff --git a/example/c-vt-selection/src/main.c b/example/c-vt-selection/src/main.c new file mode 100644 index 000000000..83384ec15 --- /dev/null +++ b/example/c-vt-selection/src/main.c @@ -0,0 +1,136 @@ +#include +#include +#include +#include + +//! [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] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 75bbb3b5b..7a6a9758a 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -136,6 +136,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index 358e95f66..5cdcd11a3 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -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. * diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index d1a3687d9..f1f201c44 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -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. * diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 9f878fadc..142877a97 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -9,7 +9,11 @@ #include #include +#include +#include #include +#include +#include #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 diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 1751aa126..756698449 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #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; diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index e8e976207..0e35124c6 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -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). * diff --git a/src/config/Config.zig b/src/config/Config.zig index 9e6e5629c..380155127 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 12aa66bfe..71b709135 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 89fdaec1f..8e5cd1934 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -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; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ac53a2d72..becda78b7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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; diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 8cb52816c..5258210cf 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -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. diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ab6ab719b..1d78f06bb 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index af82ddfa1..f8b48353f 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -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( diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 74e96598f..cb574ecc2 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -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); +} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 662a2ec03..c3336129b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 500809d9c..d9ece57ee 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -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: { diff --git a/src/terminal/selection_codepoints.zig b/src/terminal/selection_codepoints.zig new file mode 100644 index 000000000..4c3184030 --- /dev/null +++ b/src/terminal/selection_codepoints.zig @@ -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' };