diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h index b1fcbf097..f7b48637c 100644 --- a/include/ghostty/vt/modes.h +++ b/include/ghostty/vt/modes.h @@ -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 + * #include + * + * 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 +#include #include +#include + #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 diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 0951ae38e..16b47a6be 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 1b2ae79a9..c8388ef08 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/modes.zig b/src/terminal/c/modes.zig new file mode 100644 index 000000000..d0d5fedf0 --- /dev/null +++ b/src/terminal/c/modes.zig @@ -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); +}