Convert terminal.Stream to use a tagged union, remove `hasDecl` (#9342)

This removes our `@hasDecl` usage from `terminal.Stream` and instead
uses a tagged union approach similar to what we already do for apprt
actions. The reasons to do this:

1. It is less magic. You don't get new functionality by magically
implementing a function.
2. It is safer. You can't typo a function name and Zig's exhaustive enum
handling will force you to handle all cases (even if most cases are
no-ops). This also helps you as at the implementor know when new
functionality pops up.
3. It is easier to integrate into C (for libghostty-vt). We can expose a
single tagged union type with a single callback rather than whatever the
previous mess was. This PR doesn't do this yet.

In addition, this PR adds in some helpers necessary to make it easier to
make C ABI compatible tagged unions. This lays the groundwork for our
libghostty-vt work but isn't exposed directly there yet. This PR has no
functional changes. Everything should behave identically as before.

I'm PRing this now because its already a huge diff, and I want to get
this in before I make more meaningful changes such as exposing some of
this to libghostty or adding a simpler Stream handler that maps to
terminal state for the Zig module and so on.

## Benchmarks

There's no meaningful impact on VT processing, I'd say all changes seen
below are noise:

<img width="2038" height="1392" alt="CleanShot 2025-10-25 at 07 10
04@2x"
src="https://github.com/user-attachments/assets/af6fa611-5b35-44d0-91ae-26955b1f980a"
/>

## One more `@hasDecl`

There is one more `hasDecl` remaining for `handleManually`. This is a
special case that's only used by our inspector. I think there is a
better way to do this but I didn't want to bloat this PR with anything
more! This doesn't impact our primary consumers of stream.

## AI Disclosure

I used AI considerably in handling the rote tasks in refactoring this. I
did the design myself manually but then prompted AI to help complete it
step by step. I did review each manually and understand it but I want to
take a careful review again...
pull/9346/head
Mitchell Hashimoto 2025-10-25 13:33:18 -07:00 committed by GitHub
commit 83104ff27a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2015 additions and 1075 deletions

View File

@ -138,8 +138,15 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
const Handler = struct {
t: *Terminal,
pub fn print(self: *Handler, cp: u21) !void {
try self.t.print(cp);
pub fn vt(
self: *Handler,
comptime action: Stream.Action.Tag,
value: Stream.Action.Value(action),
) !void {
switch (action) {
.print => try self.t.print(value.cp),
else => {},
}
}
};

View File

@ -333,6 +333,15 @@ pub const VTHandler = struct {
cimgui.c.ImGuiTextFilter_destroy(self.filter_text);
}
pub fn vt(
self: *VTHandler,
comptime action: Stream.Action.Tag,
value: Stream.Action.Value(action),
) !void {
_ = self;
_ = value;
}
/// This is called with every single terminal action.
pub fn handleManually(self: *VTHandler, action: terminal.Parser.Action) !bool {
const insp = self.surface.inspector orelse return false;

View File

@ -1,4 +1,5 @@
const std = @import("std");
const Target = @import("target.zig").Target;
/// Create an enum type with the given keys that is C ABI compatible
/// if we're targeting C, otherwise a Zig enum with smallest possible
@ -58,11 +59,6 @@ pub fn Enum(
return Result;
}
pub const Target = union(enum) {
c,
zig,
};
test "zig" {
const testing = std.testing;
const T = Enum(.zig, &.{ "a", "b", "c", "d" });

View File

@ -1,9 +1,14 @@
const std = @import("std");
const enumpkg = @import("enum.zig");
const types = @import("types.zig");
const unionpkg = @import("union.zig");
pub const allocator = @import("allocator.zig");
pub const Enum = enumpkg.Enum;
pub const EnumTarget = enumpkg.Target;
pub const String = types.String;
pub const Struct = @import("struct.zig").Struct;
pub const Target = @import("target.zig").Target;
pub const TaggedUnion = unionpkg.TaggedUnion;
test {
std.testing.refAllDecls(@This());

31
src/lib/struct.zig Normal file
View File

@ -0,0 +1,31 @@
const std = @import("std");
const Target = @import("target.zig").Target;
pub fn Struct(
comptime target: Target,
comptime Zig: type,
) type {
return switch (target) {
.zig => Zig,
.c => c: {
const info = @typeInfo(Zig).@"struct";
var fields: [info.fields.len]std.builtin.Type.StructField = undefined;
for (info.fields, 0..) |field, i| {
fields[i] = .{
.name = field.name,
.type = field.type,
.default_value_ptr = field.default_value_ptr,
.is_comptime = field.is_comptime,
.alignment = field.alignment,
};
}
break :c @Type(.{ .@"struct" = .{
.layout = .@"extern",
.fields = &fields,
.decls = &.{},
.is_tuple = info.is_tuple,
} });
},
};
}

6
src/lib/target.zig Normal file
View File

@ -0,0 +1,6 @@
/// The target for ABI generation. The detection of this is left to the
/// caller since there are multiple ways to do that.
pub const Target = union(enum) {
c,
zig,
};

13
src/lib/types.zig Normal file
View File

@ -0,0 +1,13 @@
pub const String = extern struct {
ptr: [*]const u8,
len: usize,
pub fn init(zig: anytype) String {
return switch (@TypeOf(zig)) {
[]u8, []const u8 => .{
.ptr = zig.ptr,
.len = zig.len,
},
};
}
};

172
src/lib/union.zig Normal file
View File

@ -0,0 +1,172 @@
const std = @import("std");
const assert = std.debug.assert;
const testing = std.testing;
const Target = @import("target.zig").Target;
/// Create a tagged union type that supports a C ABI and maintains
/// C ABI compatibility when adding new tags. This returns a set of types
/// and functions to augment the given Union type, not create a wholly new
/// union type.
///
/// The C ABI compatible types and functions are only available when the
/// target produces C values.
///
/// The `Union` type should be a standard Zig tagged union. The tag type
/// should be explicit (i.e. not `union(enum)`) and the tag type should
/// be an enum created with the `Enum` function in this library, so that
/// automatic C ABI compatibility is ensured.
///
/// The `Padding` type is a type that is always added to the C union
/// with the key `_padding`. This should be set to a type that has the size
/// and alignment needed to pad the C union to the expected size. This
/// should never change to ensure ABI compatibility.
pub fn TaggedUnion(
comptime target: Target,
comptime Union: type,
comptime Padding: type,
) type {
return struct {
comptime {
switch (target) {
.zig => {},
// For ABI compatibility, we expect that this is our union size.
.c => if (@sizeOf(CValue) != @sizeOf(Padding)) {
@compileLog(@sizeOf(CValue));
@compileError("TaggedUnion CValue size does not match expected fixed size");
},
}
}
/// The tag type.
pub const Tag = @typeInfo(Union).@"union".tag_type.?;
/// The Zig union.
pub const Zig = Union;
/// The C ABI compatible tagged union type.
pub const C = switch (target) {
.zig => struct {},
.c => extern struct {
tag: Tag,
value: CValue,
},
};
/// The C ABI compatible union value type.
pub const CValue = cvalue: {
switch (target) {
.zig => break :cvalue extern struct {},
.c => {},
}
const tag_fields = @typeInfo(Tag).@"enum".fields;
var union_fields: [tag_fields.len + 1]std.builtin.Type.UnionField = undefined;
for (tag_fields, 0..) |field, i| {
const action = @unionInit(Union, field.name, undefined);
const Type = t: {
const Type = @TypeOf(@field(action, field.name));
// Types can provide custom types for their CValue.
switch (@typeInfo(Type)) {
.@"enum", .@"struct", .@"union" => if (@hasDecl(Type, "C")) break :t Type.C,
else => {},
}
break :t Type;
};
union_fields[i] = .{
.name = field.name,
.type = Type,
.alignment = @alignOf(Type),
};
}
union_fields[tag_fields.len] = .{
.name = "_padding",
.type = Padding,
.alignment = @alignOf(Padding),
};
break :cvalue @Type(.{ .@"union" = .{
.layout = .@"extern",
.tag_type = null,
.fields = &union_fields,
.decls = &.{},
} });
};
/// Convert to C union.
pub fn cval(self: Union) C {
const value: CValue = switch (self) {
inline else => |v, tag| @unionInit(
CValue,
@tagName(tag),
value: {
switch (@typeInfo(@TypeOf(v))) {
.@"enum", .@"struct", .@"union" => if (@hasDecl(@TypeOf(v), "cval")) break :value v.cval(),
else => {},
}
break :value v;
},
),
};
return .{
.tag = @as(Tag, self),
.value = value,
};
}
/// Returns the value type for the given tag.
pub fn Value(comptime tag: Tag) type {
@setEvalBranchQuota(10000);
inline for (@typeInfo(Union).@"union".fields) |field| {
const field_tag = @field(Tag, field.name);
if (field_tag == tag) return field.type;
}
unreachable;
}
};
}
test "TaggedUnion: matching size" {
const Tag = enum(c_int) { a, b };
const U = TaggedUnion(
.c,
union(Tag) {
a: u32,
b: u64,
},
u64,
);
try testing.expectEqual(8, @sizeOf(U.CValue));
}
test "TaggedUnion: padded size" {
const Tag = enum(c_int) { a };
const U = TaggedUnion(
.c,
union(Tag) {
a: u32,
},
u64,
);
try testing.expectEqual(8, @sizeOf(U.CValue));
}
test "TaggedUnion: c conversion" {
const Tag = enum(c_int) { a, b };
const U = TaggedUnion(.c, union(Tag) {
a: u32,
b: u64,
}, u64);
const c = U.cval(.{ .a = 42 });
try testing.expectEqual(Tag.a, c.tag);
try testing.expectEqual(42, c.value.a);
}

View File

@ -193,6 +193,7 @@ test {
_ = @import("crash/main.zig");
_ = @import("datastruct/main.zig");
_ = @import("inspector/main.zig");
_ = @import("lib/main.zig");
_ = @import("terminal/main.zig");
_ = @import("terminfo/main.zig");
_ = @import("simd/main.zig");

View File

@ -127,6 +127,14 @@ pub const Action = union(enum) {
intermediates: []const u8 = "",
params: []const u16 = &.{},
final: u8,
pub const C = extern struct {
intermediates: [*]const u8,
intermediates_len: usize,
params: [*]const u16,
params_len: usize,
final: u8,
};
};
// Implement formatter for logging. This is mostly copied from the

View File

@ -581,7 +581,7 @@ fn printCell(
if (unmapped_c > std.math.maxInt(u8)) break :c ' ';
// Get our lookup table and map it
const table = set.table();
const table = charsets.table(set);
break :c @intCast(table[@intCast(unmapped_c)]);
};

View File

@ -1,3 +1,7 @@
const build_options = @import("terminal_options");
const lib = @import("../lib/main.zig");
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
/// C0 (7-bit) control characters from ANSI.
///
/// This is not complete, control characters are only added to this
@ -49,33 +53,28 @@ pub const RenditionAspect = enum(u16) {
};
/// The device attribute request type (ESC [ c).
pub const DeviceAttributeReq = enum {
primary, // Blank
secondary, // >
tertiary, // =
};
pub const DeviceAttributeReq = lib.Enum(
lib_target,
&.{
"primary", // Blank
"secondary", // >
"tertiary", // =
},
);
/// Possible cursor styles (ESC [ q)
pub const CursorStyle = enum(u16) {
default = 0,
blinking_block = 1,
steady_block = 2,
blinking_underline = 3,
steady_underline = 4,
blinking_bar = 5,
steady_bar = 6,
// Non-exhaustive so that @intToEnum never fails for unsupported modes.
_,
/// True if the cursor should blink.
pub fn blinking(self: CursorStyle) bool {
return switch (self) {
.blinking_block, .blinking_underline, .blinking_bar => true,
else => false,
};
}
};
pub const CursorStyle = lib.Enum(
lib_target,
&.{
"default",
"blinking_block",
"steady_block",
"blinking_underline",
"steady_underline",
"blinking_bar",
"steady_bar",
},
);
/// The status line type for DECSSDT.
pub const StatusLineType = enum(u16) {
@ -88,19 +87,27 @@ pub const StatusLineType = enum(u16) {
};
/// The display to target for status updates (DECSASD).
pub const StatusDisplay = enum(u16) {
main = 0,
status_line = 1,
};
pub const StatusDisplay = lib.Enum(
lib_target,
&.{
"main",
"status_line",
},
);
/// The possible modify key formats to ESC[>{a};{b}m
/// Note: this is not complete, we should add more as we support more
pub const ModifyKeyFormat = union(enum) {
legacy: void,
cursor_keys: void,
function_keys: void,
other_keys: enum { none, numeric_except, numeric },
};
pub const ModifyKeyFormat = lib.Enum(
lib_target,
&.{
"legacy",
"cursor_keys",
"function_keys",
"other_keys_none",
"other_keys_numeric_except",
"other_keys_numeric",
},
);
/// The protection modes that can be set for the terminal. See DECSCA and
/// ESC V, W.

View File

@ -1,88 +1,89 @@
const std = @import("std");
const build_options = @import("terminal_options");
const assert = std.debug.assert;
const LibEnum = @import("../lib/enum.zig").Enum;
/// The available charset slots for a terminal.
pub const Slots = enum(u3) {
G0 = 0,
G1 = 1,
G2 = 2,
G3 = 3,
};
pub const Slots = LibEnum(
if (build_options.c_abi) .c else .zig,
&.{ "G0", "G1", "G2", "G3" },
);
/// The name of the active slots.
pub const ActiveSlot = enum { GL, GR };
pub const ActiveSlot = LibEnum(
if (build_options.c_abi) .c else .zig,
&.{ "GL", "GR" },
);
/// The list of supported character sets and their associated tables.
pub const Charset = enum {
utf8,
ascii,
british,
dec_special,
pub const Charset = LibEnum(
if (build_options.c_abi) .c else .zig,
&.{ "utf8", "ascii", "british", "dec_special" },
);
/// The table for the given charset. This returns a pointer to a
/// slice that is guaranteed to be 255 chars that can be used to map
/// ASCII to the given charset.
pub fn table(set: Charset) []const u16 {
return switch (set) {
.british => &british,
.dec_special => &dec_special,
/// The table for the given charset. This returns a pointer to a
/// slice that is guaranteed to be 255 chars that can be used to map
/// ASCII to the given charset.
pub fn table(set: Charset) []const u16 {
return switch (set) {
.british => &british,
.dec_special => &dec_special,
// utf8 is not a table, callers should double-check if the
// charset is utf8 and NOT use tables.
.utf8 => unreachable,
// utf8 is not a table, callers should double-check if the
// charset is utf8 and NOT use tables.
.utf8 => unreachable,
// recommended that callers just map ascii directly but we can
// support a table
.ascii => &ascii,
};
}
};
// recommended that callers just map ascii directly but we can
// support a table
.ascii => &ascii,
};
}
/// Just a basic c => c ascii table
const ascii = initTable();
/// https://vt100.net/docs/vt220-rm/chapter2.html
const british = british: {
var table = initTable();
table[0x23] = 0x00a3;
break :british table;
var tbl = initTable();
tbl[0x23] = 0x00a3;
break :british tbl;
};
/// https://en.wikipedia.org/wiki/DEC_Special_Graphics
const dec_special = tech: {
var table = initTable();
table[0x60] = 0x25C6;
table[0x61] = 0x2592;
table[0x62] = 0x2409;
table[0x63] = 0x240C;
table[0x64] = 0x240D;
table[0x65] = 0x240A;
table[0x66] = 0x00B0;
table[0x67] = 0x00B1;
table[0x68] = 0x2424;
table[0x69] = 0x240B;
table[0x6a] = 0x2518;
table[0x6b] = 0x2510;
table[0x6c] = 0x250C;
table[0x6d] = 0x2514;
table[0x6e] = 0x253C;
table[0x6f] = 0x23BA;
table[0x70] = 0x23BB;
table[0x71] = 0x2500;
table[0x72] = 0x23BC;
table[0x73] = 0x23BD;
table[0x74] = 0x251C;
table[0x75] = 0x2524;
table[0x76] = 0x2534;
table[0x77] = 0x252C;
table[0x78] = 0x2502;
table[0x79] = 0x2264;
table[0x7a] = 0x2265;
table[0x7b] = 0x03C0;
table[0x7c] = 0x2260;
table[0x7d] = 0x00A3;
table[0x7e] = 0x00B7;
break :tech table;
var tbl = initTable();
tbl[0x60] = 0x25C6;
tbl[0x61] = 0x2592;
tbl[0x62] = 0x2409;
tbl[0x63] = 0x240C;
tbl[0x64] = 0x240D;
tbl[0x65] = 0x240A;
tbl[0x66] = 0x00B0;
tbl[0x67] = 0x00B1;
tbl[0x68] = 0x2424;
tbl[0x69] = 0x240B;
tbl[0x6a] = 0x2518;
tbl[0x6b] = 0x2510;
tbl[0x6c] = 0x250C;
tbl[0x6d] = 0x2514;
tbl[0x6e] = 0x253C;
tbl[0x6f] = 0x23BA;
tbl[0x70] = 0x23BB;
tbl[0x71] = 0x2500;
tbl[0x72] = 0x23BC;
tbl[0x73] = 0x23BD;
tbl[0x74] = 0x251C;
tbl[0x75] = 0x2524;
tbl[0x76] = 0x2534;
tbl[0x77] = 0x252C;
tbl[0x78] = 0x2502;
tbl[0x79] = 0x2264;
tbl[0x7a] = 0x2265;
tbl[0x7b] = 0x03C0;
tbl[0x7c] = 0x2260;
tbl[0x7d] = 0x00A3;
tbl[0x7e] = 0x00B7;
break :tech tbl;
};
/// Our table length is 256 so we can contain all ASCII chars.
@ -104,11 +105,11 @@ test {
// utf8 has no table
if (@field(Charset, field.name) == .utf8) continue;
const table = @field(Charset, field.name).table();
const tbl = table(@field(Charset, field.name));
// Yes, I could use `table_len` here, but I want to explicitly use a
// hardcoded constant so that if there are miscompilations or a comptime
// issue, we catch it.
try testing.expectEqual(@as(usize, 256), table.len);
try testing.expectEqual(@as(usize, 256), tbl.len);
}
}

View File

@ -68,6 +68,12 @@ pub const Name = enum(u8) {
// Remainders are valid unnamed values in the 256 color palette.
_,
pub const C = u8;
pub fn cval(self: Name) C {
return @intFromEnum(self);
}
/// Default colors for tagged values.
pub fn default(self: Name) !RGB {
return switch (self) {
@ -179,6 +185,20 @@ pub const RGB = packed struct(u24) {
g: u8 = 0,
b: u8 = 0,
pub const C = extern struct {
r: u8,
g: u8,
b: u8,
};
pub fn cval(self: RGB) C {
return .{
.r = self.r,
.g = self.g,
.b = self.b,
};
}
pub fn eql(self: RGB, other: RGB) bool {
return self.r == other.r and self.g == other.g and self.b == other.b;
}

View File

@ -1,3 +1,7 @@
const build_options = @import("terminal_options");
const lib = @import("../lib/main.zig");
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
/// Modes for the ED CSI command.
pub const EraseDisplay = enum(u8) {
below = 0,
@ -33,13 +37,16 @@ pub const TabClear = enum(u8) {
};
/// Style formats for terminal size reports.
pub const SizeReportStyle = enum {
// XTWINOPS
csi_14_t,
csi_16_t,
csi_18_t,
csi_21_t,
};
pub const SizeReportStyle = lib.Enum(
lib_target,
&.{
// XTWINOPS
"csi_14_t",
"csi_16_t",
"csi_18_t",
"csi_21_t",
},
);
/// XTWINOPS CSI 22/23
pub const TitlePushPop = struct {

View File

@ -1,4 +1,6 @@
const std = @import("std");
const build_options = @import("terminal_options");
const LibEnum = @import("../../lib/enum.zig").Enum;
const terminal = @import("../main.zig");
const RGB = terminal.color.RGB;
const Terminator = terminal.osc.Terminator;
@ -16,6 +18,13 @@ pub const OSC = struct {
/// We must reply with the same string terminator (ST) as used in the
/// request.
terminator: Terminator = .st,
/// We don't currently support encoding this to C in any way.
pub const C = void;
pub fn cval(_: OSC) C {
return {};
}
};
pub const Special = enum {

View File

@ -25,6 +25,7 @@ pub const x11_color = @import("x11_color.zig");
pub const Charset = charsets.Charset;
pub const CharsetSlot = charsets.Slots;
pub const CharsetActiveSlot = charsets.ActiveSlot;
pub const charsetTable = charsets.table;
pub const Cell = page.Cell;
pub const Coordinate = point.Coordinate;
pub const CSI = Parser.Action.CSI;

View File

@ -271,6 +271,8 @@ pub const Terminator = enum {
/// Some applications and terminals use BELL (0x07) as the string terminator.
bel,
pub const C = LibEnum(.c, &.{ "st", "bel" });
/// Initialize the terminator based on the last byte seen. If the
/// last byte is a BEL then we use BEL, otherwise we just assume ST.
pub fn init(ch: ?u8) Terminator {
@ -289,6 +291,13 @@ pub const Terminator = enum {
};
}
pub fn cval(self: Terminator) C {
return switch (self) {
.st => .st,
.bel => .bel,
};
}
pub fn format(
self: Terminator,
comptime _: []const u8,

View File

@ -1,26 +1,22 @@
//! SGR (Select Graphic Rendition) attrinvbute parsing and types.
const std = @import("std");
const build_options = @import("terminal_options");
const assert = std.debug.assert;
const testing = std.testing;
const lib = @import("../lib/main.zig");
const color = @import("color.zig");
const SepList = @import("Parser.zig").Action.CSI.SepList;
/// Attribute type for SGR
pub const Attribute = union(enum) {
pub const Tag = std.meta.FieldEnum(Attribute);
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
/// Attribute type for SGR
pub const Attribute = union(Tag) {
/// Unset all attributes
unset,
/// Unknown attribute, the raw CSI command parameters are here.
unknown: struct {
/// Full is the full SGR input.
full: []const u16,
/// Partial is the remaining, where we got hung up.
partial: []const u16,
},
unknown: Unknown,
/// Bold the text.
bold,
@ -85,6 +81,68 @@ pub const Attribute = union(enum) {
/// Set foreground color as 256-color palette.
@"256_fg": u8,
pub const Tag = lib.Enum(
lib_target,
&.{
"unset",
"unknown",
"bold",
"reset_bold",
"italic",
"reset_italic",
"faint",
"underline",
"reset_underline",
"underline_color",
"256_underline_color",
"reset_underline_color",
"overline",
"reset_overline",
"blink",
"reset_blink",
"inverse",
"reset_inverse",
"invisible",
"reset_invisible",
"strikethrough",
"reset_strikethrough",
"direct_color_fg",
"direct_color_bg",
"8_bg",
"8_fg",
"reset_fg",
"reset_bg",
"8_bright_bg",
"8_bright_fg",
"256_bg",
"256_fg",
},
);
pub const Unknown = struct {
/// Full is the full SGR input.
full: []const u16,
/// Partial is the remaining, where we got hung up.
partial: []const u16,
pub const C = extern struct {
full_ptr: [*]const u16,
full_len: usize,
partial_ptr: [*]const u16,
partial_len: usize,
};
pub fn cval(self: Unknown) Unknown.C {
return .{
.full_ptr = self.full.ptr,
.full_len = self.full.len,
.partial_ptr = self.partial.ptr,
.partial_len = self.partial.len,
};
}
};
pub const Underline = enum(u3) {
none = 0,
single = 1,
@ -92,7 +150,28 @@ pub const Attribute = union(enum) {
curly = 3,
dotted = 4,
dashed = 5,
pub const C = u8;
pub fn cval(self: Underline) Underline.C {
return @intFromEnum(self);
}
};
/// C ABI functions.
const c_union = lib.TaggedUnion(
lib_target,
@This(),
// Padding size for C ABI compatibility.
// Largest variant is Unknown.C: 2 pointers + 2 usize = 32 bytes on 64-bit.
// We use [8]u64 (64 bytes) to allow room for future expansion while
// maintaining ABI compatibility.
[8]u64,
);
pub const Value = c_union.Value;
pub const C = c_union.C;
pub const CValue = c_union.CValue;
pub const cval = c_union.cval;
};
/// Parser parses the attributes from a list of SGR parameters.
@ -380,6 +459,10 @@ fn testParseColon(params: []const u16) Attribute {
return p.next().?;
}
test "sgr: Attribute C compat" {
_ = Attribute.C;
}
test "sgr: Parser" {
try testing.expect(testParse(&[_]u16{}) == .unset);
try testing.expect(testParse(&[_]u16{0}) == .unset);

File diff suppressed because it is too large Load Diff

View File

@ -64,7 +64,7 @@ mailbox: termio.Mailbox,
/// The stream parser. This parses the stream of escape codes and so on
/// from the child process and calls callbacks in the stream handler.
terminal_stream: terminalpkg.Stream(StreamHandler),
terminal_stream: StreamHandler.Stream,
/// Last time the cursor was reset. This is used to prevent message
/// flooding with cursor resets.

View File

@ -95,6 +95,8 @@ pub const StreamHandler = struct {
/// this to determine if we need to default the window title.
seen_title: bool = false,
pub const Stream = terminal.Stream(StreamHandler);
pub fn deinit(self: *StreamHandler) void {
self.apc.deinit();
self.dcs.deinit();
@ -186,6 +188,156 @@ pub const StreamHandler = struct {
_ = self.renderer_mailbox.push(msg, .{ .forever = {} });
}
pub fn vt(
self: *StreamHandler,
comptime action: Stream.Action.Tag,
value: Stream.Action.Value(action),
) !void {
switch (action) {
.print => try self.terminal.print(value.cp),
.print_repeat => try self.terminal.printRepeat(value),
.bell => self.bell(),
.backspace => self.terminal.backspace(),
.horizontal_tab => try self.horizontalTab(value),
.horizontal_tab_back => try self.horizontalTabBack(value),
.linefeed => try self.linefeed(),
.carriage_return => self.terminal.carriageReturn(),
.enquiry => try self.enquiry(),
.invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking),
.cursor_up => self.terminal.cursorUp(value.value),
.cursor_down => self.terminal.cursorDown(value.value),
.cursor_left => self.terminal.cursorLeft(value.value),
.cursor_right => self.terminal.cursorRight(value.value),
.cursor_pos => self.terminal.setCursorPos(value.row, value.col),
.cursor_col => self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, value.value),
.cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screen.cursor.x + 1),
.cursor_col_relative => self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1,
self.terminal.screen.cursor.x + 1 +| value.value,
),
.cursor_row_relative => self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1 +| value.value,
self.terminal.screen.cursor.x + 1,
),
.cursor_style => try self.setCursorStyle(value),
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => {
try self.terminal.scrollViewport(.{ .bottom = {} });
try self.queueRender();
self.terminal.eraseDisplay(.complete, value);
},
.erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value),
.erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value),
.erase_line_right => self.terminal.eraseLine(.right, value),
.erase_line_left => self.terminal.eraseLine(.left, value),
.erase_line_complete => self.terminal.eraseLine(.complete, value),
.erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value),
.delete_chars => self.terminal.deleteChars(value),
.erase_chars => self.terminal.eraseChars(value),
.insert_lines => self.terminal.insertLines(value),
.insert_blanks => self.terminal.insertBlanks(value),
.delete_lines => self.terminal.deleteLines(value),
.scroll_up => self.terminal.scrollUp(value),
.scroll_down => self.terminal.scrollDown(value),
.tab_clear_current => self.terminal.tabClear(.current),
.tab_clear_all => self.terminal.tabClear(.all),
.tab_set => self.terminal.tabSet(),
.tab_reset => self.terminal.tabReset(),
.index => try self.index(),
.next_line => try self.nextLine(),
.reverse_index => try self.reverseIndex(),
.full_reset => try self.fullReset(),
.set_mode => try self.setMode(value.mode, true),
.reset_mode => try self.setMode(value.mode, false),
.save_mode => self.terminal.modes.save(value.mode),
.restore_mode => {
// For restore mode we have to restore but if we set it, we
// always have to call setMode because setting some modes have
// side effects and we want to make sure we process those.
const v = self.terminal.modes.restore(value.mode);
try self.setMode(value.mode, v);
},
.request_mode => try self.requestMode(value.mode),
.request_mode_unknown => try self.requestModeUnknown(value.mode, value.ansi),
.top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right),
.left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right),
.left_and_right_margin_ambiguous => {
if (self.terminal.modes.get(.enable_left_and_right_margin)) {
self.terminal.setLeftAndRightMargin(0, 0);
} else {
self.terminal.saveCursor();
}
},
.save_cursor => try self.saveCursor(),
.restore_cursor => try self.restoreCursor(),
.modify_key_format => try self.setModifyKeyFormat(value),
.protected_mode_off => self.terminal.setProtectedMode(.off),
.protected_mode_iso => self.terminal.setProtectedMode(.iso),
.protected_mode_dec => self.terminal.setProtectedMode(.dec),
.mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false,
.size_report => self.sendSizeReport(value),
.xtversion => try self.reportXtversion(),
.device_attributes => try self.deviceAttributes(value),
.device_status => try self.deviceStatusReport(value.request),
.kitty_keyboard_query => try self.queryKittyKeyboard(),
.kitty_keyboard_push => {
log.debug("pushing kitty keyboard mode: {}", .{value.flags});
self.terminal.screen.kitty_keyboard.push(value.flags);
},
.kitty_keyboard_pop => {
log.debug("popping kitty keyboard mode n={}", .{value});
self.terminal.screen.kitty_keyboard.pop(@intCast(value));
},
.kitty_keyboard_set => {
log.debug("setting kitty keyboard mode: set {}", .{value.flags});
self.terminal.screen.kitty_keyboard.set(.set, value.flags);
},
.kitty_keyboard_set_or => {
log.debug("setting kitty keyboard mode: or {}", .{value.flags});
self.terminal.screen.kitty_keyboard.set(.@"or", value.flags);
},
.kitty_keyboard_set_not => {
log.debug("setting kitty keyboard mode: not {}", .{value.flags});
self.terminal.screen.kitty_keyboard.set(.not, value.flags);
},
.kitty_color_report => try self.kittyColorReport(value),
.color_operation => try self.colorOperation(value.op, &value.requests, value.terminator),
.prompt_end => try self.promptEnd(),
.end_of_input => try self.endOfInput(),
.end_hyperlink => try self.endHyperlink(),
.active_status_display => self.terminal.status_display = value,
.decaln => try self.decaln(),
.window_title => try self.windowTitle(value.title),
.report_pwd => try self.reportPwd(value.url),
.show_desktop_notification => try self.showDesktopNotification(value.title, value.body),
.progress_report => self.progressReport(value),
.start_hyperlink => try self.startHyperlink(value.uri, value.id),
.clipboard_contents => try self.clipboardContents(value.kind, value.data),
.prompt_start => self.promptStart(value.aid, value.redraw),
.prompt_continuation => self.promptContinuation(value.aid),
.end_of_command => self.endOfCommand(value.exit_code),
.mouse_shape => try self.setMouseShape(value),
.configure_charset => self.configureCharset(value.slot, value.charset),
.set_attribute => switch (value) {
.unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}),
else => self.terminal.setAttribute(value) catch |err|
log.warn("error setting attribute {}: {}", .{ value, err }),
},
.dcs_hook => try self.dcsHook(value),
.dcs_put => try self.dcsPut(value),
.dcs_unhook => try self.dcsUnhook(),
.apc_start => self.apc.start(),
.apc_end => try self.apcEnd(),
.apc_put => self.apc.feed(self.alloc, value),
// Unimplemented
.title_push,
.title_pop,
=> {},
}
}
pub inline fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void {
var cmd = self.dcs.hook(self.alloc, dcs) orelse return;
defer cmd.deinit();
@ -293,14 +445,6 @@ pub const StreamHandler = struct {
}
}
pub inline fn apcStart(self: *StreamHandler) !void {
self.apc.start();
}
pub inline fn apcPut(self: *StreamHandler, byte: u8) !void {
self.apc.feed(self.alloc, byte);
}
pub fn apcEnd(self: *StreamHandler) !void {
var cmd = self.apc.end() orelse return;
defer cmd.deinit(self.alloc);
@ -322,23 +466,11 @@ pub const StreamHandler = struct {
}
}
pub inline fn print(self: *StreamHandler, ch: u21) !void {
try self.terminal.print(ch);
}
pub inline fn printRepeat(self: *StreamHandler, count: usize) !void {
try self.terminal.printRepeat(count);
}
pub inline fn bell(self: *StreamHandler) !void {
inline fn bell(self: *StreamHandler) void {
self.surfaceMessageWriter(.ring_bell);
}
pub inline fn backspace(self: *StreamHandler) !void {
self.terminal.backspace();
}
pub inline fn horizontalTab(self: *StreamHandler, count: u16) !void {
inline fn horizontalTab(self: *StreamHandler, count: u16) !void {
for (0..count) |_| {
const x = self.terminal.screen.cursor.x;
try self.terminal.horizontalTab();
@ -346,7 +478,7 @@ pub const StreamHandler = struct {
}
}
pub inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void {
inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void {
for (0..count) |_| {
const x = self.terminal.screen.cursor.x;
try self.terminal.horizontalTabBack();
@ -354,94 +486,12 @@ pub const StreamHandler = struct {
}
}
pub inline fn linefeed(self: *StreamHandler) !void {
inline fn linefeed(self: *StreamHandler) !void {
// Small optimization: call index instead of linefeed because they're
// identical and this avoids one layer of function call overhead.
try self.terminal.index();
}
pub inline fn carriageReturn(self: *StreamHandler) !void {
self.terminal.carriageReturn();
}
pub inline fn setCursorLeft(self: *StreamHandler, amount: u16) !void {
self.terminal.cursorLeft(amount);
}
pub inline fn setCursorRight(self: *StreamHandler, amount: u16) !void {
self.terminal.cursorRight(amount);
}
pub inline fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void {
self.terminal.cursorDown(amount);
if (carriage) self.terminal.carriageReturn();
}
pub inline fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void {
self.terminal.cursorUp(amount);
if (carriage) self.terminal.carriageReturn();
}
pub inline fn setCursorCol(self: *StreamHandler, col: u16) !void {
self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col);
}
pub inline fn setCursorColRelative(self: *StreamHandler, offset: u16) !void {
self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1,
self.terminal.screen.cursor.x + 1 +| offset,
);
}
pub inline fn setCursorRow(self: *StreamHandler, row: u16) !void {
self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1);
}
pub inline fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void {
self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1 +| offset,
self.terminal.screen.cursor.x + 1,
);
}
pub inline fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void {
self.terminal.setCursorPos(row, col);
}
pub inline fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void {
if (mode == .complete) {
// Whenever we erase the full display, scroll to bottom.
try self.terminal.scrollViewport(.{ .bottom = {} });
try self.queueRender();
}
self.terminal.eraseDisplay(mode, protected);
}
pub inline fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void {
self.terminal.eraseLine(mode, protected);
}
pub inline fn deleteChars(self: *StreamHandler, count: usize) !void {
self.terminal.deleteChars(count);
}
pub inline fn eraseChars(self: *StreamHandler, count: usize) !void {
self.terminal.eraseChars(count);
}
pub inline fn insertLines(self: *StreamHandler, count: usize) !void {
self.terminal.insertLines(count);
}
pub inline fn insertBlanks(self: *StreamHandler, count: usize) !void {
self.terminal.insertBlanks(count);
}
pub inline fn deleteLines(self: *StreamHandler, count: usize) !void {
self.terminal.deleteLines(count);
}
pub inline fn reverseIndex(self: *StreamHandler) !void {
self.terminal.reverseIndex();
}
@ -455,48 +505,25 @@ pub const StreamHandler = struct {
self.terminal.carriageReturn();
}
pub inline fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void {
self.terminal.setTopAndBottomMargin(top, bot);
}
pub inline fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void {
if (self.terminal.modes.get(.enable_left_and_right_margin)) {
try self.setLeftAndRightMargin(0, 0);
} else {
try self.saveCursor();
}
}
pub inline fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void {
self.terminal.setLeftAndRightMargin(left, right);
}
pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void {
self.terminal.flags.modify_other_keys_2 = false;
switch (format) {
.other_keys => |v| switch (v) {
.numeric => self.terminal.flags.modify_other_keys_2 = true,
else => {},
},
.other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true,
else => {},
}
}
pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void {
// Get the mode value and respond.
const code: u8 = code: {
const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0;
if (self.terminal.modes.get(mode)) break :code 1;
break :code 2;
};
fn requestMode(self: *StreamHandler, mode: terminal.Mode) !void {
const tag: terminal.modes.ModeTag = @bitCast(@intFromEnum(mode));
const code: u8 = if (self.terminal.modes.get(mode)) 1 else 2;
var msg: termio.Message = .{ .write_small = .{} };
const resp = try std.fmt.bufPrint(
&msg.write_small.data,
"\x1B[{s}{};{}$y",
.{
if (ansi) "" else "?",
mode_raw,
if (tag.ansi) "" else "?",
tag.value,
code,
},
);
@ -504,18 +531,18 @@ pub const StreamHandler = struct {
self.messageWriter(msg);
}
pub inline fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void {
// log.debug("save mode={}", .{mode});
self.terminal.modes.save(mode);
}
pub inline fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void {
// For restore mode we have to restore but if we set it, we
// always have to call setMode because setting some modes have
// side effects and we want to make sure we process those.
const v = self.terminal.modes.restore(mode);
// log.debug("restore mode={} v={}", .{ mode, v });
try self.setMode(mode, v);
fn requestModeUnknown(self: *StreamHandler, mode_raw: u16, ansi: bool) !void {
var msg: termio.Message = .{ .write_small = .{} };
const resp = try std.fmt.bufPrint(
&msg.write_small.data,
"\x1B[{s}{};0$y",
.{
if (ansi) "" else "?",
mode_raw,
},
);
msg.write_small.len = @intCast(resp.len);
self.messageWriter(msg);
}
pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void {
@ -696,20 +723,7 @@ pub const StreamHandler = struct {
}
}
pub inline fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void {
self.terminal.flags.mouse_shift_capture = if (v) .true else .false;
}
pub inline fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void {
switch (attr) {
.unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}),
else => self.terminal.setAttribute(attr) catch |err|
log.warn("error setting attribute {}: {}", .{ attr, err }),
}
}
pub inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void {
inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void {
try self.terminal.screen.startHyperlink(uri, id);
}
@ -720,10 +734,7 @@ pub const StreamHandler = struct {
pub fn deviceAttributes(
self: *StreamHandler,
req: terminal.DeviceAttributeReq,
params: []const u16,
) !void {
_ = params;
// For the below, we quack as a VT220. We don't quack as
// a 420 because we don't support DCS sequences.
switch (req) {
@ -827,31 +838,13 @@ pub const StreamHandler = struct {
self.terminal.screen.cursor.cursor_style = .bar;
self.terminal.modes.set(.cursor_blinking, false);
},
else => log.warn("unimplemented cursor style: {}", .{style}),
}
}
pub inline fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void {
self.terminal.setProtectedMode(mode);
}
pub inline fn decaln(self: *StreamHandler) !void {
try self.terminal.decaln();
}
pub inline fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void {
self.terminal.tabClear(cmd);
}
pub inline fn tabSet(self: *StreamHandler) !void {
self.terminal.tabSet();
}
pub inline fn tabReset(self: *StreamHandler) !void {
self.terminal.tabReset();
}
pub inline fn saveCursor(self: *StreamHandler) !void {
self.terminal.saveCursor();
}
@ -865,38 +858,14 @@ pub const StreamHandler = struct {
self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response));
}
pub inline fn scrollDown(self: *StreamHandler, count: usize) !void {
self.terminal.scrollDown(count);
}
pub inline fn scrollUp(self: *StreamHandler, count: usize) !void {
self.terminal.scrollUp(count);
}
pub fn setActiveStatusDisplay(
self: *StreamHandler,
req: terminal.StatusDisplay,
) !void {
self.terminal.status_display = req;
}
pub fn configureCharset(
fn configureCharset(
self: *StreamHandler,
slot: terminal.CharsetSlot,
set: terminal.Charset,
) !void {
) void {
self.terminal.configureCharset(slot, set);
}
pub fn invokeCharset(
self: *StreamHandler,
active: terminal.CharsetActiveSlot,
slot: terminal.CharsetSlot,
single: bool,
) !void {
self.terminal.invokeCharset(active, slot, single);
}
pub fn fullReset(
self: *StreamHandler,
) !void {
@ -922,28 +891,6 @@ pub const StreamHandler = struct {
});
}
pub fn pushKittyKeyboard(
self: *StreamHandler,
flags: terminal.kitty.KeyFlags,
) !void {
log.debug("pushing kitty keyboard mode: {}", .{flags});
self.terminal.screen.kitty_keyboard.push(flags);
}
pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void {
log.debug("popping kitty keyboard mode n={}", .{n});
self.terminal.screen.kitty_keyboard.pop(@intCast(n));
}
pub fn setKittyKeyboard(
self: *StreamHandler,
mode: terminal.kitty.KeySetMode,
flags: terminal.kitty.KeyFlags,
) !void {
log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags });
self.terminal.screen.kitty_keyboard.set(mode, flags);
}
pub fn reportXtversion(
self: *StreamHandler,
) !void {
@ -964,7 +911,7 @@ pub const StreamHandler = struct {
//-------------------------------------------------------------------------
// OSC
pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void {
fn windowTitle(self: *StreamHandler, title: []const u8) !void {
var buf: [256]u8 = undefined;
if (title.len >= buf.len) {
log.warn("change title requested larger than our buffer size, ignoring", .{});
@ -995,7 +942,7 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.{ .set_title = buf });
}
pub inline fn setMouseShape(
inline fn setMouseShape(
self: *StreamHandler,
shape: terminal.MouseShape,
) !void {
@ -1007,7 +954,7 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.{ .set_mouse_shape = shape });
}
pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void {
fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void {
// Note: we ignore the "kind" field and always use the standard clipboard.
// iTerm also appears to do this but other terminals seem to only allow
// certain. Let's investigate more.
@ -1037,13 +984,13 @@ pub const StreamHandler = struct {
});
}
pub inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void {
inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) void {
_ = aid;
self.terminal.markSemanticPrompt(.prompt);
self.terminal.flags.shell_redraws_prompt = redraw;
}
pub inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void {
inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) void {
_ = aid;
self.terminal.markSemanticPrompt(.prompt_continuation);
}
@ -1057,11 +1004,11 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.start_command);
}
pub inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) !void {
inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) void {
self.surfaceMessageWriter(.{ .stop_command = exit_code });
}
pub fn reportPwd(self: *StreamHandler, url: []const u8) !void {
fn reportPwd(self: *StreamHandler, url: []const u8) !void {
// Special handling for the empty URL. We treat the empty URL
// as resetting the pwd as if we never saw a pwd. I can't find any
// other terminal that does this but it seems like a reasonable
@ -1075,7 +1022,7 @@ pub const StreamHandler = struct {
// If we haven't seen a title, we're using the pwd as our title.
// Set it to blank which will reset our title behavior.
if (!self.seen_title) {
try self.changeWindowTitle("");
try self.windowTitle("");
assert(!self.seen_title);
}
@ -1155,12 +1102,12 @@ pub const StreamHandler = struct {
// If we haven't seen a title, use our pwd as the title.
if (!self.seen_title) {
try self.changeWindowTitle(path);
try self.windowTitle(path);
self.seen_title = false;
}
}
pub fn handleColorOperation(
fn colorOperation(
self: *StreamHandler,
op: terminal.osc.color.Operation,
requests: *const terminal.osc.color.List,
@ -1409,7 +1356,7 @@ pub const StreamHandler = struct {
}
}
pub fn showDesktopNotification(
fn showDesktopNotification(
self: *StreamHandler,
title: []const u8,
body: []const u8,
@ -1437,7 +1384,7 @@ pub const StreamHandler = struct {
}
}
pub fn sendKittyColorReport(
fn kittyColorReport(
self: *StreamHandler,
request: terminal.kitty.color.OSC,
) !void {
@ -1562,7 +1509,7 @@ pub const StreamHandler = struct {
}
/// Display a GUI progress report.
pub fn handleProgressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) error{}!void {
fn progressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) void {
self.surfaceMessageWriter(.{ .progress_report = report });
}
};