From b8fcb57923ae3a5d39630650a05217f3536c87f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 11:17:55 -0700 Subject: [PATCH] vt: expose device_attributes effect in the C API Rename device_status.h to device.h and add C-compatible structs for device attributes (DA1/DA2/DA3) responses. The new header includes defines for all known conformance levels, DA1 feature codes, and DA2 device type identifiers. Add a GhosttyTerminalDeviceAttributesFn callback that C consumers can set via GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES. The callback follows the existing bool + out-pointer pattern used by color_scheme and size callbacks. When the callback is unset or returns false, the trampoline returns a default VT220 response (conformance level 62, ANSI color). The DA1 primary features use a fixed [64]uint16_t inline array with a num_features count rather than a pointer, so the entire struct is value-typed and can be safely copied without lifetime concerns. --- include/ghostty/vt.h | 2 +- include/ghostty/vt/device.h | 150 ++++++++++++++ include/ghostty/vt/device_status.h | 28 --- include/ghostty/vt/terminal.h | 33 ++- src/terminal/c/terminal.zig | 315 +++++++++++++++++++++++++++++ 5 files changed, 498 insertions(+), 30 deletions(-) create mode 100644 include/ghostty/vt/device.h delete mode 100644 include/ghostty/vt/device_status.h diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 536f27111..2a52f4b08 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -109,7 +109,7 @@ extern "C" { #include #include #include -#include +#include #include #include #include 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 +#include + +/* 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/device_status.h b/include/ghostty/vt/device_status.h deleted file mode 100644 index d34b9ec6d..000000000 --- a/include/ghostty/vt/device_status.h +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file device_status.h - * - * Device status types used by the terminal. - */ - -#ifndef GHOSTTY_VT_DEVICE_STATUS_H -#define GHOSTTY_VT_DEVICE_STATUS_H - -#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; - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_DEVICE_STATUS_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index a4a921dfe..050ebd841 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -12,7 +12,7 @@ #include #include #include -#include +#include #include #include #include @@ -236,6 +236,27 @@ 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 size queries (XTWINOPS). * @@ -329,6 +350,16 @@ typedef enum { * 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; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 4378091a3..15850acf8 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -11,6 +11,7 @@ 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"); @@ -40,6 +41,7 @@ const Effects = struct { 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, @@ -75,6 +77,35 @@ const Effects = struct { /// 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); @@ -98,6 +129,38 @@ const Effects = struct { 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); + var features: [64]device_attributes.Primary.Feature = undefined; + for (0..n) |i| features[i] = @enumFromInt(c_attrs.primary.features[i]); + + return .{ + .primary = .{ + .conformance_level = @enumFromInt(c_attrs.primary.conformance_level), + .features = features[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); @@ -193,6 +256,7 @@ fn new_( handler.effects.write_pty = &Effects.writePtyTrampoline; handler.effects.bell = &Effects.bellTrampoline; handler.effects.color_scheme = &Effects.colorSchemeTrampoline; + handler.effects.device_attributes = &Effects.deviceAttributesTrampoline; handler.effects.enquiry = &Effects.enquiryTrampoline; handler.effects.xtversion = &Effects.xtversionTrampoline; handler.effects.title_changed = &Effects.titleChangedTrampoline; @@ -225,6 +289,7 @@ pub const Option = enum(c_int) { 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 { @@ -233,6 +298,7 @@ pub const Option = enum(c_int) { .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, @@ -273,6 +339,7 @@ fn setTyped( .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, @@ -1377,6 +1444,254 @@ test "size without callback is silent" { 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(