libghostty: selection formatting

This commit is contained in:
Mitchell Hashimoto
2026-05-24 13:56:54 -07:00
parent eb777b8036
commit 2ce5db29ca
6 changed files with 376 additions and 17 deletions

View File

@@ -32,23 +32,6 @@ extern "C" {
* @{
*/
/**
* Output format.
*
* @ingroup formatter
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** 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,
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyFormatterFormat;
/**
* Extra screen state to include in styled output.
*

View File

@@ -10,8 +10,10 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/grid_ref.h>
#include <ghostty/vt/point.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
@@ -161,6 +163,46 @@ typedef struct {
bool semantic_prompt_boundary;
} GhosttyTerminalSelectLineOptions;
/**
* Options for one-shot formatting of a terminal selection.
*
* This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
*
* If selection is NULL, the terminal's current active selection is used.
* If selection is non-NULL, that caller-provided snapshot selection is used.
*
* The selection is formatted from the terminal's active screen using the same
* formatting semantics as GhosttyFormatter. For copy/clipboard behavior
* matching Ghostty's Screen.selectionString(), use plain output with unwrap
* and trim both set to true.
*
* @ingroup selection
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions). */
size_t size;
/** 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;
/**
* Optional selection to format.
*
* If NULL, the terminal's current active selection is used. If the terminal
* has no active selection, formatting returns GHOSTTY_NO_VALUE.
*
* If non-NULL, the pointed-to selection must be a valid snapshot selection
* for this terminal and must obey GhosttySelection lifetime rules.
*/
const GhosttySelection *selection;
} GhosttyTerminalSelectionFormatOptions;
/**
* Ordering of a selection's endpoints in terminal coordinates.
*
@@ -355,6 +397,74 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_output(
GhosttyGridRef ref,
GhosttySelection* out_selection);
/**
* Format a terminal selection into a caller-provided buffer.
*
* This is a one-shot convenience API for formatting either the terminal's
* active selection or a caller-provided GhosttySelection without explicitly
* creating a GhosttyFormatter.
*
* Pass NULL for buf to query the required output size. In that case,
* out_written receives the required size and the function returns
* GHOSTTY_OUT_OF_SPACE.
*
* If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes
* the required size to out_written. The caller can then retry with a larger
* buffer.
*
* If options.selection is NULL and the terminal has no active selection, the
* function returns GHOSTTY_NO_VALUE.
*
* @param terminal The terminal to read from (must not be NULL)
* @param options Selection formatting options
* @param buf Output buffer, or NULL to query required size
* @param buf_len Length of buf in bytes
* @param out_written Number of bytes written, or required size on
* GHOSTTY_OUT_OF_SPACE (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_buf(
GhosttyTerminal terminal,
GhosttyTerminalSelectionFormatOptions options,
uint8_t* buf,
size_t buf_len,
size_t* out_written);
/**
* Format a terminal selection into an allocated buffer.
*
* This is a one-shot convenience API for formatting either the terminal's
* active selection or a caller-provided GhosttySelection without explicitly
* creating a GhosttyFormatter.
*
* The returned buffer is allocated using allocator, or the default allocator
* if NULL is passed. The caller owns the returned buffer and must free it with
* ghostty_free(), passing the same allocator and returned length.
*
* The returned bytes are not NUL-terminated. This supports plain text, VT, and
* HTML uniformly as byte output.
*
* If options.selection is NULL and the terminal has no active selection, the
* function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0.
*
* @param terminal The terminal to read from (must not be NULL)
* @param allocator Allocator used for the returned buffer, or NULL for the default allocator
* @param options Selection formatting options
* @param out_ptr Receives the allocated output buffer (must not be NULL)
* @param out_len Receives the output length in bytes (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup selection
*/
GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_alloc(
GhosttyTerminal terminal,
const GhosttyAllocator* allocator,
GhosttyTerminalSelectionFormatOptions options,
uint8_t** out_ptr,
size_t* out_len);
/**
* Adjust a selection snapshot using terminal selection semantics.
*

View File

@@ -194,6 +194,23 @@ typedef struct GhosttyOscCommandImpl* GhosttyOscCommand;
/* ---- Common value types ---- */
/**
* Terminal content output format.
*
* @ingroup formatter
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** 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,
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyFormatterFormat;
/**
* A borrowed byte string (pointer + length).
*

View File

@@ -209,6 +209,8 @@ comptime {
@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" });
@export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" });
@export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" });
@export(&c.render_state_new, .{ .name = "ghostty_render_state_new" });
@export(&c.render_state_update, .{ .name = "ghostty_render_state_update" });
@export(&c.render_state_get, .{ .name = "ghostty_render_state_get" });

View File

@@ -175,6 +175,8 @@ pub const terminal_select_word_between = selection.word_between;
pub const terminal_select_line = selection.line;
pub const terminal_select_all = selection.all;
pub const terminal_select_output = selection.output;
pub const terminal_selection_format_buf = selection.format_buf;
pub const terminal_selection_format_alloc = selection.format_alloc;
pub const terminal_selection_adjust = selection.adjust;
pub const terminal_selection_order = selection.order;
pub const terminal_selection_ordered = selection.ordered;

View File

@@ -1,6 +1,8 @@
const std = @import("std");
const testing = std.testing;
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const formatterpkg = @import("../formatter.zig");
const grid_ref = @import("grid_ref.zig");
const point = @import("../point.zig");
const selection_codepoints = @import("../selection_codepoints.zig");
@@ -12,6 +14,7 @@ const log = std.log.scoped(.selection_c);
pub const Adjustment = Selection.Adjustment;
pub const Order = Selection.Order;
pub const Format = formatterpkg.Format;
/// C: GhosttySelection
pub const CSelection = extern struct {
@@ -61,6 +64,15 @@ pub const SelectLineOptions = extern struct {
semantic_prompt_boundary: bool = false,
};
/// C: GhosttyTerminalSelectionFormatOptions
pub const FormatOptions = extern struct {
size: usize = @sizeOf(FormatOptions),
emit: Format,
unwrap: bool,
trim: bool,
selection: ?*const CSelection = null,
};
pub fn word(
terminal: terminal_c.Terminal,
options: ?*const SelectWordOptions,
@@ -163,6 +175,101 @@ pub fn output(
return .success;
}
pub fn format_buf(
terminal: terminal_c.Terminal,
opts: FormatOptions,
out_: ?[*]u8,
out_len: usize,
out_written: *usize,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
if (out_ == null) {
var discarding: std.Io.Writer.Discarding = .init(&.{});
formatSelection(t, opts, &discarding.writer) catch |err| return switch (err) {
error.InvalidValue => .invalid_value,
error.NoValue => .no_value,
error.WriteFailed => unreachable,
};
out_written.* = @intCast(discarding.count);
return .out_of_space;
}
var writer: std.Io.Writer = .fixed(out_.?[0..out_len]);
formatSelection(t, opts, &writer) catch |err| switch (err) {
error.InvalidValue => return .invalid_value,
error.NoValue => return .no_value,
error.WriteFailed => {
var discarding: std.Io.Writer.Discarding = .init(&.{});
formatSelection(t, opts, &discarding.writer) catch unreachable;
out_written.* = @intCast(discarding.count);
return .out_of_space;
},
};
out_written.* = writer.end;
return .success;
}
pub fn format_alloc(
terminal: terminal_c.Terminal,
alloc_: ?*const CAllocator,
opts: FormatOptions,
out_ptr: *?[*]u8,
out_len: *usize,
) callconv(lib.calling_conv) Result {
out_ptr.* = null;
out_len.* = 0;
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const alloc = lib.alloc.default(alloc_);
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
formatSelection(t, opts, &aw.writer) catch |err| return switch (err) {
error.InvalidValue => .invalid_value,
error.NoValue => .no_value,
error.WriteFailed => .out_of_memory,
};
const buf = aw.toOwnedSlice() catch return .out_of_memory;
out_ptr.* = buf.ptr;
out_len.* = buf.len;
return .success;
}
fn formatSelection(
t: *terminal_c.ZigTerminal,
opts: FormatOptions,
writer: *std.Io.Writer,
) error{ InvalidValue, NoValue, WriteFailed }!void {
var formatter = selectionFormatter(t, opts) catch |err| return err;
try formatter.format(writer);
}
fn selectionFormatter(
t: *terminal_c.ZigTerminal,
opts: FormatOptions,
) error{ InvalidValue, NoValue }!formatterpkg.TerminalFormatter {
if (opts.size < @sizeOf(FormatOptions)) return error.InvalidValue;
_ = std.meta.intToEnum(Format, @intFromEnum(opts.emit)) catch
return error.InvalidValue;
const sel = if (opts.selection) |sel|
sel.toZig() orelse return error.InvalidValue
else
t.screens.active.selection orelse return error.NoValue;
var formatter: formatterpkg.TerminalFormatter = .init(t, .{
.emit = opts.emit,
.unwrap = opts.unwrap,
.trim = opts.trim,
});
formatter.content = .{ .selection = sel };
return formatter;
}
/// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`.
///
/// `NULL + len 0` returns null, which callers treat as “use the API default
@@ -284,3 +391,141 @@ pub fn equal(
out.* = sel_a.eql(sel_b);
return .success;
}
test "selection_format_alloc uses active selection" {
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 World", 11);
var start_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 6, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 10, .y = 0 } },
}, &end_ref));
const sel: CSelection = .{
.start = start_ref,
.end = end_ref,
};
try testing.expectEqual(Result.success, terminal_c.set(t, .selection, @ptrCast(&sel)));
const opts: FormatOptions = .{
.emit = .plain,
.unwrap = true,
.trim = true,
};
var required: usize = 0;
try testing.expectEqual(Result.out_of_space, format_buf(
t,
opts,
null,
0,
&required,
));
try testing.expectEqual(@as(usize, 5), required);
var out_ptr: ?[*]u8 = null;
var out_len: usize = 0;
try testing.expectEqual(Result.success, format_alloc(
t,
&lib.alloc.test_allocator,
opts,
&out_ptr,
&out_len,
));
const ptr = out_ptr orelse return error.TestExpectedEqual;
defer lib.alloc.default(&lib.alloc.test_allocator).free(ptr[0..out_len]);
try testing.expectEqualStrings("World", ptr[0..out_len]);
}
test "selection_format_buf uses provided selection" {
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 World", 11);
var start_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref.CGridRef = .{};
try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 4, .y = 0 } },
}, &end_ref));
const sel: CSelection = .{
.start = start_ref,
.end = end_ref,
};
const opts: FormatOptions = .{
.emit = .plain,
.unwrap = true,
.trim = true,
.selection = &sel,
};
var small: [2]u8 = undefined;
var written: usize = 0;
try testing.expectEqual(Result.out_of_space, format_buf(
t,
opts,
&small,
small.len,
&written,
));
try testing.expectEqual(@as(usize, 5), written);
var buf: [32]u8 = undefined;
try testing.expectEqual(Result.success, format_buf(
t,
opts,
&buf,
buf.len,
&written,
));
try testing.expectEqualStrings("Hello", buf[0..written]);
}
test "selection_format_alloc returns no_value without active selection" {
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 out_ptr: ?[*]u8 = @ptrFromInt(1);
var out_len: usize = 123;
try testing.expectEqual(Result.no_value, format_alloc(
t,
&lib.alloc.test_allocator,
.{ .emit = .plain, .unwrap = true, .trim = true },
&out_ptr,
&out_len,
));
try testing.expect(out_ptr == null);
try testing.expectEqual(@as(usize, 0), out_len);
}