terminal: add ReadonlyStream that updates terminal state (#9346)
This adds a new stream handler implementation that updates terminal state in reaction to VT sequences, but doesn't perform any of the actions that would require responses (e.g. queries). This is exposed in two ways: first, as a standalone `ReadonlyStream` and `ReadonlyHandler` type that contains all the implementation. Second, as a convenience func on `Terminal` as `vtStream` and `vtHandler` which return their respective types preconfigured to update the calling terminal state. This dramatically simplifies libghostty-vt usage from Zig (and will eventually be exposed to C, too) since a Terminal on its own is ready to go as a full VT parser and state machine without needing to build any custom types! There's a second big bonus here which is that our `stream_readonly.zig` tests are true end-to-end tests for raw bytes to terminal state. This will let us test a wider variety of situations more broadly. To start, there are only a handful of tests implemented here. **AI disclosure:** Amp wrote basically this whole thing, but I reviewed it. https://ampcode.com/threads/T-3490efd2-1137-4112-96f6-4bf8a0141ff5pull/9350/head
parent
186b91ef84
commit
580262c96f
|
|
@ -94,7 +94,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt]
|
||||
dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt, zig-vt-stream]
|
||||
name: Example ${{ matrix.dir }}
|
||||
runs-on: namespace-profile-ghostty-sm
|
||||
needs: test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# Example: `vtStream` API for Parsing Terminal Streams
|
||||
|
||||
This example demonstrates how to use the `vtStream` API to parse and process
|
||||
VT sequences. The `vtStream` API is ideal for read-only terminal applications
|
||||
that need to parse terminal output without responding to queries, such as:
|
||||
|
||||
- Replay tooling
|
||||
- CI log viewers
|
||||
- PaaS builder output
|
||||
- etc.
|
||||
|
||||
The stream processes VT escape sequences and updates terminal state, while
|
||||
ignoring sequences that require responses (like device status queries).
|
||||
|
||||
Requires the Zig version stated in the `build.zig.zon` file.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the program:
|
||||
|
||||
```shell-session
|
||||
zig build run
|
||||
```
|
||||
|
||||
The example will process various VT sequences including:
|
||||
|
||||
- Plain text output
|
||||
- ANSI color codes
|
||||
- Cursor positioning
|
||||
- Line clearing
|
||||
- Multiple line handling
|
||||
|
||||
And display the final terminal state after processing all sequences.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
const test_step = b.step("test", "Run unit tests");
|
||||
|
||||
const exe_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
if (b.lazyDependency("ghostty", .{})) |dep| {
|
||||
exe_mod.addImport(
|
||||
"ghostty-vt",
|
||||
dep.module("ghostty-vt"),
|
||||
);
|
||||
}
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "zig_vt_stream",
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
const exe_unit_tests = b.addTest(.{
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
|
||||
test_step.dependOn(&run_exe_unit_tests.step);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
.{
|
||||
.name = .zig_vt_stream,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x34c1f71303690b3f,
|
||||
.minimum_zig_version = "0.15.1",
|
||||
.dependencies = .{
|
||||
.ghostty = .{ .path = "../../" },
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
const std = @import("std");
|
||||
const ghostty_vt = @import("ghostty-vt");
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa: std.heap.DebugAllocator(.{}) = .init;
|
||||
defer _ = gpa.deinit();
|
||||
const alloc = gpa.allocator();
|
||||
|
||||
var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Create a read-only VT stream for parsing terminal sequences
|
||||
var stream = t.vtStream();
|
||||
defer stream.deinit();
|
||||
|
||||
// Basic text with newline
|
||||
try stream.nextSlice("Hello, World!\r\n");
|
||||
|
||||
// ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset
|
||||
try stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n");
|
||||
|
||||
// Cursor positioning: ESC[1;1H = move to row 1, column 1
|
||||
try stream.nextSlice("\x1b[1;1HTop-left corner\r\n");
|
||||
|
||||
// Cursor movement: ESC[5B = move down 5 lines
|
||||
try stream.nextSlice("\x1b[5B");
|
||||
try stream.nextSlice("Moved down!\r\n");
|
||||
|
||||
// Erase line: ESC[2K = clear entire line
|
||||
try stream.nextSlice("\x1b[2K");
|
||||
try stream.nextSlice("New content\r\n");
|
||||
|
||||
// Multiple lines
|
||||
try stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n");
|
||||
|
||||
// Get the final terminal state as a plain string
|
||||
const str = try t.plainString(alloc);
|
||||
defer alloc.free(str);
|
||||
std.debug.print("{s}\n", .{str});
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ const sgr = @import("sgr.zig");
|
|||
const Tabstops = @import("Tabstops.zig");
|
||||
const color = @import("color.zig");
|
||||
const mouse_shape_pkg = @import("mouse_shape.zig");
|
||||
const ReadonlyHandler = @import("stream_readonly.zig").Handler;
|
||||
const ReadonlyStream = @import("stream_readonly.zig").Stream;
|
||||
|
||||
const size = @import("size.zig");
|
||||
const pagepkg = @import("page.zig");
|
||||
|
|
@ -239,6 +241,19 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
|
|||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Return a terminal.Stream that can process VT streams and update this
|
||||
/// terminal state. The streams will only process read-only data that
|
||||
/// modifies terminal state. Sequences that query or otherwise require
|
||||
/// output will be ignored.
|
||||
pub fn vtStream(self: *Terminal) ReadonlyStream {
|
||||
return .initAlloc(self.gpa(), self.vtHandler());
|
||||
}
|
||||
|
||||
/// This is the handler-side only for vtStream.
|
||||
pub fn vtHandler(self: *Terminal) ReadonlyHandler {
|
||||
return .init(self);
|
||||
}
|
||||
|
||||
/// The general allocator we should use for this terminal.
|
||||
fn gpa(self: *Terminal) Allocator {
|
||||
return self.screen.alloc;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const ansi = @import("ansi.zig");
|
|||
const csi = @import("csi.zig");
|
||||
const hyperlink = @import("hyperlink.zig");
|
||||
const sgr = @import("sgr.zig");
|
||||
const stream_readonly = @import("stream_readonly.zig");
|
||||
const style = @import("style.zig");
|
||||
pub const apc = @import("apc.zig");
|
||||
pub const dcs = @import("dcs.zig");
|
||||
|
|
@ -36,6 +37,8 @@ pub const PageList = @import("PageList.zig");
|
|||
pub const Parser = @import("Parser.zig");
|
||||
pub const Pin = PageList.Pin;
|
||||
pub const Point = point.Point;
|
||||
pub const ReadonlyHandler = stream_readonly.Handler;
|
||||
pub const ReadonlyStream = stream_readonly.Stream;
|
||||
pub const Screen = @import("Screen.zig");
|
||||
pub const ScreenType = Terminal.ScreenType;
|
||||
pub const Scrollbar = PageList.Scrollbar;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const std = @import("std");
|
|||
const build_options = @import("terminal_options");
|
||||
const assert = std.debug.assert;
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const simd = @import("../simd/main.zig");
|
||||
const lib = @import("../lib/main.zig");
|
||||
const Parser = @import("Parser.zig");
|
||||
|
|
@ -29,6 +30,8 @@ const debug = false;
|
|||
|
||||
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
|
||||
|
||||
/// The possible actions that can be emitted by the Stream
|
||||
/// function for handling.
|
||||
pub const Action = union(Key) {
|
||||
print: Print,
|
||||
print_repeat: usize,
|
||||
|
|
@ -456,6 +459,8 @@ pub const Action = union(Key) {
|
|||
/// about in its pursuit of implementing a terminal emulator or other
|
||||
/// functionality.
|
||||
///
|
||||
/// The Handler type must also have a `deinit` function.
|
||||
///
|
||||
/// The "comptime" key is on purpose (vs. a standard Zig tagged union)
|
||||
/// because it allows the compiler to optimize away unimplemented actions.
|
||||
/// e.g. you don't need to pay a conditional branching cost on every single
|
||||
|
|
@ -476,6 +481,19 @@ pub fn Stream(comptime Handler: type) type {
|
|||
parser: Parser,
|
||||
utf8decoder: UTF8Decoder,
|
||||
|
||||
/// Initialize an allocation-free stream. This will preallocate various
|
||||
/// sizes as necessary and anything over that will be dropped. If you
|
||||
/// want to support more dynamic behavior use initAlloc instead.
|
||||
///
|
||||
/// As a concrete example of something that requires heap allocation,
|
||||
/// consider OSC 52 (clipboard operations) which can be arbitrarily
|
||||
/// large.
|
||||
///
|
||||
/// If you want to limit allocation size, use an allocator with
|
||||
/// a size limit with initAlloc.
|
||||
///
|
||||
/// This takes ownership of the handler and will call deinit
|
||||
/// when the stream is deinitialized.
|
||||
pub fn init(h: Handler) Self {
|
||||
return .{
|
||||
.handler = h,
|
||||
|
|
@ -484,8 +502,16 @@ pub fn Stream(comptime Handler: type) type {
|
|||
};
|
||||
}
|
||||
|
||||
/// Initialize the stream that supports heap allocation as necessary.
|
||||
pub fn initAlloc(alloc: Allocator, h: Handler) Self {
|
||||
var self: Self = .init(h);
|
||||
self.parser.osc_parser.alloc = alloc;
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.parser.deinit();
|
||||
self.handler.deinit();
|
||||
}
|
||||
|
||||
/// Process a string of characters.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,542 @@
|
|||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
const stream = @import("stream.zig");
|
||||
const Action = stream.Action;
|
||||
const CursorStyle = @import("Screen.zig").CursorStyle;
|
||||
const Mode = @import("modes.zig").Mode;
|
||||
const Terminal = @import("Terminal.zig");
|
||||
|
||||
/// This is a Stream implementation that processes actions against
|
||||
/// a Terminal and updates the Terminal state. It is called "readonly" because
|
||||
/// it only processes actions that modify terminal state, while ignoring
|
||||
/// any actions that require a response (like queries).
|
||||
///
|
||||
/// If you're implementing a terminal emulator that only needs to render
|
||||
/// output and doesn't need to respond (since it maybe isn't running the
|
||||
/// actual program), this is the stream type to use. For example, this is
|
||||
/// ideal for replay tooling, CI logs, PaaS builder output, etc.
|
||||
pub const Stream = stream.Stream(Handler);
|
||||
|
||||
/// See Stream, which is just the stream wrapper around this.
|
||||
///
|
||||
/// This isn't attached directly to Terminal because there is additional
|
||||
/// state and options we plan to add in the future, such as APC/DCS which
|
||||
/// don't make sense to me to add to the Terminal directly. Instead, you
|
||||
/// can call `vtHandler` on Terminal to initialize this handler.
|
||||
pub const Handler = struct {
|
||||
/// The terminal state to modify.
|
||||
terminal: *Terminal,
|
||||
|
||||
pub fn init(terminal: *Terminal) Handler {
|
||||
return .{
|
||||
.terminal = terminal,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Handler) void {
|
||||
// Currently does nothing but may in the future so callers should
|
||||
// call this.
|
||||
_ = self;
|
||||
}
|
||||
|
||||
pub fn vt(
|
||||
self: *Handler,
|
||||
comptime action: Action.Tag,
|
||||
value: Action.Value(action),
|
||||
) !void {
|
||||
switch (action) {
|
||||
.print => try self.terminal.print(value.cp),
|
||||
.print_repeat => try self.terminal.printRepeat(value),
|
||||
.backspace => self.terminal.backspace(),
|
||||
.carriage_return => self.terminal.carriageReturn(),
|
||||
.linefeed => try self.terminal.linefeed(),
|
||||
.index => try self.terminal.index(),
|
||||
.next_line => {
|
||||
try self.terminal.index();
|
||||
self.terminal.carriageReturn();
|
||||
},
|
||||
.reverse_index => self.terminal.reverseIndex(),
|
||||
.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 => {
|
||||
const blink = switch (value) {
|
||||
.default, .steady_block, .steady_bar, .steady_underline => false,
|
||||
.blinking_block, .blinking_bar, .blinking_underline => true,
|
||||
};
|
||||
const style: CursorStyle = switch (value) {
|
||||
.default, .blinking_block, .steady_block => .block,
|
||||
.blinking_bar, .steady_bar => .bar,
|
||||
.blinking_underline, .steady_underline => .underline,
|
||||
};
|
||||
self.terminal.modes.set(.cursor_blinking, blink);
|
||||
self.terminal.screen.cursor.cursor_style = style;
|
||||
},
|
||||
.erase_display_below => self.terminal.eraseDisplay(.below, value),
|
||||
.erase_display_above => self.terminal.eraseDisplay(.above, value),
|
||||
.erase_display_complete => 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),
|
||||
.horizontal_tab => try self.horizontalTab(value),
|
||||
.horizontal_tab_back => try self.horizontalTabBack(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(),
|
||||
.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 => {
|
||||
const v = self.terminal.modes.restore(value.mode);
|
||||
try self.setMode(value.mode, v);
|
||||
},
|
||||
.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 => self.terminal.saveCursor(),
|
||||
.restore_cursor => try self.terminal.restoreCursor(),
|
||||
.invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking),
|
||||
.configure_charset => self.terminal.configureCharset(value.slot, value.charset),
|
||||
.set_attribute => switch (value) {
|
||||
.unknown => {},
|
||||
else => self.terminal.setAttribute(value) catch {},
|
||||
},
|
||||
.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,
|
||||
.kitty_keyboard_push => self.terminal.screen.kitty_keyboard.push(value.flags),
|
||||
.kitty_keyboard_pop => self.terminal.screen.kitty_keyboard.pop(@intCast(value)),
|
||||
.kitty_keyboard_set => self.terminal.screen.kitty_keyboard.set(.set, value.flags),
|
||||
.kitty_keyboard_set_or => self.terminal.screen.kitty_keyboard.set(.@"or", value.flags),
|
||||
.kitty_keyboard_set_not => self.terminal.screen.kitty_keyboard.set(.not, value.flags),
|
||||
.modify_key_format => {
|
||||
self.terminal.flags.modify_other_keys_2 = false;
|
||||
switch (value) {
|
||||
.other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true,
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
.active_status_display => self.terminal.status_display = value,
|
||||
.decaln => try self.terminal.decaln(),
|
||||
.full_reset => self.terminal.fullReset(),
|
||||
.start_hyperlink => try self.terminal.screen.startHyperlink(value.uri, value.id),
|
||||
.end_hyperlink => self.terminal.screen.endHyperlink(),
|
||||
.prompt_start => {
|
||||
self.terminal.screen.cursor.page_row.semantic_prompt = .prompt;
|
||||
self.terminal.flags.shell_redraws_prompt = value.redraw;
|
||||
},
|
||||
.prompt_continuation => self.terminal.screen.cursor.page_row.semantic_prompt = .prompt_continuation,
|
||||
.prompt_end => self.terminal.markSemanticPrompt(.input),
|
||||
.end_of_input => self.terminal.markSemanticPrompt(.command),
|
||||
.end_of_command => self.terminal.screen.cursor.page_row.semantic_prompt = .input,
|
||||
.mouse_shape => self.terminal.mouse_shape = value,
|
||||
|
||||
// No supported DCS commands have any terminal-modifying effects,
|
||||
// but they may in the future. For now we just ignore it.
|
||||
.dcs_hook,
|
||||
.dcs_put,
|
||||
.dcs_unhook,
|
||||
=> {},
|
||||
|
||||
// APC can modify terminal state (Kitty graphics) but we don't
|
||||
// currently support it in the readonly stream.
|
||||
.apc_start,
|
||||
.apc_end,
|
||||
.apc_put,
|
||||
=> {},
|
||||
|
||||
// Have no terminal-modifying effect
|
||||
.bell,
|
||||
.enquiry,
|
||||
.request_mode,
|
||||
.request_mode_unknown,
|
||||
.size_report,
|
||||
.xtversion,
|
||||
.device_attributes,
|
||||
.device_status,
|
||||
.kitty_keyboard_query,
|
||||
.kitty_color_report,
|
||||
.color_operation,
|
||||
.window_title,
|
||||
.report_pwd,
|
||||
.show_desktop_notification,
|
||||
.progress_report,
|
||||
.clipboard_contents,
|
||||
.title_push,
|
||||
.title_pop,
|
||||
=> {},
|
||||
}
|
||||
}
|
||||
|
||||
inline fn horizontalTab(self: *Handler, count: u16) !void {
|
||||
for (0..count) |_| {
|
||||
const x = self.terminal.screen.cursor.x;
|
||||
try self.terminal.horizontalTab();
|
||||
if (x == self.terminal.screen.cursor.x) break;
|
||||
}
|
||||
}
|
||||
|
||||
inline fn horizontalTabBack(self: *Handler, count: u16) !void {
|
||||
for (0..count) |_| {
|
||||
const x = self.terminal.screen.cursor.x;
|
||||
try self.terminal.horizontalTabBack();
|
||||
if (x == self.terminal.screen.cursor.x) break;
|
||||
}
|
||||
}
|
||||
|
||||
fn setMode(self: *Handler, mode: Mode, enabled: bool) !void {
|
||||
// Set the mode on the terminal
|
||||
self.terminal.modes.set(mode, enabled);
|
||||
|
||||
// Some modes require additional processing
|
||||
switch (mode) {
|
||||
.autorepeat,
|
||||
.reverse_colors,
|
||||
=> {},
|
||||
|
||||
.origin => self.terminal.setCursorPos(1, 1),
|
||||
|
||||
.enable_left_and_right_margin => if (!enabled) {
|
||||
self.terminal.scrolling_region.left = 0;
|
||||
self.terminal.scrolling_region.right = self.terminal.cols - 1;
|
||||
},
|
||||
|
||||
.alt_screen_legacy => self.terminal.switchScreenMode(.@"47", enabled),
|
||||
.alt_screen => self.terminal.switchScreenMode(.@"1047", enabled),
|
||||
.alt_screen_save_cursor_clear_enter => self.terminal.switchScreenMode(.@"1049", enabled),
|
||||
|
||||
.save_cursor => if (enabled) {
|
||||
self.terminal.saveCursor();
|
||||
} else {
|
||||
try self.terminal.restoreCursor();
|
||||
},
|
||||
|
||||
.enable_mode_3 => {},
|
||||
|
||||
.@"132_column" => try self.terminal.deccolm(
|
||||
self.terminal.screen.alloc,
|
||||
if (enabled) .@"132_cols" else .@"80_cols",
|
||||
),
|
||||
|
||||
.synchronized_output,
|
||||
.linefeed,
|
||||
.in_band_size_reports,
|
||||
.focus_event,
|
||||
=> {},
|
||||
|
||||
.mouse_event_x10 => {
|
||||
if (enabled) {
|
||||
self.terminal.flags.mouse_event = .x10;
|
||||
} else {
|
||||
self.terminal.flags.mouse_event = .none;
|
||||
}
|
||||
},
|
||||
.mouse_event_normal => {
|
||||
if (enabled) {
|
||||
self.terminal.flags.mouse_event = .normal;
|
||||
} else {
|
||||
self.terminal.flags.mouse_event = .none;
|
||||
}
|
||||
},
|
||||
.mouse_event_button => {
|
||||
if (enabled) {
|
||||
self.terminal.flags.mouse_event = .button;
|
||||
} else {
|
||||
self.terminal.flags.mouse_event = .none;
|
||||
}
|
||||
},
|
||||
.mouse_event_any => {
|
||||
if (enabled) {
|
||||
self.terminal.flags.mouse_event = .any;
|
||||
} else {
|
||||
self.terminal.flags.mouse_event = .none;
|
||||
}
|
||||
},
|
||||
|
||||
.mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10,
|
||||
.mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10,
|
||||
.mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10,
|
||||
.mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10,
|
||||
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "basic print" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
try s.nextSlice("Hello");
|
||||
try testing.expectEqual(@as(usize, 5), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
||||
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("Hello", str);
|
||||
}
|
||||
|
||||
test "cursor movement" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Move cursor using escape sequences
|
||||
try s.nextSlice("Hello\x1B[1;1H");
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
||||
|
||||
// Move to position 2,3
|
||||
try s.nextSlice("\x1B[2;3H");
|
||||
try testing.expectEqual(@as(usize, 2), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 1), t.screen.cursor.y);
|
||||
}
|
||||
|
||||
test "erase operations" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 20, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Print some text
|
||||
try s.nextSlice("Hello World");
|
||||
try testing.expectEqual(@as(usize, 11), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
||||
|
||||
// Move cursor to position 1,6 and erase from cursor to end of line
|
||||
try s.nextSlice("\x1B[1;6H");
|
||||
try s.nextSlice("\x1B[K");
|
||||
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("Hello", str);
|
||||
}
|
||||
|
||||
test "tabs" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
try s.nextSlice("A\tB");
|
||||
try testing.expectEqual(@as(usize, 9), t.screen.cursor.x);
|
||||
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("A B", str);
|
||||
}
|
||||
|
||||
test "modes" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Test wraparound mode
|
||||
try testing.expect(t.modes.get(.wraparound));
|
||||
try s.nextSlice("\x1B[?7l"); // Disable wraparound
|
||||
try testing.expect(!t.modes.get(.wraparound));
|
||||
try s.nextSlice("\x1B[?7h"); // Enable wraparound
|
||||
try testing.expect(t.modes.get(.wraparound));
|
||||
}
|
||||
|
||||
test "scrolling regions" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Set scrolling region from line 5 to 20
|
||||
try s.nextSlice("\x1B[5;20r");
|
||||
try testing.expectEqual(@as(usize, 4), t.scrolling_region.top);
|
||||
try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom);
|
||||
try testing.expectEqual(@as(usize, 0), t.scrolling_region.left);
|
||||
try testing.expectEqual(@as(usize, 79), t.scrolling_region.right);
|
||||
}
|
||||
|
||||
test "charsets" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Configure G0 as DEC special graphics
|
||||
try s.nextSlice("\x1B(0");
|
||||
try s.nextSlice("`"); // Should print diamond character
|
||||
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("◆", str);
|
||||
}
|
||||
|
||||
test "alt screen" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Write to primary screen
|
||||
try s.nextSlice("Primary");
|
||||
try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen);
|
||||
|
||||
// Switch to alt screen
|
||||
try s.nextSlice("\x1B[?1049h");
|
||||
try testing.expectEqual(Terminal.ScreenType.alternate, t.active_screen);
|
||||
|
||||
// Write to alt screen
|
||||
try s.nextSlice("Alt");
|
||||
|
||||
// Switch back to primary
|
||||
try s.nextSlice("\x1B[?1049l");
|
||||
try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen);
|
||||
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("Primary", str);
|
||||
}
|
||||
|
||||
test "cursor save and restore" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Move cursor to 10,15
|
||||
try s.nextSlice("\x1B[10;15H");
|
||||
try testing.expectEqual(@as(usize, 14), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 9), t.screen.cursor.y);
|
||||
|
||||
// Save cursor
|
||||
try s.nextSlice("\x1B7");
|
||||
|
||||
// Move cursor elsewhere
|
||||
try s.nextSlice("\x1B[1;1H");
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
||||
|
||||
// Restore cursor
|
||||
try s.nextSlice("\x1B8");
|
||||
try testing.expectEqual(@as(usize, 14), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 9), t.screen.cursor.y);
|
||||
}
|
||||
|
||||
test "attributes" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Set bold and write text
|
||||
try s.nextSlice("\x1B[1mBold\x1B[0m");
|
||||
|
||||
// Verify we can write attributes - just check the string was written
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("Bold", str);
|
||||
}
|
||||
|
||||
test "DECALN screen alignment" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 3 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Run DECALN
|
||||
try s.nextSlice("\x1B#8");
|
||||
|
||||
// Verify entire screen is filled with 'E'
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str);
|
||||
|
||||
// Cursor should be at 1,1
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
||||
}
|
||||
|
||||
test "full reset" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// Make some changes
|
||||
try s.nextSlice("Hello");
|
||||
try s.nextSlice("\x1B[10;20H");
|
||||
try s.nextSlice("\x1B[5;20r"); // Set scroll region
|
||||
try s.nextSlice("\x1B[?7l"); // Disable wraparound
|
||||
|
||||
// Full reset
|
||||
try s.nextSlice("\x1Bc");
|
||||
|
||||
// Verify reset state
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
|
||||
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
||||
try testing.expectEqual(@as(usize, 0), t.scrolling_region.top);
|
||||
try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom);
|
||||
try testing.expect(t.modes.get(.wraparound));
|
||||
}
|
||||
|
||||
test "ignores query actions" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
// These should be ignored without error
|
||||
try s.nextSlice("\x1B[c"); // Device attributes
|
||||
try s.nextSlice("\x1B[5n"); // Device status report
|
||||
try s.nextSlice("\x1B[6n"); // Cursor position report
|
||||
|
||||
// Terminal should still be functional
|
||||
try s.nextSlice("Test");
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("Test", str);
|
||||
}
|
||||
|
|
@ -313,13 +313,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
|
|||
.size = opts.size,
|
||||
.backend = backend,
|
||||
.mailbox = opts.mailbox,
|
||||
.terminal_stream = stream: {
|
||||
var s: terminalpkg.Stream(StreamHandler) = .init(handler);
|
||||
// Populate the OSC parser allocator (optional) because
|
||||
// we want to support large OSC payloads such as OSC 52.
|
||||
s.parser.osc_parser.alloc = alloc;
|
||||
break :stream s;
|
||||
},
|
||||
.terminal_stream = .initAlloc(alloc, handler),
|
||||
.thread_enter_state = thread_enter_state,
|
||||
};
|
||||
}
|
||||
|
|
@ -331,7 +325,6 @@ pub fn deinit(self: *Termio) void {
|
|||
self.mailbox.deinit(self.alloc);
|
||||
|
||||
// Clear any StreamHandler state
|
||||
self.terminal_stream.handler.deinit();
|
||||
self.terminal_stream.deinit();
|
||||
|
||||
// Clear any initial state if we have it
|
||||
|
|
|
|||
Loading…
Reference in New Issue