vt: add mode report encoding to C API

Add ghostty_mode_report_encode() which encodes a DECRPM response
sequence into a caller-provided buffer. The function takes a mode
tag, a report state integer, an output buffer, and writes the
appropriate CSI sequence (with ? prefix for DEC private modes).

The Zig-side ReportState is a non-exhaustive c_int enum that uses
std.meta.intToEnum for safe conversion to the internal type,
returning GHOSTTY_INVALID_VALUE on overflow. The C header exposes
a GhosttyModeReportState enum with named constants for the five
standard DECRPM state values.
This commit is contained in:
Mitchell Hashimoto
2026-03-16 16:09:02 -07:00
parent 1c03770e2b
commit a460743b2a
4 changed files with 187 additions and 0 deletions

View File

@@ -43,12 +43,44 @@
* }
* @endcode
*
* ## DECRPM Report Encoding
*
* Use ghostty_mode_report_encode() to encode a DECRPM response into a
* caller-provided buffer:
*
* @code{.c}
* #include <stdio.h>
* #include <ghostty/vt.h>
*
* int main() {
* char buf[32];
* size_t written = 0;
*
* // Encode a report that DEC mode 25 (cursor visible) is set
* GhosttyResult result = ghostty_mode_report_encode(
* GHOSTTY_MODE_CURSOR_VISIBLE,
* GHOSTTY_MODE_REPORT_SET,
* buf, sizeof(buf), &written);
*
* if (result == GHOSTTY_SUCCESS) {
* printf("Encoded %zu bytes: ", written);
* fwrite(buf, 1, written, stdout);
* printf("\n"); // prints: ESC[?25;1$y
* }
*
* return 0;
* }
* @endcode
*
* @{
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
#endif
@@ -151,6 +183,53 @@ static inline bool ghostty_mode_tag_ansi(GhosttyModeTag tag) {
return (tag >> 15) != 0;
}
/**
* DECRPM report state values.
*
* These correspond to the Ps2 parameter in a DECRPM response
* sequence (CSI ? Ps1 ; Ps2 $ y).
*/
typedef enum {
/** Mode is not recognized */
GHOSTTY_MODE_REPORT_NOT_RECOGNIZED = 0,
/** Mode is set (enabled) */
GHOSTTY_MODE_REPORT_SET = 1,
/** Mode is reset (disabled) */
GHOSTTY_MODE_REPORT_RESET = 2,
/** Mode is permanently set */
GHOSTTY_MODE_REPORT_PERMANENTLY_SET = 3,
/** Mode is permanently reset */
GHOSTTY_MODE_REPORT_PERMANENTLY_RESET = 4,
} GhosttyModeReportState;
/**
* Encode a DECRPM (DEC Private Mode Report) response sequence.
*
* Writes a mode report escape sequence into the provided buffer.
* The generated sequence has the form:
* - DEC private mode: CSI ? Ps1 ; Ps2 $ y
* - ANSI mode: CSI Ps1 ; Ps2 $ y
*
* If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE
* and writes the required buffer size to @p out_written. The caller can
* then retry with a sufficiently sized buffer.
*
* @param tag The mode tag identifying the mode to report on
* @param state The report state for this mode
* @param buf Output buffer to write the encoded sequence into (may be NULL)
* @param buf_len Size of the output buffer in bytes
* @param[out] out_written On success, the number of bytes written. On
* GHOSTTY_OUT_OF_SPACE, the required buffer size.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer
* is too small
*/
GhosttyResult ghostty_mode_report_encode(
GhosttyModeTag tag,
GhosttyModeReportState state,
char* buf,
size_t buf_len,
size_t* out_written);
#ifdef __cplusplus
}
#endif

View File

@@ -165,6 +165,7 @@ comptime {
@export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" });
@export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" });
@export(&c.focus_encode, .{ .name = "ghostty_focus_encode" });
@export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" });
@export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" });
@export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" });
@export(&c.sgr_new, .{ .name = "ghostty_sgr_new" });

View File

@@ -1,6 +1,7 @@
pub const color = @import("color.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const modes = @import("modes.zig");
pub const osc = @import("osc.zig");
pub const key_event = @import("key_event.zig");
pub const key_encode = @import("key_encode.zig");
@@ -23,6 +24,8 @@ pub const color_rgb_get = color.rgb_get;
pub const focus_encode = focus.encode;
pub const mode_report_encode = modes.report_encode;
pub const formatter_terminal_new = formatter.terminal_new;
pub const formatter_format_buf = formatter.format_buf;
pub const formatter_format_alloc = formatter.format_alloc;
@@ -97,6 +100,7 @@ test {
_ = color;
_ = focus;
_ = formatter;
_ = modes;
_ = osc;
_ = key_event;
_ = key_encode;

103
src/terminal/c/modes.zig Normal file
View File

@@ -0,0 +1,103 @@
const std = @import("std");
const modes = @import("../modes.zig");
const Result = @import("result.zig").Result;
/// C: GhosttyModeReportState
pub const ReportState = enum(c_int) {
_,
fn toZig(self: ReportState) ?modes.Report.State {
return std.meta.intToEnum(
modes.Report.State,
@intFromEnum(self),
) catch null;
}
};
pub fn report_encode(
tag: modes.ModeTag.Backing,
state: ReportState,
out_: ?[*]u8,
out_len: usize,
out_written: *usize,
) callconv(.c) Result {
const mode_tag: modes.ModeTag = @bitCast(tag);
const report: modes.Report = .{
.tag = mode_tag,
.state = state.toZig() orelse return .invalid_value,
};
var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{});
report.encode(&writer) catch |err| switch (err) {
error.WriteFailed => {
var discarding: std.Io.Writer.Discarding = .init(&.{});
report.encode(&discarding.writer) catch unreachable;
out_written.* = @intCast(discarding.count);
return .out_of_space;
},
};
out_written.* = writer.end;
return .success;
}
test "encode DEC mode set" {
var buf: [modes.Report.max_size]u8 = undefined;
var written: usize = 0;
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false });
const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written);
try std.testing.expectEqual(.success, result);
try std.testing.expectEqualStrings("\x1B[?1;1$y", buf[0..written]);
}
test "encode DEC mode reset" {
var buf: [modes.Report.max_size]u8 = undefined;
var written: usize = 0;
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false });
const result = report_encode(tag, @enumFromInt(2), &buf, buf.len, &written);
try std.testing.expectEqual(.success, result);
try std.testing.expectEqualStrings("\x1B[?1;2$y", buf[0..written]);
}
test "encode ANSI mode" {
var buf: [modes.Report.max_size]u8 = undefined;
var written: usize = 0;
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 4, .ansi = true });
const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written);
try std.testing.expectEqual(.success, result);
try std.testing.expectEqualStrings("\x1B[4;1$y", buf[0..written]);
}
test "encode not recognized" {
var buf: [modes.Report.max_size]u8 = undefined;
var written: usize = 0;
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false });
const result = report_encode(tag, @enumFromInt(0), &buf, buf.len, &written);
try std.testing.expectEqual(.success, result);
try std.testing.expectEqualStrings("\x1B[?9999;0$y", buf[0..written]);
}
test "encode with insufficient buffer" {
var buf: [1]u8 = undefined;
var written: usize = 0;
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false });
const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written);
try std.testing.expectEqual(.out_of_space, result);
try std.testing.expect(written > 1);
}
test "encode with invalid state" {
var buf: [modes.Report.max_size]u8 = undefined;
var written: usize = 0;
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false });
const result = report_encode(tag, @enumFromInt(99), &buf, buf.len, &written);
try std.testing.expectEqual(.invalid_value, result);
}
test "encode with null buffer" {
var written: usize = 0;
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false });
const result = report_encode(tag, @enumFromInt(1), null, 0, &written);
try std.testing.expectEqual(.out_of_space, result);
try std.testing.expect(written > 0);
}