diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 34a7f2d4b..5b670f80a 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -102,6 +102,7 @@ extern "C" { #include #include #include +#include #include #ifdef __cplusplus diff --git a/include/ghostty/vt/size_report.h b/include/ghostty/vt/size_report.h new file mode 100644 index 000000000..4622b5bcf --- /dev/null +++ b/include/ghostty/vt/size_report.h @@ -0,0 +1,126 @@ +/** + * @file size_report.h + * + * Size report encoding - encode terminal size reports into escape sequences. + */ + +#ifndef GHOSTTY_VT_SIZE_REPORT_H +#define GHOSTTY_VT_SIZE_REPORT_H + +/** @defgroup size_report Size Report Encoding + * + * Utilities for encoding terminal size reports into escape sequences, + * supporting in-band size reports (mode 2048) and XTWINOPS responses + * (CSI 14 t, CSI 16 t, CSI 18 t). + * + * ## Basic Usage + * + * Use ghostty_size_report_encode() to encode a size report into a + * caller-provided buffer. If the buffer is too small, the function + * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the + * output parameter. + * + * ## Example + * + * @code{.c} + * #include + * #include + * + * int main() { + * GhosttySizeReportSize size = { + * .rows = 24, + * .columns = 80, + * .cell_width = 9, + * .cell_height = 18, + * }; + * + * char buf[64]; + * size_t written = 0; + * + * GhosttyResult result = ghostty_size_report_encode( + * GHOSTTY_SIZE_REPORT_MODE_2048, size, buf, sizeof(buf), &written); + * + * if (result == GHOSTTY_SUCCESS) { + * printf("Encoded %zu bytes: ", written); + * fwrite(buf, 1, written, stdout); + * printf("\n"); + * } + * + * return 0; + * } + * @endcode + * + * @{ + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Size report style. + * + * Determines the output format for the terminal size report. + */ +typedef enum { + /** In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t */ + GHOSTTY_SIZE_REPORT_MODE_2048 = 0, + /** XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_14_T = 1, + /** XTWINOPS cell size in pixels: ESC [ 6 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_16_T = 2, + /** XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t */ + GHOSTTY_SIZE_REPORT_CSI_18_T = 3, +} GhosttySizeReportStyle; + +/** + * Terminal size information for encoding size reports. + */ +typedef struct { + /** Terminal row count in cells. */ + uint16_t rows; + /** Terminal column count in cells. */ + uint16_t columns; + /** Width of a single terminal cell in pixels. */ + uint32_t cell_width; + /** Height of a single terminal cell in pixels. */ + uint32_t cell_height; +} GhosttySizeReportSize; + +/** + * Encode a terminal size report into an escape sequence. + * + * Encodes a size report in the format specified by @p style into the + * provided buffer. + * + * 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 style The size report format to encode + * @param size Terminal size information + * @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_size_report_encode( + GhosttySizeReportStyle style, + GhosttySizeReportSize size, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SIZE_REPORT_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 16b47a6be..aabd78592 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -53,6 +53,7 @@ pub const RenderState = terminal.RenderState; pub const Screen = terminal.Screen; pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; +pub const size_report = terminal.size_report; pub const SizeReportStyle = terminal.SizeReportStyle; pub const StringMap = terminal.StringMap; pub const Style = terminal.Style; @@ -167,6 +168,7 @@ comptime { @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.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); diff --git a/src/renderer/size.zig b/src/renderer/size.zig index 565e95e2c..7a022cdb4 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -1,8 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const configpkg = @import("../config.zig"); -const font = @import("../font/main.zig"); -const terminal = @import("../terminal/main.zig"); +const terminal_size = @import("../terminal/size.zig"); const log = std.log.scoped(.renderer_size); @@ -225,7 +224,7 @@ pub const ScreenSize = extern struct { /// The dimensions of the grid itself, in rows/columns units. pub const GridSize = extern struct { - pub const Unit = terminal.size.CellCountInt; + pub const Unit = terminal_size.CellCountInt; columns: Unit = 0, rows: Unit = 0, diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index c8388ef08..72fa74345 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -9,6 +9,7 @@ pub const mouse_event = @import("mouse_event.zig"); pub const mouse_encode = @import("mouse_encode.zig"); pub const paste = @import("paste.zig"); pub const sgr = @import("sgr.zig"); +pub const size_report = @import("size_report.zig"); pub const terminal = @import("terminal.zig"); // The full C API, unexported. @@ -87,6 +88,8 @@ pub const mouse_encoder_encode = mouse_encode.encode; pub const paste_is_safe = paste.is_safe; +pub const size_report_encode = size_report.encode; + pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; @@ -108,6 +111,7 @@ test { _ = mouse_encode; _ = paste; _ = sgr; + _ = size_report; _ = terminal; // We want to make sure we run the tests for the C allocator interface. diff --git a/src/terminal/c/size_report.zig b/src/terminal/c/size_report.zig new file mode 100644 index 000000000..dac909a5c --- /dev/null +++ b/src/terminal/c/size_report.zig @@ -0,0 +1,81 @@ +const std = @import("std"); +const terminal_size_report = @import("../size_report.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttySizeReportStyle +pub const Style = terminal_size_report.Style; + +/// C: GhosttySizeReportSize +pub const Size = terminal_size_report.Size; + +pub fn encode( + style: Style, + size: Size, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(.c) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + terminal_size_report.encode(&writer, style, size) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + terminal_size_report.encode(&discarding.writer, style, size) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode mode 2048" { + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(.mode_2048, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[48;24;80;432;720t", buf[0..written]); +} + +test "encode csi 14 t" { + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(.csi_14_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1b[4;432;720t", buf[0..written]); +} + +test "encode with insufficient buffer" { + var buf: [1]u8 = undefined; + var written: usize = 0; + const result = encode(.csi_18_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 1); +} + +test "encode with null buffer" { + var written: usize = 0; + const result = encode(.csi_18_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 0); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index bbdea1542..17cc3a81d 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -21,6 +21,7 @@ pub const parse_table = @import("parse_table.zig"); pub const search = @import("search.zig"); pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); +pub const size_report = @import("size_report.zig"); pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); diff --git a/src/terminal/size_report.zig b/src/terminal/size_report.zig new file mode 100644 index 000000000..d8089f96e --- /dev/null +++ b/src/terminal/size_report.zig @@ -0,0 +1,162 @@ +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; +const CellCountInt = @import("size.zig").CellCountInt; + +/// Output formats for terminal size reports written to the PTY. +pub const Style = lib.Enum(lib_target, &.{ + // In-band size reports (mode 2048) + "mode_2048", + // XTWINOPS: report text area size in pixels + "csi_14_t", + // XTWINOPS: report cell size in pixels + "csi_16_t", + // XTWINOPS: report text area size in characters + "csi_18_t", +}); + +/// Runtime size values used to encode terminal size reports. +pub const Size = lib.Struct(lib_target, struct { + /// Terminal row count in cells. + rows: CellCountInt, + + /// Terminal column count in cells. + columns: CellCountInt, + + /// Width of a single terminal cell in pixels. + cell_width: u32, + + /// Height of a single terminal cell in pixels. + cell_height: u32, +}); + +fn widthPixels(s: Size) u64 { + return @as(u64, s.columns) * @as(u64, s.cell_width); +} + +fn heightPixels(s: Size) u64 { + return @as(u64, s.rows) * @as(u64, s.cell_height); +} + +/// Encode a terminal size report sequence. +pub fn encode( + writer: *std.Io.Writer, + style: Style, + size: Size, +) std.Io.Writer.Error!void { + switch (style) { + .mode_2048 => try writer.print( + "\x1B[48;{};{};{};{}t", + .{ + size.rows, + size.columns, + heightPixels(size), + widthPixels(size), + }, + ), + + .csi_14_t => try writer.print( + "\x1b[4;{};{}t", + .{ + heightPixels(size), + widthPixels(size), + }, + ), + + .csi_16_t => try writer.print( + "\x1b[6;{};{}t", + .{ + size.cell_height, + size.cell_width, + }, + ), + + .csi_18_t => try writer.print( + "\x1b[8;{};{}t", + .{ + size.rows, + size.columns, + }, + ), + } +} + +fn testSize() Size { + return .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }; +} + +test "encode mode 2048" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .mode_2048, testSize()); + + try std.testing.expectEqualStrings("\x1B[48;24;80;432;720t", writer.buffered()); +} + +test "encode csi 14 t" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .csi_14_t, testSize()); + + try std.testing.expectEqualStrings("\x1b[4;432;720t", writer.buffered()); +} + +test "encode csi 16 t" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .csi_16_t, testSize()); + + try std.testing.expectEqualStrings("\x1b[6;18;9t", writer.buffered()); +} + +test "encode csi 18 t" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .csi_18_t, testSize()); + + try std.testing.expectEqualStrings("\x1b[8;24;80t", writer.buffered()); +} + +test "encode max values for all fields" { + const max_size: Size = .{ + .rows = std.math.maxInt(@FieldType(Size, "rows")), + .columns = std.math.maxInt(@FieldType(Size, "columns")), + .cell_width = std.math.maxInt(@FieldType(Size, "cell_width")), + .cell_height = std.math.maxInt(@FieldType(Size, "cell_height")), + }; + + const Case = struct { + style: Style, + expected: []const u8, + }; + + inline for ([_]Case{ + .{ + .style = .mode_2048, + .expected = "\x1B[48;65535;65535;281470681677825;281470681677825t", + }, + .{ + .style = .csi_14_t, + .expected = "\x1b[4;281470681677825;281470681677825t", + }, + .{ + .style = .csi_16_t, + .expected = "\x1b[6;4294967295;4294967295t", + }, + .{ + .style = .csi_18_t, + .expected = "\x1b[8;65535;65535t", + }, + }) |case| { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, case.style, max_size); + try std.testing.expectEqualStrings(case.expected, writer.buffered()); + } +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 73e1c46d5..4a99e8221 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -526,48 +526,24 @@ pub fn sizeReport(self: *Termio, td: *ThreadData, style: termio.Message.SizeRepo fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void { const grid_size = self.size.grid(); + const report_size: terminalpkg.size_report.Size = .{ + .rows = grid_size.rows, + .columns = grid_size.columns, + .cell_width = self.size.cell.width, + .cell_height = self.size.cell.height, + }; // 1024 bytes should be enough for size report since report // in columns and pixels. var buf: [1024]u8 = undefined; - const message = switch (style) { - .mode_2048 => try std.fmt.bufPrint( - &buf, - "\x1B[48;{};{};{};{}t", - .{ - grid_size.rows, - grid_size.columns, - grid_size.rows * self.size.cell.height, - grid_size.columns * self.size.cell.width, - }, - ), - .csi_14_t => try std.fmt.bufPrint( - &buf, - "\x1b[4;{};{}t", - .{ - grid_size.rows * self.size.cell.height, - grid_size.columns * self.size.cell.width, - }, - ), - .csi_16_t => try std.fmt.bufPrint( - &buf, - "\x1b[6;{};{}t", - .{ - self.size.cell.height, - self.size.cell.width, - }, - ), - .csi_18_t => try std.fmt.bufPrint( - &buf, - "\x1b[8;{};{}t", - .{ - grid_size.rows, - grid_size.columns, - }, - ), - }; + var writer: std.Io.Writer = .fixed(&buf); + try terminalpkg.size_report.encode( + &writer, + style, + report_size, + ); - try self.queueWrite(td, message, false); + try self.queueWrite(td, writer.buffered(), false); } /// Reset the synchronized output mode. This is usually called by timer diff --git a/src/termio/message.zig b/src/termio/message.zig index d7a59bf5e..4ee7f245e 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -93,13 +93,8 @@ pub const Message = union(enum) { }; } - /// The types of size reports that we support - pub const SizeReport = enum { - mode_2048, - csi_14_t, - csi_16_t, - csi_18_t, - }; + /// The types of size reports that we support. + pub const SizeReport = terminal.size_report.Style; }; test {