diff --git a/example/c-vt-formatter/src/main.c b/example/c-vt-formatter/src/main.c index 9e7d250b9..066d70146 100644 --- a/example/c-vt-formatter/src/main.c +++ b/example/c-vt-formatter/src/main.c @@ -4,6 +4,24 @@ #include #include +typedef struct { + FILE *file; + size_t written; +} OutputWriter; + +static bool write_output(void *userdata, const uint8_t *data, size_t len) { + OutputWriter *output = userdata; + size_t offset = 0; + while (offset < len) { + size_t written = fwrite(data + offset, 1, len - offset, output->file); + output->written += written; + offset += written; + if (written == 0) return false; + } + + return true; +} + int main() { // Create a terminal with a small grid GhosttyTerminal terminal; @@ -14,8 +32,8 @@ int main() { // cursor movement and styling sequences. const char *commands[] = { "Line 1: Hello World!\r\n", // Simple text on row 1 - "Line 2: \033[1mBold\033[0m and " // Bold text on row 2 - "\033[4mUnderline\033[0m\r\n", + ("Line 2: \033[1mBold\033[0m and " // Bold text on row 2 + "\033[4mUnderline\033[0m\r\n"), "Line 3: placeholder\r\n", // Will be overwritten below "\033[3;1H", // CUP: move cursor back to row 3, col 1 "\033[2K", // EL: erase the entire line @@ -39,19 +57,16 @@ int main() { result = ghostty_formatter_terminal_new(NULL, &formatter, terminal, fmt_opts); assert(result == GHOSTTY_SUCCESS); - // Format into an allocated buffer - uint8_t *buf = NULL; - size_t len = 0; - result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len); + // Stream the formatted output directly to stdout. The writer retains the + // exact byte count and reports destination errors through its return value. + OutputWriter output = {.file = stdout}; + GhosttyWriter writer = {.write = write_output, .userdata = &output}; + printf("Formatted output:\n"); + result = ghostty_formatter_format(formatter, writer); assert(result == GHOSTTY_SUCCESS); - - // Print the formatted output - printf("Formatted output (%zu bytes):\n", len); - fwrite(buf, 1, len, stdout); - printf("\n"); + printf("\n(%zu bytes)\n", output.written); // Clean up - ghostty_free(NULL, buf, len); ghostty_formatter_free(formatter); ghostty_terminal_free(terminal); return 0; diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index 5cdcd11a3..210f42284 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -137,6 +138,30 @@ GHOSTTY_API GhosttyResult ghostty_formatter_terminal_new( GhosttyTerminal terminal, GhosttyFormatterTerminalOptions options); +/** + * Run the formatter and stream output to a writer. + * + * Each call formats the current terminal state and invokes the writer + * synchronously as output becomes available. The callback may be called more + * than once and must not call formatter or terminal APIs using the same + * formatter or its terminal. + * + * If an error occurs, the writer may already contain a partial formatted + * output. The operation cannot be resumed from that partial output. This + * function does not flush or make the caller's destination durable. + * + * @param formatter The formatter handle (must not be NULL) + * @param writer Destination writer whose write callback must not be NULL + * @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects + * output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + * GHOSTTY_INVALID_VALUE if an argument is invalid + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_format( + GhosttyFormatter formatter, + GhosttyWriter writer); + /** * Run the formatter and produce output into the caller-provided buffer. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 28722f0b3..ba97dae1e 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -270,6 +270,7 @@ comptime { @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); if (features.formatter) { @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); + @export(&c.formatter_format, .{ .name = "ghostty_formatter_format" }); @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); diff --git a/src/terminal/c/formatter.zig b/src/terminal/c/formatter.zig index 9b3c143a4..ded71a68d 100644 --- a/src/terminal/c/formatter.zig +++ b/src/terminal/c/formatter.zig @@ -2,6 +2,7 @@ const std = @import("std"); const testing = std.testing; const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; +const io_c = @import("io.zig"); const terminal_c = @import("terminal.zig"); const grid_ref = @import("grid_ref.zig"); const selection_c = @import("selection.zig"); @@ -160,6 +161,26 @@ fn terminal_new_( return ptr; } +/// Format directly to a synchronous C writer callback. +pub fn format( + formatter_: Formatter, + writer: io_c.Writer, +) callconv(lib.calling_conv) Result { + const wrapper = formatter_ orelse return .invalid_value; + if (!writer.valid()) return .invalid_value; + + var adapter: io_c.WriterAdapter = .init(writer); + switch (wrapper.kind) { + .terminal => |*t| t.format(adapter.writer()) catch { + if (adapter.invalid_write) return .limit_exceeded; + if (adapter.callback_failed) return .io_error; + return .io_error; + }, + } + + return .success; +} + pub fn format_buf( formatter_: Formatter, out_: ?[*]u8, @@ -281,6 +302,96 @@ test "format plain" { try testing.expectEqualStrings("Hello", buf[0..written]); } +test "format streams to writer" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello\r\nWorld", 12); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + const Context = struct { + destination: []u8, + offset: usize = 0, + calls: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + if (len > self.destination.len - self.offset) return false; + @memcpy(self.destination[self.offset..][0..len], data[0..len]); + self.offset += len; + self.calls += 1; + return true; + } + }; + + var buf: [1024]u8 = undefined; + var context: Context = .{ .destination = &buf }; + try testing.expectEqual(Result.success, format(f, .{ + .write = &Context.write, + .userdata = &context, + })); + try testing.expect(context.calls > 0); + try testing.expectEqualStrings("Hello\nWorld", buf[0..context.offset]); +} + +test "format writer errors" { + const FailWriter = struct { + fn write( + _: ?*anyopaque, + _: [*]const u8, + _: usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + + try testing.expectEqual(Result.invalid_value, format(null, .{ + .write = &FailWriter.write, + })); + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer terminal_c.free(t); + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + try testing.expectEqual(Result.invalid_value, format(f, .{})); + try testing.expectEqual(Result.io_error, format(f, .{ + .write = &FailWriter.write, + })); +} + test "format reflects terminal changes" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 9189822b3..a7b72aa05 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -78,6 +78,7 @@ 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 = formatter.format; pub const formatter_format_buf = formatter.format_buf; pub const formatter_format_alloc = formatter.format_alloc; pub const formatter_free = formatter.free;