mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 15:18:40 +00:00
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:
482
src/benchmark/TerminalFormatter.zig
Normal file
482
src/benchmark/TerminalFormatter.zig
Normal 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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
68
src/fastprint.zig
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
410
src/synthetic/cli/Styled.zig
Normal file
410
src/synthetic/cli/Styled.zig
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user