vt: wip formatter api

This commit is contained in:
Mitchell Hashimoto
2026-03-14 13:48:03 -07:00
parent 8e6bf829a7
commit b5fb7ecaaa
8 changed files with 556 additions and 39 deletions

View File

@@ -75,6 +75,7 @@ extern "C" {
#include <ghostty/vt/result.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/formatter.h>
#include <ghostty/vt/terminal.h>
#include <ghostty/vt/osc.h>
#include <ghostty/vt/sgr.h>

View File

@@ -0,0 +1,156 @@
/**
* @file formatter.h
*
* Format terminal content as plain text, VT sequences, or HTML.
*/
#ifndef GHOSTTY_VT_FORMATTER_H
#define GHOSTTY_VT_FORMATTER_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/result.h>
#include <ghostty/vt/terminal.h>
#ifdef __cplusplus
extern "C" {
#endif
/** @defgroup formatter Formatter
*
* Format terminal content as plain text, VT sequences, or HTML.
*
* A formatter captures a reference to a terminal and formatting options.
* It can be used repeatedly to produce output that reflects the current
* terminal state at the time of each format call.
*
* The terminal must outlive the formatter.
*
* @{
*/
/**
* Output format.
*
* @ingroup formatter
*/
typedef enum {
/** Plain text (no escape sequences). */
GHOSTTY_FORMATTER_FORMAT_PLAIN,
/** VT sequences preserving colors, styles, URLs, etc. */
GHOSTTY_FORMATTER_FORMAT_VT,
/** HTML with inline styles. */
GHOSTTY_FORMATTER_FORMAT_HTML,
} GhosttyFormatterFormat;
/**
* Extra terminal state to include in styled output.
*
* @ingroup formatter
*/
typedef enum {
/** Emit no extra state. */
GHOSTTY_FORMATTER_EXTRA_NONE,
/** Emit style-relevant state (palette, cursor style, hyperlinks). */
GHOSTTY_FORMATTER_EXTRA_STYLES,
/** Emit all state to reconstruct terminal as closely as possible. */
GHOSTTY_FORMATTER_EXTRA_ALL,
} GhosttyFormatterExtra;
/**
* Opaque handle to a formatter instance.
*
* @ingroup formatter
*/
typedef struct GhosttyFormatter* GhosttyFormatter;
/**
* Options for creating a terminal formatter.
*
* @ingroup formatter
*/
typedef struct {
/** Output format to emit. */
GhosttyFormatterFormat emit;
/** Whether to unwrap soft-wrapped lines. */
bool unwrap;
/** Whether to trim trailing whitespace on non-blank lines. */
bool trim;
/** Extra terminal state to include in styled output. */
GhosttyFormatterExtra extra;
} GhosttyFormatterTerminalOptions;
/**
* Create a formatter for a terminal's active screen.
*
* The terminal must outlive the formatter. The formatter stores a borrowed
* reference to the terminal and reads its current state on each format call.
*
* @param allocator Pointer to allocator, or NULL to use the default allocator
* @param formatter Pointer to store the created formatter handle
* @param terminal The terminal to format (must not be NULL)
* @param options Formatting options
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup formatter
*/
GhosttyResult ghostty_formatter_terminal_new(
const GhosttyAllocator* allocator,
GhosttyFormatter* formatter,
GhosttyTerminal terminal,
GhosttyFormatterTerminalOptions options);
/**
* Run the formatter and produce output into the caller-provided buffer.
*
* Each call formats the current terminal state. Pass NULL for buf to
* query the required buffer size without writing any output; in that case
* out_written receives the required size and the return value is
* GHOSTTY_OUT_OF_SPACE.
*
* If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets
* out_written to the required size. The caller can then retry with a
* larger buffer.
*
* @param formatter The formatter handle (must not be NULL)
* @param buf Pointer to the output buffer, or NULL to query size
* @param buf_len Length of the output buffer in bytes
* @param out_written Pointer to receive the number of bytes written,
* or the required size on failure
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup formatter
*/
GhosttyResult ghostty_formatter_format(GhosttyFormatter formatter,
uint8_t* buf,
size_t buf_len,
size_t* out_written);
/**
* Free a formatter instance.
*
* Releases all resources associated with the formatter. After this call,
* the formatter handle becomes invalid.
*
* @param formatter The formatter handle to free (may be NULL)
*
* @ingroup formatter
*/
void ghostty_formatter_free(GhosttyFormatter formatter);
/** @} */
#ifdef __cplusplus
}
#endif
#endif /* GHOSTTY_VT_FORMATTER_H */

View File

@@ -17,6 +17,8 @@ typedef enum {
GHOSTTY_OUT_OF_MEMORY = -1,
/** Operation failed due to invalid value */
GHOSTTY_INVALID_VALUE = -2,
/** Operation failed because the provided buffer was too small */
GHOSTTY_OUT_OF_SPACE = -3,
} GhosttyResult;
#endif /* GHOSTTY_VT_RESULT_H */

View File

@@ -143,6 +143,9 @@ comptime {
@export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" });
@export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" });
@export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" });
@export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" });
@export(&c.formatter_format, .{ .name = "ghostty_formatter_format" });
@export(&c.formatter_free, .{ .name = "ghostty_formatter_free" });
@export(&c.terminal_new, .{ .name = "ghostty_terminal_new" });
@export(&c.terminal_free, .{ .name = "ghostty_terminal_free" });
@export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" });

View File

@@ -0,0 +1,344 @@
const std = @import("std");
const testing = std.testing;
const lib_alloc = @import("../../lib/allocator.zig");
const CAllocator = lib_alloc.Allocator;
const terminal_c = @import("terminal.zig");
const ZigTerminal = @import("../Terminal.zig");
const formatterpkg = @import("../formatter.zig");
const Result = @import("result.zig").Result;
/// Wrapper around formatter that tracks the allocator for C API usage.
const FormatterWrapper = struct {
kind: Kind,
alloc: std.mem.Allocator,
const Kind = union(enum) {
terminal: formatterpkg.TerminalFormatter,
};
};
/// C: GhosttyFormatter
pub const Formatter = ?*FormatterWrapper;
/// C: GhosttyFormatterFormat
pub const Format = formatterpkg.Format;
/// C: GhosttyFormatterExtra
pub const Extra = enum(c_int) {
none = 0,
styles = 1,
all = 2,
};
/// C: GhosttyFormatterTerminalOptions
pub const TerminalOptions = extern struct {
emit: Format,
unwrap: bool,
trim: bool,
extra: Extra,
};
pub fn terminal_new(
alloc_: ?*const CAllocator,
result: *Formatter,
terminal_: terminal_c.Terminal,
opts: TerminalOptions,
) callconv(.c) Result {
result.* = terminal_new_(
alloc_,
terminal_,
opts,
) catch |err| {
result.* = null;
return switch (err) {
error.InvalidValue => .invalid_value,
error.OutOfMemory => .out_of_memory,
};
};
return .success;
}
fn terminal_new_(
alloc_: ?*const CAllocator,
terminal_: terminal_c.Terminal,
opts: TerminalOptions,
) error{
InvalidValue,
OutOfMemory,
}!*FormatterWrapper {
const t = terminal_ orelse return error.InvalidValue;
const alloc = lib_alloc.default(alloc_);
const ptr = alloc.create(FormatterWrapper) catch
return error.OutOfMemory;
errdefer alloc.destroy(ptr);
const extra: formatterpkg.TerminalFormatter.Extra = switch (opts.extra) {
.none => .none,
.styles => .styles,
.all => .all,
};
var formatter: formatterpkg.TerminalFormatter = .init(t, .{
.emit = opts.emit,
.unwrap = opts.unwrap,
.trim = opts.trim,
});
formatter.extra = extra;
ptr.* = .{
.kind = .{ .terminal = formatter },
.alloc = alloc,
};
return ptr;
}
pub fn format(
formatter_: Formatter,
out_: ?[*]u8,
out_len: usize,
out_written: *usize,
) callconv(.c) Result {
const wrapper = formatter_ orelse return .invalid_value;
var writer: std.Io.Writer = .fixed(if (out_) |out|
out[0..out_len]
else
&.{});
switch (wrapper.kind) {
.terminal => |*t| t.format(&writer) catch |err| switch (err) {
error.WriteFailed => {
// On write failed we always report how much
// space we actually needed.
var discarding: std.Io.Writer.Discarding = .init(&.{});
t.format(&discarding.writer) catch unreachable;
out_written.* = @intCast(discarding.count);
return .out_of_space;
},
},
}
out_written.* = writer.end;
return .success;
}
pub fn free(formatter_: Formatter) callconv(.c) void {
const wrapper = formatter_ orelse return;
const alloc = wrapper.alloc;
alloc.destroy(wrapper);
}
test "terminal_new/free" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(t);
var f: Formatter = null;
try testing.expectEqual(Result.success, terminal_new(
&lib_alloc.test_allocator,
&f,
t,
.{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none },
));
try testing.expect(f != null);
free(f);
}
test "terminal_new invalid_value on null terminal" {
var f: Formatter = null;
try testing.expectEqual(Result.invalid_value, terminal_new(
&lib_alloc.test_allocator,
&f,
null,
.{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none },
));
try testing.expect(f == null);
}
test "free null" {
free(null);
}
test "format plain" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
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 = .none },
));
defer free(f);
var buf: [1024]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written));
try testing.expectEqualStrings("Hello", buf[0..written]);
}
test "format reflects terminal changes" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
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 = .none },
));
defer free(f);
var buf: [1024]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written));
try testing.expectEqualStrings("Hello", buf[0..written]);
// Write more data and re-format
terminal_c.vt_write(t, "\r\nWorld", 7);
try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written));
try testing.expectEqualStrings("Hello\nWorld", buf[0..written]);
}
test "format null returns required size" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
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 = .none },
));
defer free(f);
// Pass null buffer to query required size
var required: usize = 0;
try testing.expectEqual(Result.out_of_space, format(f, null, 0, &required));
try testing.expect(required > 0);
// Now allocate and format
var buf: [1024]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written));
try testing.expectEqual(required, written);
}
test "format buffer too small" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
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 = .none },
));
defer free(f);
// Buffer too small
var buf: [2]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.out_of_space, format(f, &buf, buf.len, &written));
// written contains the required size
try testing.expectEqual(@as(usize, 5), written);
}
test "format null formatter" {
var written: usize = 0;
try testing.expectEqual(Result.invalid_value, format(null, null, 0, &written));
}
test "format vt" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(t);
terminal_c.vt_write(t, "Test", 4);
var f: Formatter = null;
try testing.expectEqual(Result.success, terminal_new(
&lib_alloc.test_allocator,
&f,
t,
.{ .emit = .vt, .unwrap = false, .trim = true, .extra = .styles },
));
defer free(f);
var buf: [65536]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written));
try testing.expect(written > 0);
try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null);
}
test "format html" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(t);
terminal_c.vt_write(t, "Html", 4);
var f: Formatter = null;
try testing.expectEqual(Result.success, terminal_new(
&lib_alloc.test_allocator,
&f,
t,
.{ .emit = .html, .unwrap = false, .trim = true, .extra = .none },
));
defer free(f);
var buf: [65536]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written));
try testing.expect(written > 0);
try testing.expect(std.mem.indexOf(u8, buf[0..written], "Html") != null);
}

View File

@@ -1,4 +1,5 @@
pub const color = @import("color.zig");
pub const formatter = @import("formatter.zig");
pub const osc = @import("osc.zig");
pub const key_event = @import("key_event.zig");
pub const key_encode = @import("key_encode.zig");
@@ -17,6 +18,10 @@ pub const osc_command_data = osc.commandData;
pub const color_rgb_get = color.rgb_get;
pub const formatter_terminal_new = formatter.terminal_new;
pub const formatter_format = formatter.format;
pub const formatter_free = formatter.free;
pub const sgr_new = sgr.new;
pub const sgr_free = sgr.free;
pub const sgr_reset = sgr.reset;
@@ -62,6 +67,7 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport;
test {
_ = color;
_ = formatter;
_ = osc;
_ = key_event;
_ = key_encode;

View File

@@ -3,4 +3,5 @@ pub const Result = enum(c_int) {
success = 0,
out_of_memory = -1,
invalid_value = -2,
out_of_space = -3,
};

View File

@@ -1,5 +1,8 @@
const std = @import("std");
const build_options = @import("terminal_options");
const assert = @import("../quirks.zig").inlineAssert;
const lib = @import("../lib/main.zig");
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
const Allocator = std.mem.Allocator;
const color = @import("color.zig");
const size = @import("size.zig");
@@ -19,46 +22,47 @@ const Selection = @import("Selection.zig");
const Style = @import("style.zig").Style;
/// Formats available.
pub const Format = enum {
/// Plain text.
plain,
pub const Format = lib.Enum(lib_target, &.{
// Plain text.
"plain",
/// Include VT sequences to preserve colors, styles, URLs, etc.
/// This is predominantly SGR sequences but may contain others as needed.
///
/// Note that for reference colors, like palette indices, this will
/// vary based on the formatter and you should see the docs. For example,
/// PageFormatter with VT will emit SGR sequences with palette indices,
/// not the color itself.
///
/// For VT, newlines will be emitted as `\r\n` so that the cursor properly
/// moves back to the beginning prior emitting follow-up lines.
vt,
// Include VT sequences to preserve colors, styles, URLs, etc.
// This is predominantly SGR sequences but may contain others as needed.
//
// Note that for reference colors, like palette indices, this will
// vary based on the formatter and you should see the docs. For example,
// PageFormatter with VT will emit SGR sequences with palette indices,
// not the color itself.
//
// For VT, newlines will be emitted as `\r\n` so that the cursor properly
// moves back to the beginning prior emitting follow-up lines.
"vt",
/// HTML output.
///
/// This will emit inline styles for as much styling as possible,
/// in the interest of simplicity and ease of editing. This isn't meant
/// to build the most beautiful or efficient HTML, but rather to be
/// stylistically correct.
///
/// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette
/// indices use CSS variables (var(--vt-palette-N)). The palette colors are
/// emitted by TerminalFormatter.Extra.palette as a <style> block if you
/// want to also include that. But if you only format a screen or lower,
/// the formatter doesn't have access to the current palette to render it.
///
/// Newlines are emitted as actual '\n' characters. Consumers should use
/// CSS white-space: pre or pre-wrap to preserve spacing and alignment.
html,
// HTML output.
//
// This will emit inline styles for as much styling as possible,
// in the interest of simplicity and ease of editing. This isn't meant
// to build the most beautiful or efficient HTML, but rather to be
// stylistically correct.
//
// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette
// indices use CSS variables (var(--vt-palette-N)). The palette colors are
// emitted by TerminalFormatter.Extra.palette as a <style> block if you
// want to also include that. But if you only format a screen or lower,
// the formatter doesn't have access to the current palette to render it.
//
// Newlines are emitted as actual '\n' characters. Consumers should use
// CSS white-space: pre or pre-wrap to preserve spacing and alignment.
"html",
});
pub fn styled(self: Format) bool {
return switch (self) {
.plain => false,
.html, .vt => true,
};
}
};
/// Returns true if the format emits styled output (not plaintext).
pub fn formatStyled(fmt: Format) bool {
return switch (fmt) {
.plain => false,
.html, .vt => true,
};
}
pub const CodepointMap = struct {
/// Unicode codepoint range to replace.
@@ -1130,7 +1134,7 @@ pub const PageFormatter = struct {
// If we're emitting styled output (not plaintext) and
// the cell has some kind of styling or is not empty
// then this isn't blank.
if (self.opts.emit.styled() and
if (formatStyled(self.opts.emit) and
(!cell.isEmpty() or cell.hasStyling())) break :blank;
// Cells with no text are blank
@@ -1186,7 +1190,7 @@ pub const PageFormatter = struct {
style: {
// If we aren't emitting styled output then we don't
// have to worry about styles.
if (!self.opts.emit.styled()) break :style;
if (!formatStyled(self.opts.emit)) break :style;
// Get our cell style.
const cell_style = self.cellStyle(cell);