diff --git a/example/c-vt-effects/README.md b/example/c-vt-effects/README.md new file mode 100644 index 000000000..5f5a22b14 --- /dev/null +++ b/example/c-vt-effects/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Terminal Effects + +This contains a simple example of how to register and use terminal +effect callbacks (`write_pty`, `bell`, `title_changed`) with the +`ghostty-vt` C library. + +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-effects/build.zig b/example/c-vt-effects/build.zig new file mode 100644 index 000000000..c3b1af73b --- /dev/null +++ b/example/c-vt-effects/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_effects", + .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-effects/build.zig.zon b/example/c-vt-effects/build.zig.zon new file mode 100644 index 000000000..0275f4f68 --- /dev/null +++ b/example/c-vt-effects/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_effects, + .version = "0.0.0", + .fingerprint = 0xc02634cd65f5b583, + .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-effects/src/main.c b/example/c-vt-effects/src/main.c new file mode 100644 index 000000000..c6688cf5c --- /dev/null +++ b/example/c-vt-effects/src/main.c @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include +#include + +//! [effects-write-pty] +void on_write_pty(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len) { + (void)terminal; + (void)userdata; + printf(" write_pty (%zu bytes): ", len); + fwrite(data, 1, len, stdout); + printf("\n"); +} +//! [effects-write-pty] + +//! [effects-bell] +void on_bell(GhosttyTerminal terminal, void* userdata) { + (void)terminal; + int* count = (int*)userdata; + (*count)++; + printf(" bell! (count=%d)\n", *count); +} +//! [effects-bell] + +//! [effects-title-changed] +void on_title_changed(GhosttyTerminal terminal, void* userdata) { + (void)userdata; + // Query the cursor position to confirm the terminal processed the + // title change (the title itself is tracked by the embedder via the + // OSC parser or its own state). + uint16_t col = 0; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_CURSOR_X, &col); + printf(" title changed (cursor at col %u)\n", col); +} +//! [effects-title-changed] + +//! [effects-register] +int main() { + // Create a terminal + GhosttyTerminal terminal = NULL; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + if (ghostty_terminal_new(NULL, &terminal, opts) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to create terminal\n"); + return 1; + } + + // Set up userdata — a simple bell counter + int bell_count = 0; + void* ud = &bell_count; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_USERDATA, &ud); + + // Register effect callbacks + GhosttyTerminalWritePtyFn write_fn = on_write_pty; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, &write_fn); + + GhosttyTerminalBellFn bell_fn = on_bell; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_BELL, &bell_fn); + + GhosttyTerminalTitleChangedFn title_fn = on_title_changed; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, &title_fn); + + // Feed VT data that triggers effects: + + // 1. Bell (BEL = 0x07) + printf("Sending BEL:\n"); + const uint8_t bel = 0x07; + ghostty_terminal_vt_write(terminal, &bel, 1); + + // 2. Title change (OSC 2 ; ST) + printf("Sending title change:\n"); + const char* title_seq = "\x1B]2;Hello Effects\x1B\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)title_seq, + strlen(title_seq)); + + // 3. Device status report (DECRQM for wraparound mode ?7) + // triggers write_pty with the response + printf("Sending DECRQM query:\n"); + const char* decrqm = "\x1B[?7$p"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)decrqm, + strlen(decrqm)); + + // 4. Another bell to show the counter increments + printf("Sending another BEL:\n"); + ghostty_terminal_vt_write(terminal, &bel, 1); + + printf("Total bells: %d\n", bell_count); + + ghostty_terminal_free(terminal); + return 0; +} +//! [effects-register] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 55ceb430d..2a52f4b08 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -109,6 +109,7 @@ extern "C" { #include <ghostty/vt/allocator.h> #include <ghostty/vt/build_info.h> #include <ghostty/vt/color.h> +#include <ghostty/vt/device.h> #include <ghostty/vt/focus.h> #include <ghostty/vt/formatter.h> #include <ghostty/vt/render.h> diff --git a/include/ghostty/vt/device.h b/include/ghostty/vt/device.h new file mode 100644 index 000000000..fdf6bca7d --- /dev/null +++ b/include/ghostty/vt/device.h @@ -0,0 +1,150 @@ +/** + * @file device.h + * + * Device types used by the terminal for device status and device attribute + * queries. + */ + +#ifndef GHOSTTY_VT_DEVICE_H +#define GHOSTTY_VT_DEVICE_H + +#include <stddef.h> +#include <stdint.h> + +/* DA1 conformance levels (Pp parameter). */ +#define GHOSTTY_DA_CONFORMANCE_VT100 1 +#define GHOSTTY_DA_CONFORMANCE_VT101 1 +#define GHOSTTY_DA_CONFORMANCE_VT102 6 +#define GHOSTTY_DA_CONFORMANCE_VT125 12 +#define GHOSTTY_DA_CONFORMANCE_VT131 7 +#define GHOSTTY_DA_CONFORMANCE_VT132 4 +#define GHOSTTY_DA_CONFORMANCE_VT220 62 +#define GHOSTTY_DA_CONFORMANCE_VT240 62 +#define GHOSTTY_DA_CONFORMANCE_VT320 63 +#define GHOSTTY_DA_CONFORMANCE_VT340 63 +#define GHOSTTY_DA_CONFORMANCE_VT420 64 +#define GHOSTTY_DA_CONFORMANCE_VT510 65 +#define GHOSTTY_DA_CONFORMANCE_VT520 65 +#define GHOSTTY_DA_CONFORMANCE_VT525 65 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_2 62 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_3 63 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_4 64 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_5 65 + +/* DA1 feature codes (Ps parameters). */ +#define GHOSTTY_DA_FEATURE_COLUMNS_132 1 +#define GHOSTTY_DA_FEATURE_PRINTER 2 +#define GHOSTTY_DA_FEATURE_REGIS 3 +#define GHOSTTY_DA_FEATURE_SIXEL 4 +#define GHOSTTY_DA_FEATURE_SELECTIVE_ERASE 6 +#define GHOSTTY_DA_FEATURE_USER_DEFINED_KEYS 8 +#define GHOSTTY_DA_FEATURE_NATIONAL_REPLACEMENT 9 +#define GHOSTTY_DA_FEATURE_TECHNICAL_CHARACTERS 15 +#define GHOSTTY_DA_FEATURE_LOCATOR 16 +#define GHOSTTY_DA_FEATURE_TERMINAL_STATE 17 +#define GHOSTTY_DA_FEATURE_WINDOWING 18 +#define GHOSTTY_DA_FEATURE_HORIZONTAL_SCROLLING 21 +#define GHOSTTY_DA_FEATURE_ANSI_COLOR 22 +#define GHOSTTY_DA_FEATURE_RECTANGULAR_EDITING 28 +#define GHOSTTY_DA_FEATURE_ANSI_TEXT_LOCATOR 29 +#define GHOSTTY_DA_FEATURE_CLIPBOARD 52 + +/* DA2 device type identifiers (Pp parameter). */ +#define GHOSTTY_DA_DEVICE_TYPE_VT100 0 +#define GHOSTTY_DA_DEVICE_TYPE_VT220 1 +#define GHOSTTY_DA_DEVICE_TYPE_VT240 2 +#define GHOSTTY_DA_DEVICE_TYPE_VT330 18 +#define GHOSTTY_DA_DEVICE_TYPE_VT340 19 +#define GHOSTTY_DA_DEVICE_TYPE_VT320 24 +#define GHOSTTY_DA_DEVICE_TYPE_VT382 32 +#define GHOSTTY_DA_DEVICE_TYPE_VT420 41 +#define GHOSTTY_DA_DEVICE_TYPE_VT510 61 +#define GHOSTTY_DA_DEVICE_TYPE_VT520 64 +#define GHOSTTY_DA_DEVICE_TYPE_VT525 65 + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Color scheme reported in response to a CSI ? 996 n query. + * + * @ingroup terminal + */ +typedef enum { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, +} GhosttyColorScheme; + +/** + * Primary device attributes (DA1) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI c query. + * The conformance_level is the Pp parameter and features contains the Ps + * feature codes. + * + * @ingroup terminal + */ +typedef struct { + /** Conformance level (Pp parameter). E.g. 62 for VT220. */ + uint16_t conformance_level; + + /** DA1 feature codes. Only the first num_features entries are valid. */ + uint16_t features[64]; + + /** Number of valid entries in the features array. */ + size_t num_features; +} GhosttyDeviceAttributesPrimary; + +/** + * Secondary device attributes (DA2) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI > c query. + * Response format: CSI > Pp ; Pv ; Pc c + * + * @ingroup terminal + */ +typedef struct { + /** Terminal type identifier (Pp). E.g. 1 for VT220. */ + uint16_t device_type; + + /** Firmware/patch version number (Pv). */ + uint16_t firmware_version; + + /** ROM cartridge registration number (Pc). Always 0 for emulators. */ + uint16_t rom_cartridge; +} GhosttyDeviceAttributesSecondary; + +/** + * Tertiary device attributes (DA3) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI = c query. + * Response format: DCS ! | D...D ST (DECRPTUI). + * + * @ingroup terminal + */ +typedef struct { + /** Unit ID encoded as 8 uppercase hex digits in the response. */ + uint32_t unit_id; +} GhosttyDeviceAttributesTertiary; + +/** + * Device attributes response data for all three DA levels. + * + * Filled by the device_attributes callback in response to CSI c, + * CSI > c, or CSI = c queries. The terminal uses whichever sub-struct + * matches the request type. + * + * @ingroup terminal + */ +typedef struct { + GhosttyDeviceAttributesPrimary primary; + GhosttyDeviceAttributesSecondary secondary; + GhosttyDeviceAttributesTertiary tertiary; +} GhosttyDeviceAttributes; + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_DEVICE_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b8e929684..a2c89d302 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -12,7 +12,9 @@ #include <stdint.h> #include <ghostty/vt/types.h> #include <ghostty/vt/allocator.h> +#include <ghostty/vt/device.h> #include <ghostty/vt/modes.h> +#include <ghostty/vt/size_report.h> #include <ghostty/vt/grid_ref.h> #include <ghostty/vt/screen.h> #include <ghostty/vt/point.h> @@ -32,6 +34,60 @@ extern "C" { * Once a terminal session is up and running, you can configure a key encoder * to write keyboard input via ghostty_key_encoder_setopt_from_terminal(). * + * ## Effects + * + * By default, the terminal sequence processing with ghostty_terminal_vt_write() + * only process sequences that directly affect terminal state and + * ignores sequences that have side effect behavior or require responses. + * These sequences include things like bell characters, title changes, device + * attributes queries, and more. To handle these sequences, the embedder + * must configure "effects." + * + * Effects are callbacks that the terminal invokes in response to VT + * sequences processed during ghostty_terminal_vt_write(). They let the + * embedding application react to terminal-initiated events such as bell + * characters, title changes, device status report responses, and more. + * + * Each effect is registered with ghostty_terminal_set() using the + * corresponding `GhosttyTerminalOption` identifier. A `NULL` value + * pointer clears the callback and disables the effect. + * + * A userdata pointer can be attached via `GHOSTTY_TERMINAL_OPT_USERDATA` + * and is passed to every callback, allowing callers to route events + * back to their own application state without global variables. + * You cannot specify different userdata for different callbacks. + * + * All callbacks are invoked synchronously during + * ghostty_terminal_vt_write(). Callbacks **must not** call + * ghostty_terminal_vt_write() on the same terminal (no reentrancy). + * And callbacks must be very careful to not block for too long or perform + * expensive operations, since they are blocking further IO processing. + * + * The available effects are: + * + * | Option | Callback Type | Trigger | + * |-----------------------------------------|-----------------------------------|-------------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | + * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | + * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | + * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | + * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | + * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | + * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | + * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| + * + * ### Defining a write_pty callback + * @snippet c-vt-effects/src/main.c effects-write-pty + * + * ### Defining a bell callback + * @snippet c-vt-effects/src/main.c effects-bell + * + * ### Defining a title_changed callback + * @snippet c-vt-effects/src/main.c effects-title-changed + * + * ### Registering effects and processing VT data + * @snippet c-vt-effects/src/main.c effects-register + * * @{ */ @@ -134,6 +190,232 @@ typedef struct { uint64_t len; } GhosttyTerminalScrollbar; +/** + * Callback function type for bell. + * + * Called when the terminal receives a BEL character (0x07). + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for color scheme queries (CSI ? 996 n). + * + * Called when the terminal receives a color scheme device status report + * query. Return true and fill *out_scheme with the current color scheme, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_scheme Pointer to store the current color scheme + * @return true if the color scheme was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalColorSchemeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyColorScheme* out_scheme); + +/** + * Callback function type for device attributes queries (DA1/DA2/DA3). + * + * Called when the terminal receives a device attributes query (CSI c, + * CSI > c, or CSI = c). Return true and fill *out_attrs with the + * response data, or return false to silently ignore the query. + * + * The terminal uses whichever sub-struct (primary, secondary, tertiary) + * matches the request type, but all three should be filled for simplicity. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_attrs Pointer to store the device attributes response + * @return true if attributes were filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalDeviceAttributesFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyDeviceAttributes* out_attrs); + +/** + * Callback function type for enquiry (ENQ, 0x05). + * + * Called when the terminal receives an ENQ character. Return the + * response bytes as a GhosttyString. The memory must remain valid + * until the callback returns. Return a zero-length string to send + * no response. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The response bytes to write back to the pty + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for size queries (XTWINOPS). + * + * Called in response to XTWINOPS size queries (CSI 14/16/18 t). + * Return true and fill *out_size with the current terminal geometry, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_size Pointer to store the terminal size information + * @return true if size was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttySizeReportSize* out_size); + +/** + * Callback function type for title_changed. + * + * Called when the terminal title changes via escape sequences + * (e.g. OSC 0 or OSC 2). The new title can be queried from the + * terminal after the callback returns. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for write_pty. + * + * Called when the terminal needs to write data back to the pty, for + * example in response to a device status report or mode query. The + * data is only valid for the duration of the call; callers must copy + * it if it needs to persist. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param data Pointer to the response bytes + * @param len Length of the response in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len); + +/** + * Callback function type for XTVERSION. + * + * Called when the terminal receives an XTVERSION query (CSI > q). + * Return the version string (e.g. "myterm 1.0") as a GhosttyString. + * The memory must remain valid until the callback returns. Return a + * zero-length string to report the default "libghostty" version. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The version string to report + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Terminal option identifiers. + * + * These values are used with ghostty_terminal_set() to configure + * terminal callbacks and associated state. + * + * @ingroup terminal + */ +typedef enum { + /** + * Opaque userdata pointer passed to all callbacks. + * + * Input type: void** + */ + GHOSTTY_TERMINAL_OPT_USERDATA = 0, + + /** + * Callback invoked when the terminal needs to write data back + * to the pty (e.g. in response to a DECRQM query or device + * status report). Set to NULL to ignore such sequences. + * + * Input type: GhosttyTerminalWritePtyFn* + */ + GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1, + + /** + * Callback invoked when the terminal receives a BEL character + * (0x07). Set to NULL to ignore bell events. + * + * Input type: GhosttyTerminalBellFn* + */ + GHOSTTY_TERMINAL_OPT_BELL = 2, + + /** + * Callback invoked when the terminal receives an ENQ character + * (0x05). Set to NULL to send no response. + * + * Input type: GhosttyTerminalEnquiryFn* + */ + GHOSTTY_TERMINAL_OPT_ENQUIRY = 3, + + /** + * Callback invoked when the terminal receives an XTVERSION query + * (CSI > q). Set to NULL to report the default "libghostty" string. + * + * Input type: GhosttyTerminalXtversionFn* + */ + GHOSTTY_TERMINAL_OPT_XTVERSION = 4, + + /** + * Callback invoked when the terminal title changes via escape + * sequences (e.g. OSC 0 or OSC 2). Set to NULL to ignore title + * change events. + * + * Input type: GhosttyTerminalTitleChangedFn* + */ + GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5, + + /** + * Callback invoked in response to XTWINOPS size queries + * (CSI 14/16/18 t). Set to NULL to silently ignore size queries. + * + * Input type: GhosttyTerminalSizeFn* + */ + GHOSTTY_TERMINAL_OPT_SIZE = 6, + + /** + * Callback invoked in response to a color scheme device status + * report query (CSI ? 996 n). Return true and fill the out pointer + * to report the current scheme, or return false to silently ignore. + * Set to NULL to ignore color scheme queries. + * + * Input type: GhosttyTerminalColorSchemeFn* + */ + GHOSTTY_TERMINAL_OPT_COLOR_SCHEME = 7, + + /** + * Callback invoked in response to a device attributes query + * (CSI c, CSI > c, or CSI = c). Return true and fill the out + * pointer with response data, or return false to silently ignore. + * Set to NULL to ignore device attributes queries. + * + * Input type: GhosttyTerminalDeviceAttributesFn* + */ + GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES = 8, +} GhosttyTerminalOption; + /** * Terminal data types. * @@ -290,15 +572,36 @@ GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, uint16_t cols, uint16_t rows); +/** + * Set an option on the terminal. + * + * Configures terminal callbacks and associated state such as the + * write_pty callback and userdata pointer. A NULL value pointer + * clears the option to its default (NULL/disabled). + * + * Callbacks are invoked synchronously during ghostty_terminal_vt_write(). + * Callbacks must not call ghostty_terminal_vt_write() on the same + * terminal (no reentrancy). + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option), + * or NULL to clear the option + * + * @ingroup terminal + */ +void ghostty_terminal_set(GhosttyTerminal terminal, + GhosttyTerminalOption option, + const void* value); + /** * Write VT-encoded data to the terminal for processing. * * Feeds raw bytes through the terminal's VT stream parser, updating - * terminal state accordingly. Only read-only sequences are processed; - * sequences that require output (queries) are ignored. - * - * In the future, a callback-based API will be added to allow handling - * of output or side effect sequences. + * terminal state accordingly. By default, sequences that require output + * (queries, device status reports) are silently ignored. Use + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install + * a callback that receives response data. * * This never fails. Any erroneous input or errors in processing the * input are logged internally but do not cause this function to fail diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 12ace266e..b5b0fa651 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -7,6 +7,9 @@ #ifndef GHOSTTY_VT_TYPES_H #define GHOSTTY_VT_TYPES_H +#include <stddef.h> +#include <stdint.h> + /** * Result codes for libghostty-vt operations. */ @@ -21,6 +24,20 @@ typedef enum { GHOSTTY_OUT_OF_SPACE = -3, } GhosttyResult; +/** + * A borrowed byte string (pointer + length). + * + * The memory is not owned by this struct. The pointer is only valid + * for the lifetime documented by the API that produces or consumes it. + */ +typedef struct { + /** Pointer to the string bytes. */ + const uint8_t* ptr; + + /** Length of the string in bytes. */ + size_t len; +} GhosttyString; + /** * Initialize a sized struct to zero and set its size field. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 81375ec8a..0a749be87 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -206,6 +206,7 @@ comptime { @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); + @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 7597228d1..d854ac60e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -132,6 +132,7 @@ pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; pub const terminal_resize = terminal.resize; +pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_mode_get = terminal.mode_get; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index bebcf4ea1..f89630ba7 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1,5 +1,6 @@ const std = @import("std"); const testing = std.testing; +const lib = @import("../../lib/main.zig"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const ZigTerminal = @import("../Terminal.zig"); @@ -10,12 +11,17 @@ const kitty = @import("../kitty/key.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); +const device_attributes = @import("../device_attributes.zig"); +const device_status = @import("../device_status.zig"); +const size_report = @import("../size_report.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); const style_c = @import("style.zig"); const Result = @import("result.zig").Result; +const Handler = @import("../stream_terminal.zig").Handler; + const log = std.log.scoped(.terminal_c); /// Wrapper around ZigTerminal that tracks additional state for C API usage, @@ -24,6 +30,176 @@ const log = std.log.scoped(.terminal_c); const TerminalWrapper = struct { terminal: *ZigTerminal, stream: Stream, + effects: Effects = .{}, +}; + +/// C callback state for terminal effects. Trampolines are always +/// installed on the stream handler; they check these fields and +/// no-op when the corresponding callback is null. +const Effects = struct { + userdata: ?*anyopaque = null, + write_pty: ?WritePtyFn = null, + bell: ?BellFn = null, + color_scheme: ?ColorSchemeFn = null, + device_attributes_cb: ?DeviceAttributesFn = null, + enquiry: ?EnquiryFn = null, + xtversion: ?XtversionFn = null, + title_changed: ?TitleChangedFn = null, + size_cb: ?SizeFn = null, + + /// Scratch buffer for DA1 feature codes. The device attributes + /// trampoline converts C feature codes into this buffer and returns + /// a slice pointing into it. Storing it here ensures the slice + /// remains valid after the trampoline returns, since the caller + /// (`reportDeviceAttributes`) reads it before any re-entrant call. + da_features_buf: [64]device_attributes.Primary.Feature = undefined, + + /// C function pointer type for the write_pty callback. + pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; + + /// C function pointer type for the bell callback. + pub const BellFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void; + + /// C function pointer type for the color_scheme callback. + /// Returns true and fills out_scheme if a color scheme is available, + /// or returns false to silently ignore the query. + pub const ColorSchemeFn = *const fn (Terminal, ?*anyopaque, *device_status.ColorScheme) callconv(.c) bool; + + /// C function pointer type for the enquiry callback. + /// Returns the response bytes. The memory must remain valid + /// until the callback returns. + pub const EnquiryFn = *const fn (Terminal, ?*anyopaque) callconv(.c) lib.String; + + /// C function pointer type for the xtversion callback. + /// Returns the version string (e.g. "ghostty 1.2.3"). The memory + /// must remain valid until the callback returns. An empty string + /// (len=0) causes the default "libghostty" to be reported. + pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(.c) lib.String; + + /// C function pointer type for the title_changed callback. + pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void; + + /// C function pointer type for the size callback. + /// Returns true and fills out_size if size is available, + /// or returns false to silently ignore the query. + pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(.c) bool; + + /// C function pointer type for the device_attributes callback. + /// Returns true and fills out_attrs if attributes are available, + /// or returns false to silently ignore the query. + pub const DeviceAttributesFn = *const fn (Terminal, ?*anyopaque, *CDeviceAttributes) callconv(.c) bool; + + /// C-compatible device attributes struct. + /// C: GhosttyDeviceAttributes + pub const CDeviceAttributes = extern struct { + primary: Primary, + secondary: Secondary, + tertiary: Tertiary, + + pub const Primary = extern struct { + conformance_level: u16, + features: [64]u16, + num_features: usize, + }; + + pub const Secondary = extern struct { + device_type: u16, + firmware_version: u16, + rom_cartridge: u16, + }; + + pub const Tertiary = extern struct { + unit_id: u32, + }; + }; + + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.write_pty orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); + } + + fn bellTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.bell orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.color_scheme orelse return null; + var scheme: device_status.ColorScheme = undefined; + if (func(@ptrCast(wrapper), wrapper.effects.userdata, &scheme)) return scheme; + return null; + } + + fn deviceAttributesTrampoline(handler: *Handler) device_attributes.Attributes { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.device_attributes_cb orelse return .{}; + + // Get our attributes from the callback. + var c_attrs: CDeviceAttributes = undefined; + if (!func(@ptrCast(wrapper), wrapper.effects.userdata, &c_attrs)) return .{}; + + // Note below we use a lot of enumFromInt but its always safe + // because all our types are non-exhaustive enums. + + const n: usize = @min(c_attrs.primary.num_features, 64); + for (0..n) |i| wrapper.effects.da_features_buf[i] = @enumFromInt(c_attrs.primary.features[i]); + + return .{ + .primary = .{ + .conformance_level = @enumFromInt(c_attrs.primary.conformance_level), + .features = wrapper.effects.da_features_buf[0..n], + }, + .secondary = .{ + .device_type = @enumFromInt(c_attrs.secondary.device_type), + .firmware_version = c_attrs.secondary.firmware_version, + .rom_cartridge = c_attrs.secondary.rom_cartridge, + }, + .tertiary = .{ + .unit_id = c_attrs.tertiary.unit_id, + }, + }; + } + + fn enquiryTrampoline(handler: *Handler) []const u8 { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.enquiry orelse return ""; + const result = func(@ptrCast(wrapper), wrapper.effects.userdata); + if (result.len == 0) return ""; + return result.ptr[0..result.len]; + } + + fn xtversionTrampoline(handler: *Handler) []const u8 { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.xtversion orelse return ""; + const result = func(@ptrCast(wrapper), wrapper.effects.userdata); + if (result.len == 0) return ""; + return result.ptr[0..result.len]; + } + + fn titleChangedTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.title_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + + fn sizeTrampoline(handler: *Handler) ?size_report.Size { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.size_cb orelse return null; + var s: size_report.Size = undefined; + if (func(@ptrCast(wrapper), wrapper.effects.userdata, &s)) return s; + return null; + } }; /// C: GhosttyTerminal @@ -80,8 +256,19 @@ fn new_( }); errdefer t.deinit(alloc); - // Setup our stream - const handler: Stream.Handler = t.vtHandler(); + // Setup our stream with trampolines always installed so that + // setting C callbacks at any time takes effect immediately. + var handler: Stream.Handler = t.vtHandler(); + handler.effects = .{ + .write_pty = &Effects.writePtyTrampoline, + .bell = &Effects.bellTrampoline, + .color_scheme = &Effects.colorSchemeTrampoline, + .device_attributes = &Effects.deviceAttributesTrampoline, + .enquiry = &Effects.enquiryTrampoline, + .xtversion = &Effects.xtversionTrampoline, + .title_changed = &Effects.titleChangedTrampoline, + .size = &Effects.sizeTrampoline, + }; wrapper.* = .{ .terminal = t, @@ -100,6 +287,74 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +/// C: GhosttyTerminalOption +pub const Option = enum(c_int) { + userdata = 0, + write_pty = 1, + bell = 2, + enquiry = 3, + xtversion = 4, + title_changed = 5, + size_cb = 6, + color_scheme = 7, + device_attributes = 8, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*anyopaque, + .write_pty => ?Effects.WritePtyFn, + .bell => ?Effects.BellFn, + .color_scheme => ?Effects.ColorSchemeFn, + .device_attributes => ?Effects.DeviceAttributesFn, + .enquiry => ?Effects.EnquiryFn, + .xtversion => ?Effects.XtversionFn, + .title_changed => ?Effects.TitleChangedFn, + .size_cb => ?Effects.SizeFn, + }; + } +}; + +pub fn set( + terminal_: Terminal, + option: Option, + value: ?*const anyopaque, +) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("terminal_set invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + terminal_, + comptime_option, + @ptrCast(@alignCast(value)), + ), + }; +} + +fn setTyped( + terminal_: Terminal, + comptime option: Option, + value: ?*const option.InType(), +) void { + const wrapper = terminal_ orelse return; + switch (option) { + .userdata => wrapper.effects.userdata = if (value) |v| v.* else null, + .write_pty => wrapper.effects.write_pty = if (value) |v| v.* else null, + .bell => wrapper.effects.bell = if (value) |v| v.* else null, + .color_scheme => wrapper.effects.color_scheme = if (value) |v| v.* else null, + .device_attributes => wrapper.effects.device_attributes_cb = if (value) |v| v.* else null, + .enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null, + .xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null, + .title_changed => wrapper.effects.title_changed = if (value) |v| v.* else null, + .size_cb => wrapper.effects.size_cb = if (value) |v| v.* else null, + } +} + /// C: GhosttyTerminalScrollViewport pub const ScrollViewport = ZigTerminal.ScrollViewport.C; @@ -779,6 +1034,672 @@ test "grid_ref null terminal" { }, &out_ref)); } +test "set write_pty callback" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + var last_userdata: ?*anyopaque = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + last_userdata = null; + } + + fn writePty(_: Terminal, ud: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + last_userdata = ud; + } + }; + defer S.deinit(); + + // Set userdata and write_pty callback + var sentinel: u8 = 42; + const ud: ?*anyopaque = @ptrCast(&sentinel); + set(t, .userdata, @ptrCast(&ud)); + const cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&cb)); + + // DECRQM for wraparound mode (mode 7, set by default) should trigger write_pty + vt_write(t, "\x1B[?7$p", 6); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1B[?7;1$y", S.last_data.?); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); +} + +test "set write_pty without callback ignores queries" { + 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); + + // Without setting a callback, DECRQM should be silently ignored (no crash) + vt_write(t, "\x1B[?7$p", 6); +} + +test "set write_pty null clears callback" { + 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); + + const S = struct { + var called: bool = false; + fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(.c) void { + called = true; + } + }; + S.called = false; + + // Set then clear the callback + const cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&cb)); + set(t, .write_pty, null); + + vt_write(t, "\x1B[?7$p", 6); + try testing.expect(!S.called); +} + +test "set bell callback" { + 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); + + const S = struct { + var bell_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn bell(_: Terminal, ud: ?*anyopaque) callconv(.c) void { + bell_count += 1; + last_userdata = ud; + } + }; + S.bell_count = 0; + S.last_userdata = null; + + // Set userdata and bell callback + var sentinel: u8 = 99; + const ud: ?*anyopaque = @ptrCast(&sentinel); + set(t, .userdata, @ptrCast(&ud)); + const cb: ?Effects.BellFn = &S.bell; + set(t, .bell, @ptrCast(&cb)); + + // Single BEL + vt_write(t, "\x07", 1); + try testing.expectEqual(@as(usize, 1), S.bell_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + + // Multiple BELs + vt_write(t, "\x07\x07", 2); + try testing.expectEqual(@as(usize, 3), S.bell_count); +} + +test "bell without callback is silent" { + 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); + + // BEL without a callback should not crash + vt_write(t, "\x07", 1); +} + +test "set enquiry callback" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + const response = "OK"; + fn enquiry(_: Terminal, _: ?*anyopaque) callconv(.c) lib.String { + return .{ .ptr = response, .len = response.len }; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const enq_cb: ?Effects.EnquiryFn = &S.enquiry; + set(t, .enquiry, @ptrCast(&enq_cb)); + + // ENQ (0x05) should trigger the enquiry callback and write response via write_pty + vt_write(t, "\x05", 1); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("OK", S.last_data.?); +} + +test "enquiry without callback is silent" { + 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); + + // ENQ without a callback should not crash + vt_write(t, "\x05", 1); +} + +test "set xtversion callback" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + const version = "myterm 1.0"; + fn xtversion(_: Terminal, _: ?*anyopaque) callconv(.c) lib.String { + return .{ .ptr = version, .len = version.len }; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const xtv_cb: ?Effects.XtversionFn = &S.xtversion; + set(t, .xtversion, @ptrCast(&xtv_cb)); + + // XTVERSION: CSI > q + vt_write(t, "\x1B[>q", 4); + try testing.expect(S.last_data != null); + // Response should be DCS >| version ST + try testing.expectEqualStrings("\x1BP>|myterm 1.0\x1B\\", S.last_data.?); +} + +test "xtversion without callback reports default" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + // Set write_pty but not xtversion — should get default "libghostty" + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + + vt_write(t, "\x1B[>q", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); +} + +test "set title_changed callback" { + 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); + + const S = struct { + var title_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn titleChanged(_: Terminal, ud: ?*anyopaque) callconv(.c) void { + title_count += 1; + last_userdata = ud; + } + }; + S.title_count = 0; + S.last_userdata = null; + + var sentinel: u8 = 77; + const ud: ?*anyopaque = @ptrCast(&sentinel); + set(t, .userdata, @ptrCast(&ud)); + const cb: ?Effects.TitleChangedFn = &S.titleChanged; + set(t, .title_changed, @ptrCast(&cb)); + + // OSC 2 ; title ST — set window title + vt_write(t, "\x1B]2;Hello\x1B\\", 10); + try testing.expectEqual(@as(usize, 1), S.title_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + + // Another title change + vt_write(t, "\x1B]2;World\x1B\\", 10); + try testing.expectEqual(@as(usize, 2), S.title_count); +} + +test "title_changed without callback is silent" { + 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); + + // OSC 2 without a callback should not crash + vt_write(t, "\x1B]2;Hello\x1B\\", 10); +} + +test "set size callback" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(.c) bool { + out_size.* = .{ + .rows = 24, + .columns = 80, + .cell_width = 8, + .cell_height = 16, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const size_cb_fn: ?Effects.SizeFn = &S.sizeCb; + set(t, .size_cb, @ptrCast(&size_cb_fn)); + + // CSI 18 t — report text area size in characters + vt_write(t, "\x1B[18t", 5); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[8;24;80t", S.last_data.?); +} + +test "size without callback is silent" { + 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); + + // CSI 18 t without a size callback should not crash + vt_write(t, "\x1B[18t", 5); +} + +test "set device_attributes callback primary" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(.c) bool { + out.* = .{ + .primary = .{ + .conformance_level = 64, + .features = .{ 22, 52 } ++ .{0} ** 62, + .num_features = 2, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 10, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0 }, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // CSI c — primary DA + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?64;22;52c", S.last_data.?); +} + +test "set device_attributes callback secondary" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(.c) bool { + out.* = .{ + .primary = .{ + .conformance_level = 62, + .features = .{22} ++ .{0} ** 63, + .num_features = 1, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 10, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0 }, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // CSI > c — secondary DA + vt_write(t, "\x1B[>c", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[>1;10;0c", S.last_data.?); +} + +test "set device_attributes callback tertiary" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(.c) bool { + out.* = .{ + .primary = .{ + .conformance_level = 62, + .features = .{0} ** 64, + .num_features = 0, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 0, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0xAABBCCDD }, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // CSI = c — tertiary DA + vt_write(t, "\x1B[=c", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1bP!|AABBCCDD\x1b\\", S.last_data.?); +} + +test "device_attributes without callback uses default" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + + // Without setting a device_attributes callback, DA1 should return the default + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?); +} + +test "device_attributes callback returns false uses default" { + 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); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, _: *Effects.CDeviceAttributes) callconv(.c) bool { + return false; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // Callback returns false, should use default response + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?); +} + test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/device_status.zig b/src/terminal/device_status.zig index 3d0106e42..61dd569f3 100644 --- a/src/terminal/device_status.zig +++ b/src/terminal/device_status.zig @@ -1,10 +1,13 @@ const std = @import("std"); +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; /// The color scheme reported in response to a CSI ? 996 n query. -pub const ColorScheme = enum { - light, - dark, -}; +pub const ColorScheme = lib.Enum(lib_target, &.{ + "light", + "dark", +}); /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: {