inspector: clean up termio layouts

This commit is contained in:
Mitchell Hashimoto
2026-01-29 20:57:07 -08:00
parent 32ac82c66f
commit 4fa2dab20d
2 changed files with 440 additions and 428 deletions

View File

@@ -162,15 +162,11 @@ pub fn recordPtyRead(
t: *terminal.Terminal,
data: []const u8,
) !void {
// We need to setup our state so that capture works properly.
const handler: *widgets.termio.VTHandler = &self.gui.vt_stream.parser_stream.handler;
handler.state = .{
.alloc = alloc,
.terminal = t,
.events = &self.gui.vt_stream.events,
};
try self.gui.vt_stream.parser_stream.nextSlice(data);
try self.gui.vt_stream.recordPtyRead(
alloc,
t,
data,
);
}
/// Render the frame.

View File

@@ -6,426 +6,10 @@ const CircBuf = @import("../../datastruct/main.zig").CircBuf;
const Surface = @import("../../Surface.zig");
const screen = @import("screen.zig");
/// The stream handler for our inspector.
pub const ParserStream = terminal.Stream(VTHandler);
/// VT event circular buffer.
pub const VTEventRing = CircBuf(VTEvent, undefined);
/// VT event
pub const VTEvent = struct {
/// Sequence number, just monotonically increasing.
seq: usize = 1,
/// Kind of event, for filtering
kind: Kind,
/// The formatted string of the event. This is allocated. We format the
/// event for now because there is so much data to copy if we wanted to
/// store the raw event.
str: [:0]const u8,
/// Various metadata at the time of the event (before processing).
cursor: terminal.Screen.Cursor,
scrolling_region: terminal.Terminal.ScrollingRegion,
metadata: Metadata.Unmanaged = .{},
/// imgui selection state
imgui_selected: bool = false,
const Kind = enum { print, execute, csi, esc, osc, dcs, apc };
const Metadata = std.StringHashMap([:0]const u8);
/// Initialize the event information for the given parser action.
pub fn init(
alloc: Allocator,
t: *const terminal.Terminal,
action: terminal.Parser.Action,
) !VTEvent {
var md = Metadata.init(alloc);
errdefer md.deinit();
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
try encodeAction(alloc, &buf.writer, &md, action);
const str = try buf.toOwnedSliceSentinel(0);
errdefer alloc.free(str);
const kind: Kind = switch (action) {
.print => .print,
.execute => .execute,
.csi_dispatch => .csi,
.esc_dispatch => .esc,
.osc_dispatch => .osc,
.dcs_hook, .dcs_put, .dcs_unhook => .dcs,
.apc_start, .apc_put, .apc_end => .apc,
};
return .{
.kind = kind,
.str = str,
.cursor = t.screens.active.cursor,
.scrolling_region = t.scrolling_region,
.metadata = md.unmanaged,
};
}
pub fn deinit(self: *VTEvent, alloc: Allocator) void {
{
var it = self.metadata.valueIterator();
while (it.next()) |v| alloc.free(v.*);
self.metadata.deinit(alloc);
}
alloc.free(self.str);
}
/// Returns true if the event passes the given filter.
pub fn passFilter(
self: *const VTEvent,
filter: *const cimgui.c.ImGuiTextFilter,
) bool {
// Check our main string
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
self.str.ptr,
null,
)) return true;
// We also check all metadata keys and values
var it = self.metadata.iterator();
while (it.next()) |entry| {
var buf: [256]u8 = undefined;
const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch continue;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
key.ptr,
null,
)) return true;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
entry.value_ptr.ptr,
null,
)) return true;
}
return false;
}
/// Encode a parser action as a string that we show in the logs.
fn encodeAction(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
action: terminal.Parser.Action,
) !void {
switch (action) {
.print => try encodePrint(writer, action),
.execute => try encodeExecute(writer, action),
.csi_dispatch => |v| try encodeCSI(writer, v),
.esc_dispatch => |v| try encodeEsc(writer, v),
.osc_dispatch => |v| try encodeOSC(alloc, writer, md, v),
else => try writer.print("{f}", .{action}),
}
}
fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.print;
try writer.print("'{u}' (U+{X})", .{ ch, ch });
}
fn encodeExecute(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.execute;
switch (ch) {
0x00 => try writer.writeAll("NUL"),
0x01 => try writer.writeAll("SOH"),
0x02 => try writer.writeAll("STX"),
0x03 => try writer.writeAll("ETX"),
0x04 => try writer.writeAll("EOT"),
0x05 => try writer.writeAll("ENQ"),
0x06 => try writer.writeAll("ACK"),
0x07 => try writer.writeAll("BEL"),
0x08 => try writer.writeAll("BS"),
0x09 => try writer.writeAll("HT"),
0x0A => try writer.writeAll("LF"),
0x0B => try writer.writeAll("VT"),
0x0C => try writer.writeAll("FF"),
0x0D => try writer.writeAll("CR"),
0x0E => try writer.writeAll("SO"),
0x0F => try writer.writeAll("SI"),
else => try writer.writeAll("?"),
}
try writer.print(" (0x{X})", .{ch});
}
fn encodeCSI(writer: *std.Io.Writer, csi: terminal.Parser.Action.CSI) !void {
for (csi.intermediates) |v| try writer.print("{c} ", .{v});
for (csi.params, 0..) |v, i| {
if (i != 0) try writer.writeByte(';');
try writer.print("{d}", .{v});
}
if (csi.intermediates.len > 0 or csi.params.len > 0) try writer.writeByte(' ');
try writer.writeByte(csi.final);
}
fn encodeEsc(writer: *std.Io.Writer, esc: terminal.Parser.Action.ESC) !void {
for (esc.intermediates) |v| try writer.print("{c} ", .{v});
try writer.writeByte(esc.final);
}
fn encodeOSC(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
osc: terminal.osc.Command,
) !void {
// The description is just the tag
try writer.print("{s} ", .{@tagName(osc)});
// Add additional fields to metadata
switch (osc) {
inline else => |v, tag| if (tag == osc) {
try encodeMetadata(alloc, md, v);
},
}
}
fn encodeMetadata(
alloc: Allocator,
md: *Metadata,
v: anytype,
) !void {
switch (@TypeOf(v)) {
void => {},
[]const u8,
[:0]const u8,
=> try md.put("data", try alloc.dupeZ(u8, v)),
else => |T| switch (@typeInfo(T)) {
.@"struct" => |info| inline for (info.fields) |field| {
try encodeMetadataSingle(
alloc,
md,
field.name,
@field(v, field.name),
);
},
.@"union" => |info| {
const Tag = info.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, v));
inline for (info.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
if (field.type == void) {
break try md.put("data", tag_name);
} else {
break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name));
}
}
}
},
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
}
}
fn encodeMetadataSingle(
alloc: Allocator,
md: *Metadata,
key: []const u8,
value: anytype,
) !void {
const Value = @TypeOf(value);
const info = @typeInfo(Value);
switch (info) {
.optional => if (value) |unwrapped| {
try encodeMetadataSingle(alloc, md, key, unwrapped);
} else {
try md.put(key, try alloc.dupeZ(u8, "(unset)"));
},
.bool => try md.put(
key,
try alloc.dupeZ(u8, if (value) "true" else "false"),
),
.@"enum" => try md.put(
key,
try alloc.dupeZ(u8, @tagName(value)),
),
.@"union" => |u| {
const Tag = u.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, value));
inline for (u.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
const s = if (field.type == void)
try alloc.dupeZ(u8, tag_name)
else if (field.type == [:0]const u8 or field.type == []const u8)
try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{
tag_name,
@field(value, field.name),
}, 0)
else
try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{
tag_name,
@field(value, field.name),
}, 0);
try md.put(key, s);
}
}
},
.@"struct" => try md.put(
key,
try alloc.dupeZ(u8, @typeName(Value)),
),
else => switch (Value) {
[]const u8,
[:0]const u8,
=> try md.put(key, try alloc.dupeZ(u8, value)),
else => |T| switch (@typeInfo(T)) {
.int => try md.put(
key,
try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0),
),
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
},
}
}
};
/// Our VT stream handler.
pub const VTHandler = struct {
/// The capture state, must be set before use. If null, then
/// events are dropped.
state: ?State,
/// True to pause this artificially.
paused: bool,
/// Current sequence number
current_seq: usize,
/// Exclude certain actions by tag.
filter_exclude: ActionTagSet,
filter_text: cimgui.c.ImGuiTextFilter,
pub const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag);
pub const State = struct {
/// The allocator to use for the events.
alloc: Allocator,
/// The terminal state at the time of the event.
terminal: *const terminal.Terminal,
/// The event ring to write events to.
events: *VTEventRing,
};
pub const init: VTHandler = .{
.state = null,
.paused = false,
.current_seq = 1,
.filter_exclude = .initMany(&.{.print}),
.filter_text = .{},
};
pub fn deinit(self: *VTHandler) void {
// Required for the parser stream interface
_ = self;
}
pub fn vt(
self: *VTHandler,
comptime action: ParserStream.Action.Tag,
value: ParserStream.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 state: *State = if (self.state) |*s| s else return true;
const alloc = state.alloc;
const vt_events = state.events;
// We always increment the sequence number, even if we're paused or
// filter out the event. This helps show the user that there is a gap
// between events and roughly how large that gap was.
defer self.current_seq +%= 1;
// If we're manually paused, we ignore all events.
if (self.paused) return true;
// We ignore certain action types that are too noisy.
switch (action) {
.dcs_put, .apc_put => return true,
else => {},
}
// If we requested a specific type to be ignored, ignore it.
// We return true because we did "handle" it by ignoring it.
if (self.filter_exclude.contains(std.meta.activeTag(action))) return true;
// Build our event
var ev: VTEvent = try .init(
alloc,
state.terminal,
action,
);
ev.seq = self.current_seq;
errdefer ev.deinit(alloc);
// Check if the event passes the filter
if (!ev.passFilter(&self.filter_text)) {
ev.deinit(alloc);
return true;
}
const max_capacity = 100;
vt_events.append(ev) catch |err| switch (err) {
error.OutOfMemory => if (vt_events.capacity() < max_capacity) {
// We're out of memory, but we can allocate to our capacity.
const new_capacity = @min(vt_events.capacity() * 2, max_capacity);
try vt_events.resize(alloc, new_capacity);
try vt_events.append(ev);
} else {
var it = vt_events.iterator(.forward);
if (it.next()) |old_ev| old_ev.deinit(alloc);
vt_events.deleteOldest(1);
try vt_events.append(ev);
},
else => return err,
};
return true;
}
};
/// Enum representing keyboard navigation actions
const KeyAction = enum {
down,
none,
up,
};
/// VT event stream inspector widget.
pub const Stream = struct {
events: VTEventRing,
parser_stream: ParserStream,
events: VTEvent.Ring,
parser_stream: VTHandler.Stream,
/// The currently selected event sequence number for keyboard navigation
selected_event_seq: ?u32 = null,
@@ -437,7 +21,7 @@ pub const Stream = struct {
is_keyboard_selection: bool = false,
pub fn init(alloc: Allocator) !Stream {
var events: VTEventRing = try .init(alloc, 2);
var events: VTEvent.Ring = try .init(alloc, 2);
errdefer events.deinit(alloc);
var handler: VTHandler = .init;
@@ -457,6 +41,21 @@ pub const Stream = struct {
self.parser_stream.deinit();
}
pub fn recordPtyRead(
self: *Stream,
alloc: Allocator,
t: *terminal.Terminal,
data: []const u8,
) !void {
self.parser_stream.handler.state = .{
.alloc = alloc,
.terminal = t,
.events = &self.events,
};
defer self.parser_stream.handler.state = null;
try self.parser_stream.nextSlice(data);
}
pub fn draw(
self: *Stream,
alloc: Allocator,
@@ -742,3 +341,420 @@ fn getKeyAction() KeyAction {
}
return .none;
}
/// VT event. This isn't public because this is just how we store internal
/// events.
const VTEvent = struct {
/// Sequence number, just monotonically increasing.
seq: usize = 1,
/// Kind of event, for filtering
kind: Kind,
/// The formatted string of the event. This is allocated. We format the
/// event for now because there is so much data to copy if we wanted to
/// store the raw event.
str: [:0]const u8,
/// Various metadata at the time of the event (before processing).
cursor: terminal.Screen.Cursor,
scrolling_region: terminal.Terminal.ScrollingRegion,
metadata: Metadata.Unmanaged = .{},
/// imgui selection state
imgui_selected: bool = false,
const Kind = enum { print, execute, csi, esc, osc, dcs, apc };
const Metadata = std.StringHashMap([:0]const u8);
/// Circular buffer of VT events.
pub const Ring = CircBuf(VTEvent, undefined);
/// Initialize the event information for the given parser action.
pub fn init(
alloc: Allocator,
t: *const terminal.Terminal,
action: terminal.Parser.Action,
) !VTEvent {
var md = Metadata.init(alloc);
errdefer md.deinit();
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
try encodeAction(alloc, &buf.writer, &md, action);
const str = try buf.toOwnedSliceSentinel(0);
errdefer alloc.free(str);
const kind: Kind = switch (action) {
.print => .print,
.execute => .execute,
.csi_dispatch => .csi,
.esc_dispatch => .esc,
.osc_dispatch => .osc,
.dcs_hook, .dcs_put, .dcs_unhook => .dcs,
.apc_start, .apc_put, .apc_end => .apc,
};
return .{
.kind = kind,
.str = str,
.cursor = t.screens.active.cursor,
.scrolling_region = t.scrolling_region,
.metadata = md.unmanaged,
};
}
pub fn deinit(self: *VTEvent, alloc: Allocator) void {
{
var it = self.metadata.valueIterator();
while (it.next()) |v| alloc.free(v.*);
self.metadata.deinit(alloc);
}
alloc.free(self.str);
}
/// Returns true if the event passes the given filter.
pub fn passFilter(
self: *const VTEvent,
filter: *const cimgui.c.ImGuiTextFilter,
) bool {
// Check our main string
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
self.str.ptr,
null,
)) return true;
// We also check all metadata keys and values
var it = self.metadata.iterator();
while (it.next()) |entry| {
var buf: [256]u8 = undefined;
const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch continue;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
key.ptr,
null,
)) return true;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
entry.value_ptr.ptr,
null,
)) return true;
}
return false;
}
/// Encode a parser action as a string that we show in the logs.
fn encodeAction(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
action: terminal.Parser.Action,
) !void {
switch (action) {
.print => try encodePrint(writer, action),
.execute => try encodeExecute(writer, action),
.csi_dispatch => |v| try encodeCSI(writer, v),
.esc_dispatch => |v| try encodeEsc(writer, v),
.osc_dispatch => |v| try encodeOSC(alloc, writer, md, v),
else => try writer.print("{f}", .{action}),
}
}
fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.print;
try writer.print("'{u}' (U+{X})", .{ ch, ch });
}
fn encodeExecute(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.execute;
switch (ch) {
0x00 => try writer.writeAll("NUL"),
0x01 => try writer.writeAll("SOH"),
0x02 => try writer.writeAll("STX"),
0x03 => try writer.writeAll("ETX"),
0x04 => try writer.writeAll("EOT"),
0x05 => try writer.writeAll("ENQ"),
0x06 => try writer.writeAll("ACK"),
0x07 => try writer.writeAll("BEL"),
0x08 => try writer.writeAll("BS"),
0x09 => try writer.writeAll("HT"),
0x0A => try writer.writeAll("LF"),
0x0B => try writer.writeAll("VT"),
0x0C => try writer.writeAll("FF"),
0x0D => try writer.writeAll("CR"),
0x0E => try writer.writeAll("SO"),
0x0F => try writer.writeAll("SI"),
else => try writer.writeAll("?"),
}
try writer.print(" (0x{X})", .{ch});
}
fn encodeCSI(writer: *std.Io.Writer, csi: terminal.Parser.Action.CSI) !void {
for (csi.intermediates) |v| try writer.print("{c} ", .{v});
for (csi.params, 0..) |v, i| {
if (i != 0) try writer.writeByte(';');
try writer.print("{d}", .{v});
}
if (csi.intermediates.len > 0 or csi.params.len > 0) try writer.writeByte(' ');
try writer.writeByte(csi.final);
}
fn encodeEsc(writer: *std.Io.Writer, esc: terminal.Parser.Action.ESC) !void {
for (esc.intermediates) |v| try writer.print("{c} ", .{v});
try writer.writeByte(esc.final);
}
fn encodeOSC(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
osc: terminal.osc.Command,
) !void {
// The description is just the tag
try writer.print("{s} ", .{@tagName(osc)});
// Add additional fields to metadata
switch (osc) {
inline else => |v, tag| if (tag == osc) {
try encodeMetadata(alloc, md, v);
},
}
}
fn encodeMetadata(
alloc: Allocator,
md: *Metadata,
v: anytype,
) !void {
switch (@TypeOf(v)) {
void => {},
[]const u8,
[:0]const u8,
=> try md.put("data", try alloc.dupeZ(u8, v)),
else => |T| switch (@typeInfo(T)) {
.@"struct" => |info| inline for (info.fields) |field| {
try encodeMetadataSingle(
alloc,
md,
field.name,
@field(v, field.name),
);
},
.@"union" => |info| {
const Tag = info.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, v));
inline for (info.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
if (field.type == void) {
break try md.put("data", tag_name);
} else {
break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name));
}
}
}
},
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
}
}
fn encodeMetadataSingle(
alloc: Allocator,
md: *Metadata,
key: []const u8,
value: anytype,
) !void {
const Value = @TypeOf(value);
const info = @typeInfo(Value);
switch (info) {
.optional => if (value) |unwrapped| {
try encodeMetadataSingle(alloc, md, key, unwrapped);
} else {
try md.put(key, try alloc.dupeZ(u8, "(unset)"));
},
.bool => try md.put(
key,
try alloc.dupeZ(u8, if (value) "true" else "false"),
),
.@"enum" => try md.put(
key,
try alloc.dupeZ(u8, @tagName(value)),
),
.@"union" => |u| {
const Tag = u.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, value));
inline for (u.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
const s = if (field.type == void)
try alloc.dupeZ(u8, tag_name)
else if (field.type == [:0]const u8 or field.type == []const u8)
try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{
tag_name,
@field(value, field.name),
}, 0)
else
try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{
tag_name,
@field(value, field.name),
}, 0);
try md.put(key, s);
}
}
},
.@"struct" => try md.put(
key,
try alloc.dupeZ(u8, @typeName(Value)),
),
else => switch (Value) {
[]const u8,
[:0]const u8,
=> try md.put(key, try alloc.dupeZ(u8, value)),
else => |T| switch (@typeInfo(T)) {
.int => try md.put(
key,
try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0),
),
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
},
}
}
};
/// Our VT stream handler for the Stream widget. This isn't public
/// because there is no reason to use this directly.
const VTHandler = struct {
/// The capture state, must be set before use. If null, then
/// events are dropped.
state: ?State,
/// True to pause this artificially.
paused: bool,
/// Current sequence number
current_seq: usize,
/// Exclude certain actions by tag.
filter_exclude: ActionTagSet,
filter_text: cimgui.c.ImGuiTextFilter,
const Stream = terminal.Stream(VTHandler);
pub const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag);
pub const State = struct {
/// The allocator to use for the events.
alloc: Allocator,
/// The terminal state at the time of the event.
terminal: *const terminal.Terminal,
/// The event ring to write events to.
events: *VTEvent.Ring,
};
pub const init: VTHandler = .{
.state = null,
.paused = false,
.current_seq = 1,
.filter_exclude = .initMany(&.{.print}),
.filter_text = .{},
};
pub fn deinit(self: *VTHandler) void {
// Required for the parser stream interface
_ = self;
}
pub fn vt(
self: *VTHandler,
comptime action: VTHandler.Stream.Action.Tag,
value: VTHandler.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 state: *State = if (self.state) |*s| s else return true;
const alloc = state.alloc;
const vt_events = state.events;
// We always increment the sequence number, even if we're paused or
// filter out the event. This helps show the user that there is a gap
// between events and roughly how large that gap was.
defer self.current_seq +%= 1;
// If we're manually paused, we ignore all events.
if (self.paused) return true;
// We ignore certain action types that are too noisy.
switch (action) {
.dcs_put, .apc_put => return true,
else => {},
}
// If we requested a specific type to be ignored, ignore it.
// We return true because we did "handle" it by ignoring it.
if (self.filter_exclude.contains(std.meta.activeTag(action))) return true;
// Build our event
var ev: VTEvent = try .init(
alloc,
state.terminal,
action,
);
ev.seq = self.current_seq;
errdefer ev.deinit(alloc);
// Check if the event passes the filter
if (!ev.passFilter(&self.filter_text)) {
ev.deinit(alloc);
return true;
}
const max_capacity = 100;
vt_events.append(ev) catch |err| switch (err) {
error.OutOfMemory => if (vt_events.capacity() < max_capacity) {
// We're out of memory, but we can allocate to our capacity.
const new_capacity = @min(vt_events.capacity() * 2, max_capacity);
try vt_events.resize(alloc, new_capacity);
try vt_events.append(ev);
} else {
var it = vt_events.iterator(.forward);
if (it.next()) |old_ev| old_ev.deinit(alloc);
vt_events.deleteOldest(1);
try vt_events.append(ev);
},
else => return err,
};
return true;
}
};
/// Enum representing keyboard navigation actions
const KeyAction = enum {
down,
none,
up,
};