Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/zig/build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
.hash = "uucode-0.2.0-ZZjBPuS4VABmt0lQ-jDiKB4afmuZVvZpbX09FP5JrR8N",
},
.ghostty = .{
.url = "https://github.com/ghostty-org/ghostty/archive/b988efcfe584e88a3d0330e2c17c386ffa419d72.tar.gz",
.hash = "ghostty-1.3.2-dev-5UdBC9WdJAU1k1nuTdAexwmdzbmW1VTy6KhL_EfHjoTt",
.url = "https://github.com/ghostty-org/ghostty/archive/727b8a02f8734840de664c060678dd66f01931f6.tar.gz",
.hash = "ghostty-1.3.2-dev-5UdBC1isOwU05h63E04-9yG3l4mK3tQ3kUxAs9P2rUPH",
.lazy = true,
},
.yoga = .{
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/zig/embedded-terminal/ghostty.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const vt = @import("../ghostty-vt.zig").vt;

pub const Terminal = vt.Terminal;
pub const TerminalStream = vt.TerminalStream;
pub const Coordinate = vt.Coordinate;

pub const Key = struct {
action: vt.input.KeyAction = .press,
key: vt.input.Key = .unidentified,
mods: vt.input.KeyMods = .{},
consumed_mods: vt.input.KeyMods = .{},
composing: bool = false,
utf8: []const u8 = "",
unshifted_codepoint: u21 = 0,

pub fn event(self: Key) vt.input.KeyEvent {
return .{
.action = self.action,
.key = self.key,
.mods = self.mods,
.consumed_mods = self.consumed_mods,
.composing = self.composing,
.utf8 = self.utf8,
.unshifted_codepoint = self.unshifted_codepoint,
};
}
};

pub const Mouse = struct {
action: vt.input.MouseAction,
button: ?vt.input.MouseButton = null,
mods: vt.input.KeyMods = .{},
x: f32,
y: f32,
any_button_pressed: bool = false,

pub fn event(self: Mouse) vt.input.MouseEncodeEvent {
return .{
.action = self.action,
.button = self.button,
.mods = self.mods,
.pos = .{ .x = self.x, .y = self.y },
};
}
};

pub const KeyEncodeOptions = vt.input.KeyEncodeOptions;
pub const MouseEncodeOptions = vt.input.MouseEncodeOptions;
pub const PasteOptions = vt.input.PasteOptions;
pub const FocusEvent = vt.input.FocusEvent;
pub const encodeKey = vt.input.encodeKey;
pub const encodeMouse = vt.input.encodeMouse;
pub const encodePaste = vt.input.encodePaste;
pub const encodeFocus = vt.input.encodeFocus;
155 changes: 155 additions & 0 deletions packages/core/src/zig/embedded-terminal/main.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
const std = @import("std");
const ghostty = @import("ghostty.zig");

pub const Error = error{
InvalidValue,
ProcessingFailed,
ResponseOverflow,
} || std.mem.Allocator.Error;

pub const Options = struct {
cols: u16,
rows: u16,
max_scrollback: usize = 10_000,
};

pub const response_limit = 1024 * 1024;

pub const EmbeddedTerminal = struct {
allocator: std.mem.Allocator,
terminal: ghostty.Terminal,
stream: ghostty.TerminalStream,
cols: u16,
rows: u16,
responses: std.ArrayListUnmanaged(u8) = .empty,
response_error: ?Error = null,
mouse_last_cell: ?ghostty.Coordinate = null,

pub fn init(io: std.Io, allocator: std.mem.Allocator, options: Options) Error!*EmbeddedTerminal {
if (options.cols == 0 or options.rows == 0) return error.InvalidValue;

const self = try allocator.create(EmbeddedTerminal);
errdefer allocator.destroy(self);

self.* = .{
.allocator = allocator,
.terminal = try .init(io, allocator, .{
.cols = options.cols,
.rows = options.rows,
.max_scrollback_bytes = options.max_scrollback,
}),
.stream = undefined,
.cols = options.cols,
.rows = options.rows,
};
errdefer self.terminal.deinit(allocator);

var handler = self.terminal.vtHandler();
handler.effects.write_pty = &writePty;
self.stream = .init(.{ .allocator = allocator, .handler = handler });
return self;
}

pub fn deinit(self: *EmbeddedTerminal) void {
const allocator = self.allocator;
self.stream.deinit();
self.terminal.deinit(allocator);
self.responses.deinit(allocator);
allocator.destroy(self);
}

pub fn write(self: *EmbeddedTerminal, bytes: []const u8) Error!void {
self.stream.handler.semantic_failure = false;
self.stream.nextSlice(bytes);
if (self.stream.handler.semantic_failure) return error.ProcessingFailed;
}

pub fn resize(self: *EmbeddedTerminal, cols: u16, rows: u16) Error!void {
if (cols == 0 or rows == 0) return error.InvalidValue;
self.stream.handler.resize(.{ .cols = cols, .rows = rows }) catch |err| switch (err) {
error.InvalidValue => return error.InvalidValue,
error.OutOfMemory => return error.OutOfMemory,
};
self.cols = cols;
self.rows = rows;
self.mouse_last_cell = null;
}

pub fn scroll(self: *EmbeddedTerminal, delta: i32) void {
self.terminal.scrollViewport(.{ .delta = delta });
}

pub fn encodeKey(self: *EmbeddedTerminal, key: ghostty.Key) Error![]u8 {
var output: std.Io.Writer.Allocating = .init(self.allocator);
errdefer output.deinit();
ghostty.encodeKey(&output.writer, key.event(), .fromTerminal(&self.terminal)) catch return error.OutOfMemory;
return output.toOwnedSlice();
}

pub fn encodeMouse(self: *EmbeddedTerminal, mouse: ghostty.Mouse) Error![]u8 {
const MouseSize = @FieldType(ghostty.MouseEncodeOptions, "size");
const size: MouseSize = .{
.screen = .{ .width = self.cols, .height = self.rows },
.cell = .{ .width = 1, .height = 1 },
.padding = .{},
};
var options = ghostty.MouseEncodeOptions.fromTerminal(&self.terminal, size);
// OpenTUI receives terminal-cell coordinates, not physical pixels.
if (options.format == .sgr_pixels) return self.allocator.alloc(u8, 0);
options.any_button_pressed = mouse.any_button_pressed;
options.last_cell = &self.mouse_last_cell;

var output: std.Io.Writer.Allocating = .init(self.allocator);
errdefer output.deinit();
ghostty.encodeMouse(&output.writer, mouse.event(), options) catch return error.OutOfMemory;
return output.toOwnedSlice();
}

pub fn encodePaste(self: *EmbeddedTerminal, input: []const u8) Error![]u8 {
const copy = try self.allocator.dupe(u8, input);
defer self.allocator.free(copy);
const parts = ghostty.encodePaste(copy, .fromTerminal(&self.terminal));

var output: std.Io.Writer.Allocating = .init(self.allocator);
errdefer output.deinit();
for (parts) |part| output.writer.writeAll(part) catch return error.OutOfMemory;
return output.toOwnedSlice();
}

pub fn encodeFocus(self: *EmbeddedTerminal, focused: bool) Error![]u8 {
if (!self.terminal.modes.get(.focus_event)) return self.allocator.alloc(u8, 0);

var output: std.Io.Writer.Allocating = .init(self.allocator);
errdefer output.deinit();
ghostty.encodeFocus(&output.writer, if (focused) .gained else .lost) catch return error.OutOfMemory;
return output.toOwnedSlice();
}

pub fn freeEncoded(self: *EmbeddedTerminal, bytes: []u8) void {
self.allocator.free(bytes);
}

pub fn drainResponses(self: *EmbeddedTerminal, output: []u8) Error!usize {
if (self.response_error) |err| {
self.response_error = null;
return err;
}
const count = @min(output.len, self.responses.items.len);
@memcpy(output[0..count], self.responses.items[0..count]);
std.mem.copyForwards(u8, self.responses.items[0 .. self.responses.items.len - count], self.responses.items[count..]);
self.responses.items.len -= count;
return count;
}

fn writePty(handler: *ghostty.TerminalStream.Handler, data: [:0]const u8) void {
const self: *EmbeddedTerminal = @fieldParentPtr("terminal", handler.terminal);
if (self.response_error != null) return;
if (data.len > response_limit - @min(self.responses.items.len, response_limit)) {
self.response_error = error.ResponseOverflow;
return;
}
self.responses.appendSlice(self.allocator, data) catch {
self.response_error = error.OutOfMemory;
};
}
};
128 changes: 128 additions & 0 deletions packages/core/src/zig/embedded-terminal/tests.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
const std = @import("std");
const EmbeddedTerminal = @import("main.zig").EmbeddedTerminal;
const ghostty = @import("ghostty.zig");

test "embedded terminal supports lifecycle, resize, and viewport scroll" {
const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 80, .rows = 24 });
defer terminal.deinit();

try terminal.write("hello");
try terminal.resize(100, 40);
terminal.scroll(-3);
terminal.scroll(3);

try std.testing.expectEqual(@as(u16, 100), terminal.cols);
try std.testing.expectEqual(@as(u16, 40), terminal.rows);
try std.testing.expectError(error.InvalidValue, terminal.resize(0, 40));
try std.testing.expectError(error.InvalidValue, terminal.resize(100, 0));
try std.testing.expectError(error.InvalidValue, EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 0, .rows = 24 }));
}

test "embedded terminal preserves parser state and provides mode-aware input" {
const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 });
defer terminal.deinit();

try terminal.write("\x1b[?20");
try terminal.write("04h\x1b[?1004h\x1b[?1000h\x1b[?1006h");

const paste = try terminal.encodePaste("one\ntwo");
defer terminal.freeEncoded(paste);
try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", paste);

const focus = try terminal.encodeFocus(true);
defer terminal.freeEncoded(focus);
try std.testing.expectEqualStrings("\x1b[I", focus);

const key = try terminal.encodeKey(.{ .key = .enter });
defer terminal.freeEncoded(key);
try std.testing.expectEqualStrings("\r", key);

const mouse = try terminal.encodeMouse(.{
.action = .press,
.button = .left,
.x = 0,
.y = 0,
.any_button_pressed = true,
});
defer terminal.freeEncoded(mouse);
try std.testing.expectEqualStrings("\x1b[<0;1;1M", mouse);
}

test "embedded terminal encodes long Kitty associated text" {
const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 });
defer terminal.deinit();
try terminal.write("\x1b[>19u");

const text = "x" ** 2048;
const encoded = try terminal.encodeKey(.{ .key = .unidentified, .utf8 = text });
defer terminal.freeEncoded(encoded);
try std.testing.expectEqualStrings(text, encoded);
}

test "embedded terminal drains generated PTY responses incrementally" {
const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 });
defer terminal.deinit();

try terminal.write("\x1b[5n");
var first: [2]u8 = undefined;
var rest: [16]u8 = undefined;
const first_len = try terminal.drainResponses(&first);
const rest_len = try terminal.drainResponses(&rest);

var combined: [18]u8 = undefined;
@memcpy(combined[0..first_len], first[0..first_len]);
@memcpy(combined[first_len .. first_len + rest_len], rest[0..rest_len]);
try std.testing.expectEqualStrings("\x1b[0n", combined[0 .. first_len + rest_len]);
}

test "embedded terminal preserves queued responses when the bound is reached" {
const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 });
defer terminal.deinit();

const query = "\x1b[5n";
const count = @import("main.zig").response_limit / query.len + 1;
const input = try std.testing.allocator.alloc(u8, count * query.len);
defer std.testing.allocator.free(input);
for (0..count) |index| @memcpy(input[index * query.len ..][0..query.len], query);

try terminal.write(input);
var byte: [1]u8 = undefined;
try std.testing.expectError(error.ResponseOverflow, terminal.drainResponses(&byte));

var preserved: [16]u8 = undefined;
const preserved_len = try terminal.drainResponses(&preserved);
try std.testing.expect(preserved_len > 0);
}

test "embedded terminal reports semantic failures per write" {
const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 });
defer terminal.deinit();

terminal.stream.handler.semantic_failure = true;
try terminal.write("ok");
try std.testing.expect(!terminal.stream.handler.semantic_failure);
}

test "embedded terminal resets mouse motion deduplication after resize" {
const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 });
defer terminal.deinit();
try terminal.write("\x1b[?1003h\x1b[?1006h");

const mouse: ghostty.Mouse = .{ .action = .motion, .x = 0, .y = 0 };
const first = try terminal.encodeMouse(mouse);
defer terminal.freeEncoded(first);
try std.testing.expect(first.len > 0);

const duplicate = try terminal.encodeMouse(mouse);
defer terminal.freeEncoded(duplicate);
try std.testing.expectEqual(@as(usize, 0), duplicate.len);

try terminal.resize(10, 4);
const after_resize = try terminal.encodeMouse(mouse);
defer terminal.freeEncoded(after_resize);
try std.testing.expect(after_resize.len > 0);
}

comptime {
_ = ghostty;
}
2 changes: 2 additions & 0 deletions packages/core/src/zig/test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const handles_tests = @import("tests/handles_test.zig");
const yoga_tests = @import("tests/yoga_test.zig");
const ansi_tests = @import("tests/ansi_test.zig");
const ghostty_vt_tests = @import("tests/ghostty_vt_test.zig");
const embedded_terminal_tests = @import("embedded-terminal/tests.zig");
const image_tests = @import("tests/image_test.zig");
const terminal_image_tests = @import("tests/terminal-image_test.zig");
const lib_tests = @import("lib.zig");
Expand Down Expand Up @@ -83,6 +84,7 @@ comptime {
_ = yoga_tests;
_ = ansi_tests;
_ = ghostty_vt_tests;
_ = embedded_terminal_tests;
_ = image_tests;
_ = terminal_image_tests;
_ = lib_tests;
Expand Down
Loading