terminal: speed up formatting anywhere from ~1.5x to ~8x (#13587)

This PR speeds up our formatting (plain text, html, and VT) by anywhere
from ~1.5x to ~8x.

The formatter is the hot path behind multiple features in Ghostty GUI:
clipboard copy (plain/VT/HTML), `write_screen_file`, `selectionString`,
and terminal search sliding window. It's also the hot path for
libghostty users, namely people like
[zmx](https://github.com/neurosnap/zmx) which utilize the VT formatter
to restore a terminal.

This PR also adds the benchmarking infrastructure for the formatter.

## How

- **Fast cell-run optimization.** For simple cells (single codepoint, no
style/hyperlink) we encode them as a single run rather than one at a
time.
- **Make some arguments comptime.** Generates more code but benchmarks
show it improves things, specifically for per-format switches that we do
a LOT.
- **Interned style id fast path.** Styles are interned per page, so id
equality implies style equality. We track the id of the active style and
skip the per-cell `Style` copy + `eql` when it matches.
- **Fast printing.** Avoid `std.fmt` where possible and assemble
integers, RGB colors, codepoints in fixed-width buffers with a single
memcpy. This was extracted partially to `fastprint.zig` so we can reuse
it.
- **Avoid double-formatting for tracked pins.** Previously we formatted
twice (once through a `Discarding` writer to count bytes) for pin maps.
Now I'm smarter about it and do a single pass.

## Performance

All on my machine, 80x24 terminal, 10K lines of scrollback.

| workload              | main     | this PR  | speedup | throughput |
| --------------------- | -------- | -------- | ------- | ---------- |
| plain / plain         | 5.74 ms  | 1.67 ms  | 3.4x    | 364 MB/s   |
| plain / vt            | 6.46 ms  | 1.04 ms  | 6.2x    | 596 MB/s   |
| plain / html          | 7.39 ms  | 2.32 ms  | 3.2x    | 308 MB/s   |
| unicode / plain       | 9.74 ms  | 5.42 ms  | 1.8x    | 276 MB/s   |
| unicode / vt          | 10.42 ms | 5.53 ms  | 1.9x    | 275 MB/s   |
| unicode / html        | 12.53 ms | 7.35 ms  | 1.7x    | 509 MB/s   |
| styled / plain        | 5.65 ms  | 1.69 ms  | 3.4x    | 360 MB/s   |
| styled / vt           | 9.07 ms  | 4.20 ms  | 2.2x    | 409 MB/s   |
| styled / html         | 10.78 ms | 6.64 ms  | 1.6x    | 740 MB/s   |
| mixed / plain         | 8.59 ms  | 4.81 ms  | 1.8x    | 226 MB/s   |
| mixed / vt            | 11.25 ms | 6.65 ms  | 1.7x    | 250 MB/s   |
| mixed / html          | 14.47 ms | 10.52 ms | 1.4x    | 414 MB/s   |
| wrapped / plain       | 7.30 ms  | 1.11 ms  | 6.6x    | 733 MB/s   |
| wrapped / vt          | 8.14 ms  | 1.04 ms  | 7.8x    | 789 MB/s   |
| wrapped / html        | 9.00 ms  | 2.12 ms  | 4.2x    | 465 MB/s   |
| pin-map / plain       | 12.51 ms | 3.80 ms  | 3.3x    |            |
| pin-map / vt          | 13.12 ms | 2.99 ms  | 4.4x    |            |
| active screen / plain | 12.5 µs  | 2.7 µs   | 4.6x    |            |
| active screen / vt    | 18.4 µs  | 6.8 µs   | 2.7x    |            |

Workloads: 
- `plain` is ASCII lines
- `unicode` is 2/3/4-byte codepoints with 10% grapheme clusters
- `styled` is heavy SGR churn
- `mixed` is styles + Unicode + hyperlinks
-  `wrapped` is a continuous soft-wrapped stream
- `pin-map`/`active screen` are the selectionString/search-style and
visible-screen-only cases respectively.
This commit is contained in:
Mitchell Hashimoto
2026-08-03 20:56:46 -07:00
committed by GitHub
10 changed files with 1861 additions and 292 deletions

View File

@@ -0,0 +1,482 @@
//! Benchmarks the terminal formatter (`terminal/formatter.zig`).
//!
//! The formatter is the hot path for clipboard copy (plain/VT/HTML),
//! `write_screen_file`, `selectionString`, terminal search window
//! encoding, and the libghostty-vt formatter C API. This benchmark
//! measures formatting terminal contents that were built during setup
//! (outside the timed region) from a pre-generated VT stream.
//!
//! ## Input
//!
//! `--data` names a pre-generated VT byte stream (for example from
//! `ghostty-gen styled`). The stream is fed to a terminal of the
//! requested dimensions with unlimited scrollback during setup. The
//! resulting screen contents (scrollback included) are what each step
//! formats.
//!
//! ## Modes
//!
//! * `noop` performs no formatting and establishes loop/setup overhead.
//! Subtract this from `format` timings when using hyperfine.
//! * `format` formats the configured region once per loop into a
//! reusable buffer. Buffer growth happens on the first iteration only.
//! * `report` formats once and prints content/output sizes. It is for
//! computing throughput (cells/s, bytes/s), not timing comparisons.
//!
//! ## Examples
//!
//! Build benchmarks in ReleaseFast mode:
//!
//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false
//!
//! Generate a deterministic corpus, then measure:
//!
//! ghostty-gen styled --seed=42 | head -c 640000 > /tmp/plain.vt
//! hyperfine --warmup 3 \
//! 'ghostty-bench +terminal-formatter --mode=noop --data=/tmp/plain.vt' \
//! 'ghostty-bench +terminal-formatter --emit=plain --loops=50 --data=/tmp/plain.vt' \
//! 'ghostty-bench +terminal-formatter --emit=vt --loops=50 --data=/tmp/plain.vt'
const TerminalFormatter = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const formatterpkg = terminalpkg.formatter;
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Terminal = terminalpkg.Terminal;
const Selection = terminalpkg.Selection;
const global = @import("../global.zig");
const log = std.log.scoped(.@"terminal-formatter-bench");
alloc: Allocator,
opts: Options,
terminal: ?Terminal = null,
/// Reused across steps so buffer growth is a one-time setup cost.
output: std.Io.Writer.Allocating,
/// Reused pin map storage for `--pin-map=true`.
pins: formatterpkg.PinMap.Map = .empty,
pub const Options = struct {
/// Set by the shared CLI parser for string option ownership.
_arena: ?std.heap.ArenaAllocator = null,
/// Select the operation performed inside the timed benchmark step.
mode: Mode = .format,
/// The output format to emit.
emit: Emit = .vt,
/// The region of the screen to format.
region: Region = .screen,
/// Unwrap soft-wrapped lines.
unwrap: bool = false,
/// Track the source pin of every emitted byte. This exercises the
/// (documented as expensive) pin_map path used by selectionString
/// and search.
@"pin-map": bool = false,
/// Number of format operations per benchmark step. Increase this
/// when the content is too small for stable hyperfine measurements.
loops: u32 = 25,
/// The size of the terminal. This affects wrapping and page sizes.
@"terminal-rows": u16 = 24,
@"terminal-cols": u16 = 80,
/// Pre-generated VT stream fed to the terminal during setup. `-`
/// reads stdin, although a regular file is recommended so identical
/// state can be reused across runs. When unset, the terminal is
/// empty.
data: ?[]const u8 = null,
pub fn deinit(self: *Options) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
};
pub const Mode = enum {
/// Establish the benchmark loop and setup overhead.
noop,
/// Format the configured region once per loop.
format,
/// Print content and output sizes. Not a timing benchmark.
report,
/// Semantic verification, not a timing benchmark: format the
/// terminal (must be `--emit=vt`), feed the output into a fresh
/// terminal of the same dimensions, format that, and verify the
/// two outputs converge to identical bytes. This proves the VT
/// output faithfully reconstructs the terminal content even when
/// the exact byte encoding changes.
roundtrip,
};
pub const Emit = enum {
plain,
vt,
html,
fn format(self: Emit) formatterpkg.Format {
return switch (self) {
.plain => .plain,
.vt => .vt,
.html => .html,
};
}
};
pub const Region = enum {
/// Everything: scrollback and active screen.
screen,
/// Only the active screen (bottom rows).
active,
/// Only the scrollback.
history,
};
pub fn create(
alloc: Allocator,
opts: Options,
) !*TerminalFormatter {
const ptr = try alloc.create(TerminalFormatter);
errdefer alloc.destroy(ptr);
ptr.* = .{
.alloc = alloc,
.opts = opts,
.output = .init(alloc),
};
return ptr;
}
pub fn destroy(self: *TerminalFormatter, alloc: Allocator) void {
if (self.terminal) |*t| t.deinit(self.alloc);
self.output.deinit();
self.pins.deinit(self.alloc);
alloc.destroy(self);
}
pub fn benchmark(self: *TerminalFormatter) Benchmark {
return .init(self, .{
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.format => stepFormat,
.report => stepReport,
.roundtrip => stepRoundtrip,
},
.setupFn = setup,
.teardownFn = teardown,
});
}
/// Build the terminal state every mode shares. All of this is outside
/// the timed region for `Benchmark`, but is included in whole-process
/// timings, hence the `noop` mode.
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalFormatter = @ptrCast(@alignCast(ptr));
self.setupImpl() catch |err| {
log.warn("failed to prepare formatter benchmark err={}", .{err});
return error.BenchmarkFailed;
};
}
fn setupImpl(self: *TerminalFormatter) !void {
if (self.terminal) |*t| t.deinit(self.alloc);
self.terminal = null;
self.terminal = try Terminal.init(global.io(), self.alloc, .{
.cols = self.opts.@"terminal-cols",
.rows = self.opts.@"terminal-rows",
.max_scrollback_bytes = null,
.max_scrollback_lines = null,
});
const terminal = &self.terminal.?;
// Feed the input corpus through the standard VT stream.
if (try options.dataFile(self.opts.data)) |data_f| {
defer data_f.close(global.io());
var stream = terminal.vtStream();
defer stream.deinit();
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = data_f.reader(global.io(), &read_buf);
const r = &f_reader.interface;
var buf: [4096]u8 = undefined;
while (true) {
const n = try r.readSliceShort(&buf);
if (n == 0) break; // EOF reached
stream.nextSlice(buf[0..n]);
}
}
}
fn teardown(ptr: *anyopaque) void {
const self: *TerminalFormatter = @ptrCast(@alignCast(ptr));
if (self.terminal) |*t| t.deinit(self.alloc);
self.terminal = null;
self.output.shrinkRetainingCapacity(0);
self.pins.clearRetainingCapacity();
}
/// Build the screen formatter matching our options. This mirrors how
/// Surface clipboard copy and write_screen_file construct formatters.
fn formatter(self: *TerminalFormatter) ?formatterpkg.ScreenFormatter {
const screen = self.terminal.?.screens.active;
var f: formatterpkg.ScreenFormatter = .init(screen, .{
.emit = self.opts.emit.format(),
.unwrap = self.opts.unwrap,
});
f.content = switch (self.opts.region) {
.screen => .{ .selection = null },
inline .active, .history => |region| content: {
const tag: terminalpkg.point.Tag = switch (region) {
.active => .active,
.history => .history,
.screen => unreachable,
};
const tl = screen.pages.getTopLeft(tag);
const br = screen.pages.getBottomRight(tag) orelse return null;
break :content .{ .selection = Selection.init(tl, br, false) };
},
};
return f;
}
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalFormatter = @ptrCast(@alignCast(ptr));
for (0..self.opts.loops) |_| {
std.mem.doNotOptimizeAway(self.output.written());
}
}
fn stepFormat(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalFormatter = @ptrCast(@alignCast(ptr));
for (0..self.opts.loops) |_| {
self.output.shrinkRetainingCapacity(0);
var f = self.formatter() orelse continue;
if (self.opts.@"pin-map") {
self.pins.clearRetainingCapacity();
f.pin_map = .{ .alloc = self.alloc, .map = &self.pins };
}
f.format(&self.output.writer) catch |err| {
log.warn("formatting failed err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(self.output.written());
}
}
/// Print the content dimensions and emitted output size. This shares
/// the formatting code with format mode but deliberately makes no
/// timing claims. Use it to compute cells/s and bytes/s from timings.
fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalFormatter = @ptrCast(@alignCast(ptr));
self.output.shrinkRetainingCapacity(0);
if (self.formatter()) |f_init| {
var f = f_init;
if (self.opts.@"pin-map") {
self.pins.clearRetainingCapacity();
f.pin_map = .{ .alloc = self.alloc, .map = &self.pins };
}
f.format(&self.output.writer) catch |err| {
log.warn("formatting failed err={}", .{err});
return error.BenchmarkFailed;
};
}
// Count the total pages and rows in the pagelist.
const screen = self.terminal.?.screens.active;
var pages: usize = 0;
var rows: usize = 0;
{
var node = screen.pages.pages.first;
while (node) |n| : (node = n.next) {
pages += 1;
rows += n.page().size.rows;
}
}
// Hash the output (and the pin coordinates, which are stable across
// runs unlike the node pointers) so different implementations can be
// checked for identical output.
const out_hash = std.hash.Wyhash.hash(0, self.output.written());
var pin_hasher = std.hash.Wyhash.init(0);
for (self.pins.points.items) |coord| {
const x: u16 = coord.x;
const y: u16 = @intCast(coord.y);
pin_hasher.update(std.mem.asBytes(&x));
pin_hasher.update(std.mem.asBytes(&y));
}
std.debug.print(
"terminal-formatter emit={s} region={s} pages={d} rows={d} " ++
"cols={d} cells={d} out_bytes={d} pin_bytes={d} " ++
"out_hash={x} pin_hash={x}\n",
.{
@tagName(self.opts.emit),
@tagName(self.opts.region),
pages,
rows,
self.opts.@"terminal-cols",
rows * self.opts.@"terminal-cols",
self.output.written().len,
self.pins.count(),
out_hash,
pin_hasher.final(),
},
);
// Pin map storage details on a separate line so the main report
// line remains comparable across implementations.
if (self.opts.@"pin-map") {
std.debug.print(
"terminal-formatter-pins points={d} nodes={d}\n",
.{ self.pins.points.items.len, self.pins.nodes.items.len },
);
}
}
fn stepRoundtrip(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalFormatter = @ptrCast(@alignCast(ptr));
self.stepRoundtripImpl() catch |err| {
log.warn("roundtrip failed err={}", .{err});
return error.BenchmarkFailed;
};
}
/// Format the terminal, replay the output into a fresh terminal of the
/// same dimensions, format that, and require both outputs to be
/// identical. This verifies that the emitted VT sequences faithfully
/// reconstruct the terminal contents (text, styles, wrapping) without
/// requiring any specific byte encoding of the first output.
fn stepRoundtripImpl(self: *TerminalFormatter) !void {
// Format the original terminal.
self.output.shrinkRetainingCapacity(0);
if (self.formatter()) |f_init| {
var f = f_init;
try f.format(&self.output.writer);
}
const first = self.output.written();
// Replay into a fresh terminal.
var t2 = try Terminal.init(global.io(), self.alloc, .{
.cols = self.opts.@"terminal-cols",
.rows = self.opts.@"terminal-rows",
.max_scrollback_bytes = null,
.max_scrollback_lines = null,
});
defer t2.deinit(self.alloc);
{
var stream = t2.vtStream();
defer stream.deinit();
stream.nextSlice(first);
}
// Format the replayed terminal identically.
var out2: std.Io.Writer.Allocating = .init(self.alloc);
defer out2.deinit();
var f2: formatterpkg.ScreenFormatter = .init(t2.screens.active, .{
.emit = self.opts.emit.format(),
.unwrap = self.opts.unwrap,
});
try f2.format(&out2.writer);
const second = out2.written();
const equal = std.mem.eql(u8, first, second);
std.debug.print(
"terminal-formatter roundtrip emit={s} bytes={d} replay_bytes={d} equal={}\n",
.{ @tagName(self.opts.emit), first.len, second.len, equal },
);
if (!equal) {
// Find the first differing offset to ease debugging.
const n = @min(first.len, second.len);
var i: usize = 0;
while (i < n and first[i] == second[i]) i += 1;
std.debug.print(
"terminal-formatter roundtrip mismatch offset={d} " ++
"first={f} second={f}\n",
.{
i,
std.zig.fmtString(first[i -| 32..@min(first.len, i + 32)]),
std.zig.fmtString(second[i -| 32..@min(second.len, i + 32)]),
},
);
return error.RoundtripMismatch;
}
}
test TerminalFormatter {
const testing = std.testing;
const impl: *TerminalFormatter = try .create(testing.allocator, .{});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
test "TerminalFormatter roundtrip" {
const testing = std.testing;
const impl: *TerminalFormatter = try .create(testing.allocator, .{
.mode = .roundtrip,
.@"terminal-rows" = 4,
.@"terminal-cols" = 8,
});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
test "TerminalFormatter formats all emit formats and regions" {
const testing = std.testing;
inline for (.{ Emit.plain, Emit.vt, Emit.html }) |emit| {
inline for (.{ Region.screen, Region.active, Region.history }) |region| {
const impl: *TerminalFormatter = try .create(testing.allocator, .{
.emit = emit,
.region = region,
.loops = 1,
.@"terminal-rows" = 4,
.@"terminal-cols" = 8,
});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
}
}
test "TerminalFormatter pin map" {
const testing = std.testing;
const impl: *TerminalFormatter = try .create(testing.allocator, .{
.@"pin-map" = true,
.loops = 1,
.@"terminal-rows" = 4,
.@"terminal-cols" = 8,
});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@@ -14,6 +14,7 @@ pub const Action = enum {
@"page-compression",
@"scrollback-compression",
@"screen-clone",
@"terminal-formatter",
@"terminal-parser",
@"terminal-resize",
@"terminal-snapshot",
@@ -39,6 +40,7 @@ pub const Action = enum {
.@"terminal-stream" => @import("TerminalStream.zig"),
.@"codepoint-width" => @import("CodepointWidth.zig"),
.@"grapheme-break" => @import("GraphemeBreak.zig"),
.@"terminal-formatter" => @import("TerminalFormatter.zig"),
.@"terminal-parser" => @import("TerminalParser.zig"),
.@"terminal-resize" => @import("TerminalResize.zig"),
.@"terminal-snapshot" => @import("TerminalSnapshot.zig"),

View File

@@ -6,6 +6,7 @@ pub const CodepointWidth = @import("CodepointWidth.zig");
pub const GraphemeBreak = @import("GraphemeBreak.zig");
pub const HyperlinkMap = @import("HyperlinkMap.zig");
pub const ScreenClone = @import("ScreenClone.zig");
pub const TerminalFormatter = @import("TerminalFormatter.zig");
pub const TerminalParser = @import("TerminalParser.zig");
pub const TerminalResize = @import("TerminalResize.zig");
pub const TerminalSnapshot = @import("TerminalSnapshot.zig");

68
src/fastprint.zig Normal file
View File

@@ -0,0 +1,68 @@
//! Fastprint has fast printing routines that are significantly
//! faster than going through std.fmt.
const std = @import("std");
/// Print a decimal type T. The buffer is expected to be large enough so if
/// necessary use comptime with a buffer-too-large to determine your
/// max size needed. Returns the length written.
pub fn printDecimal(comptime T: type, buf: []u8, v: T) usize {
// Note this only supports types as we need them.
switch (T) {
u8 => {
if (v >= 100) {
buf[0] = '0' + v / 100;
buf[1..3].* = std.fmt.digits2(v % 100);
return 3;
}
if (v >= 10) {
buf[0..2].* = std.fmt.digits2(v);
return 2;
}
buf[0] = '0' + v;
return 1;
},
// This can probably be generalized...
u21 => {
// Maximum u21 is 2097151: at most 7 digits.
var tmp: [7]u8 = undefined;
var val: u32 = v;
var i: usize = tmp.len;
while (true) {
i -= 1;
tmp[i] = '0' + @as(u8, @intCast(val % 10));
val /= 10;
if (val == 0) break;
}
const n = tmp.len - i;
@memcpy(buf[0..n], tmp[i..]);
return n;
},
else => comptime unreachable,
}
}
test printDecimal {
const testing = std.testing;
var buf: [16]u8 = undefined;
// u8: exercise 1, 2, and 3 digit values including boundaries.
const u8_cases = [_]u8{ 0, 1, 9, 10, 99, 100, 255 };
for (u8_cases) |v| {
var expected_buf: [3]u8 = undefined;
const expected = std.fmt.bufPrint(&expected_buf, "{d}", .{v}) catch unreachable;
const len = printDecimal(u8, &buf, v);
try testing.expectEqualStrings(expected, buf[0..len]);
}
// u21: exercise digit count boundaries up to the maximum.
const u21_cases = [_]u21{ 0, 9, 10, 128, 65535, 1114111, std.math.maxInt(u21) };
for (u21_cases) |v| {
var expected_buf: [8]u8 = undefined;
const expected = std.fmt.bufPrint(&expected_buf, "{d}", .{v}) catch unreachable;
const len = printDecimal(u21, &buf, v);
try testing.expectEqualStrings(expected, buf[0..len]);
}
}

View File

@@ -9,6 +9,7 @@ pub const Action = enum {
ascii,
kitty,
osc,
styled,
utf8,
/// Returns the struct associated with the action. The struct
@@ -24,6 +25,7 @@ pub const Action = enum {
.ascii => @import("cli/Ascii.zig"),
.kitty => @import("cli/Kitty.zig"),
.osc => @import("cli/Osc.zig"),
.styled => @import("cli/Styled.zig"),
.utf8 => @import("cli/Utf8.zig"),
};
}

View File

@@ -0,0 +1,410 @@
//! Generates styled terminal content: lines of printable text
//! interspersed with SGR sequences, optional multi-byte UTF-8
//! codepoints, combining marks (multi-codepoint graphemes), and
//! OSC 8 hyperlinks.
//!
//! This exists primarily to build corpora for benchmarking code that
//! consumes terminal *contents* (e.g. the terminal formatter used for
//! clipboard copy and dumps), where we want workloads with controlled
//! amounts of styling and Unicode rather than raw random bytes.
//!
//! Examples:
//!
//! # Plain ASCII lines (equivalent to `ascii` with lines)
//! ghostty-gen styled --seed=42
//!
//! # Heavily styled ASCII
//! ghostty-gen styled --seed=42 --style-rate=0.8
//!
//! # Unicode-heavy, no styling
//! ghostty-gen styled --seed=42 --weight-two=1 --weight-three=1 \
//! --weight-four=0.5 --grapheme-rate=0.1
//!
//! # Mixed: styles, Unicode, and hyperlinks
//! ghostty-gen styled --seed=42 --style-rate=0.3 --weight-two=0.5 \
//! --weight-three=0.25 --weight-four=0.1 --grapheme-rate=0.05 \
//! --osc8-rate=0.1
const Styled = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
pub const Options = struct {
/// Seed to use for deterministic generation. If unset, a time-based
/// seed is used by the generic synthetic CLI.
seed: ?u64 = null,
/// Emit lines whose printable length (in characters, not columns)
/// is uniformly distributed in `[line-min, line-max]`. Each line is
/// terminated by CR LF.
@"line-min": usize = 40,
@"line-max": usize = 80,
/// Probability that a style change (SGR sequence) is emitted at
/// each run boundary. Zero (default) produces unstyled output.
@"style-rate": f64 = 0.0,
/// The length in characters of a "run": a sequence of characters
/// that share styling. Style/hyperlink changes only happen on run
/// boundaries.
@"run-min": usize = 4,
@"run-max": usize = 16,
/// Relative weights for choosing the UTF-8 encoding length of each
/// generated character. Unlike the raw `utf8` generator, characters
/// are drawn from curated printable ranges so the output never
/// contains control characters:
///
/// one: printable ASCII (with occasional spaces)
/// two: Latin-1 Supplement / Latin Extended (narrow)
/// three: Hiragana/Katakana and CJK ideographs (wide)
/// four: emoji (wide)
@"weight-one": f64 = 1.0,
@"weight-two": f64 = 0.0,
@"weight-three": f64 = 0.0,
@"weight-four": f64 = 0.0,
/// Probability that a generated character is followed by a
/// combining diacritical mark, producing a multi-codepoint
/// grapheme cluster.
@"grapheme-rate": f64 = 0.0,
/// Probability that an OSC 8 hyperlink is toggled (opened or
/// closed) at each run boundary.
@"osc8-rate": f64 = 0.0,
};
opts: Options,
pub fn create(
alloc: Allocator,
opts: Options,
) !*Styled {
for ([_]f64{
opts.@"style-rate",
opts.@"grapheme-rate",
opts.@"osc8-rate",
}) |rate| {
if (rate < 0 or rate > 1) return error.InvalidValue;
}
const weights = [_]f64{
opts.@"weight-one",
opts.@"weight-two",
opts.@"weight-three",
opts.@"weight-four",
};
var weight_sum: f64 = 0;
for (weights) |weight| {
if (weight < 0) return error.InvalidValue;
weight_sum += weight;
}
if (weight_sum <= 0) return error.InvalidValue;
if (opts.@"line-min" == 0) return error.InvalidValue;
if (opts.@"run-min" == 0) return error.InvalidValue;
const ptr = try alloc.create(Styled);
errdefer alloc.destroy(ptr);
ptr.* = .{ .opts = opts };
return ptr;
}
pub fn destroy(self: *Styled, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn run(self: *Styled, writer: *std.Io.Writer, rand: std.Random) !void {
var prng: ?std.Random.DefaultPrng = null;
var gen_rand = rand;
if (self.opts.seed) |seed| {
prng = std.Random.DefaultPrng.init(seed);
gen_rand = prng.?.random();
}
var state: State = .{};
while (true) {
self.writeLine(writer, gen_rand, &state) catch |err| {
const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err);
switch (@as(Error, err)) {
error.BrokenPipe => return, // stdout closed
error.WriteFailed => return, // fixed buffer full
}
};
}
}
const State = struct {
/// True when a non-default SGR style is currently active.
styled: bool = false,
/// True when an OSC 8 hyperlink is currently open.
link: bool = false,
};
fn writeLine(
self: *Styled,
writer: *std.Io.Writer,
rand: std.Random,
state: *State,
) std.Io.Writer.Error!void {
const line_min = self.opts.@"line-min";
const line_max = @max(line_min, self.opts.@"line-max");
const run_min = self.opts.@"run-min";
const run_max = @max(run_min, self.opts.@"run-max");
const line_len = rand.intRangeAtMostBiased(usize, line_min, line_max);
var remaining = line_len;
while (remaining > 0) {
if (self.opts.@"style-rate" > 0 and
rand.float(f64) < self.opts.@"style-rate")
{
try self.writeSgr(writer, rand, state);
}
if (self.opts.@"osc8-rate" > 0 and
rand.float(f64) < self.opts.@"osc8-rate")
{
try self.toggleLink(writer, rand, state);
}
const run_len = @min(
remaining,
rand.intRangeAtMostBiased(usize, run_min, run_max),
);
for (0..run_len) |_| try self.writeChar(writer, rand);
remaining -= run_len;
}
// Close any open styling/hyperlink so state never bleeds across
// lines. This mirrors what well-behaved programs do.
if (state.styled) {
try writer.writeAll("\x1b[0m");
state.styled = false;
}
if (state.link) {
try writer.writeAll("\x1b]8;;\x1b\\");
state.link = false;
}
try writer.writeAll("\r\n");
}
fn writeChar(
self: *Styled,
writer: *std.Io.Writer,
rand: std.Random,
) std.Io.Writer.Error!void {
const weights = [_]f64{
self.opts.@"weight-one",
self.opts.@"weight-two",
self.opts.@"weight-three",
self.opts.@"weight-four",
};
const cp: u21 = switch (rand.weightedIndex(f64, &weights)) {
// Printable ASCII with occasional word-ish spacing.
0 => if (rand.float(f64) < 0.15)
' '
else
rand.intRangeAtMostBiased(u21, 0x21, 0x7E),
// Latin-1 Supplement and Latin Extended-A/B (narrow).
1 => rand.intRangeAtMostBiased(u21, 0xC0, 0x24F),
// Kana or CJK ideographs (wide).
2 => if (rand.boolean())
rand.intRangeAtMostBiased(u21, 0x3041, 0x30FE)
else
rand.intRangeAtMostBiased(u21, 0x4E00, 0x9FFF),
// Emoji (wide).
3 => rand.intRangeAtMostBiased(u21, 0x1F600, 0x1F64F),
else => unreachable,
};
try writer.print("{u}", .{cp});
// Optionally follow with a combining diacritical mark to form a
// multi-codepoint grapheme cluster.
if (self.opts.@"grapheme-rate" > 0 and
rand.float(f64) < self.opts.@"grapheme-rate")
{
const mark = rand.intRangeAtMostBiased(u21, 0x300, 0x36F);
try writer.print("{u}", .{mark});
}
}
fn writeSgr(
self: *Styled,
writer: *std.Io.Writer,
rand: std.Random,
state: *State,
) std.Io.Writer.Error!void {
_ = self;
switch (rand.uintLessThan(u8, 8)) {
// Reset.
0 => {
try writer.writeAll("\x1b[0m");
state.styled = false;
return;
},
// 16-color foreground.
1 => {
const base: u8 = if (rand.boolean()) 30 else 90;
try writer.print("\x1b[{d}m", .{base + rand.uintLessThan(u8, 8)});
},
// 16-color background.
2 => {
const base: u8 = if (rand.boolean()) 40 else 100;
try writer.print("\x1b[{d}m", .{base + rand.uintLessThan(u8, 8)});
},
// 256-color foreground/background.
3 => try writer.print("\x1b[38;5;{d}m", .{rand.int(u8)}),
4 => try writer.print("\x1b[48;5;{d}m", .{rand.int(u8)}),
// RGB foreground/background. Components are quantized so the
// number of unique styles stays bounded (6^3 per channel pair)
// rather than pathologically unique per cell.
5, 6 => |kind| {
const layer: u8 = if (kind == 5) 38 else 48;
try writer.print("\x1b[{d};2;{d};{d};{d}m", .{
layer,
rand.uintLessThan(u8, 6) * 51,
rand.uintLessThan(u8, 6) * 51,
rand.uintLessThan(u8, 6) * 51,
});
},
// Attributes: bold, italic, underline, inverse, strikethrough.
7 => {
const attrs = [_]u8{ 1, 3, 4, 7, 9 };
try writer.print("\x1b[{d}m", .{
attrs[rand.uintLessThan(usize, attrs.len)],
});
},
else => unreachable,
}
state.styled = true;
}
fn toggleLink(
self: *Styled,
writer: *std.Io.Writer,
rand: std.Random,
state: *State,
) std.Io.Writer.Error!void {
_ = self;
if (state.link) {
try writer.writeAll("\x1b]8;;\x1b\\");
state.link = false;
return;
}
try writer.print(
"\x1b]8;;http://example.com/{d}\x1b\\",
.{rand.uintLessThan(u16, 1024)},
);
state.link = true;
}
test Styled {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *Styled = try .create(alloc, .{ .seed = 1 });
defer impl.destroy(alloc);
var prng = std.Random.DefaultPrng.init(1);
const rand = prng.random();
var buf: [4096]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try impl.run(&writer, rand);
const output = writer.buffered();
try testing.expect(output.len > 0);
// Default options: plain printable ASCII lines only.
for (output) |byte| {
try testing.expect(byte == '\r' or byte == '\n' or
(byte >= 0x20 and byte < 0x7F));
}
}
test "Styled styled output" {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *Styled = try .create(alloc, .{
.seed = 1,
.@"style-rate" = 0.5,
.@"osc8-rate" = 0.2,
});
defer impl.destroy(alloc);
var prng = std.Random.DefaultPrng.init(1);
const rand = prng.random();
var buf: [16384]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try impl.run(&writer, rand);
const output = writer.buffered();
// Must contain SGR and OSC 8 sequences.
try testing.expect(std.mem.indexOf(u8, output, "\x1b[") != null);
try testing.expect(std.mem.indexOf(u8, output, "\x1b]8;;") != null);
}
test "Styled unicode output" {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *Styled = try .create(alloc, .{
.seed = 1,
.@"weight-two" = 1.0,
.@"weight-three" = 1.0,
.@"weight-four" = 1.0,
.@"grapheme-rate" = 0.25,
});
defer impl.destroy(alloc);
var prng = std.Random.DefaultPrng.init(1);
const rand = prng.random();
var buf: [16384]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try impl.run(&writer, rand);
const output = writer.buffered();
// No escapes, valid UTF-8 (modulo a possibly truncated tail from
// the fixed buffer filling up mid-sequence).
try testing.expect(std.mem.indexOfScalar(u8, output, 0x1B) == null);
var end = output.len;
while (end > 0 and output[end - 1] & 0xC0 == 0x80) end -= 1;
if (end > 0 and output[end - 1] >= 0xC0) end -= 1;
try testing.expect(std.unicode.utf8ValidateSlice(output[0..end]));
}
test "Styled invalid options" {
const testing = std.testing;
const alloc = testing.allocator;
try testing.expectError(error.InvalidValue, Styled.create(alloc, .{
.@"style-rate" = 1.5,
}));
try testing.expectError(error.InvalidValue, Styled.create(alloc, .{
.@"weight-one" = 0,
}));
try testing.expectError(error.InvalidValue, Styled.create(alloc, .{
.@"line-min" = 0,
}));
}

View File

@@ -17,6 +17,7 @@ const PageList = @import("PageList.zig");
const selection_codepoints = @import("selection_codepoints.zig");
const StringMap = @import("StringMap.zig");
const ScreenFormatter = @import("formatter.zig").ScreenFormatter;
const PinMap = @import("formatter.zig").PinMap;
const osc = @import("osc.zig");
const pagepkg = @import("page.zig");
const point = @import("point.zig");
@@ -2910,7 +2911,7 @@ pub fn selectionString(
formatter.content = .{ .selection = opts.sel };
// If we have a string map, we need to set that up.
var pins: std.ArrayList(Pin) = .empty;
var pins: PinMap.Map = .empty;
defer pins.deinit(alloc);
if (opts.map != null) formatter.pin_map = .{
.alloc = alloc,
@@ -2928,11 +2929,13 @@ pub fn selectionString(
const map_string = try alloc.dupeZ(u8, text);
errdefer alloc.free(map_string);
try selectionString_tw.check(.copy_map);
const map_pins = try pins.toOwnedSlice(alloc);
map.* = .{
.string = map_string,
.map = map_pins,
.map = pins,
};
// Ownership of the pin map moved to the string map.
pins = .empty;
}
return text;

View File

@@ -6,9 +6,9 @@ const std = @import("std");
const build_options = @import("terminal_options");
const oni = @import("oniguruma");
const point = @import("point.zig");
const PinMap = @import("formatter.zig").PinMap;
const Selection = @import("Selection.zig");
const Screen = @import("Screen.zig");
const Pin = @import("PageList.zig").Pin;
const Allocator = std.mem.Allocator;
// Retry budget for StringMap regex searches.
@@ -18,11 +18,15 @@ const Allocator = std.mem.Allocator;
const oni_search_retry_limit = 100_000;
string: [:0]const u8,
map: []Pin,
/// Mapping of string byte offsets to pins. See PinMap for the
/// storage details.
map: PinMap.Map,
pub fn deinit(self: StringMap, alloc: Allocator) void {
alloc.free(self.string);
alloc.free(self.map);
var map = self.map;
map.deinit(alloc);
}
/// Returns an iterator that yields the next match of the given regex.
@@ -106,8 +110,8 @@ pub const Match = struct {
pub fn selection(self: Match) Selection {
const start_idx: usize = @intCast(self.region.starts()[0]);
const end_idx: usize = @intCast(self.region.ends()[0] - 1);
const start_pt = self.map.map[self.offset + start_idx];
const end_pt = self.map.map[self.offset + end_idx];
const start_pt = self.map.map.get(self.offset + start_idx).?;
const end_pt = self.map.map.get(self.offset + end_idx).?;
return .init(start_pt, end_pt, false);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const fastprint = @import("../fastprint.zig");
const color = @import("color.zig");
const sgr = @import("sgr.zig");
const page = @import("page.zig");
@@ -331,63 +332,141 @@ pub const Style = struct {
self: VTFormatter,
writer: *std.Io.Writer,
) !void {
// Style emission is a hot path when formatting styled terminal
// contents, so all of the sequences are assembled in a buffer
// and written in one call rather than going through the
// (slower) format string machinery with a write per sequence.
//
// Worst case: `\x1b[0m` (4) + 7 flags (28) + `\x1b[53m` (5) +
// `\x1b[4:2m` (6) + 3 RGB colors (19 each = 57) = 100.
var buf: [128]u8 = undefined;
// Always reset the style. Styles are fully self-contained.
// Even if this style is empty, then that means we want to go
// back to the default.
try writer.writeAll("\x1b[0m");
buf[0..4].* = "\x1b[0m".*;
var len: usize = 4;
// Our flags
if (self.style.flags.bold) try writer.writeAll("\x1b[1m");
if (self.style.flags.faint) try writer.writeAll("\x1b[2m");
if (self.style.flags.italic) try writer.writeAll("\x1b[3m");
if (self.style.flags.blink) try writer.writeAll("\x1b[5m");
if (self.style.flags.inverse) try writer.writeAll("\x1b[7m");
if (self.style.flags.invisible) try writer.writeAll("\x1b[8m");
if (self.style.flags.strikethrough) try writer.writeAll("\x1b[9m");
if (self.style.flags.overline) try writer.writeAll("\x1b[53m");
if (self.style.flags.bold) {
buf[len..][0..4].* = "\x1b[1m".*;
len += 4;
}
if (self.style.flags.faint) {
buf[len..][0..4].* = "\x1b[2m".*;
len += 4;
}
if (self.style.flags.italic) {
buf[len..][0..4].* = "\x1b[3m".*;
len += 4;
}
if (self.style.flags.blink) {
buf[len..][0..4].* = "\x1b[5m".*;
len += 4;
}
if (self.style.flags.inverse) {
buf[len..][0..4].* = "\x1b[7m".*;
len += 4;
}
if (self.style.flags.invisible) {
buf[len..][0..4].* = "\x1b[8m".*;
len += 4;
}
if (self.style.flags.strikethrough) {
buf[len..][0..4].* = "\x1b[9m".*;
len += 4;
}
if (self.style.flags.overline) {
buf[len..][0..5].* = "\x1b[53m".*;
len += 5;
}
switch (self.style.flags.underline) {
.none => {},
.single => try writer.writeAll("\x1b[4m"),
.double => try writer.writeAll("\x1b[4:2m"),
.curly => try writer.writeAll("\x1b[4:3m"),
.dotted => try writer.writeAll("\x1b[4:4m"),
.dashed => try writer.writeAll("\x1b[4:5m"),
.single => {
buf[len..][0..4].* = "\x1b[4m".*;
len += 4;
},
.double => {
buf[len..][0..6].* = "\x1b[4:2m".*;
len += 6;
},
.curly => {
buf[len..][0..6].* = "\x1b[4:3m".*;
len += 6;
},
.dotted => {
buf[len..][0..6].* = "\x1b[4:4m".*;
len += 6;
},
.dashed => {
buf[len..][0..6].* = "\x1b[4:5m".*;
len += 6;
},
}
// Various RGB colors.
try self.formatColor(writer, 38, self.style.fg_color);
try self.formatColor(writer, 48, self.style.bg_color);
try self.formatColor(writer, 58, self.style.underline_color);
// Various colors.
len += self.appendColor(buf[len..], 38, self.style.fg_color);
len += self.appendColor(buf[len..], 48, self.style.bg_color);
len += self.appendColor(buf[len..], 58, self.style.underline_color);
try writer.writeAll(buf[0..len]);
}
fn formatColor(
/// Appends a standalone `\x1b[{prefix};5;{idx}m` or
/// `\x1b[{prefix};2;{r};{g};{b}m` sequence to buf, returning the
/// length written.
fn appendColor(
self: VTFormatter,
writer: *std.Io.Writer,
buf: []u8,
prefix: u8,
value: Color,
) !void {
) usize {
switch (value) {
.none => {},
.none => return 0,
.palette => |idx| {
if (self.palette) |p| {
const rgb = p[idx];
try writer.print(
"\x1b[{d};2;{d};{d};{d}m",
.{ prefix, rgb.r, rgb.g, rgb.b },
);
} else {
try writer.print(
"\x1b[{d};5;{d}m",
.{ prefix, idx },
);
}
// Direct RGB: `\x1b[{prefix};2;{r};{g};{b}m`
if (self.palette) |p| return appendColorRgb(
buf,
prefix,
p[idx],
);
// Palette reference: `\x1b[{prefix};5;{idx}m`
buf[0..2].* = "\x1b[".*;
var len: usize = 2;
len += fastprint.printDecimal(u8, buf[len..], prefix);
buf[len..][0..3].* = ";5;".*;
len += 3;
len += fastprint.printDecimal(u8, buf[len..], idx);
buf[len] = 'm';
len += 1;
return len;
},
.rgb => |rgb| try writer.print(
"\x1b[{d};2;{d};{d};{d}m",
.{ prefix, rgb.r, rgb.g, rgb.b },
),
.rgb => |rgb| return appendColorRgb(buf, prefix, rgb),
}
}
/// Appends `\x1b[{prefix};2;{r};{g};{b}m` to buf, returning the
/// length written.
fn appendColorRgb(buf: []u8, prefix: u8, rgb: color.RGB) usize {
buf[0..2].* = "\x1b[".*;
var len: usize = 2;
len += fastprint.printDecimal(u8, buf[len..], prefix);
buf[len..][0..3].* = ";2;".*;
len += 3;
len += fastprint.printDecimal(u8, buf[len..], rgb.r);
buf[len] = ';';
len += 1;
len += fastprint.printDecimal(u8, buf[len..], rgb.g);
buf[len] = ';';
len += 1;
len += fastprint.printDecimal(u8, buf[len..], rgb.b);
buf[len] = 'm';
len += 1;
return len;
}
};
const HtmlFormatter = struct {
@@ -444,18 +523,52 @@ pub const Style = struct {
property: []const u8,
c: Color,
) !void {
// Style emission is a hot path when formatting styled terminal
// contents, so the values are assembled in a buffer and written
// in one call rather than going through the (slower) format
// string machinery.
var buf: [32]u8 = undefined;
var len: usize = 0;
switch (c) {
.none => {},
.none => return,
// `{property}: rgb({r}, {g}, {b});`
.palette => |idx| {
if (self.palette) |p| {
const rgb = p[idx];
try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b });
len = formatColorRgb(&buf, p[idx]);
} else {
try writer.print("{s}: var(--vt-palette-{d});", .{ property, idx });
// `{property}: var(--vt-palette-{idx});`
const prefix = ": var(--vt-palette-";
buf[0..prefix.len].* = prefix.*;
len = prefix.len;
len += fastprint.printDecimal(u8, buf[len..], idx);
buf[len..][0..2].* = ");".*;
len += 2;
}
},
.rgb => |rgb| try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }),
.rgb => |rgb| len = formatColorRgb(&buf, rgb),
}
try writer.writeAll(property);
try writer.writeAll(buf[0..len]);
}
/// Writes `: rgb({r}, {g}, {b});` into buf, returning the length
/// written.
fn formatColorRgb(buf: *[32]u8, rgb: color.RGB) usize {
buf[0..6].* = ": rgb(".*;
var len: usize = 6;
len += fastprint.printDecimal(u8, buf[len..], rgb.r);
buf[len..][0..2].* = ", ".*;
len += 2;
len += fastprint.printDecimal(u8, buf[len..], rgb.g);
buf[len..][0..2].* = ", ".*;
len += 2;
len += fastprint.printDecimal(u8, buf[len..], rgb.b);
buf[len..][0..2].* = ");".*;
len += 2;
return len;
}
};