mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 07:08:39 +00:00
synthetic: styled output generator
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user