diff --git a/packages/core/src/zig/build.zig.zon b/packages/core/src/zig/build.zig.zon index b3f8f4389..a498aecd7 100644 --- a/packages/core/src/zig/build.zig.zon +++ b/packages/core/src/zig/build.zig.zon @@ -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 = .{ diff --git a/packages/core/src/zig/embedded-terminal/ghostty.zig b/packages/core/src/zig/embedded-terminal/ghostty.zig new file mode 100644 index 000000000..8c2ca3ead --- /dev/null +++ b/packages/core/src/zig/embedded-terminal/ghostty.zig @@ -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; diff --git a/packages/core/src/zig/embedded-terminal/main.zig b/packages/core/src/zig/embedded-terminal/main.zig new file mode 100644 index 000000000..3e17d8d3f --- /dev/null +++ b/packages/core/src/zig/embedded-terminal/main.zig @@ -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; + }; + } +}; diff --git a/packages/core/src/zig/embedded-terminal/tests.zig b/packages/core/src/zig/embedded-terminal/tests.zig new file mode 100644 index 000000000..678a99f8c --- /dev/null +++ b/packages/core/src/zig/embedded-terminal/tests.zig @@ -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; +} diff --git a/packages/core/src/zig/test.zig b/packages/core/src/zig/test.zig index 964482bb6..9f5061de3 100644 --- a/packages/core/src/zig/test.zig +++ b/packages/core/src/zig/test.zig @@ -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"); @@ -83,6 +84,7 @@ comptime { _ = yoga_tests; _ = ansi_tests; _ = ghostty_vt_tests; + _ = embedded_terminal_tests; _ = image_tests; _ = terminal_image_tests; _ = lib_tests;