mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 09:01:44 +00:00
terminal: various VT processing optimizations (~1.5x to ~6x throughput increase) (#13220)
This is a series of five commits that optimizes VT processing throughput in various ways. Each commit is isolated, individually benchmarked, and carries a detailed commit message so please read each for details about each change. After #13209 made IO fully parser-bound, these gains should translate directly into end-to-end IO throughput (until some other stage becomes the new bottleneck). Plain ASCII processing went from ~128 MB/s to ~725 MB/s. `time cat ascii_150MB.txt` went from 1.5s before 13209 to 1.2s on main to 566ms on this branch. ## The changes 1. **batch printed codepoint runs into direct row fills**. Profiling showed ~85% of plain-text time inside `Terminal.print`, re-answering the same questions (margins, modes, width, charset, style) for every single character. A new `print_slice` stream action delivers runs of decoded codepoints to `Terminal.printSlice`, which hoists the invariants and fills rows with a masked-compare + branch-free store loop, falling back to `print()` for anything complex. **Result: 2.2x–5.7x on ascii plus unicode text.** 2. **dispatch CSI finals directly from stream fast paths**. Every byte through `Parser.next` copies a ~240 byte `[3]?Action` and a typical CSI copied it twice. New `csi_entry/final` fast paths dispatch directly without the action array. **Result: +17-18% on CSI streams.** 3. **bulk-parse CSI parameter bytes at the slice level**. Parameter digits/separators are consumed in a tight slice loop with parser state in locals instead of re-entering the per-byte path. **Result: +29-41% on escape-heavy streams.** 4. **skip style map update when SGR leaves style unchanged**. Skip the release/hash/probe/use churn when an SGR attribute is a no-op. **Result: +4-7% on TUI-refresh patterns, -2-3% on adversarial random-color streams** (tradeoff detailed in the commit message). This one is more questionable, but willing to measure on real workloads. ## Benchmarks Measured with `ghostty-bench +terminal-stream` (full terminal handler, 100 MB deterministic synthetic corpora, 120x80 terminal, M4 Max, macOS 26, ReleaseFast, hyperfine means of 10 runs, ~15 ms process startup included in all numbers). These are parser-stage numbers, not end-to-end app numbers. | stream | before | after | throughput | change | |----------------------------|--------|--------|------------------|--------| | ascii (no newlines) | 784 ms | 138 ms | 128 → 725 MB/s | 5.7x | | ascii lines | 833 ms | 198 ms | 120 → 505 MB/s | 4.2x | | unicode mixed-script | 779 ms | 320 ms | 128 → 313 MB/s | 2.4x | | CJK (all wide) | 424 ms | 126 ms | 236 → 794 MB/s | 3.4x | | unicode, mode 2027 on | 807 ms | 367 ms | 124 → 273 MB/s | 2.2x | | CJK, mode 2027 on | 495 ms | 198 ms | 202 → 505 MB/s | 2.5x | | csi mix (SGR/CUP/EL/modes) | 648 ms | 414 ms | 154 → 242 MB/s | 1.6x | | sgr fire (doom-fire-like) | 495 ms | 303 ms | 202 → 330 MB/s | 1.6x | | TUI redraw (repeat styles) | 642 ms | 291 ms | 156 → 344 MB/s | 2.2x | | osc | 8.26 s | 8.20 s | (untouched path) | ~1.0x | **End-to-end note:** #13209 measured the parse thread pegged while the gather thread used ~33% of a core, so parser gains of this size may make gather (or the renderer lock) the new bottleneck for plain text before the full 5.7x shows up end to end. I'll take a look at that soon... ## LLM Notes These findings were almost all found by Fable 5. I went through each change and simplified quite a lot, read every single line, re-ran verifications by hand. Fable in particular isn't good at writing elegant Zig code, so there's a lot of style stuff. Ultimately though, I understand all of this and feel comfortable with the changes.
This commit is contained in:
@@ -2,15 +2,13 @@
|
||||
//! handler from input to terminal state update. This is useful to
|
||||
//! test general throughput of VT parsing and handling.
|
||||
//!
|
||||
//! Note that the handler used for this benchmark isn't the full
|
||||
//! terminal handler, since that requires a significant amount of
|
||||
//! state. This is a simplified version that only handles specific
|
||||
//! terminal operations like printing characters. We should expand
|
||||
//! this to include more operations to improve the accuracy of the
|
||||
//! benchmark.
|
||||
//! This uses the full readonly terminal stream handler
|
||||
//! (terminal.TerminalStream) so every escape sequence updates real
|
||||
//! terminal state (styles, cursor movement, erases, modes, etc.).
|
||||
//! This closely mirrors the work done by the real IO thread.
|
||||
//!
|
||||
//! It is a fairly broad benchmark that can be used to determine
|
||||
//! if we need to optimize something more specific (e.g. the parser).
|
||||
//! For more isolated measurements see the terminal-parser and
|
||||
//! osc-parser benchmarks.
|
||||
const TerminalStream = @This();
|
||||
|
||||
const std = @import("std");
|
||||
@@ -20,13 +18,12 @@ const terminalpkg = @import("../terminal/main.zig");
|
||||
const Benchmark = @import("Benchmark.zig");
|
||||
const options = @import("options.zig");
|
||||
const Terminal = terminalpkg.Terminal;
|
||||
const Stream = terminalpkg.Stream(*Handler);
|
||||
const Stream = terminalpkg.TerminalStream;
|
||||
|
||||
const log = std.log.scoped(.@"terminal-stream-bench");
|
||||
|
||||
opts: Options,
|
||||
terminal: Terminal,
|
||||
handler: Handler,
|
||||
stream: Stream,
|
||||
|
||||
/// The file, opened in the setup function.
|
||||
@@ -61,14 +58,15 @@ pub fn create(
|
||||
.rows = opts.@"terminal-rows",
|
||||
.cols = opts.@"terminal-cols",
|
||||
}),
|
||||
.handler = .{ .t = &ptr.terminal },
|
||||
.stream = .init(&ptr.handler),
|
||||
.stream = undefined,
|
||||
};
|
||||
ptr.stream = .initAlloc(alloc, .init(&ptr.terminal));
|
||||
|
||||
return ptr;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *TerminalStream, alloc: Allocator) void {
|
||||
self.stream.deinit();
|
||||
self.terminal.deinit(alloc);
|
||||
alloc.destroy(self);
|
||||
}
|
||||
@@ -129,26 +127,6 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements the handler interface for the terminal.Stream.
|
||||
/// We should expand this to include more operations to make
|
||||
/// our benchmark more realistic.
|
||||
const Handler = struct {
|
||||
t: *Terminal,
|
||||
|
||||
pub fn vt(
|
||||
self: *Handler,
|
||||
comptime action: Stream.Action.Tag,
|
||||
value: Stream.Action.Value(action),
|
||||
) void {
|
||||
switch (action) {
|
||||
.print => self.t.print(value.cp) catch |err| {
|
||||
log.warn("error processing benchmark print err={}", .{err});
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test TerminalStream {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -1992,6 +1992,12 @@ pub fn setAttribute(
|
||||
.unknown => return,
|
||||
}
|
||||
|
||||
// If the attribute didn't change our style then we can skip the
|
||||
// style update entirely: our current style ID is already correct.
|
||||
// This is a common case in the wild where programs re-assert the
|
||||
// same style repeatedly (e.g. per span or per line).
|
||||
if (self.cursor.style.eql(old_style)) return;
|
||||
|
||||
try self.manualStyleUpdate();
|
||||
}
|
||||
|
||||
|
||||
@@ -321,6 +321,422 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print multiple codepoints to the terminal at once. This is
|
||||
/// semantically identical to calling `print` for each codepoint in
|
||||
/// order, but is much faster because it can batch cell writes and
|
||||
/// hoist per-codepoint checks out of the hot loop.
|
||||
///
|
||||
/// The codepoints must all be printable: it is illegal for any
|
||||
/// codepoint in this slice to be a C0 control character. Therefore,
|
||||
/// this should only be called as a result of a proper VT parser
|
||||
/// (like our own).
|
||||
///
|
||||
/// This is optimized for the common case: ASCII, soft-wrap, etc.
|
||||
/// Sequences of codepoints that require special handling (e.g. wide characters,
|
||||
/// grapheme clustering) are handled correctly but fall back to the
|
||||
/// slower per-codepoint path. They're less common and this is optimized
|
||||
/// for the aforementioned cases.
|
||||
pub fn printSlice(self: *Terminal, cps: []const u32) !void {
|
||||
var i: usize = 0;
|
||||
while (i < cps.len) {
|
||||
// Try the fast-path print first. This will return the number of
|
||||
// codepoints it consumed.
|
||||
const consumed = try self.printSliceFast(cps[i..]);
|
||||
if (consumed > 0) {
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Consuming zero bytes means that the fast path can't handle
|
||||
// the next codepoint or the terminal is in a state we can't
|
||||
// fast-path. Fall back to the slow cp-by-cp print then try
|
||||
// fast paths again.
|
||||
try self.print(@intCast(cps[i]));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to print a prefix of `cps` using a batched fast path that
|
||||
/// writes cells directly. Returns the number of codepoints consumed.
|
||||
/// A return value of zero means the caller must print the first
|
||||
/// codepoint via the normal `print` path.
|
||||
///
|
||||
/// The fast path handles runs of narrow (width 1) and wide (width 2)
|
||||
/// codepoints being written to simple cells. Everything else (zero
|
||||
/// width codepoints, grapheme cluster continuations, insert mode,
|
||||
/// charset mapping, hyperlinks, complex cells, etc.) is rejected so
|
||||
/// `print` can handle it with full generality.
|
||||
fn printSliceFast(self: *Terminal, cps: []const u32) !usize {
|
||||
// Only the main display is supported.
|
||||
if (self.status_display != .main) return 0;
|
||||
|
||||
// Modes that require per-codepoint handling in print(). Wraparound
|
||||
// is required (its the default) so that our row-fill logic below can
|
||||
// assume soft-wrap semantics. Insert mode shifts cells per print.
|
||||
if (self.modes.get(.insert)) return 0;
|
||||
if (!self.modes.get(.wraparound)) return 0;
|
||||
|
||||
const screen: *Screen = self.screens.active;
|
||||
|
||||
// Charset must map ASCII as-is (true unless a DEC special charset
|
||||
// is actively invoked, which is rare).
|
||||
if (screen.charset.single_shift != null) return 0;
|
||||
switch (screen.charset.charsets.get(screen.charset.gl)) {
|
||||
.utf8, .ascii => {},
|
||||
else => return 0,
|
||||
}
|
||||
|
||||
// Hyperlinks require per-cell map bookkeeping.
|
||||
if (screen.cursor.hyperlink_id != 0) return 0;
|
||||
|
||||
// Codepoints in [0x10, 0xFF] are always narrow (width 1, matching
|
||||
// the c <= 0xFF fast path in print) and can never interact with
|
||||
// grapheme clustering (which requires a codepoint > 0xFF).
|
||||
//
|
||||
// Codepoints above 0xFF are batchable if their width is 1 or 2
|
||||
// (excluding zero-width characters such as combining marks, ZWJ,
|
||||
// and variation selectors) and, when grapheme clustering (mode
|
||||
// 2027) is enabled, if they are a grapheme break from the
|
||||
// previously printed codepoint (so print would never attach them
|
||||
// to the previous cell).
|
||||
const grapheme_cluster = self.modes.get(.grapheme_cluster);
|
||||
|
||||
// When grapheme clustering is enabled and a left margin is set,
|
||||
// print() consults the cell left of the margin after wrapping,
|
||||
// which we can't reason about here. Restrict the fast path to
|
||||
// the [0x10, 0xFF] range in that case (those never cluster).
|
||||
const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0;
|
||||
|
||||
// Codepoints in [0x10, 0xFF] are always narrow: print()
|
||||
// hardcodes width 1 for c <= 0xFF (no width table lookup).
|
||||
// They also can never interact with grapheme clustering,
|
||||
// which print() only performs for c > 0xFF, so they're
|
||||
// immediately eligible for the narrow fill with no further
|
||||
// checks.
|
||||
const cp0 = cps[0];
|
||||
if (cp0 <= 0xFF) {
|
||||
// C0 control characters (0x00-0x0F) aren't printable. The
|
||||
// stream never sends these (they're routed to execute), but
|
||||
// printSlice is a public API so defer to print() for safety.
|
||||
if (cp0 < 0x10) return 0;
|
||||
return self.printSliceFill(
|
||||
.narrow,
|
||||
cps,
|
||||
grapheme_cluster,
|
||||
allow_unicode,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allow_unicode) return 0;
|
||||
if (comptime build_options.kitty_graphics) {
|
||||
// The Kitty graphics placeholder requires row bookkeeping.
|
||||
if (cp0 == kitty.graphics.unicode.placeholder) return 0;
|
||||
}
|
||||
|
||||
// The first codepoint requires care when grapheme clustering is
|
||||
// enabled: print() examines the previous *cell* which can hold
|
||||
// state (grapheme data) that we can't cheaply reason about here.
|
||||
// Note this includes the pending-wrap state: print() may attach
|
||||
// to the pending cell *instead of wrapping*. We only take the
|
||||
// first codepoint if the cursor is at column zero with no pending
|
||||
// wrap, where print() skips clustering entirely.
|
||||
if (grapheme_cluster) {
|
||||
if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0;
|
||||
}
|
||||
|
||||
// The width lookup is a runtime value while printSliceFill is
|
||||
// specialized at comptime by width class, so this switch selects
|
||||
// between the two instantiations rather than passing the width
|
||||
// through as an argument.
|
||||
return switch (unicode.table.get(@intCast(cp0)).width) {
|
||||
1 => self.printSliceFill(
|
||||
.narrow,
|
||||
cps,
|
||||
grapheme_cluster,
|
||||
allow_unicode,
|
||||
),
|
||||
2 => self.printSliceFill(
|
||||
.wide,
|
||||
cps,
|
||||
grapheme_cluster,
|
||||
allow_unicode,
|
||||
),
|
||||
else => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// The width class of a printSlice batch. Each batch contains only
|
||||
/// codepoints of a single width class because they fill cells
|
||||
/// differently: wide codepoints occupy a (wide, spacer_tail) cell
|
||||
/// pair while narrow codepoints occupy a single cell.
|
||||
const PrintSliceWidth = enum(u1) {
|
||||
narrow,
|
||||
wide,
|
||||
|
||||
/// The number of cells each codepoint of this width class occupies.
|
||||
fn cellsPerCp(comptime self: PrintSliceWidth) usize {
|
||||
return switch (self) {
|
||||
.narrow => 1,
|
||||
.wide => 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Whether a codepoint above 0xFF is eligible for the batched print
|
||||
/// fast path with the given width class.
|
||||
inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool {
|
||||
assert(cp > 0xFF);
|
||||
if (comptime build_options.kitty_graphics) {
|
||||
if (cp == kitty.graphics.unicode.placeholder) return false;
|
||||
}
|
||||
|
||||
return unicode.table.get(@intCast(cp)).width == comptime @as(u2, switch (width) {
|
||||
.narrow => 1,
|
||||
.wide => 2,
|
||||
});
|
||||
}
|
||||
|
||||
/// The row-filling portion of the printSlice fast path, specialized by
|
||||
/// width class. The first codepoint must already be validated by the
|
||||
/// caller (printSliceFast).
|
||||
fn printSliceFill(
|
||||
self: *Terminal,
|
||||
comptime width: PrintSliceWidth,
|
||||
cps: []const u32,
|
||||
grapheme_cluster: bool,
|
||||
allow_unicode: bool,
|
||||
) !usize {
|
||||
const screen: *Screen = self.screens.active;
|
||||
|
||||
// Our fast path can only handle "simple" cells. A simple cell is
|
||||
// a codepoint cell (no grapheme data or bg-color tag), narrow, and
|
||||
// not a hyperlink. The mask covers every field that must match
|
||||
// the expected value (see printSliceCheckExpected) exactly.
|
||||
const simple_mask = comptime fieldMask(Cell, &.{
|
||||
"content_tag",
|
||||
"style_id",
|
||||
"wide",
|
||||
"hyperlink",
|
||||
});
|
||||
|
||||
// The bit offset of the codepoint content within a Cell, used to
|
||||
// construct cell values from a template without field assignments.
|
||||
const cp_shift = @bitOffsetOf(Cell, "content");
|
||||
|
||||
// Determine the run of codepoints in the same width class that we
|
||||
// can batch. For codepoints after the first, the previous codepoint
|
||||
// in the run is always written as a fresh, single-codepoint cell,
|
||||
// so the grapheme break check against it is exact.
|
||||
const run_len: usize = run: {
|
||||
for (1..cps.len) |idx| {
|
||||
const cp = cps[idx];
|
||||
if (comptime width == .narrow) {
|
||||
if (cp >= 0x10 and cp <= 0xFF) continue;
|
||||
}
|
||||
if (cp > 0xFF and allow_unicode and printSliceEligible(cp, width)) {
|
||||
if (!grapheme_cluster) continue;
|
||||
var state: uucode.grapheme.BreakState = .default;
|
||||
if (unicode.graphemeBreak(@intCast(cps[idx - 1]), @intCast(cp), &state)) continue;
|
||||
}
|
||||
break :run idx;
|
||||
}
|
||||
break :run cps.len;
|
||||
};
|
||||
assert(run_len > 0);
|
||||
|
||||
// After doing any printing, wrapping, scrolling, etc. we want to
|
||||
// ensure that our screen remains in a consistent state.
|
||||
defer screen.assertIntegrity();
|
||||
|
||||
// The number of cells each codepoint occupies.
|
||||
const cells_per_cp: usize = comptime width.cellsPerCp();
|
||||
|
||||
var printed: usize = 0;
|
||||
outer: while (printed < run_len) {
|
||||
// If we're soft-wrapping, handle that first so that our cursor
|
||||
// is in the row/column that will receive the next codepoint.
|
||||
if (screen.cursor.pending_wrap) try self.printWrap();
|
||||
|
||||
// Our right margin depends on where our cursor is now,
|
||||
// matching the logic in print().
|
||||
const right_limit: usize = if (screen.cursor.x > self.scrolling_region.right)
|
||||
self.cols
|
||||
else
|
||||
self.scrolling_region.right + 1;
|
||||
|
||||
// A degenerate 1-wide region can't hold a wide char; print()
|
||||
// has special handling so fall back to it.
|
||||
if (comptime width == .wide) {
|
||||
if (right_limit - self.scrolling_region.left <= 1) break;
|
||||
}
|
||||
|
||||
const cursor = &screen.cursor;
|
||||
const avail: usize = right_limit - cursor.x;
|
||||
assert(avail > 0);
|
||||
|
||||
const page = &cursor.page_pin.node.data;
|
||||
const cells: [*]Cell = @ptrCast(cursor.page_cell);
|
||||
const style_id = cursor.style_id;
|
||||
const template: Cell = .{
|
||||
.content_tag = .codepoint,
|
||||
.content = .{ .codepoint = 0 },
|
||||
.style_id = style_id,
|
||||
.wide = .narrow,
|
||||
.protected = cursor.protected,
|
||||
.semantic_content = cursor.semantic_content,
|
||||
};
|
||||
const template_bits: u64 = @bitCast(template);
|
||||
const check_expected: u64 = printSliceCheckExpected(style_id);
|
||||
|
||||
if (comptime width == .wide) {
|
||||
if (avail == 1) {
|
||||
// Only one cell left in the row: print() writes a
|
||||
// spacer head (or a blank narrow cell if we're inside
|
||||
// a right margin) and wraps. We require a simple cell,
|
||||
// otherwise fall back to print() for the cleanup.
|
||||
const bits: u64 = @bitCast(cells[0]);
|
||||
if ((bits & simple_mask) != check_expected) break;
|
||||
|
||||
var spacer = template;
|
||||
if (right_limit == self.cols) {
|
||||
cursor.page_row.wrap = true;
|
||||
spacer.wide = .spacer_head;
|
||||
}
|
||||
cursor.page_row.dirty = true;
|
||||
if (style_id != style.default_id) cursor.page_row.styled = true;
|
||||
cells[0] = spacer;
|
||||
try self.printWrap();
|
||||
continue :outer;
|
||||
}
|
||||
}
|
||||
|
||||
// Number of codepoints and cells we're writing to this row.
|
||||
const count = @min(avail / cells_per_cp, run_len - printed);
|
||||
assert(count > 0);
|
||||
const cell_count = count * cells_per_cp;
|
||||
|
||||
// Wide cells always come in (wide, spacer_tail) pairs.
|
||||
const spacer_bits: u64 = if (comptime width == .wide) spacer: {
|
||||
var spacer = template;
|
||||
spacer.wide = .spacer_tail;
|
||||
break :spacer @bitCast(spacer);
|
||||
} else undefined;
|
||||
const wide_bits: u64 = if (comptime width == .wide) wb: {
|
||||
var w = template;
|
||||
w.wide = .wide;
|
||||
break :wb @bitCast(w);
|
||||
} else undefined;
|
||||
|
||||
var k: usize = 0; // cells written
|
||||
fill: while (k < cell_count) {
|
||||
// Find the run of simple cells so the store loop below is
|
||||
// branch-free (and vectorizable).
|
||||
var simple = k;
|
||||
while (simple < cell_count) : (simple += 1) {
|
||||
const bits: u64 = @bitCast(cells[simple]);
|
||||
if ((bits & simple_mask) != check_expected) break;
|
||||
}
|
||||
|
||||
if (comptime width == .wide) {
|
||||
// We can only write whole (wide, spacer) pairs.
|
||||
const pair_end = k + (simple - k) / 2 * 2;
|
||||
var idx = k;
|
||||
while (idx < pair_end) : (idx += 2) {
|
||||
cells[idx] = @bitCast(
|
||||
wide_bits | (@as(u64, cps[printed + idx / 2]) << cp_shift),
|
||||
);
|
||||
cells[idx + 1] = @bitCast(spacer_bits);
|
||||
}
|
||||
// If the simple run ended mid-pair we stop at the pair
|
||||
// boundary and handle the offending cell below.
|
||||
k = pair_end;
|
||||
if (simple != pair_end) {
|
||||
// The first cell of the next pair is simple but the
|
||||
// second isn't; handle both via the general path.
|
||||
simple = pair_end;
|
||||
}
|
||||
} else {
|
||||
for (k..simple) |idx| {
|
||||
cells[idx] = @bitCast(
|
||||
template_bits | (@as(u64, cps[printed + idx]) << cp_shift),
|
||||
);
|
||||
}
|
||||
k = simple;
|
||||
}
|
||||
if (k >= cell_count) break;
|
||||
|
||||
// General path for cells that failed the masked check:
|
||||
// style-only mismatches are handled inline; anything that
|
||||
// needs cleanup (wide chars and their spacers, grapheme
|
||||
// data, hyperlinks) falls back to print().
|
||||
const general_count: usize = cells_per_cp;
|
||||
for (0..general_count) |offset| {
|
||||
const cell = &cells[k + offset];
|
||||
if (cell.wide != .narrow or
|
||||
cell.hasGrapheme() or
|
||||
cell.hyperlink) break :fill;
|
||||
}
|
||||
for (0..general_count) |offset| {
|
||||
const cell = &cells[k + offset];
|
||||
if (cell.style_id != style_id) {
|
||||
if (cell.style_id != style.default_id) {
|
||||
page.styles.release(page.memory, cell.style_id);
|
||||
}
|
||||
if (style_id != style.default_id) {
|
||||
page.styles.use(page.memory, style_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (comptime width == .wide) {
|
||||
cells[k] = @bitCast(
|
||||
wide_bits | (@as(u64, cps[printed + k / 2]) << cp_shift),
|
||||
);
|
||||
cells[k + 1] = @bitCast(spacer_bits);
|
||||
} else {
|
||||
cells[k] = @bitCast(
|
||||
template_bits | (@as(u64, cps[printed + k]) << cp_shift),
|
||||
);
|
||||
}
|
||||
k += cells_per_cp;
|
||||
}
|
||||
|
||||
if (k > 0) {
|
||||
assert(k % cells_per_cp == 0);
|
||||
cursor.page_row.dirty = true;
|
||||
if (style_id != style.default_id) cursor.page_row.styled = true;
|
||||
self.previous_char = @intCast(cps[printed + k / cells_per_cp - 1]);
|
||||
printed += k / cells_per_cp;
|
||||
|
||||
// Advance the cursor. If we filled through the right limit
|
||||
// then the cursor stays on the last cell with the pending
|
||||
// wrap flag set, matching print().
|
||||
if (cursor.x + k >= right_limit) {
|
||||
assert(cursor.x + k == right_limit);
|
||||
screen.cursorRight(@intCast(k - 1));
|
||||
cursor.pending_wrap = true;
|
||||
} else {
|
||||
screen.cursorRight(@intCast(k));
|
||||
}
|
||||
}
|
||||
|
||||
// We hit a cell that requires the slow path. The cursor is
|
||||
// exactly at that cell so return and let the caller print the
|
||||
// next codepoint via print().
|
||||
if (k < cell_count) break;
|
||||
}
|
||||
|
||||
return printed;
|
||||
}
|
||||
|
||||
/// The expected value of a simple cell (per the check mask built from
|
||||
/// fieldMask in printSliceFill) that already has the given style
|
||||
/// (so no ref-counting is needed).
|
||||
inline fn printSliceCheckExpected(style_id: style.Id) u64 {
|
||||
var e: Cell = @bitCast(@as(u64, 0));
|
||||
e.style_id = style_id;
|
||||
return @bitCast(e);
|
||||
}
|
||||
|
||||
pub fn print(self: *Terminal, c: u21) !void {
|
||||
// log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x });
|
||||
|
||||
@@ -3202,6 +3618,30 @@ fn clearDirty(t: *Terminal) void {
|
||||
t.screens.active.pages.clearDirty();
|
||||
}
|
||||
|
||||
/// Returns a mask with all bits set for the given fields of the packed
|
||||
/// struct T, used for masked compares of raw backing-integer values.
|
||||
fn fieldMask(
|
||||
comptime T: type,
|
||||
comptime fields: []const []const u8,
|
||||
) @typeInfo(T).@"struct".backing_integer.? {
|
||||
// Backing int of the packed struct
|
||||
const Int = @typeInfo(T).@"struct".backing_integer.?;
|
||||
|
||||
var mask: Int = 0;
|
||||
inline for (fields) |field| {
|
||||
// The type that fits all the bits we need to set.
|
||||
const Ones = std.meta.Int(
|
||||
.unsigned,
|
||||
@bitSizeOf(@FieldType(T, field)),
|
||||
);
|
||||
|
||||
// Mask out the ones
|
||||
mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field);
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
test "Terminal: input with no control characters" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 40, .rows = 40 });
|
||||
@@ -11592,6 +12032,241 @@ test "Terminal: printRepeat no previous character" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: printSlice simple ascii" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' });
|
||||
try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x);
|
||||
try testing.expectEqual(@as(u21, 'o'), t.previous_char.?);
|
||||
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("hello", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: printSlice wraps and scrolls" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more.
|
||||
try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l' });
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("fghij\nkl", str);
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
|
||||
try testing.expect(!t.screens.active.cursor.pending_wrap);
|
||||
}
|
||||
|
||||
test "Terminal: printSlice pending wrap state" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' });
|
||||
try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x);
|
||||
try testing.expect(t.screens.active.cursor.pending_wrap);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("abcde", str);
|
||||
}
|
||||
}
|
||||
|
||||
/// Differential testing helper: applies the same logical print
|
||||
/// operations to two terminals, one using per-codepoint print() and
|
||||
/// the other using printSlice() with random chunking, verifying that
|
||||
/// the results are identical.
|
||||
fn testPrintSliceDifferential(
|
||||
alloc: Allocator,
|
||||
rand: std.Random,
|
||||
ops: usize,
|
||||
cols: size.CellCountInt,
|
||||
rows: size.CellCountInt,
|
||||
) !void {
|
||||
var t1 = try init(alloc, .{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
});
|
||||
defer t1.deinit(alloc);
|
||||
var t2 = try init(alloc, .{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
});
|
||||
defer t2.deinit(alloc);
|
||||
|
||||
// Alphabet of interesting codepoints: ascii, latin-1, combining
|
||||
// marks, CJK (wide), emoji (wide), ZWJ, variation selectors.
|
||||
const alphabet = [_]u21{
|
||||
'a', 'b', 'Z', '0', ' ', 0x10, 0x1F, 0x7F,
|
||||
'é', 0xFF, 0x301, 0x4E00, 0x4E01, 0x1F600, 0x200D, 0xFE0F,
|
||||
'x', 'y', 0x1F9D1, 0x0308, 0xAD, 0x3042, 0xAC00, 'q',
|
||||
'r', 's', 't', 'u', 'v', 'w', '1', '2',
|
||||
0x1F1E6, 0x1F1E7, 0x1100, 0x1161, 0x11A8, 0x200C, 0x0430, 0x03B1,
|
||||
};
|
||||
|
||||
var cps_buf: [64]u32 = undefined;
|
||||
var last_n: usize = 0;
|
||||
|
||||
for (0..ops) |_| {
|
||||
switch (rand.intRangeAtMost(u8, 0, 20)) {
|
||||
// Print a run of codepoints (most common op).
|
||||
0...9 => {
|
||||
const n = rand.intRangeAtMost(usize, 1, cps_buf.len);
|
||||
last_n = n;
|
||||
for (cps_buf[0..n]) |*cp| {
|
||||
cp.* = alphabet[rand.intRangeLessThan(usize, 0, alphabet.len)];
|
||||
}
|
||||
|
||||
// t1: per-codepoint print
|
||||
for (cps_buf[0..n]) |cp| try t1.print(@intCast(cp));
|
||||
|
||||
// t2: printSlice with random chunking
|
||||
var i: usize = 0;
|
||||
while (i < n) {
|
||||
const chunk = rand.intRangeAtMost(usize, 1, n - i);
|
||||
try t2.printSlice(cps_buf[i..][0..chunk]);
|
||||
i += chunk;
|
||||
}
|
||||
},
|
||||
10 => {
|
||||
t1.carriageReturn();
|
||||
t2.carriageReturn();
|
||||
try t1.linefeed();
|
||||
try t2.linefeed();
|
||||
},
|
||||
11 => {
|
||||
const row = rand.intRangeAtMost(usize, 1, rows);
|
||||
const col = rand.intRangeAtMost(usize, 1, cols);
|
||||
t1.setCursorPos(row, col);
|
||||
t2.setCursorPos(row, col);
|
||||
},
|
||||
12 => {
|
||||
const attr: sgr.Attribute = switch (rand.intRangeAtMost(u8, 0, 3)) {
|
||||
0 => .{ .unset = {} },
|
||||
1 => .{ .bold = {} },
|
||||
2 => .{ .direct_color_fg = .{
|
||||
.r = rand.int(u8),
|
||||
.g = rand.int(u8),
|
||||
.b = rand.int(u8),
|
||||
} },
|
||||
3 => .{ .@"8_fg" = .red },
|
||||
else => unreachable,
|
||||
};
|
||||
try t1.setAttribute(attr);
|
||||
try t2.setAttribute(attr);
|
||||
},
|
||||
13 => {
|
||||
const v = rand.boolean();
|
||||
t1.modes.set(.insert, v);
|
||||
t2.modes.set(.insert, v);
|
||||
},
|
||||
14 => {
|
||||
const v = rand.boolean();
|
||||
t1.modes.set(.wraparound, v);
|
||||
t2.modes.set(.wraparound, v);
|
||||
},
|
||||
15 => {
|
||||
const v = rand.boolean();
|
||||
// Erase the display first: grapheme clusters created
|
||||
// while mode 2027 was off can trip a pre-existing
|
||||
// debug assert in print()'s cluster walk when the mode
|
||||
// is toggled on (unrelated to printSlice; it reproduces
|
||||
// with per-codepoint print alone).
|
||||
t1.eraseDisplay(.complete, false);
|
||||
t2.eraseDisplay(.complete, false);
|
||||
t1.modes.set(.grapheme_cluster, v);
|
||||
t2.modes.set(.grapheme_cluster, v);
|
||||
},
|
||||
16 => {
|
||||
// Margins.
|
||||
t1.modes.set(.enable_left_and_right_margin, true);
|
||||
t2.modes.set(.enable_left_and_right_margin, true);
|
||||
const left = rand.intRangeAtMost(usize, 1, cols / 2);
|
||||
const right = rand.intRangeAtMost(usize, cols / 2, cols);
|
||||
t1.setLeftAndRightMargin(left, right);
|
||||
t2.setLeftAndRightMargin(left, right);
|
||||
},
|
||||
17 => {
|
||||
t1.setLeftAndRightMargin(0, 0);
|
||||
t2.setLeftAndRightMargin(0, 0);
|
||||
},
|
||||
18 => {
|
||||
try t1.screens.active.startHyperlink("http://example.com", null);
|
||||
try t2.screens.active.startHyperlink("http://example.com", null);
|
||||
},
|
||||
19 => {
|
||||
t1.screens.active.endHyperlink();
|
||||
t2.screens.active.endHyperlink();
|
||||
},
|
||||
20 => {
|
||||
const set: charsets.Charset = if (rand.boolean())
|
||||
.dec_special
|
||||
else
|
||||
.utf8;
|
||||
t1.configureCharset(.G0, set);
|
||||
t2.configureCharset(.G0, set);
|
||||
},
|
||||
else => unreachable,
|
||||
}
|
||||
|
||||
// Cursor state must match exactly after every op.
|
||||
try testing.expectEqual(t1.screens.active.cursor.x, t2.screens.active.cursor.x);
|
||||
try testing.expectEqual(t1.screens.active.cursor.y, t2.screens.active.cursor.y);
|
||||
try testing.expectEqual(
|
||||
t1.screens.active.cursor.pending_wrap,
|
||||
t2.screens.active.cursor.pending_wrap,
|
||||
);
|
||||
|
||||
// Full screen contents must match after every op. On failure,
|
||||
// dump diagnostics that make the failure reproducible.
|
||||
{
|
||||
const str1 = try t1.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer alloc.free(str1);
|
||||
const str2 = try t2.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer alloc.free(str2);
|
||||
testing.expectEqualStrings(str1, str2) catch |err| {
|
||||
std.debug.print("last print cps: {any}\n", .{cps_buf[0..last_n]});
|
||||
std.debug.print("modes: 2027={} insert={} wrap={} sr.left={} sr.right={} cols={}\n", .{
|
||||
t1.modes.get(.grapheme_cluster),
|
||||
t1.modes.get(.insert),
|
||||
t1.modes.get(.wraparound),
|
||||
t1.scrolling_region.left,
|
||||
t1.scrolling_region.right,
|
||||
cols,
|
||||
});
|
||||
return err;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Page integrity (styles refcounts, grapheme maps, etc.) must hold.
|
||||
try t1.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc);
|
||||
try t2.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc);
|
||||
}
|
||||
|
||||
test "Terminal: printSlice differential fuzz vs print" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Multiple seeds and terminal sizes for coverage, including a
|
||||
// tiny terminal to stress wrap/scroll edge cases.
|
||||
var prng = std.Random.DefaultPrng.init(0xC0FFEE);
|
||||
const rand = prng.random();
|
||||
try testPrintSliceDifferential(alloc, rand, 500, 80, 24);
|
||||
try testPrintSliceDifferential(alloc, rand, 500, 10, 4);
|
||||
try testPrintSliceDifferential(alloc, rand, 500, 5, 2);
|
||||
try testPrintSliceDifferential(alloc, rand, 200, 2, 2);
|
||||
}
|
||||
|
||||
test "Terminal: printAttributes" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
|
||||
|
||||
@@ -33,6 +33,7 @@ const debug = false;
|
||||
/// function for handling.
|
||||
pub const Action = union(Key) {
|
||||
print: Print,
|
||||
print_slice: PrintSlice,
|
||||
print_repeat: usize,
|
||||
bell,
|
||||
backspace,
|
||||
@@ -130,6 +131,7 @@ pub const Action = union(Key) {
|
||||
lib.target,
|
||||
&.{
|
||||
"print",
|
||||
"print_slice",
|
||||
"print_repeat",
|
||||
"bell",
|
||||
"backspace",
|
||||
@@ -252,6 +254,27 @@ pub const Action = union(Key) {
|
||||
}
|
||||
};
|
||||
|
||||
/// A run of printable codepoints. This is emitted instead of
|
||||
/// individual print actions when the stream can decode multiple
|
||||
/// printable codepoints at once, so handlers can process them in
|
||||
/// batch with per-run rather than per-codepoint overhead (see
|
||||
/// Terminal.printSlice). A naive handler can simply loop and
|
||||
/// handle each codepoint like a print action.
|
||||
///
|
||||
/// The slice is only valid for the duration of the handler call.
|
||||
pub const PrintSlice = struct {
|
||||
cps: []const u32,
|
||||
|
||||
pub const C = extern struct {
|
||||
cps: [*]const u32,
|
||||
len: usize,
|
||||
};
|
||||
|
||||
pub fn cval(self: PrintSlice) PrintSlice.C {
|
||||
return .{ .cps = self.cps.ptr, .len = self.cps.len };
|
||||
}
|
||||
};
|
||||
|
||||
pub const InvokeCharset = lib.Struct(lib.target, struct {
|
||||
bank: charsets.ActiveSlot,
|
||||
charset: charsets.Slots,
|
||||
@@ -411,6 +434,11 @@ pub const Action = union(Key) {
|
||||
/// about in its pursuit of implementing a terminal emulator or other
|
||||
/// functionality.
|
||||
///
|
||||
/// Note that printable text is delivered via `print_slice` actions
|
||||
/// (runs of codepoints) whenever the stream can decode multiple
|
||||
/// codepoints at once, and via `print` actions otherwise. Handlers
|
||||
/// that care about text must handle both.
|
||||
///
|
||||
/// The Handler type must also have a `deinit` function.
|
||||
///
|
||||
/// The "comptime" key is on purpose (vs. a standard Zig tagged union)
|
||||
@@ -521,12 +549,25 @@ pub fn Stream(comptime H: type) type {
|
||||
// up to that point are just UTF-8.
|
||||
while (self.parser.state == .ground and offset < input.len) {
|
||||
const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf);
|
||||
for (cp_buf[0..res.decoded]) |cp| {
|
||||
const cps = cp_buf[0..res.decoded];
|
||||
|
||||
// Hand runs of printable codepoints to the handler as
|
||||
// print_slice actions so it can process them with
|
||||
// per-run rather than per-codepoint overhead.
|
||||
var i: usize = 0;
|
||||
while (i < cps.len) {
|
||||
const cp = cps[i];
|
||||
if (cp <= 0xF) {
|
||||
@branchHint(.unlikely);
|
||||
self.execute(@intCast(cp));
|
||||
} else {
|
||||
self.print(@intCast(cp));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var end = i + 1;
|
||||
while (end < cps.len and cps[end] > 0xF) end += 1;
|
||||
self.handler.vt(.print_slice, .{ .cps = cps[i..end] });
|
||||
i = end;
|
||||
}
|
||||
// Consume the bytes we just processed.
|
||||
offset += res.consumed;
|
||||
@@ -570,12 +611,88 @@ pub fn Stream(comptime H: type) type {
|
||||
var offset: usize = 0;
|
||||
while (self.parser.state != .ground) {
|
||||
if (offset >= input.len) return input.len;
|
||||
|
||||
// Bulk-consume CSI parameter bytes. This can't be used
|
||||
// for handlers with a vtRaw hook because it dispatches
|
||||
// the CSI directly (see nextNonUtf8).
|
||||
if (comptime !@hasDecl(T, "vtRaw")) {
|
||||
if (self.parser.state == .csi_param) {
|
||||
offset += self.consumeCsiParams(input[offset..]);
|
||||
if (offset >= input.len) return input.len;
|
||||
// If we're still in csi_param then the next byte
|
||||
// isn't a parameter byte; let nextNonUtf8 below
|
||||
// handle it. Otherwise re-check our state.
|
||||
if (self.parser.state != .csi_param) continue;
|
||||
}
|
||||
}
|
||||
|
||||
self.nextNonUtf8(input[offset]);
|
||||
offset += 1;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// Bulk-consume CSI parameter bytes (digits and separators)
|
||||
/// and, if reached, the final byte (dispatching the CSI).
|
||||
/// Returns the number of bytes consumed. Stops at the first
|
||||
/// byte that isn't handled here, leaving the parser in the
|
||||
/// csi_param state so the caller can process that byte.
|
||||
fn consumeCsiParams(self: *Self, input: []const u8) usize {
|
||||
const p = &self.parser;
|
||||
assert(p.state == .csi_param);
|
||||
|
||||
// Accumulate parser state in locals for the hot loop.
|
||||
var acc = p.param_acc;
|
||||
var acc_idx = p.param_acc_idx;
|
||||
var idx = p.params_idx;
|
||||
|
||||
var offset: usize = 0;
|
||||
while (offset < input.len) {
|
||||
const c = input[offset];
|
||||
switch (c) {
|
||||
// A parameter digit.
|
||||
'0'...'9' => {
|
||||
if (idx < Parser.MAX_PARAMS) {
|
||||
acc *|= 10;
|
||||
acc +|= c - '0';
|
||||
acc_idx |= 1;
|
||||
}
|
||||
offset += 1;
|
||||
},
|
||||
|
||||
// A parameter separator.
|
||||
':', ';' => {
|
||||
if (idx < Parser.MAX_PARAMS) {
|
||||
p.params[idx] = acc;
|
||||
if (c == ':') p.params_sep.set(idx);
|
||||
idx += 1;
|
||||
acc = 0;
|
||||
acc_idx = 0;
|
||||
}
|
||||
offset += 1;
|
||||
},
|
||||
|
||||
// A final byte: dispatch the CSI.
|
||||
0x40...0x7E => {
|
||||
p.param_acc = acc;
|
||||
p.param_acc_idx = acc_idx;
|
||||
p.params_idx = idx;
|
||||
self.csiDispatchFinal(c);
|
||||
return offset + 1;
|
||||
},
|
||||
|
||||
// Anything else (C0 controls, intermediates, etc.)
|
||||
// is handled by the caller.
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
|
||||
p.param_acc = acc;
|
||||
p.param_acc_idx = acc_idx;
|
||||
p.params_idx = idx;
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// Like nextSlice but takes one byte and is necessarily a scalar
|
||||
/// operation that can't use SIMD. Prefer nextSlice if you can and
|
||||
/// try to get multiple bytes at once.
|
||||
@@ -654,6 +771,13 @@ pub fn Stream(comptime H: type) type {
|
||||
self.parser.state = .csi_entry;
|
||||
return;
|
||||
}
|
||||
|
||||
// The fast paths below dispatch actions directly rather than
|
||||
// going through Parser.next, so they'd bypass a handler's
|
||||
// vtRaw hook. Handlers with vtRaw (e.g. the inspector) use
|
||||
// the general path for anything that produces an action.
|
||||
const has_vt_raw = comptime @hasDecl(T, "vtRaw");
|
||||
|
||||
// Fast path for CSI params.
|
||||
if (self.parser.state == .csi_param) csi_param: {
|
||||
// csi_param is the most common parser state
|
||||
@@ -690,6 +814,10 @@ pub fn Stream(comptime H: type) type {
|
||||
self.parser.param_acc = 0;
|
||||
self.parser.param_acc_idx = 0;
|
||||
},
|
||||
// A final byte: dispatch the CSI directly.
|
||||
0x40...0x7E => if (comptime !has_vt_raw) {
|
||||
self.csiDispatchFinal(c);
|
||||
} else break :csi_param,
|
||||
// Explicitly ignored:
|
||||
0x7F => {},
|
||||
// Defer to the state machine to
|
||||
@@ -699,6 +827,42 @@ pub fn Stream(comptime H: type) type {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast path for CSI entry, the state right after "ESC [".
|
||||
// Virtually every CSI sequence spends exactly one byte in
|
||||
// this state, on either a digit, a private marker, or a
|
||||
// final byte.
|
||||
if (comptime !has_vt_raw) {
|
||||
if (self.parser.state == .csi_entry) csi_entry: {
|
||||
switch (c) {
|
||||
// First parameter digit.
|
||||
'0'...'9' => {
|
||||
self.parser.state = .csi_param;
|
||||
// param_acc is zero (cleared on escape entry)
|
||||
// so accumulating is just the digit value.
|
||||
self.parser.param_acc = c - '0';
|
||||
self.parser.param_acc_idx = 1;
|
||||
},
|
||||
// An empty first parameter.
|
||||
';' => {
|
||||
self.parser.state = .csi_param;
|
||||
self.parser.params[0] = 0;
|
||||
self.parser.params_idx = 1;
|
||||
},
|
||||
// Private marker (e.g. '?' in "ESC [ ? 2004 h").
|
||||
0x3C...0x3F => {
|
||||
self.parser.state = .csi_param;
|
||||
self.parser.collect(c);
|
||||
},
|
||||
// A final byte: a parameterless CSI.
|
||||
0x40...0x7E => self.csiDispatchFinal(c),
|
||||
// Defer to the state machine for anything else
|
||||
// (C0 controls, intermediates, colon).
|
||||
else => break :csi_entry,
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// We explicitly inline this call here for performance reasons.
|
||||
//
|
||||
// We do this rather than mark Parser.next as inline because doing
|
||||
@@ -744,6 +908,48 @@ pub fn Stream(comptime H: type) type {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize and dispatch a CSI directly from parser state for
|
||||
/// the fast paths in nextNonUtf8, without going through
|
||||
/// Parser.next. This must match the behavior of the parser's
|
||||
/// csi_dispatch action.
|
||||
fn csiDispatchFinal(self: *Self, c: u8) void {
|
||||
const p = &self.parser;
|
||||
p.state = .ground;
|
||||
|
||||
// Ignore sequences with too many parameters, matching the
|
||||
// parser's behavior of dropping the dispatch entirely.
|
||||
if (p.params_idx >= Parser.MAX_PARAMS) {
|
||||
@branchHint(.unlikely);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize the last parameter if we have one.
|
||||
if (p.param_acc_idx > 0) {
|
||||
p.params[p.params_idx] = p.param_acc;
|
||||
p.params_idx += 1;
|
||||
}
|
||||
|
||||
const action: Parser.Action.CSI = .{
|
||||
.intermediates = p.intermediates[0..p.intermediates_idx],
|
||||
.params = p.params[0..p.params_idx],
|
||||
.params_sep = p.params_sep,
|
||||
.final = c,
|
||||
};
|
||||
|
||||
// We only allow colon or mixed separators for the 'm' command.
|
||||
if (c != 'm' and p.params_sep.count() > 0) {
|
||||
@branchHint(.cold);
|
||||
log.warn(
|
||||
"CSI colon or mixed separators only allowed for 'm' command, got: {f}",
|
||||
.{action},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (comptime debug) log.info("action: {f}", .{Parser.Action{ .csi_dispatch = action }});
|
||||
self.csiDispatch(action);
|
||||
}
|
||||
|
||||
inline fn print(self: *Self, c: u21) void {
|
||||
self.handler.vt(.print, .{ .cp = c });
|
||||
}
|
||||
@@ -2415,6 +2621,7 @@ test "simd: print invalid utf-8" {
|
||||
) void {
|
||||
switch (action) {
|
||||
.print => self.c = value.cp,
|
||||
.print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
@@ -2436,6 +2643,7 @@ test "simd: complete incomplete utf-8" {
|
||||
) void {
|
||||
switch (action) {
|
||||
.print => self.c = value.cp,
|
||||
.print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ pub const Handler = struct {
|
||||
) !void {
|
||||
switch (action) {
|
||||
.print => try self.terminal.print(value.cp),
|
||||
.print_slice => try self.terminal.printSlice(value.cps),
|
||||
.print_repeat => try self.terminal.printRepeat(value),
|
||||
.backspace => self.terminal.backspace(),
|
||||
.carriage_return => self.terminal.carriageReturn(),
|
||||
|
||||
@@ -205,6 +205,10 @@ pub const StreamHandler = struct {
|
||||
@branchHint(.likely);
|
||||
try self.terminal.print(value.cp);
|
||||
},
|
||||
.print_slice => {
|
||||
@branchHint(.likely);
|
||||
try self.terminal.printSlice(value.cps);
|
||||
},
|
||||
.print_repeat => try self.terminal.printRepeat(value),
|
||||
.bell => self.bell(),
|
||||
.backspace => self.terminal.backspace(),
|
||||
|
||||
Reference in New Issue
Block a user