aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-07 18:29:21 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-07 18:29:21 +0300
commitdcfe11a7588fcd42471159453810abc9b7720a57 (patch)
treec32a77266028e73ca2382b96c96f8438a5fcd621 /vendor/libghostty-vt/src
parent2dfed860103c4e4505ac7f25def9fe37da1e1e6a (diff)
build: update libghostty-vt vendor
vendor ghostty 063ac3e and remove the musl libc++ link workaround. expose the new selection formatter and hyperlink uri apis for follow-up rendering work. add manual artifact validation for static linux builds.
Diffstat (limited to 'vendor/libghostty-vt/src')
-rw-r--r--vendor/libghostty-vt/src/App.zig19
-rw-r--r--vendor/libghostty-vt/src/Command.zig22
-rw-r--r--vendor/libghostty-vt/src/Surface.zig62
-rw-r--r--vendor/libghostty-vt/src/apprt/embedded.zig24
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig3
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/window.zig5
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp13
-rw-r--r--vendor/libghostty-vt/src/benchmark/AGENTS.md48
-rw-r--r--vendor/libghostty-vt/src/benchmark/TerminalParser.zig16
-rw-r--r--vendor/libghostty-vt/src/build/CombineArchivesStep.zig50
-rw-r--r--vendor/libghostty-vt/src/build/Config.zig60
-rw-r--r--vendor/libghostty-vt/src/build/GhosttyLib.zig119
-rw-r--r--vendor/libghostty-vt/src/build/GhosttyLibVt.zig306
-rw-r--r--vendor/libghostty-vt/src/build/GhosttyXCFramework.zig18
-rw-r--r--vendor/libghostty-vt/src/build/GhosttyZig.zig62
-rw-r--r--vendor/libghostty-vt/src/build/GitVersion.zig13
-rw-r--r--vendor/libghostty-vt/src/build/SharedDeps.zig54
-rw-r--r--vendor/libghostty-vt/src/build/combine_archives.zig55
-rw-r--r--vendor/libghostty-vt/src/cli/list_fonts.zig8
-rw-r--r--vendor/libghostty-vt/src/config.zig1
-rw-r--r--vendor/libghostty-vt/src/config/CApi.zig4
-rw-r--r--vendor/libghostty-vt/src/config/Config.zig61
-rw-r--r--vendor/libghostty-vt/src/font/DeferredFace.zig71
-rw-r--r--vendor/libghostty-vt/src/font/SharedGridSet.zig2
-rw-r--r--vendor/libghostty-vt/src/font/backend.zig18
-rw-r--r--vendor/libghostty-vt/src/font/discovery.zig314
-rw-r--r--vendor/libghostty-vt/src/font/face.zig1
-rw-r--r--vendor/libghostty-vt/src/font/library.zig1
-rw-r--r--vendor/libghostty-vt/src/font/opentype.zig2
-rw-r--r--vendor/libghostty-vt/src/font/opentype/glyf.zig585
-rw-r--r--vendor/libghostty-vt/src/font/shape.zig1
-rw-r--r--vendor/libghostty-vt/src/font/shaper/coretext.zig4
-rw-r--r--vendor/libghostty-vt/src/font/shaper/harfbuzz.zig4
-rw-r--r--vendor/libghostty-vt/src/input/Binding.zig47
-rw-r--r--vendor/libghostty-vt/src/input/function_keys.zig7
-rw-r--r--vendor/libghostty-vt/src/input/key_encode.zig120
-rw-r--r--vendor/libghostty-vt/src/lib_vt.zig69
-rw-r--r--vendor/libghostty-vt/src/main_c.zig12
-rw-r--r--vendor/libghostty-vt/src/os/TempDir.zig33
-rw-r--r--vendor/libghostty-vt/src/os/file.zig103
-rw-r--r--vendor/libghostty-vt/src/os/flatpak.zig15
-rw-r--r--vendor/libghostty-vt/src/os/i18n_locales.zig3
-rw-r--r--vendor/libghostty-vt/src/os/main.zig2
-rw-r--r--vendor/libghostty-vt/src/os/windows.zig6
-rw-r--r--vendor/libghostty-vt/src/renderer/State.zig40
-rw-r--r--vendor/libghostty-vt/src/renderer/image.zig2
-rw-r--r--vendor/libghostty-vt/src/simd/codepoint_width.cpp113
-rw-r--r--vendor/libghostty-vt/src/simd/index_of.cpp2
-rw-r--r--vendor/libghostty-vt/src/simd/index_of.h24
-rw-r--r--vendor/libghostty-vt/src/simd/vt.cpp213
-rw-r--r--vendor/libghostty-vt/src/simd/vt.zig373
-rw-r--r--vendor/libghostty-vt/src/synthetic/Utf8.zig151
-rw-r--r--vendor/libghostty-vt/src/synthetic/cli/Utf8.zig67
-rw-r--r--vendor/libghostty-vt/src/terminal/PageList.zig11
-rw-r--r--vendor/libghostty-vt/src/terminal/Screen.zig92
-rw-r--r--vendor/libghostty-vt/src/terminal/Terminal.zig91
-rw-r--r--vendor/libghostty-vt/src/terminal/apc.zig76
-rw-r--r--vendor/libghostty-vt/src/terminal/build_options.zig40
-rw-r--r--vendor/libghostty-vt/src/terminal/c/AGENTS.md7
-rw-r--r--vendor/libghostty-vt/src/terminal/c/build_info.zig18
-rw-r--r--vendor/libghostty-vt/src/terminal/c/cell.zig60
-rw-r--r--vendor/libghostty-vt/src/terminal/c/formatter.zig58
-rw-r--r--vendor/libghostty-vt/src/terminal/c/grid_ref.zig96
-rw-r--r--vendor/libghostty-vt/src/terminal/c/key_encode.zig7
-rw-r--r--vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig1710
-rw-r--r--vendor/libghostty-vt/src/terminal/c/main.zig34
-rw-r--r--vendor/libghostty-vt/src/terminal/c/render.zig197
-rw-r--r--vendor/libghostty-vt/src/terminal/c/row.zig61
-rw-r--r--vendor/libghostty-vt/src/terminal/c/selection.zig16
-rw-r--r--vendor/libghostty-vt/src/terminal/c/sys.zig430
-rw-r--r--vendor/libghostty-vt/src/terminal/c/terminal.zig346
-rw-r--r--vendor/libghostty-vt/src/terminal/c/types.zig3
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig114
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig13
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig271
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig19
-rw-r--r--vendor/libghostty-vt/src/terminal/main.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/modes.zig5
-rw-r--r--vendor/libghostty-vt/src/terminal/stream_terminal.zig100
-rw-r--r--vendor/libghostty-vt/src/terminal/sys.zig54
-rw-r--r--vendor/libghostty-vt/src/terminfo/ghostty.zig4
-rw-r--r--vendor/libghostty-vt/src/termio/Exec.zig133
-rw-r--r--vendor/libghostty-vt/src/termio/Termio.zig26
84 files changed, 6832 insertions, 690 deletions
diff --git a/vendor/libghostty-vt/src/App.zig b/vendor/libghostty-vt/src/App.zig
index 5446f4bf..93ee7dea 100644
--- a/vendor/libghostty-vt/src/App.zig
+++ b/vendor/libghostty-vt/src/App.zig
@@ -315,25 +315,6 @@ pub fn focusEvent(self: *App, focused: bool) void {
self.focused = focused;
}
-/// Returns true if the given key event would trigger a keybinding
-/// if it were to be processed. This is useful for determining if
-/// a key event should be sent to the terminal or not.
-pub fn keyEventIsBinding(
- self: *App,
- rt_app: *apprt.App,
- event: input.KeyEvent,
-) bool {
- _ = self;
-
- switch (event.action) {
- .release => return false,
- .press, .repeat => {},
- }
-
- // If we have a keybinding for this event then we return true.
- return rt_app.config.keybind.set.getEvent(event) != null;
-}
-
/// Handle a key event at the app-scope. If this key event is used,
/// this will return true and the caller shouldn't continue processing
/// the event. If the event is not used, this will return false.
diff --git a/vendor/libghostty-vt/src/Command.zig b/vendor/libghostty-vt/src/Command.zig
index 2b381912..b8193625 100644
--- a/vendor/libghostty-vt/src/Command.zig
+++ b/vendor/libghostty-vt/src/Command.zig
@@ -258,12 +258,20 @@ fn startPosix(self: *Command, arena: Allocator) !void {
}
fn startWindows(self: *Command, arena: Allocator) !void {
- const application_w = try std.unicode.utf8ToUtf16LeAllocZ(arena, self.path);
const cwd_w = if (self.cwd) |cwd| try std.unicode.utf8ToUtf16LeAllocZ(arena, cwd) else null;
- const command_line_w = if (self.args.len > 0) b: {
- const command_line = try windowsCreateCommandLine(arena, self.args);
- break :b try std.unicode.utf8ToUtf16LeAllocZ(arena, command_line);
- } else null;
+
+ // Pass null for lpApplicationName and put the program as the first
+ // token of lpCommandLine. This lets CreateProcessW perform the
+ // standard program search (parent-app dir, CWD, system dirs, PATH)
+ // and append ".exe" when the name has no extension, which is what
+ // users expect for bare commands like `wsl ~` or `pwsh.exe`.
+ // It also preserves the child's argv[0] as written by the caller
+ // rather than replacing it with the resolved absolute path.
+ const command_line = if (self.args.len > 0)
+ try windowsCreateCommandLine(arena, self.args)
+ else
+ try windowsCreateCommandLine(arena, &.{self.path});
+ const command_line_w = try std.unicode.utf8ToUtf16LeAllocZ(arena, command_line);
const env_w = if (self.env) |env_map| try createWindowsEnvBlock(arena, env_map) else null;
const any_null_fd = self.stdin == null or self.stdout == null or self.stderr == null;
@@ -345,8 +353,8 @@ fn startWindows(self: *Command, arena: Allocator) !void {
var process_information: windows.PROCESS_INFORMATION = undefined;
if (windows.exp.kernel32.CreateProcessW(
- application_w.ptr,
- if (command_line_w) |w| w.ptr else null,
+ null,
+ command_line_w.ptr,
null,
null,
windows.TRUE,
diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig
index dfc3a50e..5d16f332 100644
--- a/vendor/libghostty-vt/src/Surface.zig
+++ b/vendor/libghostty-vt/src/Surface.zig
@@ -311,6 +311,7 @@ const DerivedConfig = struct {
clipboard_codepoint_map: configpkg.Config.RepeatableClipboardCodepointMap,
copy_on_select: configpkg.CopyOnSelect,
right_click_action: configpkg.RightClickAction,
+ middle_click_action: configpkg.MiddleClickAction,
confirm_close_surface: configpkg.ConfirmCloseSurface,
cursor_click_to_move: bool,
desktop_notifications: bool,
@@ -389,6 +390,7 @@ const DerivedConfig = struct {
.clipboard_codepoint_map = try config.@"clipboard-codepoint-map".clone(alloc),
.copy_on_select = config.@"copy-on-select",
.right_click_action = config.@"right-click-action",
+ .middle_click_action = config.@"middle-click-action",
.confirm_close_surface = config.@"confirm-close-surface",
.cursor_click_to_move = config.@"cursor-click-to-move",
.desktop_notifications = config.@"desktop-notifications",
@@ -3422,19 +3424,23 @@ pub fn scrollCallback(
const yoff_adjusted: f64 = if (scroll_mods.precision)
yoff * self.config.mouse_scroll_multiplier.precision
else yoff_adjusted: {
- // Round out the yoff to an absolute minimum of 1. macos tries to
- // simulate precision scrolling with non precision events by
- // ramping up the magnitude of the offsets as it detects faster
- // scrolling. Single click (very slow) scrolls are reported with a
- // magnitude of 0.1 which would normally require a few clicks
- // before we register an actual scroll event (depending on cell
- // height and the mouse_scroll_multiplier setting).
- const yoff_max: f64 = if (yoff > 0)
- @max(yoff, 1)
- else
- @min(yoff, -1);
+ if (comptime builtin.target.os.tag.isDarwin()) {
+ // Round out the yoff to an absolute minimum of 1. macos tries to
+ // simulate precision scrolling with non precision events by
+ // ramping up the magnitude of the offsets as it detects faster
+ // scrolling. Single click (very slow) scrolls are reported with a
+ // magnitude of 0.1 which would normally require a few clicks
+ // before we register an actual scroll event (depending on cell
+ // height and the mouse_scroll_multiplier setting).
+ const yoff_max: f64 = if (yoff > 0)
+ @max(yoff, 1)
+ else
+ @min(yoff, -1);
- break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier.discrete;
+ break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier.discrete;
+ } else {
+ break :yoff_adjusted yoff * cell_size * self.config.mouse_scroll_multiplier.discrete;
+ }
};
// Add our previously saved pending amount to the offset to get the
@@ -3821,6 +3827,8 @@ pub fn mouseButtonCallback(
// clicked link will swallow the event.
if (self.mouse.over_link) {
const pos = try self.rt_surface.getCursorPos();
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
if (self.processLinks(pos)) |processed| {
if (processed) return true;
} else |err| {
@@ -4007,14 +4015,24 @@ pub fn mouseButtonCallback(
}
}
- // Middle-click pastes from our selection clipboard
- if (button == .middle and action == .press) {
- const clipboard: apprt.Clipboard = if (self.rt_surface.supportsClipboard(.selection))
- .selection
- else
- .standard;
- _ = try self.startClipboardRequest(clipboard, .{ .paste = {} });
- }
+ // Middle-click paste source follows copy-on-select: when copy-on-select
+ // targets the selection clipboard, middle-click reads from it; when
+ // copy-on-select targets the system clipboard, middle-click reads from
+ // that instead. Falls back to the standard clipboard on platforms that
+ // do not support the selection clipboard.
+ if (button == .middle and action == .press) switch (self.config.middle_click_action) {
+ .ignore => {},
+ .@"primary-paste" => {
+ const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) {
+ .clipboard => .standard,
+ .true, .false => if (self.rt_surface.supportsClipboard(.selection))
+ .selection
+ else
+ .standard,
+ };
+ _ = try self.startClipboardRequest(clipboard, .{ .paste = {} });
+ },
+ };
// Right-click down selects word for context menus. If the apprt
// doesn't implement context menus this can be a bit weird but they
@@ -4295,7 +4313,9 @@ fn linkAtPin(
const line = screen.selectLine(.{
.pin = mouse_pin,
.whitespace = null,
- .semantic_prompt_boundary = false,
+ // Respect semantic prompt boundaries so link/path matching doesn't
+ // merge shell prompt content with the text beside it.
+ .semantic_prompt_boundary = true,
}) orelse return null;
var strmap: terminal.StringMap = undefined;
diff --git a/vendor/libghostty-vt/src/apprt/embedded.zig b/vendor/libghostty-vt/src/apprt/embedded.zig
index 519a35f2..730913eb 100644
--- a/vendor/libghostty-vt/src/apprt/embedded.zig
+++ b/vendor/libghostty-vt/src/apprt/embedded.zig
@@ -20,6 +20,7 @@ const CoreInspector = @import("../inspector/main.zig").Inspector;
const CoreSurface = @import("../Surface.zig");
const configpkg = @import("../config.zig");
const Config = configpkg.Config;
+const String = @import("../main_c.zig").String;
const log = std.log.scoped(.embedded_window);
@@ -1465,8 +1466,8 @@ pub const CAPI = struct {
/// if it were sent to the surface right now. The "right now"
/// is important because things like trigger sequences are only
/// valid until the next key event.
- export fn ghostty_app_key_is_binding(
- app: *App,
+ export fn ghostty_config_key_is_binding(
+ config: *Config,
event: KeyEvent,
) bool {
const core_event = event.keyEvent().core() orelse {
@@ -1474,7 +1475,7 @@ pub const CAPI = struct {
return false;
};
- return app.core_app.keyEventIsBinding(app, core_event);
+ return config.keyEventIsBinding(core_event);
}
/// Notify the app that the keyboard was changed. This causes the
@@ -1709,6 +1710,23 @@ pub const CAPI = struct {
};
}
+ /// Returns the PID of the foreground process for the surface PTY.
+ export fn ghostty_surface_foreground_pid(surface: *Surface) u64 {
+ return surface.core_surface.getProcessInfo(.foreground_pid) orelse 0;
+ }
+
+ /// Returns the PTY name for the surface. The returned string must be
+ /// freed by the caller via ghostty_string_free.
+ export fn ghostty_surface_tty_name(surface: *Surface) String {
+ const tty_name = surface.core_surface.getProcessInfo(.tty_name) orelse return .empty;
+ const copy = surface.app.core_app.alloc.dupeZ(u8, tty_name) catch |err| {
+ log.err("error allocating tty name err={}", .{err});
+ return .empty;
+ };
+
+ return .fromSlice(copy);
+ }
+
/// Update the color scheme of the surface.
export fn ghostty_surface_set_color_scheme(surface: *Surface, scheme_raw: c_int) void {
const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
index dd4f17d1..6ccb0a0d 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
@@ -163,8 +163,7 @@ pub const SurfaceScrolledWindow = extern struct {
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
- const child: *gtk.Widget = self.as(Parent).getChild().?;
- const scrolled_window = gobject.ext.cast(gtk.ScrolledWindow, child).?;
+ const scrolled_window = self.private().scrolled_window.as(gtk.ScrolledWindow);
scrolled_window.setChild(if (priv.surface) |s| s.as(gtk.Widget) else null);
// Unbind old config binding if it exists
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
index bf2a2fe7..0c8dfaa7 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
@@ -1079,7 +1079,10 @@ pub const Window = extern struct {
// Hide quick-terminal if set to autohide
if (self.isQuickTerminal()) {
if (self.getConfig()) |cfg| {
- if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) {
+ if (cfg.get().@"quick-terminal-autohide" and
+ self.as(gtk.Window).isActive() == 0 and
+ self.as(gtk.Widget).isVisible() == 1)
+ {
self.toggleVisibility();
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp
index 75582a89..8ba27cb6 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp
@@ -3,9 +3,14 @@ using Adw 1;
template $GhostttySurfaceScrolledWindow: Adw.Bin {
notify::surface => $notify_surface();
-
- Gtk.ScrolledWindow scrolled_window {
- hscrollbar-policy: never;
- vscrollbar-policy: bind $scrollbar_policy(template.config) as <Gtk.PolicyType>;
+ // The double-nesting is required due to a GTK bug where you can't
+ // bind the first child of a builder layout. If you do, you get a double
+ // dispose. Easiest way to see that is simply remove this and see the
+ // GTK critical errors (and sometimes crashes).
+ Adw.Bin {
+ Gtk.ScrolledWindow scrolled_window {
+ hscrollbar-policy: never;
+ vscrollbar-policy: bind $scrollbar_policy(template.config) as <Gtk.PolicyType>;
+ }
}
}
diff --git a/vendor/libghostty-vt/src/benchmark/AGENTS.md b/vendor/libghostty-vt/src/benchmark/AGENTS.md
new file mode 100644
index 00000000..bccefc12
--- /dev/null
+++ b/vendor/libghostty-vt/src/benchmark/AGENTS.md
@@ -0,0 +1,48 @@
+# Benchmarking
+
+The benchmark tools are split into two roles:
+
+- `ghostty-gen` generates synthetic input data.
+- `ghostty-bench` consumes existing input data and runs a benchmark.
+
+## Workflow
+
+- For timing comparisons, generate data first and benchmark it later.
+- Do not pipe `ghostty-gen` directly into `ghostty-bench` when comparing
+ performance. That mixes generation cost into the measurement and makes
+ branch-to-branch comparisons noisy.
+- Reuse the exact same generated files when comparing revisions.
+- Prefer deterministic generation inputs such as fixed seeds when the
+ generator supports them.
+- Keep large generated benchmark corpora outside the repository unless the
+ change explicitly requires checked-in test data.
+
+## Running Benchmarks
+
+- Prefer `hyperfine` to compare benchmark timings.
+- Benchmark the `ghostty-bench` command line, not the generator.
+- Use `ghostty-bench ... --data <path>` with pre-generated files.
+- Run multiple warmups and repeated measurements so branch comparisons are
+ based on medians instead of single runs.
+- When comparing branches, keep all benchmark inputs and CLI flags the same,
+ including terminal dimensions.
+- Never run multiple benchmarks in parallel on the same machine, as they will
+ interfere with each other and produce unreliable results.
+
+## Building
+
+- Build benchmark tools with `zig build -Demit-bench -Doptimize=ReleaseFast`.
+- On macOS, add `-Demit-macos-app=false` to avoid building the macOS app.
+- Make sure you specify `-Doptimize=ReleaseFast` when building benchmarks,
+ otherwise the debug build will be very slow and not representative of real
+ performance.
+
+## Comparing Branches
+
+- When comparing branches, switch to that branch, build the binary, then
+ rename it e.g. `zig-out/bin/ghostty-bench` to `zig-out/bin/ghostty-bench-branch1`.
+ Replace branch1 with something better.
+- Then switch to the other branch, build it, and rename it to
+ `zig-out/bin/ghostty-bench-branch2`. Replace branch2 with something better.
+- Then run all the benchmarks with `hyperfine` comparing the N binaries
+ we want to.
diff --git a/vendor/libghostty-vt/src/benchmark/TerminalParser.zig b/vendor/libghostty-vt/src/benchmark/TerminalParser.zig
index e0008176..78c93312 100644
--- a/vendor/libghostty-vt/src/benchmark/TerminalParser.zig
+++ b/vendor/libghostty-vt/src/benchmark/TerminalParser.zig
@@ -88,11 +88,17 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
- for (buf[0..n]) |c| {
- const actions = p.next(c);
- //std.log.warn("actions={any}", .{actions});
- _ = actions;
- }
+ parseAll(&p, buf[0..n]);
+ }
+}
+
+/// Separated from `step` so that the tight per-byte loop gets its own
+/// function alignment, insulating it from code-layout changes elsewhere
+/// in the binary that would otherwise shift its cache-line placement.
+noinline fn parseAll(p: *terminalpkg.Parser, data: []const u8) void {
+ for (data) |c| {
+ const actions = p.next(c);
+ _ = actions;
}
}
diff --git a/vendor/libghostty-vt/src/build/CombineArchivesStep.zig b/vendor/libghostty-vt/src/build/CombineArchivesStep.zig
new file mode 100644
index 00000000..cebd8e9f
--- /dev/null
+++ b/vendor/libghostty-vt/src/build/CombineArchivesStep.zig
@@ -0,0 +1,50 @@
+//! Combines multiple static archives into a single fat archive.
+//! Uses libtool on Darwin and a cross-platform MRI-script build tool
+//! on all other platforms (including Windows).
+const std = @import("std");
+const builtin = @import("builtin");
+const LibtoolStep = @import("LibtoolStep.zig");
+
+/// Combine multiple static archives into a single fat archive.
+///
+/// `name` identifies the library (e.g. "ghostty-internal", "ghostty-vt").
+/// Output uses a `-fat` suffix to distinguish the combined archive from
+/// the single-library archive in the build cache.
+pub fn create(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ name: []const u8,
+ sources: []const std.Build.LazyPath,
+) struct { step: *std.Build.Step, output: std.Build.LazyPath } {
+ if (target.result.os.tag.isDarwin() and
+ comptime builtin.os.tag.isDarwin())
+ {
+ const libtool = LibtoolStep.create(b, .{
+ .name = name,
+ .out_name = b.fmt("lib{s}-fat.a", .{name}),
+ .sources = @constCast(sources),
+ });
+ return .{ .step = libtool.step, .output = libtool.output };
+ }
+
+ // On non-Darwin, use a build tool that generates an MRI script and
+ // pipes it to `zig ar -M`. This works on all platforms including
+ // Windows (the previous /bin/sh approach did not).
+ const tool = b.addExecutable(.{
+ .name = "combine_archives",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/build/combine_archives.zig"),
+ .target = b.graph.host,
+ }),
+ });
+ const run = b.addRunArtifact(tool);
+ run.addArg(b.graph.zig_exe);
+ const out_name = if (target.result.os.tag == .windows)
+ b.fmt("{s}-fat.lib", .{name})
+ else
+ b.fmt("lib{s}-fat.a", .{name});
+ const output = run.addOutputFileArg(out_name);
+ for (sources) |source| run.addFileArg(source);
+
+ return .{ .step = &run.step, .output = output };
+}
diff --git a/vendor/libghostty-vt/src/build/Config.zig b/vendor/libghostty-vt/src/build/Config.zig
index 88968aab..0a994731 100644
--- a/vendor/libghostty-vt/src/build/Config.zig
+++ b/vendor/libghostty-vt/src/build/Config.zig
@@ -38,6 +38,7 @@ wasm_shared: bool = true,
/// Ghostty exe properties
exe_entrypoint: ExeEntrypoint = .ghostty,
version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 },
+lib_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 },
/// Binary properties
pie: bool = false,
@@ -68,7 +69,7 @@ is_dep: bool = false,
/// Environmental properties
env: std.process.EnvMap,
-pub fn init(b: *std.Build, appVersion: []const u8) !Config {
+pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Config {
// Setup our standard Zig target and optimize options, i.e.
// `-Doptimize` and `-Dtarget`.
const optimize = b.standardOptimizeOption(.{});
@@ -294,6 +295,20 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config {
};
};
+ // libghostty-vt properties
+
+ const lib_version_string = b.option(
+ []const u8,
+ "lib-version-string",
+ "A specific version string to use for the build of libghostty-vt. " ++
+ "If not specified, git will be used. This must be a semantic version.",
+ );
+
+ config.lib_version = if (lib_version_string) |v|
+ try std.SemanticVersion.parse(v)
+ else
+ try std.SemanticVersion.parse(libVersion);
+
//---------------------------------------------------------------
// Binary Properties
@@ -429,13 +444,22 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config {
bool,
"emit-xcframework",
"Build and install the xcframework for the macOS library.",
- ) orelse !config.emit_lib_vt and
- builtin.target.os.tag.isDarwin() and
- target.result.os.tag == .macos and
- config.app_runtime == .none and
- (!config.emit_bench and
- !config.emit_test_exe and
- !config.emit_helpgen);
+ ) orelse emit_xcfw: {
+ if (!builtin.target.os.tag.isDarwin() or target.result.os.tag != .macos)
+ break :emit_xcfw false;
+ if (config.emit_lib_vt) {
+ // In lib-vt mode default to whether xcodebuild is available,
+ // since xcodebuild is required to produce the XCFramework.
+ const path = expandPath(b.allocator, "xcodebuild") catch
+ break :emit_xcfw false;
+ defer if (path) |p| b.allocator.free(p);
+ break :emit_xcfw path != null;
+ }
+ break :emit_xcfw config.app_runtime == .none and
+ (!config.emit_bench and
+ !config.emit_test_exe and
+ !config.emit_helpgen);
+ };
config.emit_macos_app = b.option(
bool,
@@ -519,13 +543,20 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void {
// Our version. We also add the string version so we don't need
// to do any allocations at runtime. This has to be long enough to
// accommodate realistic large branch names for dev versions.
- var buf: [1024]u8 = undefined;
+ var app_version_buf: [1024]u8 = undefined;
step.addOption(std.SemanticVersion, "app_version", self.version);
step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ(
- &buf,
+ &app_version_buf,
"{f}",
.{self.version},
));
+ var lib_version_buf: [1024]u8 = undefined;
+ step.addOption(std.SemanticVersion, "lib_version", self.lib_version);
+ step.addOption([:0]const u8, "lib_version_string", try std.fmt.bufPrintZ(
+ &lib_version_buf,
+ "{f}",
+ .{self.lib_version},
+ ));
step.addOption(
ReleaseChannel,
"release_channel",
@@ -539,13 +570,16 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void {
/// Returns the build options for the terminal module. This assumes a
/// Ghostty executable being built. Callers should modify this as needed.
-pub fn terminalOptions(self: *const Config) TerminalBuildOptions {
+pub fn terminalOptions(self: *const Config, artifact: TerminalBuildOptions.Artifact) TerminalBuildOptions {
return .{
- .artifact = .ghostty,
+ .artifact = artifact,
.simd = self.simd,
.oniguruma = true,
.c_abi = false,
- .version = self.version,
+ .version = switch (artifact) {
+ .ghostty => self.version,
+ .lib => self.lib_version,
+ },
.slow_runtime_safety = switch (self.optimize) {
.Debug => true,
.ReleaseSafe,
diff --git a/vendor/libghostty-vt/src/build/GhosttyLib.zig b/vendor/libghostty-vt/src/build/GhosttyLib.zig
index 4e15fbbf..b762da8b 100644
--- a/vendor/libghostty-vt/src/build/GhosttyLib.zig
+++ b/vendor/libghostty-vt/src/build/GhosttyLib.zig
@@ -2,9 +2,9 @@ const GhosttyLib = @This();
const std = @import("std");
const RunStep = std.Build.Step.Run;
+const CombineArchivesStep = @import("CombineArchivesStep.zig");
const Config = @import("Config.zig");
const SharedDeps = @import("SharedDeps.zig");
-const LibtoolStep = @import("LibtoolStep.zig");
const LipoStep = @import("LipoStep.zig");
/// The step that generates the file.
@@ -13,6 +13,8 @@ step: *std.Build.Step,
/// The final static library file
output: std.Build.LazyPath,
dsym: ?std.Build.LazyPath,
+pkg_config: ?std.Build.LazyPath,
+pkg_config_static: ?std.Build.LazyPath,
pub fn initStatic(
b: *std.Build,
@@ -46,30 +48,23 @@ pub fn initStatic(
}
// Add our dependencies. Get the list of all static deps so we can
- // build a combined archive if necessary.
+ // build a combined archive.
var lib_list = try deps.add(lib);
try lib_list.append(b.allocator, lib.getEmittedBin());
- if (!deps.config.target.result.os.tag.isDarwin()) return .{
- .step = &lib.step,
- .output = lib.getEmittedBin(),
- .dsym = null,
- };
-
- // Create a static lib that contains all our dependencies.
- const libtool = LibtoolStep.create(b, .{
- .name = "ghostty",
- .out_name = "libghostty-fat.a",
- .sources = lib_list.items,
- });
- libtool.step.dependOn(&lib.step);
+ // Combine all archives into a single fat static library so
+ // consumers only need to link one file.
+ const combined = CombineArchivesStep.create(b, deps.config.target, "ghostty-internal", lib_list.items);
+ combined.step.dependOn(&lib.step);
return .{
- .step = libtool.step,
- .output = libtool.output,
+ .step = combined.step,
+ .output = combined.output,
// Static libraries cannot have dSYMs because they aren't linked.
.dsym = null,
+ .pkg_config = null,
+ .pkg_config_static = null,
};
}
@@ -146,10 +141,20 @@ pub fn initShared(
break :dsymutil output;
};
+ // pkg-config
+ //
+ // pkg-config's --static only expands Libs.private / Requires.private;
+ // it doesn't rewrite Libs: into an archive-only reference when both
+ // shared and static libraries are installed. Install a dedicated
+ // static module so consumers can request the archive explicitly.
+ const pcs = pkgConfigFiles(b, deps);
+
return .{
.step = &lib.step,
.output = lib.getEmittedBin(),
.dsym = dsymutil,
+ .pkg_config = pcs.shared,
+ .pkg_config_static = pcs.static,
};
}
@@ -168,7 +173,7 @@ pub fn initMacOSUniversal(
const universal = LipoStep.create(b, .{
.name = "ghostty",
- .out_name = "libghostty.a",
+ .out_name = "ghostty-internal.a",
.input_a = aarch64.output,
.input_b = x86_64.output,
});
@@ -180,13 +185,31 @@ pub fn initMacOSUniversal(
// You can't run dsymutil on a universal binary, you have to
// do it on the individual binaries.
.dsym = null,
+ .pkg_config = null,
+ .pkg_config_static = null,
};
}
pub fn install(self: *const GhosttyLib, name: []const u8) void {
const b = self.step.owner;
+ const step = b.getInstallStep();
const lib_install = b.addInstallLibFile(self.output, name);
- b.getInstallStep().dependOn(&lib_install.step);
+ step.dependOn(&lib_install.step);
+
+ if (self.pkg_config) |pc| {
+ step.dependOn(&b.addInstallFileWithDir(
+ pc,
+ .prefix,
+ "share/pkgconfig/ghostty-internal.pc",
+ ).step);
+ }
+ if (self.pkg_config_static) |pc| {
+ step.dependOn(&b.addInstallFileWithDir(
+ pc,
+ .prefix,
+ "share/pkgconfig/ghostty-internal-static.pc",
+ ).step);
+ }
}
pub fn installHeader(self: *const GhosttyLib) void {
@@ -197,3 +220,61 @@ pub fn installHeader(self: *const GhosttyLib) void {
);
b.getInstallStep().dependOn(&header_install.step);
}
+
+const PkgConfigFiles = struct {
+ shared: std.Build.LazyPath,
+ static: std.Build.LazyPath,
+};
+
+fn pkgConfigFiles(
+ b: *std.Build,
+ deps: *const SharedDeps,
+) PkgConfigFiles {
+ const os_tag = deps.config.target.result.os.tag;
+ const wf = b.addWriteFiles();
+
+ return .{
+ .shared = wf.add("ghostty-internal.pc", b.fmt(
+ \\prefix={s}
+ \\includedir=${{prefix}}/include
+ \\libdir=${{prefix}}/lib
+ \\
+ \\Name: ghostty-internal
+ \\URL: https://github.com/ghostty-org/ghostty
+ \\Description: Ghostty internal library (not for external use)
+ \\Version: {f}
+ \\Cflags: -I${{includedir}}
+ \\Libs: ${{libdir}}/{s}
+ \\Libs.private:
+ \\Requires.private:
+ , .{ b.install_prefix, deps.config.version, sharedLibraryName(os_tag) })),
+ .static = wf.add("ghostty-internal-static.pc", b.fmt(
+ \\prefix={s}
+ \\includedir=${{prefix}}/include
+ \\libdir=${{prefix}}/lib
+ \\
+ \\Name: ghostty-internal-static
+ \\URL: https://github.com/ghostty-org/ghostty
+ \\Description: Ghostty internal library, static (not for external use)
+ \\Version: {f}
+ \\Cflags: -I${{includedir}}
+ \\Libs: ${{libdir}}/{s}
+ \\Libs.private:
+ \\Requires.private:
+ , .{ b.install_prefix, deps.config.version, staticLibraryName(os_tag) })),
+ };
+}
+
+fn sharedLibraryName(os_tag: std.Target.Os.Tag) []const u8 {
+ return if (os_tag == .windows)
+ "ghostty-internal.dll"
+ else
+ "ghostty-internal.so";
+}
+
+fn staticLibraryName(os_tag: std.Target.Os.Tag) []const u8 {
+ return if (os_tag == .windows)
+ "ghostty-internal-static.lib"
+ else
+ "ghostty-internal.a";
+}
diff --git a/vendor/libghostty-vt/src/build/GhosttyLibVt.zig b/vendor/libghostty-vt/src/build/GhosttyLibVt.zig
index 3e1be477..65f945bf 100644
--- a/vendor/libghostty-vt/src/build/GhosttyLibVt.zig
+++ b/vendor/libghostty-vt/src/build/GhosttyLibVt.zig
@@ -4,9 +4,12 @@ const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const RunStep = std.Build.Step.Run;
+const CombineArchivesStep = @import("CombineArchivesStep.zig");
+const Config = @import("Config.zig");
const GhosttyZig = @import("GhosttyZig.zig");
-const LibtoolStep = @import("LibtoolStep.zig");
+const LipoStep = @import("LipoStep.zig");
const SharedDeps = @import("SharedDeps.zig");
+const XCFrameworkStep = @import("XCFrameworkStep.zig");
/// The step that generates the file.
step: *std.Build.Step,
@@ -21,6 +24,7 @@ kind: Kind,
output: std.Build.LazyPath,
dsym: ?std.Build.LazyPath,
pkg_config: ?std.Build.LazyPath,
+pkg_config_static: ?std.Build.LazyPath,
/// The kind of library being built. This is similar to LinkMode but
/// also includes wasm which is an executable, not a library.
@@ -40,7 +44,7 @@ pub fn initWasm(
const exe = b.addExecutable(.{
.name = "ghostty-vt",
.root_module = zig.vt_c,
- .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 },
+ .version = zig.version,
});
// Allow exported symbols to actually be exported.
@@ -82,6 +86,7 @@ pub fn initWasm(
.output = output,
.dsym = null,
.pkg_config = null,
+ .pkg_config_static = null,
};
}
@@ -99,6 +104,94 @@ pub fn initShared(
return initLib(b, zig, .dynamic);
}
+/// Apple platform targets for xcframework slices.
+pub const ApplePlatform = enum {
+ macos_universal,
+ ios,
+ ios_simulator,
+ // tvOS, watchOS, and visionOS are not yet supported by Zig's
+ // standard library (missing PATH_MAX, mcontext fields, etc.).
+
+ /// Platforms that have device + simulator pairs, gated on SDK detection.
+ const sdk_platforms = [_]struct {
+ os_tag: std.Target.Os.Tag,
+ device: ApplePlatform,
+ simulator: ApplePlatform,
+ }{
+ .{ .os_tag = .ios, .device = .ios, .simulator = .ios_simulator },
+ };
+};
+
+/// Static libraries for each Apple platform, keyed by `ApplePlatform`.
+pub const AppleLibs = std.EnumMap(ApplePlatform, GhosttyLibVt);
+
+/// Build static libraries for all available Apple platforms.
+/// Always builds a macOS universal (arm64 + x86_64) fat binary.
+/// Additional platforms are included if their SDK is detected.
+pub fn initStaticAppleUniversal(
+ b: *std.Build,
+ cfg: *const Config,
+ deps: *const SharedDeps,
+ zig: *const GhosttyZig,
+) !AppleLibs {
+ var result: AppleLibs = .{};
+
+ // macOS universal (arm64 + x86_64)
+ const aarch64_zig = try zig.retarget(
+ b,
+ cfg,
+ deps,
+ Config.genericMacOSTarget(b, .aarch64),
+ );
+ const x86_64_zig = try zig.retarget(
+ b,
+ cfg,
+ deps,
+ Config.genericMacOSTarget(b, .x86_64),
+ );
+ const aarch64 = try initStatic(b, &aarch64_zig);
+ const x86_64 = try initStatic(b, &x86_64_zig);
+ const universal = LipoStep.create(b, .{
+ .name = "ghostty-vt",
+ .out_name = "libghostty-vt.a",
+ .input_a = aarch64.output,
+ .input_b = x86_64.output,
+ });
+ result.put(.macos_universal, .{
+ .step = universal.step,
+ .artifact = universal.step,
+ .kind = .static,
+ .output = universal.output,
+ .dsym = null,
+ .pkg_config = null,
+ .pkg_config_static = null,
+ });
+
+ // Additional Apple platforms, each gated on SDK availability.
+ for (ApplePlatform.sdk_platforms) |p| {
+ const target_query: std.Target.Query = .{
+ .cpu_arch = .aarch64,
+ .os_tag = p.os_tag,
+ .os_version_min = Config.osVersionMin(p.os_tag),
+ };
+ if (detectAppleSDK(b.resolveTargetQuery(target_query).result)) {
+ const dev_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(target_query));
+ result.put(p.device, try initStatic(b, &dev_zig));
+
+ const sim_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(.{
+ .cpu_arch = .aarch64,
+ .os_tag = p.os_tag,
+ .os_version_min = Config.osVersionMin(p.os_tag),
+ .abi = .simulator,
+ .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 },
+ }));
+ result.put(p.simulator, try initStatic(b, &sim_zig));
+ }
+ }
+
+ return result;
+}
+
fn initLib(
b: *std.Build,
zig: *const GhosttyZig,
@@ -113,7 +206,7 @@ fn initLib(
.name = if (kind == .static) "ghostty-vt-static" else "ghostty-vt",
.linkage = linkage,
.root_module = zig.vt_c,
- .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 },
+ .version = zig.version,
});
lib.installHeadersDirectory(
b.path("include/ghostty"),
@@ -174,36 +267,27 @@ fn initLib(
};
// pkg-config
- const pc: std.Build.LazyPath = pc: {
- const wf = b.addWriteFiles();
- break :pc wf.add("libghostty-vt.pc", b.fmt(
- \\prefix={s}
- \\includedir=${{prefix}}/include
- \\libdir=${{prefix}}/lib
- \\
- \\Name: libghostty-vt
- \\URL: https://github.com/ghostty-org/ghostty
- \\Description: Ghostty VT library
- \\Version: 0.1.0
- \\Cflags: -I${{includedir}}
- \\Libs: -L${{libdir}} -lghostty-vt
- \\Libs.private: {s}
- \\Requires.private: {s}
- , .{ b.install_prefix, libsPrivate(zig), requiresPrivate(b) }));
- };
+ //
+ // pkg-config's --static only expands Libs.private / Requires.private;
+ // it doesn't change -lghostty-vt into an archive-only reference when
+ // both shared and static libraries are installed. Install a dedicated
+ // static module so consumers can request the archive explicitly.
+ const pcs: ?PkgConfigFiles = if (kind == .shared)
+ pkgConfigFiles(b, zig, target.result.os.tag)
+ else
+ null;
// For static libraries with vendored SIMD dependencies, combine
// all archives into a single fat archive so consumers only need
- // to link one file. Skip on Windows where ar/libtool aren't available.
+ // to link one file.
if (kind == .static and
- zig.simd_libs.items.len > 0 and
- target.result.os.tag != .windows)
+ zig.simd_libs.items.len > 0)
{
var sources: SharedDeps.LazyPathList = .empty;
try sources.append(b.allocator, lib.getEmittedBin());
try sources.appendSlice(b.allocator, zig.simd_libs.items);
- const combined = combineArchives(b, target, sources.items);
+ const combined = CombineArchivesStep.create(b, target, "ghostty-vt", sources.items);
combined.step.dependOn(&lib.step);
return .{
@@ -212,7 +296,8 @@ fn initLib(
.kind = kind,
.output = combined.output,
.dsym = dsymutil,
- .pkg_config = pc,
+ .pkg_config = if (pcs) |v| v.shared else null,
+ .pkg_config_static = if (pcs) |v| v.static else null,
};
}
@@ -222,64 +307,79 @@ fn initLib(
.kind = kind,
.output = lib.getEmittedBin(),
.dsym = dsymutil,
- .pkg_config = pc,
+ .pkg_config = if (pcs) |v| v.shared else null,
+ .pkg_config_static = if (pcs) |v| v.static else null,
};
}
-/// Combine multiple static archives into a single fat archive.
-/// Uses libtool on Darwin and ar MRI scripts on other platforms.
-fn combineArchives(
- b: *std.Build,
- target: std.Build.ResolvedTarget,
- sources: []const std.Build.LazyPath,
-) struct { step: *std.Build.Step, output: std.Build.LazyPath } {
- if (target.result.os.tag.isDarwin()) {
- const libtool = LibtoolStep.create(b, .{
- .name = "ghostty-vt",
- .out_name = "libghostty-vt.a",
- .sources = @constCast(sources),
- });
- return .{ .step = libtool.step, .output = libtool.output };
- }
-
- // On non-Darwin, use an MRI script with ar -M to combine archives
- // directly without extracting. This avoids issues with ar x
- // producing full-path member names and read-only permissions.
- const run = RunStep.create(b, "combine-archives ghostty-vt");
- run.addArgs(&.{
- "/bin/sh", "-c",
- \\set -e
- \\out="$1"; shift
- \\script="CREATE $out"
- \\for a in "$@"; do
- \\ script="$script
- \\ADDLIB $a"
- \\done
- \\script="$script
- \\SAVE
- \\END"
- \\echo "$script" | ar -M
- ,
- "_",
- });
- const output = run.addOutputFileArg("libghostty-vt.a");
- for (sources) |source| run.addFileArg(source);
-
- return .{ .step = &run.step, .output = output };
-}
-
/// Returns the Libs.private value for the pkg-config file.
-/// This includes the C++ standard library needed by SIMD code.
-///
-/// Zig compiles C++ code with LLVM's libc++ (not GNU libstdc++),
-/// so consumers linking the static library need a libc++-compatible
-/// toolchain: `zig cc`, `clang`, or GCC with `-lc++` installed.
+/// Vendored C++ dependencies are built in no-libcxx mode so consumers
+/// don't need libc++. System-provided simdutf still requires it.
fn libsPrivate(
zig: *const GhosttyZig,
) []const u8 {
return if (zig.vt_c.link_libcpp orelse false) "-lc++" else "";
}
+const PkgConfigFiles = struct {
+ shared: std.Build.LazyPath,
+ static: std.Build.LazyPath,
+};
+
+fn pkgConfigFiles(
+ b: *std.Build,
+ zig: *const GhosttyZig,
+ os_tag: std.Target.Os.Tag,
+) PkgConfigFiles {
+ const wf = b.addWriteFiles();
+ const libs_private = libsPrivate(zig);
+ const requires_private = requiresPrivate(b);
+
+ return .{
+ .shared = wf.add("libghostty-vt.pc", b.fmt(
+ \\prefix={s}
+ \\includedir=${{prefix}}/include
+ \\libdir=${{prefix}}/lib
+ \\
+ \\Name: libghostty-vt
+ \\URL: https://github.com/ghostty-org/ghostty
+ \\Description: Ghostty VT library
+ \\Version: {f}
+ \\Cflags: -I${{includedir}}
+ \\Libs: -L${{libdir}} -lghostty-vt
+ \\Libs.private: {s}
+ \\Requires.private: {s}
+ , .{ b.install_prefix, zig.version, libs_private, requires_private })),
+ .static = wf.add("libghostty-vt-static.pc", b.fmt(
+ \\prefix={s}
+ \\includedir=${{prefix}}/include
+ \\libdir=${{prefix}}/lib
+ \\
+ \\Name: libghostty-vt-static
+ \\URL: https://github.com/ghostty-org/ghostty
+ \\Description: Ghostty VT library (static)
+ \\Version: {f}
+ \\Cflags: -I${{includedir}}
+ \\Libs: ${{libdir}}/{s}
+ \\Libs.private: {s}
+ \\Requires.private: {s}
+ , .{
+ b.install_prefix,
+ zig.version,
+ staticLibraryName(os_tag),
+ libs_private,
+ requires_private,
+ })),
+ };
+}
+
+fn staticLibraryName(os_tag: std.Target.Os.Tag) []const u8 {
+ return if (os_tag == .windows)
+ "ghostty-vt-static.lib"
+ else
+ "libghostty-vt.a";
+}
+
/// Returns the Requires.private value for the pkg-config file.
/// When SIMD dependencies are provided by the system (via
/// -Dsystem-integration), we reference their pkg-config names so
@@ -294,6 +394,59 @@ fn requiresPrivate(b: *std.Build) []const u8 {
return "";
}
+/// Create an XCFramework bundle from Apple platform static libraries.
+pub fn xcframework(
+ apple_libs: *const AppleLibs,
+ b: *std.Build,
+) *XCFrameworkStep {
+ // Generate a headers directory with a module map for Swift PM.
+ // We can't use include/ directly because it contains a module map
+ // for GhosttyKit (the macOS app library).
+ const wf = b.addWriteFiles();
+ _ = wf.addCopyDirectory(
+ b.path("include/ghostty"),
+ "ghostty",
+ .{ .include_extensions = &.{".h"} },
+ );
+ _ = wf.add("module.modulemap",
+ \\module GhosttyVt {
+ \\ umbrella header "ghostty/vt.h"
+ \\ export *
+ \\}
+ \\
+ );
+ const headers = wf.getDirectory();
+
+ var libraries: [AppleLibs.len]XCFrameworkStep.Library = undefined;
+ var lib_count: usize = 0;
+ for (std.enums.values(ApplePlatform)) |platform| {
+ if (apple_libs.get(platform)) |lib| {
+ libraries[lib_count] = .{
+ .library = lib.output,
+ .headers = headers,
+ .dsym = null,
+ };
+ lib_count += 1;
+ }
+ }
+
+ return XCFrameworkStep.create(b, .{
+ .name = "ghostty-vt",
+ .out_path = b.pathJoin(&.{ b.install_prefix, "lib/ghostty-vt.xcframework" }),
+ .libraries = libraries[0..lib_count],
+ });
+}
+
+/// Returns true if the Apple SDK for the given target is installed.
+fn detectAppleSDK(target: std.Target) bool {
+ _ = std.zig.LibCInstallation.findNative(.{
+ .allocator = std.heap.page_allocator,
+ .target = &target,
+ .verbose = false,
+ }) catch return false;
+ return true;
+}
+
pub fn install(
self: *const GhosttyLibVt,
step: *std.Build.Step,
@@ -307,4 +460,11 @@ pub fn install(
"share/pkgconfig/libghostty-vt.pc",
).step);
}
+ if (self.pkg_config_static) |pkg_config_static| {
+ step.dependOn(&b.addInstallFileWithDir(
+ pkg_config_static,
+ .prefix,
+ "share/pkgconfig/libghostty-vt-static.pc",
+ ).step);
+ }
}
diff --git a/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig b/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig
index 3afeb907..a19dd18a 100644
--- a/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig
+++ b/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig
@@ -53,6 +53,16 @@ pub fn init(
}),
));
+ // Generate a headers directory with only ghostty.h and the module
+ // map. We can't use include/ directly because it also contains the
+ // libghostty-vt headers under include/ghostty/, which would trigger
+ // "umbrella header does not include header" warnings from Clang's
+ // module system.
+ const wf = b.addWriteFiles();
+ _ = wf.addCopyFile(b.path("include/ghostty.h"), "ghostty.h");
+ _ = wf.addCopyFile(b.path("include/module.modulemap"), "module.modulemap");
+ const headers = wf.getDirectory();
+
// The xcframework wraps our ghostty library so that we can link
// it to the final app built with Swift.
const xcframework = XCFrameworkStep.create(b, .{
@@ -62,24 +72,24 @@ pub fn init(
.universal => &.{
.{
.library = macos_universal.output,
- .headers = b.path("include"),
+ .headers = headers,
.dsym = macos_universal.dsym,
},
.{
.library = ios.output,
- .headers = b.path("include"),
+ .headers = headers,
.dsym = ios.dsym,
},
.{
.library = ios_sim.output,
- .headers = b.path("include"),
+ .headers = headers,
.dsym = ios_sim.dsym,
},
},
.native => &.{.{
.library = macos_native.output,
- .headers = b.path("include"),
+ .headers = headers,
.dsym = macos_native.dsym,
}},
},
diff --git a/vendor/libghostty-vt/src/build/GhosttyZig.zig b/vendor/libghostty-vt/src/build/GhosttyZig.zig
index 4901180d..44c300e1 100644
--- a/vendor/libghostty-vt/src/build/GhosttyZig.zig
+++ b/vendor/libghostty-vt/src/build/GhosttyZig.zig
@@ -11,6 +11,9 @@ const TerminalBuildOptions = @import("../terminal/build_options.zig").Options;
vt: *std.Build.Module,
vt_c: *std.Build.Module,
+/// The libghostty-vt version
+version: std.SemanticVersion,
+
/// Static library paths for vendored SIMD dependencies. Populated
/// only when the dependencies are built from source (not provided
/// by the system via -Dsystem-integration). Used to produce a
@@ -22,8 +25,46 @@ pub fn init(
cfg: *const Config,
deps: *const SharedDeps,
) !GhosttyZig {
+ return initInner(b, cfg, deps, "ghostty-vt", "ghostty-vt-c");
+}
+
+/// Create a new GhosttyZig with modules retargeted to a different
+/// architecture. Used to produce universal (fat) binaries on macOS.
+pub fn retarget(
+ self: *const GhosttyZig,
+ b: *std.Build,
+ cfg: *const Config,
+ deps: *const SharedDeps,
+ target: std.Build.ResolvedTarget,
+) !GhosttyZig {
+ _ = self;
+ const retargeted_config = try b.allocator.create(Config);
+ retargeted_config.* = cfg.*;
+ retargeted_config.target = target;
+
+ const retargeted_deps = try b.allocator.create(SharedDeps);
+ retargeted_deps.* = try deps.retarget(b, target);
+
+ // Use unique module names to avoid collisions with the original target.
+ const arch_name = @tagName(target.result.cpu.arch);
+ return initInner(
+ b,
+ retargeted_config,
+ retargeted_deps,
+ b.fmt("ghostty-vt-{s}", .{arch_name}),
+ b.fmt("ghostty-vt-c-{s}", .{arch_name}),
+ );
+}
+
+fn initInner(
+ b: *std.Build,
+ cfg: *const Config,
+ deps: *const SharedDeps,
+ vt_name: []const u8,
+ vt_c_name: []const u8,
+) !GhosttyZig {
// Terminal module build options
- var vt_options = cfg.terminalOptions();
+ var vt_options = cfg.terminalOptions(.lib);
vt_options.artifact = .lib;
// We presently don't allow Oniguruma in our Zig module at all.
// We should expose this as a build option in the future so we can
@@ -34,7 +75,7 @@ pub fn init(
return .{
.vt = try initVt(
- "ghostty-vt",
+ vt_name,
b,
cfg,
deps,
@@ -43,7 +84,7 @@ pub fn init(
),
.vt_c = try initVt(
- "ghostty-vt-c",
+ vt_c_name,
b,
cfg,
deps,
@@ -55,6 +96,8 @@ pub fn init(
&simd_libs,
),
+ .version = cfg.lib_version,
+
.simd_libs = simd_libs,
};
}
@@ -76,13 +119,14 @@ fn initVt(
.target = cfg.target,
.optimize = cfg.optimize,
- // SIMD require libc/libcpp (both) but otherwise we don't care.
- // On MSVC, we must not use linkLibCpp because Zig passes
- // -nostdinc++ and adds its bundled libc++/libc++abi headers
- // which conflict with MSVC's C++ runtime. The MSVC SDK dirs
- // added via link_libc contain both C and C++ headers.
+ // SIMD requires libc. Vendored C++ dependencies are built with
+ // no-libcxx mode (HWY_NO_LIBCXX / SIMDUTF_NO_LIBCXX) so we
+ // don't need libcpp. System-provided simdutf headers still
+ // use C++ stdlib headers, so we need libcpp in that case.
.link_libc = if (cfg.simd) true else null,
- .link_libcpp = if (cfg.simd and cfg.target.result.abi != .msvc) true else null,
+ .link_libcpp = if (cfg.simd and
+ b.systemIntegrationOption("simdutf", .{}) and
+ cfg.target.result.abi != .msvc) true else null,
});
vt.addOptions("build_options", general_options);
vt_options.add(b, vt);
diff --git a/vendor/libghostty-vt/src/build/GitVersion.zig b/vendor/libghostty-vt/src/build/GitVersion.zig
index 8b368d2c..41cc7f84 100644
--- a/vendor/libghostty-vt/src/build/GitVersion.zig
+++ b/vendor/libghostty-vt/src/build/GitVersion.zig
@@ -29,11 +29,14 @@ pub fn detect(b: *std.Build) !Version {
error.ExitCodeFailure => return error.GitNotRepository,
else => return err,
};
- // Replace any '/' with '-' as including slashes will mess up building
- // the dist tarball - the tarball uses the branch as part of the
- // name and including slashes means that the tarball will end up in
- // subdirectories instead of where it's supposed to be.
- std.mem.replaceScalar(u8, tmp, '/', '-');
+
+ // Replace characters that are not valid in semantic version
+ // pre-release identifiers (which only allow [0-9A-Za-z-]).
+ // Slashes would also mess up dist tarball paths.
+ for (tmp) |*c| {
+ if (!std.ascii.isAlphanumeric(c.*) and c.* != '-') c.* = '-';
+ }
+
break :b tmp;
};
diff --git a/vendor/libghostty-vt/src/build/SharedDeps.zig b/vendor/libghostty-vt/src/build/SharedDeps.zig
index aa63c082..b68be92d 100644
--- a/vendor/libghostty-vt/src/build/SharedDeps.zig
+++ b/vendor/libghostty-vt/src/build/SharedDeps.zig
@@ -133,7 +133,7 @@ pub fn add(
step.root_module.addOptions("build_options", self.options);
// Every exe needs the terminal options
- self.config.terminalOptions().add(b, step.root_module);
+ self.config.terminalOptions(.ghostty).add(b, step.root_module);
// C imports for locale constants and functions
{
@@ -412,7 +412,16 @@ pub fn add(
// C files
step.linkLibC();
step.addIncludePath(b.path("src/stb"));
- step.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"} });
+ // Disable ubsan for MSVC: Zig's ubsan runtime cannot be bundled
+ // on Windows (LNK4229), leaving __ubsan_handle_* unresolved when
+ // the static archive is consumed by an external linker.
+ step.addCSourceFiles(.{
+ .files = &.{"src/stb/stb.c"},
+ .flags = if (step.rootModuleTarget().abi == .msvc)
+ &.{ "-fno-sanitize=undefined", "-fno-sanitize-trap=undefined" }
+ else
+ &.{},
+ });
if (step.rootModuleTarget().os.tag == .linux) {
step.addIncludePath(b.path("src/apprt/gtk"));
}
@@ -753,6 +762,7 @@ pub fn addSimd(
) !void {
const target = m.resolved_target.?;
const optimize = m.optimize.?;
+ const system_highway = b.systemIntegrationOption("highway", .{ .default = false });
// Simdutf
if (b.systemIntegrationOption("simdutf", .{})) {
@@ -761,6 +771,7 @@ pub fn addSimd(
if (b.lazyDependency("simdutf", .{
.target = target,
.optimize = optimize,
+ .no_libcxx = true,
})) |simdutf_dep| {
m.linkLibrary(simdutf_dep.artifact("simdutf"));
if (static_libs) |v| try v.append(
@@ -771,7 +782,7 @@ pub fn addSimd(
}
// Highway
- if (b.systemIntegrationOption("highway", .{ .default = false })) {
+ if (system_highway) {
m.linkSystemLibrary("libhwy", dynamic_link_opts);
} else {
if (b.lazyDependency("highway", .{
@@ -786,18 +797,6 @@ pub fn addSimd(
}
}
- // utfcpp - This is used as a dependency on our hand-written C++ code
- if (b.lazyDependency("utfcpp", .{
- .target = target,
- .optimize = optimize,
- })) |utfcpp_dep| {
- m.linkLibrary(utfcpp_dep.artifact("utfcpp"));
- if (static_libs) |v| try v.append(
- b.allocator,
- utfcpp_dep.artifact("utfcpp").getEmittedBin(),
- );
- }
-
// SIMD C++ files
m.addIncludePath(b.path("src"));
{
@@ -830,10 +829,27 @@ pub fn addSimd(
"-std=c++17",
);
- // Disable ubsan for MSVC to avoid undefined references to
- // __ubsan_handle_* symbols that require a runtime we don't link
- // and bundle. Hopefully we can fix this one day since ubsan is nice!
- if (target.result.abi == .msvc) try flags.appendSlice(b.allocator, &.{
+ // Keep our SIMD sources in the same Highway header mode as the
+ // vendored package build so HWY's inline dispatch/runtime helpers
+ // have a consistent ABI.
+ if (!system_highway) try flags.append(
+ b.allocator,
+ "-DHWY_NO_LIBCXX",
+ );
+
+ // When using the vendored simdutf, build its headers in no-libcxx
+ // mode so we don't need C++ standard library headers at all.
+ // System simdutf headers may not support this define.
+ if (!b.systemIntegrationOption("simdutf", .{})) try flags.append(
+ b.allocator,
+ "-DSIMDUTF_NO_LIBCXX",
+ );
+
+ // Disable ubsan for Windows C/C++ objects to avoid undefined
+ // __ubsan_handle_* references. The Zig libraries on Windows don't
+ // currently bundle a matching UBSan runtime for these objects in
+ // our build configurations (this affects both MSVC and GNU ABIs).
+ if (target.result.os.tag == .windows) try flags.appendSlice(b.allocator, &.{
"-fno-sanitize=undefined",
"-fno-sanitize-trap=undefined",
});
diff --git a/vendor/libghostty-vt/src/build/combine_archives.zig b/vendor/libghostty-vt/src/build/combine_archives.zig
new file mode 100644
index 00000000..04ec8e97
--- /dev/null
+++ b/vendor/libghostty-vt/src/build/combine_archives.zig
@@ -0,0 +1,55 @@
+//! Build tool that combines multiple static archives into a single fat
+//! archive using an MRI script piped to `zig ar -M`.
+//!
+//! MRI scripts require stdin piping (`ar -M < script`), which can't be
+//! expressed as a single command in the zig build system's RunStep. The
+//! previous approach used `/bin/sh -c` to do the piping, but that isn't
+//! available on Windows. This tool handles both the script generation
+//! and the piping in a single cross-platform executable.
+//!
+//! Usage: combine_archives <zig_exe> <output.a> <input1.a> [input2.a ...]
+
+const std = @import("std");
+
+pub fn main() !void {
+ var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
+ const alloc = gpa.allocator();
+
+ const args = try std.process.argsAlloc(alloc);
+ if (args.len < 4) {
+ std.log.err("usage: combine_archives <zig_exe> <output> <input...>", .{});
+ std.process.exit(1);
+ }
+
+ const zig_exe = args[1];
+ const output_path = args[2];
+ const inputs = args[3..];
+
+ // Build the MRI script.
+ var script: std.ArrayListUnmanaged(u8) = .empty;
+ try script.appendSlice(alloc, "CREATE ");
+ try script.appendSlice(alloc, output_path);
+ try script.append(alloc, '\n');
+ for (inputs) |input| {
+ try script.appendSlice(alloc, "ADDLIB ");
+ try script.appendSlice(alloc, input);
+ try script.append(alloc, '\n');
+ }
+ try script.appendSlice(alloc, "SAVE\nEND\n");
+
+ var child: std.process.Child = .init(&.{ zig_exe, "ar", "-M" }, alloc);
+ child.stdin_behavior = .Pipe;
+ child.stdout_behavior = .Inherit;
+ child.stderr_behavior = .Inherit;
+
+ try child.spawn();
+ try child.stdin.?.writeAll(script.items);
+ child.stdin.?.close();
+ child.stdin = null;
+
+ const term = try child.wait();
+ if (term.Exited != 0) {
+ std.log.err("zig ar -M exited with code {d}", .{term.Exited});
+ std.process.exit(1);
+ }
+}
diff --git a/vendor/libghostty-vt/src/cli/list_fonts.zig b/vendor/libghostty-vt/src/cli/list_fonts.zig
index 396c4e8a..6e08aa83 100644
--- a/vendor/libghostty-vt/src/cli/list_fonts.zig
+++ b/vendor/libghostty-vt/src/cli/list_fonts.zig
@@ -100,8 +100,12 @@ fn runArgs(alloc_gpa: Allocator, argsIter: anytype) !u8 {
var families: std.ArrayList([]const u8) = .empty;
var map: std.StringHashMap(std.ArrayListUnmanaged([]const u8)) = .init(alloc);
- // Look up all available fonts
- var disco = font.Discover.init();
+ // Look up all available fonts. The library is only used by backends
+ // that need it (the Windows backend opens candidate font files with
+ // FreeType); other backends ignore it.
+ var font_lib = try font.Library.init(alloc);
+ defer font_lib.deinit();
+ var disco = font.Discover.init(font_lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = config.family,
diff --git a/vendor/libghostty-vt/src/config.zig b/vendor/libghostty-vt/src/config.zig
index 289b6a81..05a2761a 100644
--- a/vendor/libghostty-vt/src/config.zig
+++ b/vendor/libghostty-vt/src/config.zig
@@ -23,6 +23,7 @@ pub const Command = Config.Command;
pub const ConfirmCloseSurface = Config.ConfirmCloseSurface;
pub const CopyOnSelect = Config.CopyOnSelect;
pub const RightClickAction = Config.RightClickAction;
+pub const MiddleClickAction = Config.MiddleClickAction;
pub const CustomShaderAnimation = Config.CustomShaderAnimation;
pub const FontSyntheticStyle = Config.FontSyntheticStyle;
pub const FontShapingBreak = Config.FontShapingBreak;
diff --git a/vendor/libghostty-vt/src/config/CApi.zig b/vendor/libghostty-vt/src/config/CApi.zig
index 6e835b4a..7d3366c9 100644
--- a/vendor/libghostty-vt/src/config/CApi.zig
+++ b/vendor/libghostty-vt/src/config/CApi.zig
@@ -2,7 +2,7 @@ const builtin = @import("builtin");
const std = @import("std");
const inputpkg = @import("../input.zig");
const state = &@import("../global.zig").state;
-const c = @import("../main_c.zig");
+const String = @import("../main_c.zig").String;
const Config = @import("Config.zig");
const c_get = @import("c_get.zig");
@@ -132,7 +132,7 @@ export fn ghostty_config_get_diagnostic(self: *Config, idx: u32) Diagnostic {
return .{ .message = message.ptr };
}
-export fn ghostty_config_open_path() c.String {
+export fn ghostty_config_open_path() String {
const path = edit.openPath(state.alloc) catch |err| {
log.err("error opening config in editor err={}", .{err});
return .empty;
diff --git a/vendor/libghostty-vt/src/config/Config.zig b/vendor/libghostty-vt/src/config/Config.zig
index 13f78eea..5b1d73de 100644
--- a/vendor/libghostty-vt/src/config/Config.zig
+++ b/vendor/libghostty-vt/src/config/Config.zig
@@ -2404,8 +2404,12 @@ keybind: Keybinds = .{},
/// The value `clipboard` will always copy text to the selection clipboard
/// as well as the system clipboard.
///
-/// Middle-click paste will always use the selection clipboard. Middle-click
-/// paste is always enabled even if this is `false`.
+/// Middle-click primary paste (see `middle-click-action`) is enabled by
+/// default even if this is `false`. The clipboard it pastes from follows
+/// this setting: with `true` (or `false`) it reads from the selection
+/// clipboard (falling back to the system clipboard on platforms without a
+/// selection clipboard); with `clipboard` it reads from the system
+/// clipboard.
///
/// The default value is true on Linux and macOS.
@"copy-on-select": CopyOnSelect = switch (builtin.os.tag) {
@@ -2427,6 +2431,16 @@ keybind: Keybinds = .{},
/// The default value is `context-menu`.
@"right-click-action": RightClickAction = .@"context-menu",
+/// The action to take when the user middle-clicks on the terminal surface.
+///
+/// Valid values:
+/// * `primary-paste` - Paste from the selection (or system) clipboard per
+/// `copy-on-select`.
+/// * `ignore` - Do nothing, ignore the middle click.
+///
+/// The default value is `primary-paste`.
+@"middle-click-action": MiddleClickAction = .@"primary-paste",
+
/// The time in milliseconds between clicks to consider a click a repeat
/// (double, triple, etc.) or an entirely new single click. A value of zero will
/// use a platform-specific default. The default on macOS is determined by the
@@ -6397,6 +6411,22 @@ pub const RepeatableFontVariation = struct {
}
};
+/// Returns true if the given key event would trigger a keybinding
+/// if it were to be processed. This is useful for determining if
+/// a key event should be sent to the terminal or not.
+pub fn keyEventIsBinding(
+ self: *Config,
+ event: inputpkg.KeyEvent,
+) bool {
+ switch (event.action) {
+ .release => return false,
+ .press, .repeat => {},
+ }
+
+ // If we have a keybinding for this event then we return true.
+ return self.keybind.set.getEvent(event) != null;
+}
+
/// Stores a set of keybinds.
pub const Keybinds = struct {
set: inputpkg.Binding.Set = .{},
@@ -6747,15 +6777,29 @@ pub const Keybinds = struct {
// Semantic prompts
try self.set.put(
alloc,
- .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true, .ctrl = true } },
+ .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true, .ctrl = true } },
.{ .jump_to_prompt = -1 },
);
try self.set.put(
alloc,
- .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true, .ctrl = true } },
+ .{ .key = .{ .physical = .arrow_down }, .mods = .{ .shift = true, .ctrl = true } },
.{ .jump_to_prompt = 1 },
);
+ // Move tab
+ try self.set.putFlags(
+ alloc,
+ .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true, .ctrl = true } },
+ .{ .move_tab = -1 },
+ .{ .performable = true },
+ );
+ try self.set.putFlags(
+ alloc,
+ .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true, .ctrl = true } },
+ .{ .move_tab = 1 },
+ .{ .performable = true },
+ );
+
// Search
try self.set.putFlags(
alloc,
@@ -8620,6 +8664,15 @@ pub const RightClickAction = enum {
@"context-menu",
};
+/// Options for middle-click actions.
+pub const MiddleClickAction = enum {
+ /// Paste from the selection/standard clipboard per `copy-on-select`.
+ @"primary-paste",
+
+ /// No action is taken on middle click.
+ ignore,
+};
+
/// Shell integration values
pub const ShellIntegration = enum {
none,
diff --git a/vendor/libghostty-vt/src/font/DeferredFace.zig b/vendor/libghostty-vt/src/font/DeferredFace.zig
index e818cca3..425f3c28 100644
--- a/vendor/libghostty-vt/src/font/DeferredFace.zig
+++ b/vendor/libghostty-vt/src/font/DeferredFace.zig
@@ -26,6 +26,10 @@ fc: if (options.backend == .fontconfig_freetype) ?Fontconfig else void =
ct: if (font.Discover == font.discovery.CoreText) ?CoreText else void =
if (font.Discover == font.discovery.CoreText) null else {},
+/// Windows (FreeType directory scan)
+win: if (options.backend == .freetype_windows) ?Windows else void =
+ if (options.backend == .freetype_windows) null else {},
+
/// Canvas
wc: if (options.backend == .web_canvas) ?WebCanvas else void =
if (options.backend == .web_canvas) null else {},
@@ -51,6 +55,42 @@ pub const Fontconfig = struct {
}
};
+/// Windows specific data. Only present with the freetype_windows backend.
+///
+/// Unlike Fontconfig/CoreText which carry lightweight descriptor handles,
+/// the Windows backend has no external descriptor service — the "deferred"
+/// metadata is the FreeType face itself. We keep a pre-loaded face (loaded
+/// at discovery time) to answer `hasCodepoint` cheaply without re-opening
+/// the file on every query, and remember the path so `load()` can open a
+/// fresh face at the caller's requested size/options.
+pub const Windows = struct {
+ /// Path to the font file. Owned here.
+ path: [:0]const u8,
+
+ /// Face index within the file (for .ttc collections).
+ face_index: i32,
+
+ /// Variations to apply on load.
+ variations: []const font.face.Variation,
+
+ /// Pre-loaded face used for cheap metadata queries (glyphIndex,
+ /// hasColor). The size it was opened at is irrelevant for these
+ /// queries since the CMap is size-independent. Deinit'd with us.
+ peek: Face,
+
+ /// Whether the face presents as emoji (has color glyphs) or text.
+ presentation: Presentation,
+
+ /// Allocator that owns `path`.
+ alloc: Allocator,
+
+ pub fn deinit(self: *Windows) void {
+ self.peek.deinit();
+ self.alloc.free(self.path);
+ self.* = undefined;
+ }
+};
+
/// CoreText specific data. This is only present when building with CoreText.
pub const CoreText = struct {
/// The initialized font
@@ -88,6 +128,7 @@ pub fn deinit(self: *DeferredFace) void {
switch (options.backend) {
.fontconfig_freetype => if (self.fc) |*fc| fc.deinit(),
.freetype => {},
+ .freetype_windows => if (self.win) |*w| w.deinit(),
.web_canvas => if (self.wc) |*wc| wc.deinit(),
.coretext,
.coretext_freetype,
@@ -103,6 +144,8 @@ pub fn familyName(self: DeferredFace, buf: []u8) ![]const u8 {
switch (options.backend) {
.freetype => {},
+ .freetype_windows => if (self.win) |w| return try w.peek.name(buf),
+
.fontconfig_freetype => if (self.fc) |fc|
return (try fc.pattern.get(.family, 0)).string,
@@ -131,6 +174,8 @@ pub fn name(self: DeferredFace, buf: []u8) ![]const u8 {
switch (options.backend) {
.freetype => {},
+ .freetype_windows => if (self.win) |w| return try w.peek.name(buf),
+
.fontconfig_freetype => if (self.fc) |fc|
return (try fc.pattern.get(.fullname, 0)).string,
@@ -164,6 +209,7 @@ pub fn load(
) !Face {
return switch (options.backend) {
.fontconfig_freetype => try self.loadFontconfig(lib, opts),
+ .freetype_windows => try self.loadWindows(lib, opts),
.coretext, .coretext_harfbuzz, .coretext_noshape => try self.loadCoreText(lib, opts),
.coretext_freetype => try self.loadCoreTextFreetype(lib, opts),
.web_canvas => try self.loadWebCanvas(opts),
@@ -191,6 +237,19 @@ fn loadFontconfig(
return face;
}
+fn loadWindows(
+ self: *DeferredFace,
+ lib: Library,
+ opts: font.face.Options,
+) !Face {
+ const w = self.win.?;
+
+ var face = try Face.initFile(lib, w.path, w.face_index, opts);
+ errdefer face.deinit();
+ try face.setVariations(w.variations, opts);
+ return face;
+}
+
fn loadCoreText(
self: *DeferredFace,
lib: Library,
@@ -287,6 +346,14 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool {
}
},
+ .freetype_windows => {
+ // Use the pre-loaded peek face for a cheap CMap lookup.
+ if (self.win) |w| {
+ if (p) |desired| if (w.presentation != desired) return false;
+ return w.peek.glyphIndex(cp) != null;
+ }
+ },
+
.coretext,
.coretext_freetype,
.coretext_harfbuzz,
@@ -411,7 +478,7 @@ test "fontconfig" {
// Get a deferred face from fontconfig
var def = def: {
- var fc = discovery.Fontconfig.init();
+ var fc = discovery.Fontconfig.init(lib);
defer fc.deinit();
var it = try fc.discover(alloc, .{ .family = "monospace", .size = 12 });
defer it.deinit();
@@ -443,7 +510,7 @@ test "coretext" {
// Get a deferred face from fontconfig
var def = def: {
- var fc = discovery.CoreText.init();
+ var fc = discovery.CoreText.init(lib);
var it = try fc.discover(alloc, .{ .family = "Monaco", .size = 12 });
defer it.deinit();
break :def (try it.next()).?;
diff --git a/vendor/libghostty-vt/src/font/SharedGridSet.zig b/vendor/libghostty-vt/src/font/SharedGridSet.zig
index b832139b..9d8148bd 100644
--- a/vendor/libghostty-vt/src/font/SharedGridSet.zig
+++ b/vendor/libghostty-vt/src/font/SharedGridSet.zig
@@ -442,7 +442,7 @@ fn discover(self: *SharedGridSet) !?*Discover {
// If we initialized, use it
if (self.font_discover) |*v| return v;
- self.font_discover = .init();
+ self.font_discover = .init(self.font_lib);
return &self.font_discover.?;
}
diff --git a/vendor/libghostty-vt/src/font/backend.zig b/vendor/libghostty-vt/src/font/backend.zig
index d24edec7..867421de 100644
--- a/vendor/libghostty-vt/src/font/backend.zig
+++ b/vendor/libghostty-vt/src/font/backend.zig
@@ -6,6 +6,12 @@ pub const Backend = enum {
/// FreeType for font rendering with no font discovery enabled.
freetype,
+ /// FreeType for font rendering with a built-in Windows font directory
+ /// scanner (C:\Windows\Fonts + %LOCALAPPDATA%\Microsoft\Windows\Fonts).
+ /// Used when DirectWrite is not available; matches by family_name and
+ /// SFNT name table without any external index.
+ freetype_windows,
+
/// Fontconfig for font discovery and FreeType for font rendering.
fontconfig_freetype,
@@ -42,10 +48,10 @@ pub const Backend = enum {
if (target.os.tag == .windows) {
// Avoid fontconfig on Windows because its libxml2 dependency
- // may not unpack due to symlinks. Use plain freetype for now
- // which means no font discovery. Full solution would likely use
- // DirectWrite which has its own discovery API.
- return .freetype;
+ // may not unpack due to symlinks. Use the FreeType-based
+ // Windows font-directory scanner for discovery. A future
+ // DirectWrite backend can replace this if needed.
+ return .freetype_windows;
}
// macOS also supports "coretext_freetype" but there is no scenario
@@ -60,6 +66,7 @@ pub const Backend = enum {
pub fn hasFreetype(self: Backend) bool {
return switch (self) {
.freetype,
+ .freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
=> true,
@@ -81,6 +88,7 @@ pub const Backend = enum {
=> true,
.freetype,
+ .freetype_windows,
.fontconfig_freetype,
.web_canvas,
=> false,
@@ -92,6 +100,7 @@ pub const Backend = enum {
.fontconfig_freetype => true,
.freetype,
+ .freetype_windows,
.coretext,
.coretext_freetype,
.coretext_harfbuzz,
@@ -104,6 +113,7 @@ pub const Backend = enum {
pub fn hasHarfbuzz(self: Backend) bool {
return switch (self) {
.freetype,
+ .freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
.coretext_harfbuzz,
diff --git a/vendor/libghostty-vt/src/font/discovery.zig b/vendor/libghostty-vt/src/font/discovery.zig
index c419d36a..b945aa01 100644
--- a/vendor/libghostty-vt/src/font/discovery.zig
+++ b/vendor/libghostty-vt/src/font/discovery.zig
@@ -1,4 +1,5 @@
const std = @import("std");
+const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const assert = @import("../quirks.zig").inlineAssert;
const fontconfig = @import("fontconfig");
@@ -7,6 +8,9 @@ const opentype = @import("opentype.zig");
const options = @import("main.zig").options;
const Collection = @import("main.zig").Collection;
const DeferredFace = @import("main.zig").DeferredFace;
+const Face = @import("main.zig").Face;
+const Library = @import("main.zig").Library;
+const Presentation = @import("main.zig").Presentation;
const Variation = @import("main.zig").face.Variation;
const log = std.log.scoped(.discovery);
@@ -14,6 +18,7 @@ const log = std.log.scoped(.discovery);
/// Discover implementation for the compile options.
pub const Discover = switch (options.backend) {
.freetype => void, // no discovery
+ .freetype_windows => Windows,
.fontconfig_freetype => Fontconfig,
.web_canvas => void, // no discovery
.coretext,
@@ -242,7 +247,8 @@ pub const Descriptor = struct {
pub const Fontconfig = struct {
fc_config: *fontconfig.Config,
- pub fn init() Fontconfig {
+ pub fn init(lib: Library) Fontconfig {
+ _ = lib;
// safe to call multiple times and concurrently
_ = fontconfig.init();
return .{ .fc_config = fontconfig.initLoadConfigAndFonts() };
@@ -333,7 +339,8 @@ pub const Fontconfig = struct {
};
pub const CoreText = struct {
- pub fn init() CoreText {
+ pub fn init(lib: Library) CoreText {
+ _ = lib;
// Required for the "interface" but does nothing for CoreText.
return .{};
}
@@ -879,6 +886,263 @@ pub const CoreText = struct {
};
};
+/// Windows font discovery. Enumerates font files in the system and
+/// per-user font directories and matches them to a descriptor via
+/// FreeType's family_name field (with a fallback to the SFNT name
+/// table when family_name is missing).
+///
+/// No external service is used; each discover() call walks the
+/// directories, opening candidate files with FreeType only as needed.
+/// For typical Windows installations (~300 fonts) a name query is in
+/// the tens of milliseconds. A codepoint fallback query may be
+/// noticeably slower because every candidate has to be opened to
+/// probe its CMap.
+pub const Windows = struct {
+ lib: Library,
+
+ pub fn init(lib: Library) Windows {
+ return .{ .lib = lib };
+ }
+
+ pub fn deinit(self: *Windows) void {
+ _ = self;
+ }
+
+ pub fn discover(
+ self: *const Windows,
+ alloc: Allocator,
+ desc: Descriptor,
+ ) !DiscoverIterator {
+ return .{
+ .alloc = alloc,
+ .lib = self.lib,
+ .desc = desc,
+ .variations = desc.variations,
+ .state = .system,
+ .dir = null,
+ .iter = null,
+ .system_path = null,
+ .user_path = null,
+ };
+ }
+
+ pub fn discoverFallback(
+ self: *const Windows,
+ alloc: Allocator,
+ collection: *Collection,
+ desc: Descriptor,
+ ) !DiscoverIterator {
+ _ = collection;
+ return self.discover(alloc, desc);
+ }
+
+ pub const DiscoverIterator = struct {
+ alloc: Allocator,
+ lib: Library,
+ desc: Descriptor,
+ variations: []const Variation,
+ state: State,
+ dir: ?std.fs.Dir,
+ iter: ?std.fs.Dir.Iterator,
+ system_path: ?[:0]const u8,
+ user_path: ?[:0]const u8,
+
+ const State = enum { system, user, done };
+
+ pub fn deinit(self: *DiscoverIterator) void {
+ if (self.dir) |*d| d.close();
+ if (self.system_path) |p| self.alloc.free(p);
+ if (self.user_path) |p| self.alloc.free(p);
+ self.* = undefined;
+ }
+
+ pub fn next(self: *DiscoverIterator) !?DeferredFace {
+ while (true) {
+ // Ensure we have a directory iterator for the current state.
+ if (self.iter == null) {
+ switch (self.state) {
+ .system => {
+ const path = self.systemFontsPath() orelse {
+ self.state = .user;
+ continue;
+ };
+ self.system_path = path;
+ self.dir = std.fs.openDirAbsoluteZ(
+ path,
+ .{ .iterate = true },
+ ) catch {
+ self.state = .user;
+ continue;
+ };
+ self.iter = self.dir.?.iterate();
+ },
+ .user => {
+ const path = self.userFontsPath() orelse {
+ self.state = .done;
+ continue;
+ };
+ self.user_path = path;
+ self.dir = std.fs.openDirAbsoluteZ(
+ path,
+ .{ .iterate = true },
+ ) catch {
+ self.state = .done;
+ continue;
+ };
+ self.iter = self.dir.?.iterate();
+ },
+ .done => return null,
+ }
+ }
+
+ const entry = (self.iter.?.next() catch null) orelse {
+ // Finished this directory; advance state.
+ if (self.dir) |*d| d.close();
+ self.dir = null;
+ self.iter = null;
+ self.state = switch (self.state) {
+ .system => .user,
+ .user => .done,
+ .done => .done,
+ };
+ continue;
+ };
+
+ if (entry.kind != .file) continue;
+ if (!isFontFile(entry.name)) continue;
+
+ if (try self.tryMatch(entry.name)) |face| return face;
+ }
+ }
+
+ /// Build the system fonts directory from %SYSTEMROOT%. Returns null
+ /// if SYSTEMROOT is unset, which shouldn't happen on a healthy
+ /// Windows install but we just skip the directory rather than
+ /// falling back to a hardcoded drive letter.
+ fn systemFontsPath(self: *DiscoverIterator) ?[:0]const u8 {
+ const systemroot = std.process.getEnvVarOwned(
+ self.alloc,
+ "SYSTEMROOT",
+ ) catch return null;
+ defer self.alloc.free(systemroot);
+ return std.fmt.allocPrintSentinel(
+ self.alloc,
+ "{s}\\Fonts",
+ .{systemroot},
+ 0,
+ ) catch null;
+ }
+
+ fn userFontsPath(self: *DiscoverIterator) ?[:0]const u8 {
+ const local_appdata = std.process.getEnvVarOwned(
+ self.alloc,
+ "LOCALAPPDATA",
+ ) catch return null;
+ defer self.alloc.free(local_appdata);
+ return std.fmt.allocPrintSentinel(
+ self.alloc,
+ "{s}\\Microsoft\\Windows\\Fonts",
+ .{local_appdata},
+ 0,
+ ) catch null;
+ }
+
+ fn tryMatch(
+ self: *DiscoverIterator,
+ name: []const u8,
+ ) !?DeferredFace {
+ const dir_path = switch (self.state) {
+ .system => self.system_path.?,
+ .user => self.user_path.?,
+ .done => return null,
+ };
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const full_path = std.fmt.bufPrintZ(
+ &path_buf,
+ "{s}\\{s}",
+ .{ dir_path, name },
+ ) catch return null;
+
+ const is_ttc = std.ascii.endsWithIgnoreCase(name, ".ttc");
+ const max_faces: i32 = if (is_ttc) 16 else 1;
+
+ // Probe each face in the file.
+ var face_index: i32 = 0;
+ while (face_index < max_faces) : (face_index += 1) {
+ var face = Face.initFile(
+ self.lib,
+ full_path,
+ face_index,
+ .{ .size = .{ .points = 12 } },
+ ) catch break;
+
+ if (self.matches(&face)) {
+ return try self.makeDeferred(face, full_path, face_index);
+ }
+
+ face.deinit();
+ }
+
+ return null;
+ }
+
+ /// Check whether the given face matches the descriptor.
+ fn matches(self: *const DiscoverIterator, face: *Face) bool {
+ if (self.desc.family) |family| {
+ if (!familyMatches(face, family)) return false;
+ }
+ if (self.desc.codepoint != 0) {
+ if (face.glyphIndex(self.desc.codepoint) == null) return false;
+ }
+ return true;
+ }
+
+ fn makeDeferred(
+ self: *DiscoverIterator,
+ face: Face,
+ full_path: []const u8,
+ face_index: i32,
+ ) !DeferredFace {
+ const path_owned = try self.alloc.dupeZ(u8, full_path);
+ errdefer self.alloc.free(path_owned);
+
+ const presentation: Presentation =
+ if (face.hasColor()) .emoji else .text;
+
+ return DeferredFace{
+ .win = .{
+ .path = path_owned,
+ .face_index = face_index,
+ .variations = self.variations,
+ .peek = face,
+ .presentation = presentation,
+ .alloc = self.alloc,
+ },
+ };
+ }
+ };
+
+ fn isFontFile(name: []const u8) bool {
+ return std.ascii.endsWithIgnoreCase(name, ".ttf") or
+ std.ascii.endsWithIgnoreCase(name, ".ttc") or
+ std.ascii.endsWithIgnoreCase(name, ".otf");
+ }
+
+ /// Compare a face's family against a requested family name. Checks
+ /// FreeType's family_name first, then falls back to the SFNT name
+ /// table entry.
+ fn familyMatches(face: *Face, family: [:0]const u8) bool {
+ const ft_family: ?[*:0]const u8 = face.face.handle.*.family_name;
+ if (ft_family) |f| {
+ if (std.ascii.eqlIgnoreCase(std.mem.span(f), family)) return true;
+ }
+ var buf: [256]u8 = undefined;
+ const sfnt = face.name(&buf) catch "";
+ return sfnt.len > 0 and std.ascii.eqlIgnoreCase(sfnt, family);
+ }
+};
+
test "descriptor hash" {
const testing = std.testing;
@@ -900,7 +1164,10 @@ test "fontconfig" {
const testing = std.testing;
const alloc = testing.allocator;
- var fc = Fontconfig.init();
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var fc = Fontconfig.init(lib);
defer fc.deinit();
var it = try fc.discover(alloc, .{ .family = "monospace", .size = 12 });
defer it.deinit();
@@ -912,7 +1179,10 @@ test "fontconfig codepoint" {
const testing = std.testing;
const alloc = testing.allocator;
- var fc = Fontconfig.init();
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var fc = Fontconfig.init(lib);
defer fc.deinit();
var it = try fc.discover(alloc, .{ .codepoint = 'A', .size = 12 });
defer it.deinit();
@@ -934,7 +1204,10 @@ test "coretext" {
const testing = std.testing;
const alloc = testing.allocator;
- var ct = CoreText.init();
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var ct = CoreText.init(lib);
defer ct.deinit();
var it = try ct.discover(alloc, .{ .family = "Monaco", .size = 12 });
defer it.deinit();
@@ -952,7 +1225,10 @@ test "coretext codepoint" {
const testing = std.testing;
const alloc = testing.allocator;
- var ct = CoreText.init();
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var ct = CoreText.init(lib);
defer ct.deinit();
var it = try ct.discover(alloc, .{ .codepoint = 'A', .size = 12 });
defer it.deinit();
@@ -981,7 +1257,10 @@ test "coretext sorting" {
const testing = std.testing;
const alloc = testing.allocator;
- var ct = CoreText.init();
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var ct = CoreText.init(lib);
defer ct.deinit();
// We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro,
@@ -1047,3 +1326,24 @@ test "coretext sorting" {
try testing.expectEqualStrings("SF Pro Bold Italic", name);
}
}
+
+test "windows" {
+ if (options.backend != .freetype_windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var win = Windows.init(lib);
+ defer win.deinit();
+
+ // Arial ships on every stock Windows install.
+ var it = try win.discover(alloc, .{ .family = "Arial", .size = 12 });
+ defer it.deinit();
+
+ var face = (try it.next()) orelse return error.TestFontNotFound;
+ defer face.deinit();
+ try testing.expect(face.hasCodepoint('A', null));
+}
diff --git a/vendor/libghostty-vt/src/font/face.zig b/vendor/libghostty-vt/src/font/face.zig
index a1312c45..d77253ad 100644
--- a/vendor/libghostty-vt/src/font/face.zig
+++ b/vendor/libghostty-vt/src/font/face.zig
@@ -11,6 +11,7 @@ pub const web_canvas = @import("face/web_canvas.zig");
/// Face implementation for the compile options.
pub const Face = switch (options.backend) {
.freetype,
+ .freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
=> freetype.Face,
diff --git a/vendor/libghostty-vt/src/font/library.zig b/vendor/libghostty-vt/src/font/library.zig
index dce6dbd5..56946343 100644
--- a/vendor/libghostty-vt/src/font/library.zig
+++ b/vendor/libghostty-vt/src/font/library.zig
@@ -10,6 +10,7 @@ const font = @import("main.zig");
pub const Library = switch (options.backend) {
// Freetype requires a state library
.freetype,
+ .freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
=> FreetypeLibrary,
diff --git a/vendor/libghostty-vt/src/font/opentype.zig b/vendor/libghostty-vt/src/font/opentype.zig
index dd02efeb..0cba9225 100644
--- a/vendor/libghostty-vt/src/font/opentype.zig
+++ b/vendor/libghostty-vt/src/font/opentype.zig
@@ -5,12 +5,14 @@ const os2 = @import("opentype/os2.zig");
const post = @import("opentype/post.zig");
const hhea = @import("opentype/hhea.zig");
const head = @import("opentype/head.zig");
+const glyf = @import("opentype/glyf.zig");
pub const SVG = svg.SVG;
pub const OS2 = os2.OS2;
pub const Post = post.Post;
pub const Hhea = hhea.Hhea;
pub const Head = head.Head;
+pub const Glyf = glyf.Glyf;
test {
@import("std").testing.refAllDecls(@This());
diff --git a/vendor/libghostty-vt/src/font/opentype/glyf.zig b/vendor/libghostty-vt/src/font/opentype/glyf.zig
new file mode 100644
index 00000000..7392729a
--- /dev/null
+++ b/vendor/libghostty-vt/src/font/opentype/glyf.zig
@@ -0,0 +1,585 @@
+const std = @import("std");
+const sfnt = @import("sfnt.zig");
+
+/// Glyph Data Table
+///
+/// This takes a little bit of a different form than other tables that we
+/// have parsers for. Due to the fact that this table contains arrays of
+/// arbitrary length, we store a pointer (slice) to the underlying data,
+/// and then have functions for getting and interpreting specific parts.
+///
+/// References:
+/// - https://learn.microsoft.com/en-us/typography/opentype/spec/glyf
+///
+/// Field names are in camelCase to match names in spec.
+pub const Glyf = struct {
+ data: []const u8,
+
+ /// https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#table-organization
+ pub const Entry = struct {
+ header: Header,
+
+ /// We store a reference to the original bytes so that we can
+ /// validate or iterate the contours or components of the glyph.
+ ///
+ /// This data starts immediately after the header.
+ data: []const u8,
+
+ /// The header that's always present at
+ /// the start of any glyph in the table.
+ ///
+ /// Depending on the number of contours, the data that
+ /// comes afterwards must be interpreted differently.
+ ///
+ /// References:
+ /// - https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#glyph-headers
+ pub const Header = extern struct {
+ /// If the number of contours is greater than
+ /// or equal to zero, this is a simple glyph.
+ ///
+ /// If negative, this is a composite glyph — the
+ /// value -1 should be used for composite glyphs.
+ numberOfContours: sfnt.int16 align(1),
+
+ /// Minimum x for coordinate data.
+ xMin: sfnt.int16 align(1),
+
+ /// Minimum y for coordinate data.
+ yMin: sfnt.int16 align(1),
+
+ /// Maximum x for coordinate data.
+ xMax: sfnt.int16 align(1),
+
+ /// Maximum y for coordinate data.
+ yMax: sfnt.int16 align(1),
+ };
+
+ /// The bit flags that describe the point data in simple glyph entries.
+ ///
+ /// Doc strings for each field are copied with minimal modification
+ /// from the opentype spec. Field names are altered to be clearer and
+ /// more succinct, and mentions of those field names in doc strings
+ /// have been similarly modified to match the ones in the struct.
+ ///
+ /// The relationship between the <x|y>_short and <x|y>_repeat_or_sign
+ /// fields is important, and poorly explained in prose, so instead of
+ /// that, here's a table that should make it easier to understand.
+ ///
+ /// x_short > | false | true |
+ /// x_repeat_or_sign V |------------------|------------------|
+ /// | The x-coordinate | The x-coordinate |
+ /// false | of this point is | of this point is |
+ /// | a signed 16-bit | an unsigned byte |
+ /// | value added to | value treated as |
+ /// | the *Coordinates | negative, added |
+ /// | array. | to the array of |
+ /// | | xCoordinates. |
+ /// -------------------|------------------|------------------|
+ /// | The x-coordinate | The x-coordinate |
+ /// true | of this point is | of this point is |
+ /// | the same as the | an unsigned byte |
+ /// | previous point; | value treated as |
+ /// | nothing added to | positive, added |
+ /// | the xCoordinates | to the array of |
+ /// | array. | xCoordinates. |
+ /// -------------------|------------------|------------------|
+ ///
+ /// References:
+ /// - https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#simple-glyph-description
+ pub const SimpleFlags = packed struct(u8) {
+ /// If set, the point is on the curve; otherwise, it is off the curve.
+ on_curve: bool,
+
+ /// If set, the corresponding x-coordinate is 1 byte long,
+ /// and the sign is determined by the x_repeat_or_sign flag.
+ ///
+ /// If not set, its interpretation depends on the x_repeat_or_sign
+ /// flag: If that other flag is set, the x-coordinate is the same
+ /// as the previous x-coordinate, and no element is added to the
+ /// xCoordinates array. If both flags are not set, the corresponding
+ /// element in the xCoordinates array is two bytes and interpreted
+ /// as a signed integer.
+ ///
+ /// See the description of the x_repeat_or_sign flag for additional
+ /// information.
+ x_short: bool,
+
+ /// If set, the corresponding y-coordinate is 1 byte long,
+ /// and the sign is determined by the y_repeat_or_sign flag.
+ ///
+ /// If not set, its interpretation depends on the y_repeat_or_sign
+ /// flag: If that other flag is set, the y-coordinate is the same
+ /// as the previous y-coordinate, and no element is added to the
+ /// yCoordinates array. If both flags are not set, the corresponding
+ /// element in the yCoordinates array is two bytes and interpreted
+ /// as a signed integer.
+ ///
+ /// See the description of the y_repeat_or_sign flag for additional
+ /// information.
+ y_short: bool,
+
+ /// If set, the next byte (read as unsigned) specifies the number
+ /// of additional times this flag byte is to be repeated in the
+ /// logical flags array — that is, the number of additional logical
+ /// flag entries inserted after this entry. (In the expanded logical
+ /// array, this bit is ignored.) In this way, the number of flags
+ /// listed can be smaller than the number of points in the glyph
+ /// description.
+ repeat: bool,
+
+ /// This flag has two meanings, depending on how the x_short flag
+ /// is set. If x_short is set, this bit describes the sign of the
+ /// value, with 1 equaling positive and 0 negative. If x_short is
+ /// not set and this bit is set, then the current x-coordinate is
+ /// the same as the previous x-coordinate. If x_short is not set
+ /// and this bit is also not set, the current x-coordinate is a
+ /// signed 16-bit delta vector.
+ x_repeat_or_sign: bool,
+
+ /// This flag has two meanings, depending on how the y_short flag
+ /// is set. If y_short is set, this bit describes the sign of the
+ /// value, with 1 equaling positive and 0 negative. If y_short is
+ /// not set and this bit is set, then the current y-coordinate is
+ /// the same as the previous y-coordinate. If y_short is not set
+ /// and this bit is also not set, the current y-coordinate is a
+ /// signed 16-bit delta vector.
+ y_repeat_or_sign: bool,
+
+ /// If set, contours in the glyph description could overlap.
+ ///
+ /// Use of this flag is not required — that is, contours may
+ /// overlap without having this flag set. When used, it must
+ /// be set on the first flag byte for the glyph.
+ overlap: bool,
+
+ /// Bit 7 is reserved: set to zero.
+ reserved: bool,
+
+ /// Determine the size (in bytes) of the corresponding
+ /// value in the `xCoordinates` array for this flagset.
+ ///
+ /// See doc comments on the struct for an explanation.
+ pub inline fn xBytes(self: SimpleFlags) u2 {
+ return if (self.x_short)
+ // short, 1 byte
+ 1
+ else if (self.x_repeat_or_sign)
+ // repeat, 0 bytes
+ 0
+ else
+ // otherwise, 16-bit, 2 bytes.
+ 2;
+ }
+
+ /// Determine the size (in bytes) of the corresponding
+ /// value in the `yCoordinates` array for this flagset.
+ ///
+ /// See doc comments on the struct for an explanation.
+ pub inline fn yBytes(self: SimpleFlags) u2 {
+ return if (self.y_short)
+ // short, 1 byte
+ 1
+ else if (self.y_repeat_or_sign)
+ // repeat, 0 bytes
+ 0
+ else
+ // otherwise, 16-bit, 2 bytes.
+ 2;
+ }
+ };
+
+ pub const Type = enum {
+ /// A glyph made of standard contours.
+ simple,
+ /// A glyph made of references to other glyphs.
+ composite,
+ };
+
+ /// Initialize an entry from the provided data.
+ ///
+ /// This DOES NOT COPY the data, it only stores a pointer to it.
+ ///
+ /// The lifetime of this struct, then, is the same as the
+ /// lifetime of the data that is used to initialize it.
+ pub fn init(data: []const u8) error{EndOfStream}!Entry {
+ var fbs = std.io.fixedBufferStream(data);
+ const reader = fbs.reader();
+ const header = try reader.readStructEndian(Header, .big);
+ return .{ .header = header, .data = data[fbs.pos..] };
+ }
+
+ /// Identifies what type (simple or composite) of entry this is.
+ pub fn entryType(self: Entry) Type {
+ return if (self.header.numberOfContours >= 0)
+ .simple
+ else
+ .composite;
+ }
+
+ /// Errors that can be returned from `Entry.size()`.
+ pub const SizeError = error{
+ /// The entry's data wasn't large enough, ran
+ /// out of bytes before we were done reading.
+ EndOfStream,
+
+ /// The entry contains hinting instructions,
+ /// which we don't currently support.
+ InstructionsNotSupported,
+
+ /// The entry is a composite glyph,
+ /// which we don't currently support.
+ CompositeNotSupported,
+
+ /// The elements of the end points array
+ /// must strictly monotonically increase.
+ ///
+ /// This error means the provided entry violated that.
+ EndPointsOutOfOrder,
+
+ /// This entry defines points past the index determined
+ /// by the final element of the endPtsOfContours array.
+ TooManyPoints,
+ };
+
+ /// Determines the size (in bytes) of this entry.
+ ///
+ /// If the entry is valid, returns the number of bytes
+ /// taken up by this entry, including its header.
+ ///
+ /// NOTE: Currently produces errors when given composite glyphs
+ /// or any glyphs that have hinting instructions included.
+ pub fn size(self: Entry) SizeError!usize {
+ var fbs = std.io.fixedBufferStream(self.data);
+ const reader = fbs.reader();
+ switch (self.entryType()) {
+ // https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#simple-glyph-description
+ .simple => {
+ const num_contours: usize = @intCast(self.header.numberOfContours);
+
+ // From the spec:
+ //
+ // > If a glyph has zero contours, no additional glyph
+ // > data beyond the header is required. A glyph with
+ // > zero contours may have additional data, however;
+ // > in particular, it may have instructions that
+ // > operate on phantom points.
+ //
+ // If our number of contours is 0, and there's less than
+ // two bytes in the remaining data, then we just return
+ // the size of the header as our size. The reason for
+ // two bytes is because that's the minimum size of the
+ // extra data, since `instructionLength` is 16 bits.
+ if (num_contours == 0 and self.data.len < 2) {
+ return @sizeOf(Header);
+ }
+
+ // uint16 endPtsOfContours[numberOfContours]
+ //
+ // Array of point indices for the last point
+ // of each contour, in increasing numeric order.
+ var max_point_index: isize = -1;
+ for (0..num_contours) |_| {
+ const index = try reader.readInt(sfnt.uint16, .big);
+ // The endpoints are supposed to monotonically increase.
+ if (index <= max_point_index) return error.EndPointsOutOfOrder;
+ max_point_index = index;
+ }
+
+ // uint16 instructionLength
+ //
+ // Total number of bytes for instructions.
+ //
+ // If instructionLength is zero, no instructions
+ // are present for this glyph, and this field is
+ // followed directly by the flags field.
+ const instructions_length = try reader.readInt(sfnt.uint16, .big);
+
+ // Since we don't have code that validates instruction
+ // byte code, we just reject all glyphs that contain any.
+ //
+ // In the future we could change this to just ignore the
+ // instructions, or even validate them, but for now this
+ // is fine, since we only need this function at all to
+ // validate glyf entries from the glyph protocol, which
+ // explicitly forbids instructions anyway.
+ if (instructions_length > 0) return error.InstructionsNotSupported;
+
+ // uint8 flags[variable]
+ //
+ // Array of flag elements.
+ //
+ // ---
+ //
+ // We do additional accounting here to figure out how many
+ // bytes the next two fields (the [x|y]Coordinates arrays)
+ // should take, so that we can just try to throw out that
+ // many bytes in order to validate them. This is because
+ // the length of each one depends on the flags.
+ //
+ // We're using `i` here to count the number of logical
+ // entries we have, which should reach the number of
+ // points defined by the final endpoint (from earlier).
+ var i: usize = 0;
+ var x_coords_len: usize = 0;
+ var y_coords_len: usize = 0;
+ while (i <= max_point_index) : (i += 1) {
+ const flag: SimpleFlags = @bitCast(try reader.readByte());
+
+ // Determine how many bytes the x and y coordinates will
+ // be represented with in the corresponding arrays, add
+ // them to our tallies.
+ x_coords_len += flag.xBytes();
+ y_coords_len += flag.yBytes();
+
+ // 0x08 REPEAT_FLAG
+ // Bit 3: If set, the next byte (read as unsigned)
+ // specifies the number of additional times this flag
+ // byte is to be repeated in the logical flags array
+ // — that is, the number of additional logical flag
+ // entries inserted after this entry.
+ if (flag.repeat) {
+ // The flag is repeated a certain number of times,
+ // which means that the point count is increased by
+ // that count, and the x_coords_len and y_coords_len
+ // must be increased by the correct number of bytes
+ // as well.
+ const repeat_count: usize = try reader.readByte();
+ i += repeat_count;
+ x_coords_len += repeat_count * flag.xBytes();
+ y_coords_len += repeat_count * flag.yBytes();
+
+ // If the repeat count pushes our logical point
+ // number beyond the max point index which we
+ // figured out earlier from the end points, then
+ // there's an issue with this entry, error out.
+ if (i > max_point_index) return error.TooManyPoints;
+ }
+ }
+
+ // uint8 or int16 xCoordinates[variable]
+ //
+ // Contour point x-coordinates.
+ //
+ // ---
+ //
+ // We determined the length of this section (in bytes)
+ // above while processing the flags, so that we can just
+ // skip that many bytes to validate this field.
+ try reader.skipBytes(x_coords_len, .{});
+
+ // uint8 or int16 yCoordinates[variable]
+ //
+ // Contour point y-coordinates.
+ //
+ // ---
+ //
+ // We determined the length of this section (in bytes)
+ // above while processing the flags, so that we can just
+ // skip that many bytes to validate this field.
+ try reader.skipBytes(y_coords_len, .{});
+ },
+
+ .composite => {
+ // We don't have code for validating composite glyphs,
+ // mainly because we don't need it, since we only use
+ // this function for the glyph protocol which explicitly
+ // forbids composite glyphs anyway.
+ //
+ // So we return false for composite glyphs.
+ return error.CompositeNotSupported;
+ },
+ }
+
+ // No issues found, the glyf entry is valid, return its length.
+ return @sizeOf(Header) + fbs.pos;
+ }
+ };
+
+ /// Initialize the table from the provided data.
+ ///
+ /// This DOES NOT COPY the data, it only stores a pointer to it.
+ ///
+ /// The lifetime of this struct, then, is the same as the
+ /// lifetime of the data that is used to initialize it.
+ pub fn init(data: []const u8) Glyf {
+ return .{ .data = data };
+ }
+
+ /// Retrieve the entry at the provided offset.
+ pub fn entry(self: Glyf, index: usize) error{EndOfStream}!Entry {
+ return try Entry.init(self.data[index..]);
+ }
+};
+
+/// TESTING ONLY
+///
+/// Retrieves the glyf at the provided index from the provided font.
+///
+/// Returns it in a tuple with the expected length based on the loca table, and the entry.
+pub fn getGlyph(font: sfnt.SFNT, index: usize) !struct { usize, Glyf.Entry } {
+ comptime if (!@import("builtin").is_test)
+ @compileError("This function is for testing only! It doesn't check bounds or anything!");
+
+ const glyf = Glyf.init(font.getTable("glyf").?);
+ const head = try @import("head.zig").Head.init(font.getTable("head").?);
+ const loca = font.getTable("loca").?;
+
+ const start_offset = switch (head.indexToLocFormat) {
+ 0 => @as(usize, std.mem.bigToNative(
+ u16,
+ std.mem.bytesAsSlice(u16, loca)[index],
+ )) * 2,
+ 1 => @as(usize, std.mem.bigToNative(
+ u32,
+ std.mem.bytesAsSlice(u32, loca)[index],
+ )),
+ else => unreachable,
+ };
+
+ const end_offset = switch (head.indexToLocFormat) {
+ 0 => @as(usize, std.mem.bigToNative(
+ u16,
+ std.mem.bytesAsSlice(u16, loca)[index + 1],
+ )) * 2,
+ 1 => @as(usize, std.mem.bigToNative(
+ u32,
+ std.mem.bytesAsSlice(u32, loca)[index + 1],
+ )),
+ else => unreachable,
+ };
+
+ return .{ end_offset - start_offset, try glyf.entry(start_offset) };
+}
+
+test "glyf" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ // Cozette because it doesn't have any hinting.
+ const test_font = @import("../embedded.zig").cozette;
+
+ const font = try sfnt.SFNT.init(test_font, alloc);
+ defer font.deinit(alloc);
+
+ // Cozette doesn't actually include a glyph for notdef,
+ // but does include a glyph for `\0` (nul), at index 1.
+ const len_nul, const glyph_nul = try getGlyph(font, 1);
+ try testing.expect(glyph_nul.entryType() == .simple);
+ // It is legal for there to be extra data between two entries, just
+ // as long as the next entry starts after the previous one ends, so
+ // it's okay for the parsed size of the entry to be less than the size
+ // determined from the difference between subsequent loca offsets.
+ try testing.expect(len_nul >= try glyph_nul.size());
+
+ // Glyph "A" is at index 66.
+ const len_A, const glyph_A = try getGlyph(font, 66);
+ try testing.expect(glyph_A.entryType() == .simple);
+ try testing.expect(len_A >= try glyph_A.size());
+
+ // Glyph "Ĩ" is at index 265.
+ const len_Itilde, const glyph_Itilde = try getGlyph(font, 265);
+ try testing.expect(glyph_Itilde.entryType() == .simple);
+ try testing.expect(len_Itilde >= try glyph_Itilde.size());
+}
+
+test "glyf: reject glyphs with instructions and composite glyphs" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const test_font = @import("../embedded.zig").jetbrains_mono;
+
+ const font = try sfnt.SFNT.init(test_font, alloc);
+ defer font.deinit(alloc);
+
+ const len_notdef, const glyph_notdef = try getGlyph(font, 0);
+ try testing.expectEqual(100, len_notdef);
+ try testing.expect(glyph_notdef.entryType() == .simple);
+ try testing.expectError(
+ Glyf.Entry.SizeError.InstructionsNotSupported,
+ glyph_notdef.size(),
+ );
+
+ // Glyph "Á" is at index 2.
+ const len_Aacute, const glyph_Aacute = try getGlyph(font, 2);
+ try testing.expectEqual(24, len_Aacute);
+ try testing.expect(glyph_Aacute.entryType() == .composite);
+ try testing.expectError(
+ Glyf.Entry.SizeError.CompositeNotSupported,
+ glyph_Aacute.size(),
+ );
+}
+
+test "glyf: reject truncated" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ // Cozette because it doesn't have any hinting.
+ const test_font = @import("../embedded.zig").cozette;
+
+ const font = try sfnt.SFNT.init(test_font, alloc);
+ defer font.deinit(alloc);
+
+ _, var glyph_nul = try getGlyph(font, 1);
+ try testing.expect(glyph_nul.entryType() == .simple);
+ // Mess with the entry's data slice, truncating
+ // it before the full length (which is 228 bytes).
+ glyph_nul.data = glyph_nul.data[0 .. 227 - @sizeOf(Glyf.Entry.Header)];
+ try testing.expectError(Glyf.Entry.SizeError.EndOfStream, glyph_nul.size());
+}
+
+test "glyf: reject endpoints out of order" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ // Cozette because it doesn't have any hinting.
+ //
+ // Also we copy it with the allocator so we can mess with it.
+ const test_font = try alloc.dupe(u8, @import("../embedded.zig").cozette[0..]);
+ defer alloc.free(test_font);
+
+ const font = try sfnt.SFNT.init(test_font, alloc);
+ defer font.deinit(alloc);
+
+ _, var glyph_nul = try getGlyph(font, 1);
+ try testing.expect(glyph_nul.entryType() == .simple);
+ // Mess with the entry's data, insert a 0 in the middle of the endpoints.
+ //
+ // Because we know the underlying data is something we
+ // copied, we can just const cast it back to mutable lol.
+ std.mem.bytesAsSlice(u16, @as([]u8, @constCast(glyph_nul.data)))[3] = 0;
+ try testing.expectError(Glyf.Entry.SizeError.EndPointsOutOfOrder, glyph_nul.size());
+}
+
+test "glyf: reject too many points" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ // Cozette because it doesn't have any hinting.
+ //
+ // Also we copy it with the allocator so we can mess with it.
+ const test_font = try alloc.dupe(u8, @import("../embedded.zig").cozette[0..]);
+ defer alloc.free(test_font);
+
+ const font = try sfnt.SFNT.init(test_font, alloc);
+ defer font.deinit(alloc);
+
+ _, var glyph_nul = try getGlyph(font, 1);
+ try testing.expect(glyph_nul.entryType() == .simple);
+ // Mess with the entry's data, make the final two bytes of the flags
+ // array be a large number repeat to exceed the correct points count.
+ //
+ // Because we know the underlying data is something we
+ // copied, we can just const cast it back to mutable lol.
+ @as([]u8, @constCast(glyph_nul.data))[107] |= 0x08;
+ @as([]u8, @constCast(glyph_nul.data))[108] = 0xFF;
+ try testing.expectError(Glyf.Entry.SizeError.TooManyPoints, glyph_nul.size());
+}
+
+test "glyf: zero-contour glyph can be header-only" {
+ const testing = std.testing;
+
+ const header: Glyf.Entry.Header = .{
+ .numberOfContours = 0,
+ .xMin = 0,
+ .yMin = 0,
+ .xMax = 0,
+ .yMax = 0,
+ };
+ const glyph = try Glyf.Entry.init(std.mem.asBytes(&header));
+ try testing.expectEqual(@sizeOf(Glyf.Entry.Header), try glyph.size());
+}
diff --git a/vendor/libghostty-vt/src/font/shape.zig b/vendor/libghostty-vt/src/font/shape.zig
index 864c0f01..bc19c1f2 100644
--- a/vendor/libghostty-vt/src/font/shape.zig
+++ b/vendor/libghostty-vt/src/font/shape.zig
@@ -19,6 +19,7 @@ pub const default_features = feature.default_features;
/// Shaper implementation for our compile options.
pub const Shaper = switch (options.backend) {
.freetype,
+ .freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
.coretext_harfbuzz,
diff --git a/vendor/libghostty-vt/src/font/shaper/coretext.zig b/vendor/libghostty-vt/src/font/shaper/coretext.zig
index ff7c6d9d..3f69af6d 100644
--- a/vendor/libghostty-vt/src/font/shaper/coretext.zig
+++ b/vendor/libghostty-vt/src/font/shaper/coretext.zig
@@ -2585,7 +2585,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
});
} else {
// On CoreText we want to load Apple Emoji, we should have it.
- var disco = font.Discover.init();
+ var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = "Apple Color Emoji",
@@ -2640,7 +2640,7 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS
// Discover and add our font to the collection.
{
- var disco = font.Discover.init();
+ var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = font_req,
diff --git a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig
index 9c78d9de..a400ecaf 100644
--- a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig
+++ b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig
@@ -2071,7 +2071,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
});
} else {
// On CoreText we want to load Apple Emoji, we should have it.
- var disco = font.Discover.init();
+ var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = "Apple Color Emoji",
@@ -2126,7 +2126,7 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS
// Discover and add our font to the collection.
{
- var disco = font.Discover.init();
+ var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = font_req,
diff --git a/vendor/libghostty-vt/src/input/Binding.zig b/vendor/libghostty-vt/src/input/Binding.zig
index 62a4e39a..d60f2933 100644
--- a/vendor/libghostty-vt/src/input/Binding.zig
+++ b/vendor/libghostty-vt/src/input/Binding.zig
@@ -347,6 +347,29 @@ pub const Action = union(enum) {
reset,
/// Copy the selected text to the clipboard.
+ ///
+ /// Valid values:
+ ///
+ /// - `plain`
+ ///
+ /// Copy the selection as plain text only.
+ ///
+ /// - `vt`
+ ///
+ /// Copy the selection as plain text, preserving terminal escape
+ /// sequences (such as colors and styles).
+ ///
+ /// - `html`
+ ///
+ /// Copy the selection as HTML, preserving colors and styles as
+ /// HTML markup.
+ ///
+ /// - `mixed` (default)
+ ///
+ /// Place multiple representations on the clipboard at once
+ /// (e.g. plain text and HTML), each tagged with its content type
+ /// so the receiving OS or application can pick the most appropriate
+ /// representation when pasting.
copy_to_clipboard: CopyToClipboard,
/// Paste the contents of the default clipboard.
@@ -398,6 +421,8 @@ pub const Action = union(enum) {
/// Navigate the search results. If there is no active search, this
/// is not performed.
+ ///
+ /// Valid values: `previous`, `next`.
navigate_search: NavigateSearch,
/// Start a search if it isn't started already. This doesn't set any
@@ -681,10 +706,21 @@ pub const Action = union(enum) {
/// of the `confirm-close-surface` configuration setting.
close_surface,
- /// Close the current tab and all splits therein, close all other tabs, or
- /// close every tab to the right of the current one depending on the mode.
+ /// Close the specified tabs and all splits therein.
+ ///
+ /// Valid values:
+ ///
+ /// - `this` (default)
+ ///
+ /// Close the current tab and all splits within it.
+ ///
+ /// - `other`
+ ///
+ /// Close every tab in the current window except the current tab.
+ ///
+ /// - `right`
///
- /// If the mode is not specified, defaults to closing the current tab.
+ /// Close every tab to the right of the current tab.
///
/// This might trigger a close confirmation popup, depending on the value
/// of the `confirm-close-surface` configuration setting.
@@ -2615,9 +2651,8 @@ pub const Set = struct {
/// Get an entry for the given key event. This will attempt to find
/// a binding using multiple parts of the event in the following order:
///
- /// 1. Translated key (event.key)
- /// 2. Physical key (event.physical_key)
- /// 3. Unshifted Unicode codepoint (event.unshifted_codepoint)
+ /// 1. Physical key (event.physical_key)
+ /// 2. Unshifted Unicode codepoint (event.unshifted_codepoint)
///
pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
var trigger: Trigger = .{
diff --git a/vendor/libghostty-vt/src/input/function_keys.zig b/vendor/libghostty-vt/src/input/function_keys.zig
index efe86d9e..66ab4bc4 100644
--- a/vendor/libghostty-vt/src/input/function_keys.zig
+++ b/vendor/libghostty-vt/src/input/function_keys.zig
@@ -43,6 +43,9 @@ pub const Entry = struct {
/// The sequence to send to the pty if this entry matches.
sequence: []const u8,
+
+ /// Sequence to send to the PTY if DECBKM is set.
+ sequence_decbkm: ?[]const u8 = null,
};
/// The list of modifier combinations for modify other key sequences.
@@ -161,8 +164,8 @@ pub const keys = keys: {
.{ .mods = .{ .alt = true, .super = true, .ctrl = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;15;127~" },
.{ .mods = .{ .alt = true, .super = true, .ctrl = true, .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;16;127~" },
- .{ .mods = .{ .ctrl = true }, .sequence = "\x08" },
- .{ .sequence = "\x7f" },
+ .{ .mods = .{ .ctrl = true }, .sequence = "\x08", .sequence_decbkm = "\x7f" },
+ .{ .sequence = "\x7f", .sequence_decbkm = "\x08" },
});
result.set(.tab, &.{
diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig
index 52bb556e..6ab5a4cc 100644
--- a/vendor/libghostty-vt/src/input/key_encode.zig
+++ b/vendor/libghostty-vt/src/input/key_encode.zig
@@ -18,6 +18,12 @@ pub const Options = struct {
/// Terminal DEC mode 66
keypad_key_application: bool = false,
+ // DEC Backarrow Key Mode (DECBKM)
+ // See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170
+ // If `false` (the default), `backspace` emits 0x7f
+ // If `true`, `backspace` emits 0x08
+ backarrow_key_mode: bool = false,
+
/// Terminal DEC mode 1035
ignore_keypad_with_numlock: bool = false,
@@ -55,6 +61,7 @@ pub const Options = struct {
.alt_esc_prefix = t.modes.get(.alt_esc_prefix),
.cursor_key_application = t.modes.get(.cursor_keys),
.keypad_key_application = t.modes.get(.keypad_keys),
+ .backarrow_key_mode = t.modes.get(.backarrow_key_mode),
.ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock),
.modify_other_keys_state_2 = t.flags.modify_other_keys_2,
.kitty_flags = t.screens.active.kitty_keyboard.current(),
@@ -338,6 +345,7 @@ fn legacy(
opts.keypad_key_application,
opts.ignore_keypad_with_numlock,
opts.modify_other_keys_state_2,
+ opts.backarrow_key_mode,
)) |sequence| pc_style: {
// If we have UTF-8 text, then we never emit PC style function
// keys. Many function keys (escape, enter, backspace) have
@@ -601,6 +609,7 @@ fn pcStyleFunctionKey(
keypad_key_application_req: bool,
ignore_keypad_with_numlock: bool,
modify_other_keys: bool, // True if state 2
+ backarrow_key_mode: bool,
) ?[]const u8 {
// We only want binding-sensitive mods because lock keys
// and directional modifiers (left/right) don't matter for
@@ -653,6 +662,10 @@ fn pcStyleFunctionKey(
continue;
}
+ if (backarrow_key_mode)
+ if (entry.sequence_decbkm) |sequence|
+ return sequence;
+
return entry.sequence;
}
@@ -1245,9 +1258,20 @@ test "kitty: enter, backspace, tab" {
try testing.expectEqualStrings("\r", writer.buffered());
}
{
+ // DECBKM reset
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{ .key = .backspace, .mods = .{}, .utf8 = "" }, .{
.kitty_flags = .{ .disambiguate = true },
+ .backarrow_key_mode = false,
+ });
+ try testing.expectEqualStrings("\x7f", writer.buffered());
+ }
+ {
+ // DECBKM set (Kitty does not support DECBKM so there should be no change)
+ var writer: std.Io.Writer = .fixed(&buf);
+ try kitty(&writer, .{ .key = .backspace, .mods = .{}, .utf8 = "" }, .{
+ .kitty_flags = .{ .disambiguate = true },
+ .backarrow_key_mode = true,
});
try testing.expectEqualStrings("\x7f", writer.buffered());
}
@@ -1888,6 +1912,43 @@ test "legacy: backspace with utf8 (dead key state)" {
try testing.expectEqualStrings("", writer.buffered());
}
+test "kitty: backspace (DECBKM reset) (report_all: true)" {
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try kitty(&writer, .{
+ .key = .backspace,
+ }, .{
+ .kitty_flags = .{
+ .disambiguate = true,
+ .report_events = true,
+ .report_alternates = true,
+ .report_all = true,
+ .report_associated = true,
+ },
+ .backarrow_key_mode = false,
+ });
+ try testing.expectEqualStrings("\x1b[127u", writer.buffered());
+}
+
+test "kitty: backspace (DECBKM set) (report_all: true)" {
+ // Kitty does not support DECBKM so there should be no difference.
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try kitty(&writer, .{
+ .key = .backspace,
+ }, .{
+ .kitty_flags = .{
+ .disambiguate = true,
+ .report_events = true,
+ .report_alternates = true,
+ .report_all = true,
+ .report_associated = true,
+ },
+ .backarrow_key_mode = true,
+ });
+ try testing.expectEqualStrings("\x1b[127u", writer.buffered());
+}
+
test "legacy: enter with utf8 (dead key state)" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
@@ -2039,6 +2100,50 @@ test "legacy: ctrl+shift+backspace" {
try testing.expectEqualStrings("\x08", writer.buffered());
}
+test "legacy: backspace (DECBKM reset)" {
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try legacy(&writer, .{
+ .key = .backspace,
+ .mods = .{},
+ }, .{ .backarrow_key_mode = false });
+ try testing.expectEqualStrings("\x7f", writer.buffered());
+}
+
+test "legacy: backspace (DECBKM reset, with ctrl)" {
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try legacy(&writer, .{
+ .key = .backspace,
+ .mods = .{
+ .ctrl = true,
+ },
+ }, .{ .backarrow_key_mode = false });
+ try testing.expectEqualStrings("\x08", writer.buffered());
+}
+
+test "legacy: backspace (DECBKM set)" {
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try legacy(&writer, .{
+ .key = .backspace,
+ .mods = .{},
+ }, .{ .backarrow_key_mode = true });
+ try testing.expectEqualStrings("\x08", writer.buffered());
+}
+
+test "legacy: backspace (DECBKM set, with ctrl)" {
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try legacy(&writer, .{
+ .key = .backspace,
+ .mods = .{
+ .ctrl = true,
+ },
+ }, .{ .backarrow_key_mode = true });
+ try testing.expectEqualStrings("\x7f", writer.buffered());
+}
+
test "legacy: ctrl+shift+char with modify other state 2" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
@@ -2355,17 +2460,28 @@ test "legacy: super and other mods on macOS with text" {
try testing.expectEqualStrings("", writer.buffered());
}
-test "legacy: backspace with DEL utf8" {
+test "legacy: backspace with DEL utf8 (DECBKM reset)" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try legacy(&writer, .{
.key = .backspace,
.utf8 = &.{0x7F},
.unshifted_codepoint = 0x08,
- }, .{});
+ }, .{ .backarrow_key_mode = false });
try testing.expectEqualStrings("\x7F", writer.buffered());
}
+test "legacy: backspace with DEL utf8 (DECBKM set)" {
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try legacy(&writer, .{
+ .key = .backspace,
+ .utf8 = &.{0x7F},
+ .unshifted_codepoint = 0x08,
+ }, .{ .backarrow_key_mode = true });
+ try testing.expectEqualStrings("\x08", writer.buffered());
+}
+
test "ctrlseq: normal ctrl c" {
const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true });
try testing.expectEqual(@as(u8, 0x03), seq.?);
diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig
index 9d2442a8..ae0c87b1 100644
--- a/vendor/libghostty-vt/src/lib_vt.zig
+++ b/vendor/libghostty-vt/src/lib_vt.zig
@@ -19,6 +19,24 @@ const builtin = @import("builtin");
// or are too Ghostty-internal.
const terminal = @import("terminal/main.zig");
+/// System interface for the terminal package.
+///
+/// This module provides runtime-swappable function pointers for operations
+/// that depend on external implementations. Embedders can use this to
+/// provide or override default behaviors. These must be set at startup
+/// before any terminal functionality is used.
+///
+/// This lets libghostty-vt have no runtime dependencies on external
+/// libraries, while still allowing rich functionality that may require
+/// external libraries (e.g. image decoding or regular expresssions).
+///
+/// Setting these will enable various features of the terminal package.
+/// For example, setting a PNG decoder will enable support for PNG images in
+/// the Kitty Graphics Protocol.
+///
+/// Additional functionality will be added here over time as needed.
+pub const sys = terminal.sys;
+
pub const apc = terminal.apc;
pub const dcs = terminal.dcs;
pub const osc = terminal.osc;
@@ -171,8 +189,12 @@ comptime {
@export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" });
@export(&c.style_default, .{ .name = "ghostty_style_default" });
@export(&c.style_is_default, .{ .name = "ghostty_style_is_default" });
+ @export(&c.sys_log_stderr, .{ .name = "ghostty_sys_log_stderr" });
+ @export(&c.sys_set, .{ .name = "ghostty_sys_set" });
@export(&c.cell_get, .{ .name = "ghostty_cell_get" });
+ @export(&c.cell_get_multi, .{ .name = "ghostty_cell_get_multi" });
@export(&c.row_get, .{ .name = "ghostty_row_get" });
+ @export(&c.row_get_multi, .{ .name = "ghostty_row_get_multi" });
@export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" });
@export(&c.sgr_new, .{ .name = "ghostty_sgr_new" });
@export(&c.sgr_free, .{ .name = "ghostty_sgr_free" });
@@ -190,17 +212,20 @@ comptime {
@export(&c.render_state_new, .{ .name = "ghostty_render_state_new" });
@export(&c.render_state_update, .{ .name = "ghostty_render_state_update" });
@export(&c.render_state_get, .{ .name = "ghostty_render_state_get" });
+ @export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" });
@export(&c.render_state_set, .{ .name = "ghostty_render_state_set" });
@export(&c.render_state_colors_get, .{ .name = "ghostty_render_state_colors_get" });
@export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" });
@export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" });
@export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" });
+ @export(&c.render_state_row_get_multi, .{ .name = "ghostty_render_state_row_get_multi" });
@export(&c.render_state_row_set, .{ .name = "ghostty_render_state_row_set" });
@export(&c.render_state_row_iterator_free, .{ .name = "ghostty_render_state_row_iterator_free" });
@export(&c.render_state_row_cells_new, .{ .name = "ghostty_render_state_row_cells_new" });
@export(&c.render_state_row_cells_next, .{ .name = "ghostty_render_state_row_cells_next" });
@export(&c.render_state_row_cells_select, .{ .name = "ghostty_render_state_row_cells_select" });
@export(&c.render_state_row_cells_get, .{ .name = "ghostty_render_state_row_cells_get" });
+ @export(&c.render_state_row_cells_get_multi, .{ .name = "ghostty_render_state_row_cells_get_multi" });
@export(&c.render_state_row_cells_free, .{ .name = "ghostty_render_state_row_cells_free" });
@export(&c.render_state_free, .{ .name = "ghostty_render_state_free" });
@export(&c.terminal_new, .{ .name = "ghostty_terminal_new" });
@@ -213,11 +238,29 @@ comptime {
@export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" });
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
+ @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" });
@export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" });
- @export(&c.terminal_read_text, .{ .name = "ghostty_terminal_read_text" });
+ @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" });
+ @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" });
+ @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" });
+ @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" });
+ @export(&c.kitty_graphics_image_get_multi, .{ .name = "ghostty_kitty_graphics_image_get_multi" });
+ @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" });
+ @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" });
+ @export(&c.kitty_graphics_placement_iterator_set, .{ .name = "ghostty_kitty_graphics_placement_iterator_set" });
+ @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" });
+ @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" });
+ @export(&c.kitty_graphics_placement_get_multi, .{ .name = "ghostty_kitty_graphics_placement_get_multi" });
+ @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" });
+ @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" });
+ @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" });
+ @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" });
+ @export(&c.kitty_graphics_placement_source_rect, .{ .name = "ghostty_kitty_graphics_placement_source_rect" });
+ @export(&c.kitty_graphics_placement_render_info, .{ .name = "ghostty_kitty_graphics_placement_render_info" });
@export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" });
@export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" });
@export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" });
+ @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" });
@export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" });
@export(&c.build_info, .{ .name = "ghostty_build_info" });
@export(&c.type_json, .{ .name = "ghostty_type_json" });
@@ -243,18 +286,6 @@ comptime {
}
}
-fn silentLog(
- comptime level: std.log.Level,
- comptime scope: @Type(.enum_literal),
- comptime format: []const u8,
- args: anytype,
-) void {
- _ = level;
- _ = scope;
- _ = format;
- _ = args;
-}
-
pub const std_options: std.Options = options: {
if (builtin.target.cpu.arch.isWasm()) break :options .{
// Wasm builds we specifically want to optimize for space with small
@@ -269,13 +300,13 @@ pub const std_options: std.Options = options: {
.logFn = @import("os/wasm/log.zig").log,
};
- // Embedded libghostty-vt runs inside herdr's TUI process. Writing logs to
- // stdout/stderr corrupts the ratatui surface, so silence library-side Zig
- // logging by default for the C library build.
- break :options .{
- .log_level = .err,
- .logFn = silentLog,
+ // For C ABI builds, use a custom log function that dispatches to an
+ // embedder-provided callback (or silently discards when none is set).
+ if (terminal.options.c_abi) break :options .{
+ .logFn = @import("terminal/c/sys.zig").logFn,
};
+
+ break :options .{};
};
test {
diff --git a/vendor/libghostty-vt/src/main_c.zig b/vendor/libghostty-vt/src/main_c.zig
index ef8d9ec7..f61565f2 100644
--- a/vendor/libghostty-vt/src/main_c.zig
+++ b/vendor/libghostty-vt/src/main_c.zig
@@ -161,15 +161,17 @@ pub export fn ghostty_string_free(str: String) void {
// function that depends on CRT internal state (setlocale, malloc from C
// dependencies, C++ constructors in glslang) crashes with null pointer
// dereferences. Declaring DllMain causes Zig's start.zig to call it
-// during DLL_PROCESS_ATTACH/DETACH, and we forward to the CRT bootstrap
-// functions from libvcruntime and libucrt (already linked).
+// during DLL_PROCESS_ATTACH/DETACH, and for MSVC we forward to the CRT
+// bootstrap functions from libvcruntime and libucrt (already linked).
+// For other ABIs (MinGW) the handler is a no-op since dllcrt2.obj already
+// handles CRT init; we still need `DllMain` declared so that Zig's
+// start.zig does not fall back to calling a non-function value.
//
// This is a workaround. Zig handles MinGW DLLs correctly (via dllcrt2.obj)
// but not MSVC. No upstream issue tracks this exact gap as of 2026-03-26.
// Closest: Codeberg ziglang/zig #30936 (reimplement crt0 code).
// Remove this DllMain when Zig handles MSVC DLL CRT init natively.
-pub const DllMain = if (builtin.os.tag == .windows and
- builtin.abi == .msvc) struct {
+pub const DllMain = if (builtin.os.tag == .windows) struct {
const BOOL = std.os.windows.BOOL;
const HINSTANCE = std.os.windows.HINSTANCE;
const DWORD = std.os.windows.DWORD;
@@ -186,6 +188,8 @@ pub const DllMain = if (builtin.os.tag == .windows and
const __acrt_uninitialize = @extern(*const fn (c_int) callconv(.c) c_int, .{ .name = "__acrt_uninitialize" });
pub fn handler(_: HINSTANCE, fdwReason: DWORD, _: LPVOID) callconv(.winapi) BOOL {
+ // Only MSVC needs to bootstrap the CRT; MinGW handles it via dllcrt2.obj.
+ if (builtin.abi != .msvc) return TRUE;
switch (fdwReason) {
DLL_PROCESS_ATTACH => {
if (__vcrt_initialize() < 0) return FALSE;
diff --git a/vendor/libghostty-vt/src/os/TempDir.zig b/vendor/libghostty-vt/src/os/TempDir.zig
index 2ddf18da..c3d344d9 100644
--- a/vendor/libghostty-vt/src/os/TempDir.zig
+++ b/vendor/libghostty-vt/src/os/TempDir.zig
@@ -3,10 +3,8 @@
const TempDir = @This();
const std = @import("std");
-const testing = std.testing;
const Dir = std.fs.Dir;
-const allocTmpDir = @import("file.zig").allocTmpDir;
-const freeTmpDir = @import("file.zig").freeTmpDir;
+const file = @import("file.zig");
const log = std.log.scoped(.tempdir);
@@ -18,28 +16,26 @@ parent: Dir,
/// Name buffer that name points into. Generally do not use. To get the
/// name call the name() function.
-name_buf: [TMP_PATH_LEN:0]u8,
+name_buf: [file.random_basename_len:0]u8,
/// Create the temporary directory.
pub fn init() !TempDir {
// Note: the tmp_path_buf sentinel is important because it ensures
- // we actually always have TMP_PATH_LEN+1 bytes of available space. We
- // need that so we can set the sentinel in the case we use all the
- // possible length.
- var tmp_path_buf: [TMP_PATH_LEN:0]u8 = undefined;
- var rand_buf: [RANDOM_BYTES]u8 = undefined;
+ // we actually always have random_basename_len+1 bytes of available
+ // space. We need that so we can set the sentinel in the case we use
+ // all the possible length.
+ var tmp_path_buf: [file.random_basename_len:0]u8 = undefined;
const dir = dir: {
const cwd = std.fs.cwd();
- const tmp_dir = allocTmpDir(std.heap.page_allocator) orelse break :dir cwd;
- defer freeTmpDir(std.heap.page_allocator, tmp_dir);
+ const tmp_dir = try file.allocTmpDir(std.heap.page_allocator);
+ defer file.freeTmpDir(std.heap.page_allocator, tmp_dir);
break :dir try cwd.openDir(tmp_dir, .{});
};
// We now loop forever until we can find a directory that we can create.
while (true) {
- std.crypto.random.bytes(rand_buf[0..]);
- const tmp_path = b64_encoder.encode(&tmp_path_buf, &rand_buf);
+ const tmp_path = try file.randomBasename(&tmp_path_buf);
tmp_path_buf[tmp_path.len] = 0;
dir.makeDir(tmp_path) catch |err| switch (err) {
@@ -69,16 +65,9 @@ pub fn deinit(self: *TempDir) void {
log.err("error deleting temp dir err={}", .{err});
}
-// The amount of random bytes to get to determine our filename.
-const RANDOM_BYTES = 16;
-const TMP_PATH_LEN = b64_encoder.calcSize(RANDOM_BYTES);
-
-// Base64 encoder, replacing the standard `+/` with `-_` so that it can
-// be used in a file name on any filesystem.
-const b64_encoder = std.base64.Base64Encoder.init(b64_alphabet, null);
-const b64_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".*;
-
test {
+ const testing = std.testing;
+
var td = try init();
errdefer td.deinit();
diff --git a/vendor/libghostty-vt/src/os/file.zig b/vendor/libghostty-vt/src/os/file.zig
index 240c936e..6007ae49 100644
--- a/vendor/libghostty-vt/src/os/file.zig
+++ b/vendor/libghostty-vt/src/os/file.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
+const windows = @import("windows.zig");
const log = std.log.scoped(.os);
@@ -53,27 +54,95 @@ pub fn restoreMaxFiles(lim: rlimit) void {
posix.setrlimit(.NOFILE, lim) catch {};
}
-/// Return the recommended path for temporary files.
-/// This may not actually allocate memory, use freeTmpDir to properly
-/// free the memory when applicable.
-pub fn allocTmpDir(allocator: std.mem.Allocator) ?[]const u8 {
+/// Return the recommended path for temporary files. Any trailing
+/// path separator is stripped so callers can safely join with their
+/// own separator (e.g. `"{tmp}/{name}"`).
+///
+/// On Windows this calls `GetTempPathW` and allocates a UTF-8 copy
+/// (or duplicates a hard-fallback string if the syscall fails). On
+/// POSIX this returns `$TMPDIR`/`$TMP` (or `"/tmp"` as a fallback)
+/// without allocating. Always pair with `freeTmpDir` to release any
+/// allocation.
+pub fn allocTmpDir(allocator: std.mem.Allocator) std.mem.Allocator.Error![]const u8 {
if (builtin.os.tag == .windows) {
- // TODO: what is a good fallback path on windows?
- const v = std.process.getenvW(std.unicode.utf8ToUtf16LeStringLiteral("TMP")) orelse return null;
- return std.unicode.utf16LeToUtf8Alloc(allocator, v) catch |e| {
- log.warn("failed to convert temp dir path from windows string: {}", .{e});
- return null;
- };
+ // GetTempPathW guarantees the result fits in MAX_PATH+1.
+ var buf: [windows.MAX_PATH + 1:0]u16 = undefined;
+ const len = windows.exp.kernel32.GetTempPathW(buf.len, &buf);
+ if (len > 0) {
+ // Trim the UTF-16 string before encoding as UT8-8 so that the
+ // returned slice's length matches its underlying allocation.
+ const trimmed = std.mem.trimEnd(u16, buf[0..len], &.{std.fs.path.sep});
+ if (std.unicode.utf16LeToUtf8Alloc(allocator, trimmed)) |utf8| {
+ return utf8;
+ } else |e| switch (e) {
+ error.OutOfMemory => return error.OutOfMemory,
+ else => log.warn("failed to convert temp dir path from windows string: {}", .{e}),
+ }
+ }
+ return allocator.dupe(u8, "C:\\Windows\\Temp");
}
- if (posix.getenv("TMPDIR")) |v| return v;
- if (posix.getenv("TMP")) |v| return v;
- return "/tmp";
+ const tmpdir = posix.getenv("TMPDIR") orelse posix.getenv("TMP") orelse return "/tmp";
+ return std.mem.trimEnd(u8, tmpdir, &.{std.fs.path.sep});
}
-/// Free a path returned by tmpDir if it allocated memory.
-/// This is a "no-op" for all platforms except windows.
+/// Free a path returned by `allocTmpDir` if it allocated memory.
+/// This is a no-op on POSIX.
pub fn freeTmpDir(allocator: std.mem.Allocator, dir: []const u8) void {
- if (builtin.os.tag == .windows) {
- allocator.free(dir);
+ if (builtin.os.tag != .windows) return;
+ allocator.free(dir);
+}
+
+const random_basename_bytes = 16;
+const b64_encoder = std.base64.url_safe_no_pad.Encoder;
+
+pub const RandomBasenameError = error{BufferTooSmall};
+
+/// Length of the basename produced by `randomBasename`.
+pub const random_basename_len = b64_encoder.calcSize(random_basename_bytes);
+
+/// Write a random filesystem-safe base64 basename of length
+/// `random_basename_len` into `buf` and return a slice over the
+/// written bytes. Returns `error.BufferTooSmall` if `buf` is too
+/// short.
+pub fn randomBasename(buf: []u8) RandomBasenameError![]const u8 {
+ if (buf.len < random_basename_len) return error.BufferTooSmall;
+ var rand_buf: [random_basename_bytes]u8 = undefined;
+ std.crypto.random.bytes(&rand_buf);
+ return b64_encoder.encode(buf[0..random_basename_len], &rand_buf);
+}
+
+/// Return a freshly-allocated path of the form `{TMPDIR}/{prefix}{random}`.
+/// The caller owns the returned slice and must free it with `allocator`.
+///
+/// Nothing is created on disk; this only builds the path string. Useful
+/// for one-shot temporary file/socket paths where a full `TempDir` is
+/// overkill.
+pub fn randomTmpPath(
+ allocator: std.mem.Allocator,
+ prefix: []const u8,
+) std.mem.Allocator.Error![]u8 {
+ const tmp_dir = try allocTmpDir(allocator);
+ defer freeTmpDir(allocator, tmp_dir);
+ var name_buf: [random_basename_len]u8 = undefined;
+ const basename = randomBasename(&name_buf) catch unreachable;
+ return std.fmt.allocPrint(
+ allocator,
+ "{s}{c}{s}{s}",
+ .{ tmp_dir, std.fs.path.sep, prefix, basename },
+ );
+}
+
+test randomBasename {
+ const testing = std.testing;
+
+ var buf: [random_basename_len]u8 = undefined;
+ const name = try randomBasename(&buf);
+ try testing.expectEqual(random_basename_len, name.len);
+ for (name) |c| {
+ const ok = std.ascii.isAlphanumeric(c) or c == '-' or c == '_';
+ try testing.expect(ok);
}
+
+ var small: [random_basename_len - 1]u8 = undefined;
+ try testing.expectError(error.BufferTooSmall, randomBasename(&small));
}
diff --git a/vendor/libghostty-vt/src/os/flatpak.zig b/vendor/libghostty-vt/src/os/flatpak.zig
index 78692089..44f820ad 100644
--- a/vendor/libghostty-vt/src/os/flatpak.zig
+++ b/vendor/libghostty-vt/src/os/flatpak.zig
@@ -33,6 +33,16 @@ pub const FlatpakHostCommand = struct {
@cInclude("gio/gio.h");
@cInclude("gio/gunixfdlist.h");
});
+ /// Flags for HostCommand method
+ ///
+ /// Ref: https://docs.flatpak.org/en/latest/libflatpak-api-reference.html#gdbus-method-org-freedesktop-Flatpak-Development.HostCommand
+ const Flags = packed struct(c_uint) {
+ /// Clear the environment
+ clear_env: bool = false,
+ /// Kill the sandbox when the caller disappears from the session bus
+ watch_bus: bool = false,
+ _reserved: std.meta.Int(.unsigned, @bitSizeOf(c_uint) - 2) = 0,
+ };
/// Argv are the arguments to call on the host with argv[0] being
/// the command to execute.
@@ -366,6 +376,9 @@ pub const FlatpakHostCommand = struct {
const g_cwd = c.g_get_current_dir();
defer c.g_free(g_cwd);
+ // Terminate session if Ghostty drops off the bus (e.g. due to crashes)
+ const flags: Flags = .{ .watch_bus = true };
+
// The params for our RPC call
const params = c.g_variant_new(
"(^ay^aay@a{uh}@a{ss}u)",
@@ -373,7 +386,7 @@ pub const FlatpakHostCommand = struct {
args.ptr,
c.g_variant_builder_end(fd_builder),
c.g_variant_builder_end(env_builder),
- @as(c_int, 0),
+ @as(c_uint, @bitCast(flags)),
);
_ = c.g_variant_ref_sink(params); // take ownership
defer c.g_variant_unref(params);
diff --git a/vendor/libghostty-vt/src/os/i18n_locales.zig b/vendor/libghostty-vt/src/os/i18n_locales.zig
index a044bfa4..45556952 100644
--- a/vendor/libghostty-vt/src/os/i18n_locales.zig
+++ b/vendor/libghostty-vt/src/os/i18n_locales.zig
@@ -4,7 +4,7 @@
/// Supported locales for the application. This must be kept up to date
/// with the translations available in the `po/` directory; this is used
-/// by our build process as well runtime libghostty APIs.
+/// by our build process as well as runtime libghostty APIs.
///
/// The order also matters. For incomplete locale information (i.e. only
/// a language code available), the first match is used. For example, if
@@ -57,4 +57,5 @@ pub const locales = [_][:0]const u8{
"lv",
"vi",
"kk",
+ "be",
};
diff --git a/vendor/libghostty-vt/src/os/main.zig b/vendor/libghostty-vt/src/os/main.zig
index 2aadabac..b3018274 100644
--- a/vendor/libghostty-vt/src/os/main.zig
+++ b/vendor/libghostty-vt/src/os/main.zig
@@ -52,6 +52,7 @@ pub const fixMaxFiles = file.fixMaxFiles;
pub const restoreMaxFiles = file.restoreMaxFiles;
pub const allocTmpDir = file.allocTmpDir;
pub const freeTmpDir = file.freeTmpDir;
+pub const randomTmpPath = file.randomTmpPath;
pub const isFlatpak = flatpak.isFlatpak;
pub const FlatpakHostCommand = flatpak.FlatpakHostCommand;
pub const home = homedir.home;
@@ -67,6 +68,7 @@ pub const ShellEscapeWriter = shell.ShellEscapeWriter;
pub const getKernelInfo = kernel_info.getKernelInfo;
test {
+ _ = file;
_ = i18n;
_ = path;
_ = uri;
diff --git a/vendor/libghostty-vt/src/os/windows.zig b/vendor/libghostty-vt/src/os/windows.zig
index e92a5453..6e452fb7 100644
--- a/vendor/libghostty-vt/src/os/windows.zig
+++ b/vendor/libghostty-vt/src/os/windows.zig
@@ -18,6 +18,7 @@ pub const HANDLE = windows.HANDLE;
pub const HANDLE_FLAG_INHERIT = windows.HANDLE_FLAG_INHERIT;
pub const INFINITE = windows.INFINITE;
pub const INVALID_HANDLE_VALUE = windows.INVALID_HANDLE_VALUE;
+pub const MAX_PATH = windows.MAX_PATH;
pub const OPEN_EXISTING = windows.OPEN_EXISTING;
pub const PIPE_ACCESS_OUTBOUND = windows.PIPE_ACCESS_OUTBOUND;
pub const PIPE_TYPE_BYTE = windows.PIPE_TYPE_BYTE;
@@ -104,6 +105,11 @@ pub const exp = struct {
lpBuffer: windows.LPSTR,
nSize: *windows.DWORD,
) callconv(.winapi) windows.BOOL;
+ /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppathw
+ pub extern "kernel32" fn GetTempPathW(
+ nBufferLength: windows.DWORD,
+ lpBuffer: windows.LPWSTR,
+ ) callconv(.winapi) windows.DWORD;
};
pub const PROC_THREAD_ATTRIBUTE_NUMBER = 0x0000FFFF;
diff --git a/vendor/libghostty-vt/src/renderer/State.zig b/vendor/libghostty-vt/src/renderer/State.zig
index e882ddce..22cae8fe 100644
--- a/vendor/libghostty-vt/src/renderer/State.zig
+++ b/vendor/libghostty-vt/src/renderer/State.zig
@@ -112,7 +112,7 @@ pub const Preedit = struct {
// If our preedit goes off the end of the screen, we adjust it so
// that it shifts left.
- const end = start + w;
+ const end = if (w > 0) start + (w - 1) else start;
const start_offset = if (end > max) end - max else 0;
return .{
.start = start -| start_offset,
@@ -121,3 +121,41 @@ pub const Preedit = struct {
};
}
};
+
+const test_hangul_ga: u21 = 0xAC00; // U+AC00 HANGUL SYLLABLE GA
+
+test "preedit range covers exact cell width" {
+ const testing = std.testing;
+
+ {
+ const p: Preedit = .{
+ .codepoints = &.{.{ .codepoint = 'a' }},
+ };
+ const range = p.range(2, 9);
+ try testing.expectEqual(@as(terminalpkg.size.CellCountInt, 2), range.start);
+ try testing.expectEqual(@as(terminalpkg.size.CellCountInt, 2), range.end);
+ try testing.expectEqual(@as(usize, 0), range.cp_offset);
+ }
+
+ {
+ const p: Preedit = .{
+ .codepoints = &.{.{ .codepoint = test_hangul_ga, .wide = true }},
+ };
+ const range = p.range(2, 9);
+ try testing.expectEqual(@as(terminalpkg.size.CellCountInt, 2), range.start);
+ try testing.expectEqual(@as(terminalpkg.size.CellCountInt, 3), range.end);
+ try testing.expectEqual(@as(usize, 0), range.cp_offset);
+ }
+}
+
+test "preedit range shifts left at right edge" {
+ const testing = std.testing;
+
+ const p: Preedit = .{
+ .codepoints = &.{.{ .codepoint = test_hangul_ga, .wide = true }},
+ };
+ const range = p.range(9, 9);
+ try testing.expectEqual(@as(terminalpkg.size.CellCountInt, 8), range.start);
+ try testing.expectEqual(@as(terminalpkg.size.CellCountInt, 9), range.end);
+ try testing.expectEqual(@as(usize, 0), range.cp_offset);
+}
diff --git a/vendor/libghostty-vt/src/renderer/image.zig b/vendor/libghostty-vt/src/renderer/image.zig
index c43d2798..442b7543 100644
--- a/vendor/libghostty-vt/src/renderer/image.zig
+++ b/vendor/libghostty-vt/src/renderer/image.zig
@@ -426,7 +426,7 @@ pub const State = struct {
// Calculate the dimensions of our image, taking in to
// account the rows / columns specified by the placement.
- const dest_size = p.calculatedSize(image.*, t);
+ const dest_size = p.pixelSize(image.*, t);
// Calculate the source rectangle
const source_x = @min(image.width, p.source_x);
diff --git a/vendor/libghostty-vt/src/simd/codepoint_width.cpp b/vendor/libghostty-vt/src/simd/codepoint_width.cpp
index 294922c6..7f0dfd87 100644
--- a/vendor/libghostty-vt/src/simd/codepoint_width.cpp
+++ b/vendor/libghostty-vt/src/simd/codepoint_width.cpp
@@ -3,10 +3,41 @@
#define HWY_TARGET_INCLUDE "simd/codepoint_width.cpp" // this file
#include <hwy/foreach_target.h> // must come before highway.h
#include <hwy/highway.h>
-#include <hwy/print-inl.h>
-#include <cassert>
-#include <iterator>
+#ifndef GHOSTTY_SIMD_CPW_HELPERS_
+#define GHOSTTY_SIMD_CPW_HELPERS_
+
+#ifdef NDEBUG
+#define GHOSTTY_SIMD_ASSERT(cond) ((void)0)
+#else
+#define GHOSTTY_SIMD_ASSERT(cond) \
+ do { \
+ if (!(cond)) __builtin_trap();\
+ } while (0)
+#endif
+
+// Replacement for std::size() that works without libc++.
+template <typename T, size_t N>
+constexpr size_t array_size(const T (&)[N]) { return N; }
+
+// Constexpr min/max element over a C array (replaces std::min_element/
+// std::max_element).
+template <typename T, size_t N>
+constexpr T array_min(const T (&a)[N]) {
+ T m = a[0];
+ for (size_t i = 1; i < N; ++i)
+ if (a[i] != 0 && (m == 0 || a[i] < m)) m = a[i];
+ return m;
+}
+template <typename T, size_t N>
+constexpr T array_max(const T (&a)[N]) {
+ T m = a[0];
+ for (size_t i = 1; i < N; ++i)
+ if (a[i] > m) m = a[i];
+ return m;
+}
+
+#endif // GHOSTTY_SIMD_CPW_HELPERS_
HWY_BEFORE_NAMESPACE();
namespace ghostty {
@@ -214,18 +245,18 @@ HWY_ALIGN constexpr uint16_t nsm_lte16[] = {
};
// All our tables must be identically sized
-static_assert(std::size(eaw_gte32) == std::size(eaw_lte32));
-static_assert(std::size(eaw_gte16) == std::size(eaw_lte16));
-static_assert(std::size(zero_gte32) == std::size(zero_lte32));
-static_assert(std::size(zero_gte16) == std::size(zero_lte16));
-static_assert(std::size(nsm_gte32) == std::size(nsm_lte32));
-static_assert(std::size(nsm_gte16) == std::size(nsm_lte16));
+static_assert(array_size(eaw_gte32) == array_size(eaw_lte32));
+static_assert(array_size(eaw_gte16) == array_size(eaw_lte16));
+static_assert(array_size(zero_gte32) == array_size(zero_lte32));
+static_assert(array_size(zero_gte16) == array_size(zero_lte16));
+static_assert(array_size(nsm_gte32) == array_size(nsm_lte32));
+static_assert(array_size(nsm_gte16) == array_size(nsm_lte16));
/// Handles 16-bit codepoints.
template <class D, typename T = uint16_t>
int8_t CodepointWidth16(D d, uint16_t input) {
- assert(input > 0xFF);
- assert(input <= 0xFFFF);
+ GHOSTTY_SIMD_ASSERT(input > 0xFF);
+ GHOSTTY_SIMD_ASSERT(input <= 0xFFFF);
const size_t N = hn::Lanes(d);
const hn::Vec<D> input_vec = Set(d, input);
@@ -245,10 +276,10 @@ int8_t CodepointWidth16(D d, uint16_t input) {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
};
- static_assert(std::size(gte_keys) == std::size(lte_keys));
- static_assert(std::size(gte_keys) >= 32);
+ static_assert(array_size(gte_keys) == array_size(lte_keys));
+ static_assert(array_size(gte_keys) >= 32);
size_t i = 0;
- for (; i + N <= std::size(lte_keys) && lte_keys[i] != 0; i += N) {
+ for (; i + N <= array_size(lte_keys) && lte_keys[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, lte_keys + i);
const hn::Vec<D> gte_vec = hn::Load(d, gte_keys + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -262,17 +293,17 @@ int8_t CodepointWidth16(D d, uint16_t input) {
return 2;
}
}
- assert(i >= 7); // We should have checked all the ranges.
+ GHOSTTY_SIMD_ASSERT(i >= 7); // We should have checked all the ranges.
}
{
constexpr T zero_gte_min =
- *std::min_element(zero_gte16, zero_gte16 + std::size(zero_gte16));
+ array_min(zero_gte16);
constexpr T zero_lte_max =
- *std::max_element(zero_lte16, zero_lte16 + std::size(zero_lte16));
+ array_max(zero_lte16);
if (input >= zero_gte_min && input <= zero_lte_max) {
size_t i = 0;
- for (; i + N <= std::size(zero_gte16) && zero_gte16[i] != 0; i += N) {
+ for (; i + N <= array_size(zero_gte16) && zero_gte16[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, zero_lte16 + i);
const hn::Vec<D> gte_vec = hn::Load(d, zero_gte16 + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -286,12 +317,12 @@ int8_t CodepointWidth16(D d, uint16_t input) {
{
constexpr T eaw_gte_min =
- *std::min_element(eaw_gte16, eaw_gte16 + std::size(eaw_gte16));
+ array_min(eaw_gte16);
constexpr T eaw_lte_max =
- *std::max_element(eaw_lte16, eaw_lte16 + std::size(eaw_lte16));
+ array_max(eaw_lte16);
if (input >= eaw_gte_min && input <= eaw_lte_max) {
size_t i = 0;
- for (; i + N <= std::size(eaw_lte16) && eaw_lte16[i] != 0; i += N) {
+ for (; i + N <= array_size(eaw_lte16) && eaw_lte16[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, eaw_lte16 + i);
const hn::Vec<D> gte_vec = hn::Load(d, eaw_gte16 + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -305,12 +336,12 @@ int8_t CodepointWidth16(D d, uint16_t input) {
{
constexpr T nsm_gte_min =
- *std::min_element(nsm_gte16, nsm_gte16 + std::size(nsm_gte16));
+ array_min(nsm_gte16);
constexpr T nsm_lte_max =
- *std::max_element(nsm_lte16, nsm_lte16 + std::size(nsm_lte16));
+ array_max(nsm_lte16);
if (input >= nsm_gte_min && input <= nsm_lte_max) {
size_t i = 0;
- for (; i + N <= std::size(nsm_lte16) && nsm_lte16[i] != 0; i += N) {
+ for (; i + N <= array_size(nsm_lte16) && nsm_lte16[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, nsm_lte16 + i);
const hn::Vec<D> gte_vec = hn::Load(d, nsm_gte16 + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -328,7 +359,7 @@ int8_t CodepointWidth16(D d, uint16_t input) {
/// Handles codepoints larger than 16-bit.
template <class D, typename T = uint32_t>
int8_t CodepointWidth32(D d, T input) {
- assert(input > 0xFFFF);
+ GHOSTTY_SIMD_ASSERT(input > 0xFFFF);
const size_t N = hn::Lanes(d);
const hn::Vec<D> input_vec = Set(d, input);
@@ -342,10 +373,10 @@ int8_t CodepointWidth32(D d, T input) {
HWY_ALIGN constexpr T lte_keys[] = {
0x1f1ff, 0x2FFFD, 0x3FFFD, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
};
- static_assert(std::size(gte_keys) == std::size(lte_keys));
- static_assert(std::size(gte_keys) >= 16);
+ static_assert(array_size(gte_keys) == array_size(lte_keys));
+ static_assert(array_size(gte_keys) >= 16);
size_t i = 0;
- for (; i + N <= std::size(lte_keys) && lte_keys[i] != 0; i += N) {
+ for (; i + N <= array_size(lte_keys) && lte_keys[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, lte_keys + i);
const hn::Vec<D> gte_vec = hn::Load(d, gte_keys + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -354,7 +385,7 @@ int8_t CodepointWidth32(D d, T input) {
return 2;
}
}
- assert(i >= 2); // We should have checked all the ranges.
+ GHOSTTY_SIMD_ASSERT(i >= 2); // We should have checked all the ranges.
}
{
@@ -364,10 +395,10 @@ int8_t CodepointWidth32(D d, T input) {
HWY_ALIGN constexpr T lte_keys[] = {
0xE0FFF, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
};
- static_assert(std::size(gte_keys) == std::size(lte_keys));
- static_assert(std::size(gte_keys) >= 16);
+ static_assert(array_size(gte_keys) == array_size(lte_keys));
+ static_assert(array_size(gte_keys) >= 16);
size_t i = 0;
- for (; i + N <= std::size(lte_keys) && lte_keys[i] != 0; i += N) {
+ for (; i + N <= array_size(lte_keys) && lte_keys[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, lte_keys + i);
const hn::Vec<D> gte_vec = hn::Load(d, gte_keys + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -380,12 +411,12 @@ int8_t CodepointWidth32(D d, T input) {
{
constexpr T zero_gte_min =
- *std::min_element(zero_gte32, zero_gte32 + std::size(zero_gte32));
+ array_min(zero_gte32);
constexpr T zero_lte_max =
- *std::max_element(zero_lte32, zero_lte32 + std::size(zero_lte32));
+ array_max(zero_lte32);
if (input >= zero_gte_min && input <= zero_lte_max) {
size_t i = 0;
- for (; i + N <= std::size(zero_gte32) && zero_gte32[i] != 0; i += N) {
+ for (; i + N <= array_size(zero_gte32) && zero_gte32[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, zero_lte32 + i);
const hn::Vec<D> gte_vec = hn::Load(d, zero_gte32 + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -399,12 +430,12 @@ int8_t CodepointWidth32(D d, T input) {
{
constexpr T eaw_gte_min =
- *std::min_element(eaw_gte32, eaw_gte32 + std::size(eaw_gte32));
+ array_min(eaw_gte32);
constexpr T eaw_lte_max =
- *std::max_element(eaw_lte32, eaw_lte32 + std::size(eaw_lte32));
+ array_max(eaw_lte32);
if (input >= eaw_gte_min && input <= eaw_lte_max) {
size_t i = 0;
- for (; i + N <= std::size(eaw_lte32) && eaw_lte32[i] != 0; i += N) {
+ for (; i + N <= array_size(eaw_lte32) && eaw_lte32[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, eaw_lte32 + i);
const hn::Vec<D> gte_vec = hn::Load(d, eaw_gte32 + i);
const intptr_t idx = hn::FindFirstTrue(
@@ -418,12 +449,12 @@ int8_t CodepointWidth32(D d, T input) {
{
constexpr T nsm_gte_min =
- *std::min_element(nsm_gte32, nsm_gte32 + std::size(nsm_gte32));
+ array_min(nsm_gte32);
constexpr T nsm_lte_max =
- *std::max_element(nsm_lte32, nsm_lte32 + std::size(nsm_lte32));
+ array_max(nsm_lte32);
if (input >= nsm_gte_min && input <= nsm_lte_max) {
size_t i = 0;
- for (; i + N <= std::size(nsm_lte32) && nsm_lte32[i] != 0; i += N) {
+ for (; i + N <= array_size(nsm_lte32) && nsm_lte32[i] != 0; i += N) {
const hn::Vec<D> lte_vec = hn::Load(d, nsm_lte32 + i);
const hn::Vec<D> gte_vec = hn::Load(d, nsm_gte32 + i);
const intptr_t idx = hn::FindFirstTrue(
diff --git a/vendor/libghostty-vt/src/simd/index_of.cpp b/vendor/libghostty-vt/src/simd/index_of.cpp
index b070ae3b..8dad4bc4 100644
--- a/vendor/libghostty-vt/src/simd/index_of.cpp
+++ b/vendor/libghostty-vt/src/simd/index_of.cpp
@@ -6,8 +6,6 @@
#include <simd/index_of.h>
-#include <optional>
-
HWY_BEFORE_NAMESPACE();
namespace ghostty {
namespace HWY_NAMESPACE {
diff --git a/vendor/libghostty-vt/src/simd/index_of.h b/vendor/libghostty-vt/src/simd/index_of.h
index 531af9f8..172c9357 100644
--- a/vendor/libghostty-vt/src/simd/index_of.h
+++ b/vendor/libghostty-vt/src/simd/index_of.h
@@ -7,8 +7,7 @@
#include <hwy/highway.h>
-#include <cstddef>
-#include <optional>
+#include <stddef.h>
HWY_BEFORE_NAMESPACE();
namespace ghostty {
@@ -16,12 +15,16 @@ namespace HWY_NAMESPACE {
namespace hn = hwy::HWY_NAMESPACE;
+// Sentinel value returned by IndexOfChunk when no match is found.
+static constexpr size_t kNotFound = static_cast<size_t>(-1);
+
// Return the index of the first occurrence of `needle` in `input`, where
-// the input and needle are already loaded into vectors.
+// the input and needle are already loaded into vectors. Returns kNotFound
+// if no match is found.
template <class D, typename T = hn::TFromD<D>>
-std::optional<size_t> IndexOfChunk(D d,
- hn::Vec<D> needle_vec,
- hn::Vec<D> input_vec) {
+size_t IndexOfChunk(D d,
+ hn::Vec<D> needle_vec,
+ hn::Vec<D> input_vec) {
// Compare the input vector with the needle vector. This produces
// a vector where each lane is 0xFF if the corresponding lane in
// `input_vec` is equal to the corresponding lane in `needle_vec`.
@@ -32,9 +35,9 @@ std::optional<size_t> IndexOfChunk(D d,
// If we found a match, return the index into the input.
if (pos >= 0) {
- return std::optional<size_t>(static_cast<size_t>(pos));
+ return static_cast<size_t>(pos);
} else {
- return std::nullopt;
+ return kNotFound;
}
}
@@ -58,8 +61,9 @@ size_t IndexOfImpl(D d, T needle, const T* HWY_RESTRICT input, size_t count) {
for (; i + N <= count; i += N) {
// Load the N elements from our input into a vector and check the chunk.
const hn::Vec<D> input_vec = hn::LoadU(d, input + i);
- if (auto pos = IndexOfChunk(d, needle_vec, input_vec)) {
- return i + pos.value();
+ const size_t pos = IndexOfChunk(d, needle_vec, input_vec);
+ if (pos != kNotFound) {
+ return i + pos;
}
}
diff --git a/vendor/libghostty-vt/src/simd/vt.cpp b/vendor/libghostty-vt/src/simd/vt.cpp
index d8133abf..5bf4147d 100644
--- a/vendor/libghostty-vt/src/simd/vt.cpp
+++ b/vendor/libghostty-vt/src/simd/vt.cpp
@@ -5,8 +5,6 @@
#include <hwy/highway.h>
#include <simdutf.h>
-#include <utf8.h>
-#include <vector>
#include <simd/index_of.h>
#include <simd/vt.h>
@@ -19,12 +17,144 @@ namespace hn = hwy::HWY_NAMESPACE;
using T = uint8_t;
+// Compute the length of the maximal subpart of an ill-formed UTF-8
+// subsequence starting at p[0], per Unicode Table 3-7 and the W3C
+// "U+FFFD Substitution of Maximal Subparts" algorithm.
+//
+// The maximal subpart is the longest initial subsequence that is either:
+// (a) the start of a well-formed sequence, or
+// (b) a single byte.
+// Each maximal subpart maps to exactly one U+FFFD.
+static size_t MaximalSubpart(const unsigned char* p, size_t len) {
+ if (len == 0) return 0;
+
+ unsigned char b0 = p[0];
+
+ // Continuation bytes (80-BF), overlong leads (C0-C1), or invalid (F5-FF):
+ // each is its own maximal subpart of length 1.
+ if (b0 < 0xC2 || b0 > 0xF4) return 1;
+
+ // Determine the expected sequence length and the valid range for each
+ // continuation byte per Unicode Table 3-7.
+ size_t seq_len;
+ unsigned char lo[3], hi[3];
+
+ if (b0 <= 0xDF) {
+ seq_len = 2;
+ lo[0] = 0x80; hi[0] = 0xBF;
+ } else if (b0 == 0xE0) {
+ seq_len = 3;
+ lo[0] = 0xA0; hi[0] = 0xBF;
+ lo[1] = 0x80; hi[1] = 0xBF;
+ } else if (b0 <= 0xEC) {
+ seq_len = 3;
+ lo[0] = 0x80; hi[0] = 0xBF;
+ lo[1] = 0x80; hi[1] = 0xBF;
+ } else if (b0 == 0xED) {
+ seq_len = 3;
+ lo[0] = 0x80; hi[0] = 0x9F;
+ lo[1] = 0x80; hi[1] = 0xBF;
+ } else if (b0 <= 0xEF) {
+ seq_len = 3;
+ lo[0] = 0x80; hi[0] = 0xBF;
+ lo[1] = 0x80; hi[1] = 0xBF;
+ } else if (b0 == 0xF0) {
+ seq_len = 4;
+ lo[0] = 0x90; hi[0] = 0xBF;
+ lo[1] = 0x80; hi[1] = 0xBF;
+ lo[2] = 0x80; hi[2] = 0xBF;
+ } else if (b0 <= 0xF3) {
+ seq_len = 4;
+ lo[0] = 0x80; hi[0] = 0xBF;
+ lo[1] = 0x80; hi[1] = 0xBF;
+ lo[2] = 0x80; hi[2] = 0xBF;
+ } else { // b0 == 0xF4
+ seq_len = 4;
+ lo[0] = 0x80; hi[0] = 0x8F;
+ lo[1] = 0x80; hi[1] = 0xBF;
+ lo[2] = 0x80; hi[2] = 0xBF;
+ }
+
+ // Check continuation bytes against their specific valid ranges.
+ // The maximal subpart extends as far as bytes match.
+ size_t valid = 1; // lead byte counts
+ for (size_t i = 0; i < seq_len - 1 && valid < len; i++) {
+ unsigned char cb = p[valid];
+ if (cb < lo[i] || cb > hi[i]) break;
+ valid++;
+ }
+
+ // If we matched all bytes, the sequence is structurally valid
+ // (shouldn't happen since we're called on an error), but cap
+ // to avoid skipping a valid sequence.
+ if (valid == seq_len) return valid;
+
+ return valid;
+}
+
+// Trim trailing bytes that form a valid-but-incomplete UTF-8 sequence.
+// Only trims sequences whose bytes so far match Table 3-7 ranges (i.e.,
+// truly partial sequences that could be completed by future input).
+// Invalid lead bytes (C0, C1, F5-FF) or mismatched continuations are NOT
+// trimmed — they will be handled as errors by DecodeUTF8.
+static size_t TrimValidPartialUTF8(const uint8_t* input, size_t len) {
+ if (len == 0) return 0;
+
+ // Find the start of a potential trailing partial sequence by scanning
+ // backwards from the end. We look for a lead byte (C2-F4) that could
+ // start a multi-byte sequence, possibly followed by continuation bytes.
+ //
+ // We check up to the last 4 bytes (max UTF-8 sequence length).
+ size_t check_start = len > 4 ? len - 4 : 0;
+ for (size_t pos = len; pos > check_start; pos--) {
+ unsigned char b = input[pos - 1];
+
+ // Skip continuation bytes — they might belong to the partial sequence.
+ if ((b & 0xC0) == 0x80) continue;
+
+ // Found a non-continuation byte. Only valid multi-byte leads (C2-F4)
+ // can start a partial sequence worth trimming. Anything else (ASCII,
+ // C0, C1, F5-FF) should be consumed by DecodeUTF8.
+ if (b < 0xC2 || b > 0xF4) return len;
+
+ // Determine expected sequence length from the lead byte.
+ size_t expected;
+ if (b <= 0xDF)
+ expected = 2;
+ else if (b <= 0xEF)
+ expected = 3;
+ else
+ expected = 4;
+
+ size_t seq_remaining = len - (pos - 1);
+
+ // If we have all expected bytes, the sequence is complete (not partial).
+ if (seq_remaining >= expected) return len;
+
+ // Check if the trailing bytes form a valid prefix using MaximalSubpart.
+ const unsigned char* seq_start = input + pos - 1;
+ size_t subpart = MaximalSubpart(seq_start, seq_remaining);
+
+ // Only trim if ALL trailing bytes are part of the valid prefix
+ // (the sequence is valid-so-far but incomplete).
+ if (subpart == seq_remaining) {
+ return pos - 1;
+ }
+
+ // The sequence is ill-formed, don't trim — let DecodeUTF8 handle it.
+ return len;
+ }
+
+ return len;
+}
+
// Decode the UTF-8 text in input into output. Returns the number of decoded
// characters. This function assumes output is large enough.
//
// This function handles malformed UTF-8 sequences by inserting a
-// replacement character (U+FFFD) and continuing to decode. This function
-// will consume the entire input no matter what.
+// replacement character (U+FFFD) following the W3C/Unicode "U+FFFD
+// Substitution of Maximal Subparts" algorithm and continuing to decode.
+// This function will consume the entire input no matter what.
size_t DecodeUTF8(const uint8_t* HWY_RESTRICT input,
size_t count,
char32_t* output) {
@@ -34,27 +164,38 @@ size_t DecodeUTF8(const uint8_t* HWY_RESTRICT input,
return 0;
}
- // Assume no errors for fast path.
- const size_t decoded = simdutf::convert_utf8_to_utf32(
- reinterpret_cast<const char*>(input), count, output);
- if (decoded > 0) {
- return decoded;
+ // Decode UTF-8 to UTF-32, replacing invalid sequences with U+FFFD.
+ const char* in = reinterpret_cast<const char*>(input);
+ size_t remaining = count;
+ char32_t* out = output;
+ while (remaining > 0) {
+ auto r = simdutf::convert_utf8_to_utf32_with_errors(in, remaining, out);
+
+ // If the decode was a full success then we're done!
+ if (r.error == simdutf::SUCCESS) {
+ out += r.count;
+ break;
+ }
+
+ // On error, r.count is the input byte position of the error.
+ // The output buffer is already written up to that point, but
+ // we need count_utf8 to find how many char32_t that produced.
+ out += simdutf::count_utf8(in, r.count);
+
+ // Compute the maximal subpart at the error position and emit
+ // a single U+FFFD for it.
+ const unsigned char* err_pos =
+ reinterpret_cast<const unsigned char*>(in + r.count);
+ size_t err_remaining = remaining - r.count;
+ size_t skip = r.count + MaximalSubpart(err_pos, err_remaining);
+
+ *out++ = 0xFFFD;
+
+ in += skip;
+ remaining -= skip;
}
- // Errors in the UTF input, take a slow path and do a decode with
- // replacement (with U+FFFD). Note that simdutf doesn't have a
- // decode with replacement API:
- // https://github.com/simdutf/simdutf/issues/147
- //
- // Because of this, we use a separate library with heap allocation
- // that is much, much slower (the allocation is slower, the algorithm
- // is slower, etc.) This is just so we have something that works.
- // I want to replace this.
- std::vector<char> replacement_result;
- utf8::replace_invalid(input, input + count,
- std::back_inserter(replacement_result), 0xFFFD);
- return DecodeUTF8(reinterpret_cast<const uint8_t*>(replacement_result.data()),
- replacement_result.size(), output);
+ return static_cast<size_t>(out - output);
}
/// Decode the UTF-8 text in input into output until an escape
@@ -86,16 +227,16 @@ size_t DecodeUTF8UntilControlSeqImpl(D d,
// If we don't have any escapes we keep going. We want to accumulate
// the largest possible valid UTF-8 sequence before decoding.
// TODO(mitchellh): benchmark this vs decoding every time
- const auto esc_idx = IndexOfChunk(d, esc_vec, input_vec);
- if (!esc_idx) {
+ const size_t esc_idx = IndexOfChunk(d, esc_vec, input_vec);
+ if (esc_idx == kNotFound) {
continue;
}
// We have an ESC char, decode up to this point. We start by assuming
// a valid UTF-8 sequence and slow-path into error handling if we find
// an invalid sequence.
- *output_count = DecodeUTF8(input, i + esc_idx.value(), output);
- return i + esc_idx.value();
+ *output_count = DecodeUTF8(input, i + esc_idx, output);
+ return i + esc_idx;
}
// If we have leftover input then we decode it one byte at a time (slow!)
@@ -106,21 +247,27 @@ size_t DecodeUTF8UntilControlSeqImpl(D d,
const hn::Vec<D1> esc1 = Set(d1, hn::GetLane(esc_vec));
for (; i < count; ++i) {
const hn::Vec<D1> input_vec = hn::LoadU(d1, input + i);
- const auto esc_idx = IndexOfChunk(d1, esc1, input_vec);
- if (!esc_idx) {
+ const size_t esc_idx = IndexOfChunk(d1, esc1, input_vec);
+ if (esc_idx == kNotFound) {
continue;
}
- *output_count = DecodeUTF8(input, i + esc_idx.value(), output);
- return i + esc_idx.value();
+ *output_count = DecodeUTF8(input, i + esc_idx, output);
+ return i + esc_idx;
}
}
// If we reached this point, its possible for our input to have an
// incomplete sequence because we're consuming the full input. We need
// to trim any incomplete sequences from the end of the input.
- const size_t trimmed_len =
- simdutf::trim_partial_utf8(reinterpret_cast<const char*>(input), i);
+ //
+ // We use our own trim instead of simdutf::trim_partial_utf8 because
+ // we only want to trim sequences that are valid-so-far (true partial
+ // sequences that may be completed by future input). Invalid bytes
+ // like C0, C1, F5-FF should NOT be trimmed — they should be passed
+ // through to DecodeUTF8 which will replace them with U+FFFD per the
+ // maximal subpart algorithm.
+ const size_t trimmed_len = TrimValidPartialUTF8(input, i);
*output_count = DecodeUTF8(input, trimmed_len, output);
return trimmed_len;
}
diff --git a/vendor/libghostty-vt/src/simd/vt.zig b/vendor/libghostty-vt/src/simd/vt.zig
index fa8754fa..4230665f 100644
--- a/vendor/libghostty-vt/src/simd/vt.zig
+++ b/vendor/libghostty-vt/src/simd/vt.zig
@@ -45,36 +45,79 @@ fn utf8DecodeUntilControlSeqScalar(
const idx = indexOf(input, 0x1B) orelse input.len;
const decode = input[0..idx];
- // Go through and decode one item at a time.
+ // Go through and decode one item at a time, following the W3C/Unicode
+ // "U+FFFD Substitution of Maximal Subparts" algorithm for ill-formed
+ // subsequences.
var decode_offset: usize = 0;
var decode_count: usize = 0;
while (decode_offset < decode.len) {
- const decode_rem = decode[decode_offset..];
- const cp_len = std.unicode.utf8ByteSequenceLength(decode_rem[0]) catch {
- // Note, this is matching our SIMD behavior, but it is admittedly
- // a bit weird. See our "decode invalid leading byte" test too.
- // SIMD should be our source of truth then we copy behavior here.
- break;
- };
-
- // If we don't have that number of bytes available. we finish. We
- // assume this is a partial input and we defer to the future.
- if (decode_rem.len < cp_len) break;
-
- // We have the bytes available, so move forward
- const cp_bytes = decode_rem[0..cp_len];
- decode_offset += cp_len;
- if (std.unicode.utf8Decode(cp_bytes)) |cp| {
- output[decode_count] = @intCast(cp);
+ const b0 = decode[decode_offset];
+
+ // ASCII fast path
+ if (b0 < 0x80) {
+ output[decode_count] = b0;
decode_count += 1;
- } else |_| {
- // If decoding failed, we replace the leading byte with the
- // replacement char and then continue decoding after that
- // byte. This matches the SIMD behavior and is tested by the
- // "invalid UTF-8" tests.
+ decode_offset += 1;
+ continue;
+ }
+
+ // Continuation byte (80-BF) or invalid byte (C0-C1, F5-FF)
+ // as lead: each is its own maximal subpart → one FFFD per byte.
+ if (b0 < 0xC2 or b0 > 0xF4) {
+ output[decode_count] = 0xFFFD;
+ decode_count += 1;
+ decode_offset += 1;
+ continue;
+ }
+
+ // Multi-byte sequence. Determine expected length and the valid
+ // range for each continuation byte per Unicode Table 3-7.
+ const seq = utf8SeqInfo(b0);
+
+ // Check how many continuation bytes form a valid prefix (the
+ // maximal subpart). We check each byte against its specific
+ // valid range.
+ var valid: usize = 1; // lead byte is valid
+ for (0..seq.len - 1) |ci| {
+ if (decode_offset + valid >= decode.len) {
+ // Truncated at end of buffer: treat as incomplete
+ // input that may be completed later. Stop decoding
+ // without consuming these bytes.
+ return .{
+ .consumed = decode_offset,
+ .decoded = decode_count,
+ };
+ }
+ const cb = decode[decode_offset + valid];
+ if (cb < seq.ranges[ci][0] or cb > seq.ranges[ci][1]) {
+ // Byte doesn't match expected range. The maximal
+ // subpart ends here.
+ break;
+ }
+ valid += 1;
+ }
+
+ if (valid == seq.len) {
+ // Full sequence present and structurally valid. Decode it.
+ // (Structural validity per Table 3-7 guarantees decode success.)
+ const cp_bytes = decode[decode_offset..][0..seq.len];
+ if (std.unicode.utf8Decode(cp_bytes)) |cp| {
+ output[decode_count] = @intCast(cp);
+ decode_count += 1;
+ decode_offset += seq.len;
+ } else |_| {
+ // Should not happen given Table 3-7 validation, but
+ // be safe: emit FFFD for the lead byte.
+ output[decode_count] = 0xFFFD;
+ decode_count += 1;
+ decode_offset += 1;
+ }
+ } else {
+ // Incomplete/ill-formed: the maximal subpart (valid bytes)
+ // maps to a single FFFD.
output[decode_count] = 0xFFFD;
decode_count += 1;
- decode_offset -= cp_len - 1;
+ decode_offset += valid;
}
}
@@ -84,6 +127,27 @@ fn utf8DecodeUntilControlSeqScalar(
};
}
+const Utf8SeqInfo = struct {
+ len: u3,
+ ranges: [3][2]u8,
+};
+
+/// Returns the expected byte count and valid continuation byte ranges
+/// for a UTF-8 sequence based on its lead byte, per Unicode Table 3-7.
+fn utf8SeqInfo(lead: u8) Utf8SeqInfo {
+ return switch (lead) {
+ 0xC2...0xDF => .{ .len = 2, .ranges = .{ .{ 0x80, 0xBF }, .{ 0, 0 }, .{ 0, 0 } } },
+ 0xE0 => .{ .len = 3, .ranges = .{ .{ 0xA0, 0xBF }, .{ 0x80, 0xBF }, .{ 0, 0 } } },
+ 0xE1...0xEC => .{ .len = 3, .ranges = .{ .{ 0x80, 0xBF }, .{ 0x80, 0xBF }, .{ 0, 0 } } },
+ 0xED => .{ .len = 3, .ranges = .{ .{ 0x80, 0x9F }, .{ 0x80, 0xBF }, .{ 0, 0 } } },
+ 0xEE...0xEF => .{ .len = 3, .ranges = .{ .{ 0x80, 0xBF }, .{ 0x80, 0xBF }, .{ 0, 0 } } },
+ 0xF0 => .{ .len = 4, .ranges = .{ .{ 0x90, 0xBF }, .{ 0x80, 0xBF }, .{ 0x80, 0xBF } } },
+ 0xF1...0xF3 => .{ .len = 4, .ranges = .{ .{ 0x80, 0xBF }, .{ 0x80, 0xBF }, .{ 0x80, 0xBF } } },
+ 0xF4 => .{ .len = 4, .ranges = .{ .{ 0x80, 0x8F }, .{ 0x80, 0xBF }, .{ 0x80, 0xBF } } },
+ else => unreachable,
+ };
+}
+
test "decode no escape" {
const testing = std.testing;
@@ -131,7 +195,7 @@ test "decode incomplete UTF-8" {
var output: [64]u32 = undefined;
- // 2-byte
+ // 2-byte truncated at end of buffer
{
const str = "hello\xc2";
try testing.expectEqual(DecodeResult{
@@ -140,16 +204,18 @@ test "decode incomplete UTF-8" {
}, utf8DecodeUntilControlSeq(str, &output));
}
- // 3-byte
+ // 3-byte: \xe0 expects A0-BF next, but \x00 is not in range.
+ // \xe0 is a maximal subpart of length 1 → FFFD, then \x00 is ASCII NUL.
{
const str = "hello\xe0\x00";
- try testing.expectEqual(DecodeResult{
- .consumed = 5,
- .decoded = 5,
- }, utf8DecodeUntilControlSeq(str, &output));
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ try testing.expectEqual(@as(usize, 7), result.consumed);
+ try testing.expectEqual(@as(usize, 7), result.decoded);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[5]);
+ try testing.expectEqual(@as(u32, 0x00), output[6]);
}
- // 4-byte
+ // 4-byte truncated at end of buffer (F0 90 is valid so far)
{
const str = "hello\xf0\x90";
try testing.expectEqual(DecodeResult{
@@ -178,19 +244,248 @@ test "decode invalid UTF-8" {
try testing.expectEqual(@as(u32, 0x01), output[6]);
}
-// This is testing our current behavior so that we know we have to handle
-// this case in terminal/stream.zig. If we change this behavior, we can
-// remove the special handling in terminal/stream.zig.
-test "decode invalid leading byte isn't consumed or replaced" {
+// Per the maximal subpart spec, bytes F5-FF are each replaced with FFFD.
+test "decode invalid leading byte is replaced" {
const testing = std.testing;
var output: [64]u32 = undefined;
{
const str = "hello\xFF";
- try testing.expectEqual(DecodeResult{
- .consumed = 5,
- .decoded = 5,
- }, utf8DecodeUntilControlSeq(str, &output));
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ try testing.expectEqual(@as(usize, 6), result.consumed);
+ try testing.expectEqual(@as(usize, 6), result.decoded);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[5]);
+ }
+}
+
+test "decode invalid continuation in 3-byte sequence" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // \xe2 expects two continuation bytes, \x28 is not one
+ {
+ const str = "hello\xe2\x28world";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // "hello" + replacement + "(" + "world" = 12 codepoints
+ try testing.expectEqual(@as(usize, 12), result.decoded);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[5]);
+ try testing.expectEqual(@as(u32, '('), output[6]);
+ try testing.expectEqual(@as(u32, 'w'), output[7]);
+ }
+}
+
+test "decode invalid continuation in 4-byte sequence" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // \xf0\x90 is a valid prefix of a 4-byte sequence, but \x28 breaks it.
+ // Maximal subpart is F0 90 (length 2) → single FFFD, then '(' proceeds.
+ {
+ const str = "hello\xf0\x90\x28world";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // "hello" + FFFD + "(" + "world" = 12 codepoints
+ try testing.expectEqual(@as(usize, 12), result.decoded);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[5]);
+ try testing.expectEqual(@as(u32, '('), output[6]);
+ try testing.expectEqual(@as(u32, 'w'), output[7]);
+ }
+}
+
+test "decode multiple consecutive invalid bytes" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // Each lone continuation byte is its own maximal subpart → one FFFD each.
+ {
+ const str = "a\x80\x80b";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // "a" + FFFD + FFFD + "b" = 4 codepoints
+ try testing.expectEqual(@as(usize, 4), result.decoded);
+ try testing.expectEqual(@as(u32, 'a'), output[0]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[1]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]);
+ try testing.expectEqual(@as(u32, 'b'), output[3]);
+ }
+
+ // C0 is an invalid lead byte (< C2), each byte gets its own FFFD.
+ {
+ const str = "a\xc0\xc0b";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // "a" + FFFD + FFFD + "b" = 4 codepoints
+ try testing.expectEqual(@as(usize, 4), result.decoded);
+ try testing.expectEqual(@as(u32, 'a'), output[0]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[1]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]);
+ try testing.expectEqual(@as(u32, 'b'), output[3]);
+ }
+}
+
+test "decode unexpected continuation byte as lead" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // 0x80 is a continuation byte appearing as a lead byte
+ {
+ const str = "a\x80b";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // "a" + replacement + "b" = 3 codepoints
+ try testing.expectEqual(@as(usize, 3), result.decoded);
+ try testing.expectEqual(@as(u32, 'a'), output[0]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[1]);
+ try testing.expectEqual(@as(u32, 'b'), output[2]);
+ }
+}
+
+test "decode overlong 2-byte encoding" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // \xc0\xaf: C0 is invalid lead (< C2) → FFFD, AF is lone continuation → FFFD
+ // Per Table 3-8: C0 AF → FFFD FFFD
+ {
+ const str = "a\xc0\xafb";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // "a" + FFFD + FFFD + "b" = 4 codepoints
+ try testing.expectEqual(@as(usize, 4), result.decoded);
+ try testing.expectEqual(@as(u32, 'a'), output[0]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[1]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]);
+ try testing.expectEqual(@as(u32, 'b'), output[3]);
+ }
+}
+
+test "decode surrogate half" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // \xed\xa0\x80 encodes U+D800 (a surrogate). Per Table 3-7, after ED
+ // the next byte must be 80-9F. A0 is out of range, so ED is a maximal
+ // subpart of length 1 → FFFD. Then A0 and 80 are lone continuations
+ // → FFFD each. Per Table 3-9: ED A0 80 → FFFD FFFD FFFD
+ {
+ const str = "a\xed\xa0\x80b";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // "a" + FFFD + FFFD + FFFD + "b" = 5 codepoints
+ try testing.expectEqual(@as(usize, 5), result.decoded);
+ try testing.expectEqual(@as(u32, 'a'), output[0]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[1]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[3]);
+ try testing.expectEqual(@as(u32, 'b'), output[4]);
+ }
+}
+
+test "decode valid multibyte surrounded by invalid" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // \xc3\xa9 = é (U+00E9), surrounded by invalid continuation bytes
+ {
+ const str = "\x80\xc3\xa9\x80";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ // replacement + é + replacement = 3 codepoints
+ try testing.expectEqual(@as(usize, 3), result.decoded);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[0]);
+ try testing.expectEqual(@as(u32, 0x00E9), output[1]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]);
+ }
+}
+
+test "decode invalid byte before escape" {
+ const testing = std.testing;
+
+ var output: [64]u32 = undefined;
+
+ // Invalid byte followed by ESC - should replace then stop
+ {
+ const str = "hi\x80\x1b[0m";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ try testing.expectEqual(@as(usize, 3), result.consumed);
+ try testing.expectEqual(@as(usize, 3), result.decoded);
+ try testing.expectEqual(@as(u32, 'h'), output[0]);
+ try testing.expectEqual(@as(u32, 'i'), output[1]);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]);
+ }
+}
+
+// Unicode Table 3-8: U+FFFD for Non-Shortest Form Sequences
+// Bytes: C0 AF E0 80 BF F0 81 82 41
+// Output: FFFD FFFD FFFD FFFD FFFD FFFD FFFD FFFD 0041
+test "Table 3-8: non-shortest form sequences" {
+ const testing = std.testing;
+ var output: [64]u32 = undefined;
+
+ const str = "\xC0\xAF\xE0\x80\xBF\xF0\x81\x82\x41";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ try testing.expectEqual(@as(usize, 9), result.consumed);
+ try testing.expectEqual(@as(usize, 9), result.decoded);
+ for (0..8) |i| {
+ try testing.expectEqual(@as(u32, 0xFFFD), output[i]);
+ }
+ try testing.expectEqual(@as(u32, 0x41), output[8]);
+}
+
+// Unicode Table 3-9: U+FFFD for Ill-Formed Sequences for Surrogates
+// Bytes: ED A0 80 ED BF BF ED AF 41
+// Output: FFFD FFFD FFFD FFFD FFFD FFFD FFFD FFFD 0041
+test "Table 3-9: surrogate sequences" {
+ const testing = std.testing;
+ var output: [64]u32 = undefined;
+
+ const str = "\xED\xA0\x80\xED\xBF\xBF\xED\xAF\x41";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ try testing.expectEqual(@as(usize, 9), result.consumed);
+ try testing.expectEqual(@as(usize, 9), result.decoded);
+ for (0..8) |i| {
+ try testing.expectEqual(@as(u32, 0xFFFD), output[i]);
}
+ try testing.expectEqual(@as(u32, 0x41), output[8]);
+}
+
+// Unicode Table 3-10: U+FFFD for Other Ill-Formed Sequences
+// Bytes: F4 91 92 93 FF 41 80 BF 42
+// Output: FFFD FFFD FFFD FFFD FFFD 0041 FFFD FFFD 0042
+test "Table 3-10: other ill-formed sequences" {
+ const testing = std.testing;
+ var output: [64]u32 = undefined;
+
+ const str = "\xF4\x91\x92\x93\xFF\x41\x80\xBF\x42";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ try testing.expectEqual(@as(usize, 9), result.consumed);
+ try testing.expectEqual(@as(usize, 9), result.decoded);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[0]); // F4
+ try testing.expectEqual(@as(u32, 0xFFFD), output[1]); // 91
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]); // 92
+ try testing.expectEqual(@as(u32, 0xFFFD), output[3]); // 93
+ try testing.expectEqual(@as(u32, 0xFFFD), output[4]); // FF
+ try testing.expectEqual(@as(u32, 0x0041), output[5]); // 41
+ try testing.expectEqual(@as(u32, 0xFFFD), output[6]); // 80
+ try testing.expectEqual(@as(u32, 0xFFFD), output[7]); // BF
+ try testing.expectEqual(@as(u32, 0x0042), output[8]); // 42
+}
+
+// Unicode Table 3-11: U+FFFD for Truncated Sequences
+// Bytes: E1 80 E2 F0 91 92 F1 BF 41
+// Output: FFFD FFFD FFFD FFFD 0041
+test "Table 3-11: truncated sequences" {
+ const testing = std.testing;
+ var output: [64]u32 = undefined;
+
+ const str = "\xE1\x80\xE2\xF0\x91\x92\xF1\xBF\x41";
+ const result = utf8DecodeUntilControlSeq(str, &output);
+ try testing.expectEqual(@as(usize, 9), result.consumed);
+ try testing.expectEqual(@as(usize, 5), result.decoded);
+ try testing.expectEqual(@as(u32, 0xFFFD), output[0]); // E1 80 (truncated 3-byte)
+ try testing.expectEqual(@as(u32, 0xFFFD), output[1]); // E2 (truncated 3-byte, next byte F0 not continuation)
+ try testing.expectEqual(@as(u32, 0xFFFD), output[2]); // F0 91 92 (truncated 4-byte)
+ try testing.expectEqual(@as(u32, 0xFFFD), output[3]); // F1 BF (truncated 4-byte, next byte 41 not continuation)
+ try testing.expectEqual(@as(u32, 0x0041), output[4]); // 41
}
diff --git a/vendor/libghostty-vt/src/synthetic/Utf8.zig b/vendor/libghostty-vt/src/synthetic/Utf8.zig
index 0d72a8bb..282532dc 100644
--- a/vendor/libghostty-vt/src/synthetic/Utf8.zig
+++ b/vendor/libghostty-vt/src/synthetic/Utf8.zig
@@ -17,6 +17,27 @@ pub const Utf8Len = enum(u3) {
four = 4,
};
+const InvalidSequence = struct {
+ len: u3,
+ bytes: [4]u8,
+
+ fn slice(self: *const InvalidSequence) []const u8 {
+ return self.bytes[0..self.len];
+ }
+};
+
+const invalid_sequences = [_]InvalidSequence{
+ .{ .len = 1, .bytes = .{ 0x80, 0x00, 0x00, 0x00 } },
+ .{ .len = 1, .bytes = .{ 0xC0, 0x00, 0x00, 0x00 } },
+ .{ .len = 1, .bytes = .{ 0xFF, 0x00, 0x00, 0x00 } },
+ .{ .len = 2, .bytes = .{ 0xC2, 0x20, 0x00, 0x00 } },
+ .{ .len = 2, .bytes = .{ 0xC0, 0xAF, 0x00, 0x00 } },
+ .{ .len = 2, .bytes = .{ 0x80, 0x80, 0x00, 0x00 } },
+ .{ .len = 3, .bytes = .{ 0xED, 0xA0, 0x80, 0x00 } },
+ .{ .len = 3, .bytes = .{ 0xE2, 0x28, 0x7A, 0x00 } },
+ .{ .len = 4, .bytes = .{ 0xF0, 0x90, 0x28, 0x7A } },
+};
+
/// Random number generator.
rand: std.Random,
@@ -37,6 +58,13 @@ max_len: usize = std.math.maxInt(usize),
/// skew the distribution of lengths.
p_length: std.enums.EnumArray(Utf8Len, f64) = .initFill(1.0),
+/// If true, ASCII codepoints are limited to printable ASCII.
+ascii_printable_only: bool = false,
+
+/// Probability that the next generated sequence is malformed UTF-8.
+/// This is checked for each emitted sequence while filling a buffer.
+invalid_rate: f64 = 0,
+
pub fn generator(self: *Utf8) Generator {
return .init(self, next);
}
@@ -49,23 +77,44 @@ pub fn next(self: *Utf8, writer: *std.Io.Writer, max_len: usize) Generator.Error
var rem: usize = len;
while (rem > 0) {
- // Pick a utf8 byte count to generate.
- const utf8_len: Utf8Len = len: {
- const Indexer = @TypeOf(self.p_length).Indexer;
- const idx = self.rand.weightedIndex(f64, &self.p_length.values);
- var utf8_len = Indexer.keyForIndex(idx);
- assert(rem > 0);
- while (@intFromEnum(utf8_len) > rem) {
- // If the chosen length can't fit into the remaining buffer,
- // choose a smaller length.
- utf8_len = @enumFromInt(@intFromEnum(utf8_len) - 1);
- }
- break :len utf8_len;
- };
+ if (try self.writeInvalid(writer, rem)) |written| {
+ rem -= written;
+ continue;
+ }
+
+ const written = try self.writeValid(writer, rem);
+ rem -= written;
+ }
+}
+
+fn writeInvalid(
+ self: *Utf8,
+ writer: *std.Io.Writer,
+ rem: usize,
+) Generator.Error!?usize {
+ if (self.invalid_rate <= 0 or self.rand.float(f64) >= self.invalid_rate) {
+ return null;
+ }
+
+ const seq = self.invalidSequence(rem) orelse return null;
+ try writer.writeAll(seq.slice());
+ return seq.len;
+}
+
+fn writeValid(
+ self: *Utf8,
+ writer: *std.Io.Writer,
+ rem: usize,
+) Generator.Error!usize {
+ while (true) {
+ const utf8_len = self.utf8Len(rem);
// Generate a UTF-8 sequence that encodes to this length.
const cp: u21 = switch (utf8_len) {
- .one => self.rand.intRangeAtMostBiased(u21, 0x00, 0x7F),
+ .one => if (self.ascii_printable_only)
+ self.rand.intRangeAtMostBiased(u21, 0x20, 0x7E)
+ else
+ self.rand.intRangeAtMostBiased(u21, 0x00, 0x7F),
.two => self.rand.intRangeAtMostBiased(u21, 0x80, 0x7FF),
.three => self.rand.intRangeAtMostBiased(u21, 0x800, 0xFFFF),
.four => self.rand.intRangeAtMostBiased(u21, 0x10000, 0x10FFFF),
@@ -87,9 +136,38 @@ pub fn next(self: *Utf8, writer: *std.Io.Writer, max_len: usize) Generator.Error
// Possible, in which case we redo the loop and encode nothing.
error.Utf8CannotEncodeSurrogateHalf => continue,
};
+
try writer.writeAll(buf[0..l]);
- rem -= l;
+ return l;
+ }
+}
+
+fn utf8Len(self: *Utf8, rem: usize) Utf8Len {
+ const Indexer = @TypeOf(self.p_length).Indexer;
+ const idx = self.rand.weightedIndex(f64, &self.p_length.values);
+ var utf8_len = Indexer.keyForIndex(idx);
+ assert(rem > 0);
+ while (@intFromEnum(utf8_len) > rem) {
+ // If the chosen length can't fit into the remaining buffer,
+ // choose a smaller length.
+ utf8_len = @enumFromInt(@intFromEnum(utf8_len) - 1);
}
+ return utf8_len;
+}
+
+fn invalidSequence(self: *Utf8, rem: usize) ?InvalidSequence {
+ const candidates = &invalid_sequences;
+
+ var valid_idx: [candidates.len]usize = undefined;
+ var valid_len: usize = 0;
+ for (candidates, 0..) |candidate, i| {
+ if (candidate.len > rem) continue;
+ valid_idx[valid_len] = i;
+ valid_len += 1;
+ }
+
+ if (valid_len == 0) return null;
+ return candidates[valid_idx[self.rand.uintLessThan(usize, valid_len)]];
}
test "utf8" {
@@ -106,3 +184,46 @@ test "utf8" {
try testing.expectEqual(256, result.len);
try testing.expect(std.unicode.utf8ValidateSlice(result));
}
+
+test "utf8 printable ascii only" {
+ const testing = std.testing;
+ var prng = std.Random.DefaultPrng.init(0);
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ var v: Utf8 = .{
+ .rand = prng.random(),
+ .ascii_printable_only = true,
+ };
+ v.min_len = buf.len;
+ v.max_len = buf.len;
+ v.p_length.set(.one, 1.0);
+ v.p_length.set(.two, 0.0);
+ v.p_length.set(.three, 0.0);
+ v.p_length.set(.four, 0.0);
+
+ const gen = v.generator();
+ try gen.next(&writer, buf.len);
+ const result = writer.buffered();
+ try testing.expectEqual(256, result.len);
+ try testing.expect(std.unicode.utf8ValidateSlice(result));
+ for (result) |c| try testing.expect(std.ascii.isPrint(c));
+}
+
+test "utf8 malformed output" {
+ const testing = std.testing;
+ var prng = std.Random.DefaultPrng.init(0);
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ var v: Utf8 = .{
+ .rand = prng.random(),
+ .invalid_rate = 1.0,
+ };
+ v.min_len = buf.len;
+ v.max_len = buf.len;
+
+ const gen = v.generator();
+ try gen.next(&writer, buf.len);
+ const result = writer.buffered();
+ try testing.expectEqual(256, result.len);
+ try testing.expect(!std.unicode.utf8ValidateSlice(result));
+}
diff --git a/vendor/libghostty-vt/src/synthetic/cli/Utf8.zig b/vendor/libghostty-vt/src/synthetic/cli/Utf8.zig
index 63570475..021dbe51 100644
--- a/vendor/libghostty-vt/src/synthetic/cli/Utf8.zig
+++ b/vendor/libghostty-vt/src/synthetic/cli/Utf8.zig
@@ -1,21 +1,60 @@
const Utf8 = @This();
const std = @import("std");
-const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const synthetic = @import("../main.zig");
-const log = std.log.scoped(.@"terminal-stream-bench");
+pub const Options = struct {
+ /// Seed to use for deterministic generation. If unset, a time-based
+ /// seed is used by the generic synthetic CLI.
+ seed: ?u64 = null,
-pub const Options = struct {};
+ /// Relative weight for choosing 1-byte UTF-8 sequences.
+ @"weight-one": f64 = 1.0,
+
+ /// Relative weight for choosing 2-byte UTF-8 sequences.
+ @"weight-two": f64 = 1.0,
+
+ /// Relative weight for choosing 3-byte UTF-8 sequences.
+ @"weight-three": f64 = 1.0,
+
+ /// Relative weight for choosing 4-byte UTF-8 sequences.
+ @"weight-four": f64 = 1.0,
+
+ /// Restrict ASCII codepoints to printable characters.
+ @"ascii-printable-only": bool = false,
+
+ /// Probability that an emitted sequence is malformed UTF-8.
+ @"invalid-rate": f64 = 0.0,
+};
+
+opts: Options,
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
- _: Options,
+ opts: Options,
) !*Utf8 {
+ if (opts.@"invalid-rate" < 0 or opts.@"invalid-rate" > 1) {
+ return error.InvalidValue;
+ }
+
+ const weights = [_]f64{
+ opts.@"weight-one",
+ opts.@"weight-two",
+ opts.@"weight-three",
+ opts.@"weight-four",
+ };
+ var weight_sum: f64 = 0;
+ for (weights) |weight| {
+ if (weight < 0) return error.InvalidValue;
+ weight_sum += weight;
+ }
+ if (weight_sum <= 0) return error.InvalidValue;
+
const ptr = try alloc.create(Utf8);
errdefer alloc.destroy(ptr);
+ ptr.* = .{ .opts = opts };
return ptr;
}
@@ -24,11 +63,22 @@ pub fn destroy(self: *Utf8, alloc: Allocator) void {
}
pub fn run(self: *Utf8, writer: *std.Io.Writer, rand: std.Random) !void {
- _ = self;
+ var prng: ?std.Random.DefaultPrng = null;
+ var gen_rand = rand;
+ if (self.opts.seed) |seed| {
+ prng = std.Random.DefaultPrng.init(seed);
+ gen_rand = prng.?.random();
+ }
var gen: synthetic.Utf8 = .{
- .rand = rand,
+ .rand = gen_rand,
+ .ascii_printable_only = self.opts.@"ascii-printable-only",
+ .invalid_rate = self.opts.@"invalid-rate",
};
+ gen.p_length.set(.one, self.opts.@"weight-one");
+ gen.p_length.set(.two, self.opts.@"weight-two");
+ gen.p_length.set(.three, self.opts.@"weight-three");
+ gen.p_length.set(.four, self.opts.@"weight-four");
while (true) {
gen.next(writer, 1024) catch |err| {
@@ -46,7 +96,9 @@ test Utf8 {
const testing = std.testing;
const alloc = testing.allocator;
- const impl: *Utf8 = try .create(alloc, .{});
+ const impl: *Utf8 = try .create(alloc, .{
+ .seed = 1,
+ });
defer impl.destroy(alloc);
var prng = std.Random.DefaultPrng.init(1);
@@ -55,4 +107,5 @@ test Utf8 {
var buf: [1024]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try impl.run(&writer, rand);
+ try testing.expectEqual(@as(usize, 1024), writer.buffered().len);
}
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig
index 39873374..17eea73d 100644
--- a/vendor/libghostty-vt/src/terminal/PageList.zig
+++ b/vendor/libghostty-vt/src/terminal/PageList.zig
@@ -1137,6 +1137,17 @@ fn resizeCols(
for (total..self.rows) |_| _ = try self.grow();
}
+ // Reflow can unwrap enough rows that a history viewport pin lands in the
+ // active area before we do any preserved-cursor growth below. Switch back
+ // to the active viewport now so intermediate grow() integrity checks stay
+ // valid.
+ switch (self.viewport) {
+ .active, .top => {},
+ .pin => if (self.pinIsActive(self.viewport_pin.*)) {
+ self.viewport = .active;
+ },
+ }
+
// See preserved_cursor setup for why.
if (preserved_cursor) |c| cursor: {
const active_pt = self.pointFromPin(
diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig
index 77e05b09..39fdd610 100644
--- a/vendor/libghostty-vt/src/terminal/Screen.zig
+++ b/vendor/libghostty-vt/src/terminal/Screen.zig
@@ -255,7 +255,16 @@ pub const Options = struct {
/// The total storage limit for Kitty images in bytes for this
/// screen. Kitty image storage is per-screen.
- kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB
+ kitty_image_storage_limit: usize = switch (build_options.artifact) {
+ .ghostty => 320 * 1000 * 1000, // 320MB
+ .lib => 10 * 1000 * 1000, // 10MB
+ },
+
+ /// The limits for what medium types are allowed for Kitty image loading.
+ kitty_image_loading_limits: if (build_options.kitty_graphics)
+ kitty.graphics.LoadingImage.Limits
+ else
+ void = if (build_options.kitty_graphics) .direct else {},
/// A simple, default terminal. If you rely on specific dimensions or
/// scrollback (or lack of) then do not use this directly. This is just
@@ -313,6 +322,7 @@ pub fn init(
&result,
opts.kitty_image_storage_limit,
) catch unreachable;
+ result.kitty_images.image_limits = opts.kitty_image_loading_limits;
}
return result;
@@ -6487,6 +6497,86 @@ test "Screen: resize more cols with populated scrollback" {
}
}
+test "Screen: resize more cols bounded scrollback keeps viewport valid" {
+ // Regression test for issue #12298.
+ //
+ // This needs to live at the Screen layer rather than PageList because the
+ // bad state only appears once Screen forwards the active cursor into the
+ // resize path. A direct PageList resize repro does not hit the same bug.
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{
+ .cols = 2,
+ .rows = 10,
+ .max_scrollback = 10_000,
+ });
+ defer s.deinit();
+
+ // Build 30 rows of scrollback on top of our 10-row viewport so we have a
+ // 40-row screen with history above the active area.
+ for (0..30) |_| _ = try s.pages.grow();
+ s.cursorReload();
+ try testing.expectEqual(@as(usize, 40), s.pages.scrollbar().total);
+
+ // Fill the entire screen with two-row wrapped runs:
+ // - even rows mark the end of a wrapped line
+ // - odd rows mark the continuation
+ //
+ // With 2 columns, each logical line occupies two rows. When we grow to 4
+ // columns with reflow enabled, those pairs unwrap back into single rows.
+ // That cuts the total row count down and is what stresses the viewport pin.
+ var it = s.pages.pageIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |chunk| {
+ const page = &chunk.node.data;
+ for (chunk.start..chunk.end) |y| {
+ const rac = page.getRowAndCell(0, y);
+ if (y % 2 == 0) {
+ rac.row.wrap = true;
+ } else {
+ rac.row.wrap_continuation = true;
+ }
+
+ for (0..s.pages.cols) |x| {
+ page.getRowAndCell(x, y).cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ }
+ }
+
+ // Pin the viewport to a history row just above the active area.
+ //
+ // Before resize:
+ // - total rows = 40
+ // - active area starts at row 30
+ // - viewport is pinned at row 28
+ //
+ // After unwrap during resize:
+ // - total rows shrinks to 20
+ // - the old row 28 remaps into what is now the active area
+ //
+ // The bug was that resize/grow would temporarily keep the viewport as a
+ // history pin even after reflow had moved it into the active area, leaving
+ // fewer than `rows` visible rows beneath the pin and tripping integrity
+ // checks.
+ s.pages.scroll(.{ .pin = s.pages.pin(.{ .screen = .{ .y = 28 } }).? });
+ try testing.expect(s.pages.viewport == .pin);
+ try testing.expect(s.pages.getBottomRight(.viewport) != null);
+
+ // Growing columns triggers reflow, which unwraps the synthetic wrapped
+ // rows above. This used to panic during the resize path.
+ try s.resize(.{ .cols = 4, .rows = s.pages.rows, .reflow = true });
+
+ // After the fix, the viewport is normalized back to the active area as
+ // soon as the pinned row lands there, so viewport queries remain valid.
+ try testing.expectEqual(@as(usize, 4), s.pages.cols);
+ try testing.expect(s.pages.scrollbar().total < 40);
+ try testing.expect(s.pages.viewport == .active);
+ try testing.expect(s.pages.getBottomRight(.viewport) != null);
+}
+
test "Screen: resize more cols with reflow" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig
index 99536e7a..6740a93e 100644
--- a/vendor/libghostty-vt/src/terminal/Terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/Terminal.zig
@@ -191,6 +191,26 @@ pub const Options = struct {
/// The default mode state. When the terminal gets a reset, it
/// will revert back to this state.
default_modes: modespkg.ModePacked = .{},
+
+ /// The total storage limit for Kitty images in bytes. Has no effect
+ /// if kitty images are disabled at build-time.
+ kitty_image_storage_limit: usize = switch (build_options.artifact) {
+ .ghostty => 320 * 1000 * 1000, // 320MB
+
+ // libghostty we start with a much lower limit since this is an
+ // embedded library and we want to be more conservative with memory
+ // usage by default.
+ .lib => 10 * 1000 * 1000, // 10MB
+ },
+
+ /// The limits for what medium types are allowed for Kitty image loading.
+ /// Has no effect if kitty images are disabled otherwise. For example,
+ // if no `sys.decode_png` hook is specified, png formats are disabled
+ // no matter what.
+ kitty_image_loading_limits: if (build_options.kitty_graphics)
+ kitty.graphics.LoadingImage.Limits
+ else
+ void = if (build_options.kitty_graphics) .direct else {},
};
/// Initialize a new terminal.
@@ -205,6 +225,8 @@ pub fn init(
.cols = cols,
.rows = rows,
.max_scrollback = opts.max_scrollback,
+ .kitty_image_storage_limit = opts.kitty_image_storage_limit,
+ .kitty_image_loading_limits = opts.kitty_image_loading_limits,
});
errdefer screen_set.deinit(alloc);
@@ -551,20 +573,25 @@ pub fn print(self: *Terminal, c: u21) !void {
// it.
if (self.modes.get(.grapheme_cluster)) return;
- // If we're at cell zero, then this is malformed data and we don't
- // print anything or even store this. Zero-width characters are ALWAYS
- // attached to some other non-zero-width character at the time of
- // writing.
- if (self.screens.active.cursor.x == 0) {
+ // If we have wraparound enabled and a pending wrap, the character
+ // we're attaching to is still under the cursor. Otherwise, it's the
+ // cell to the left.
+ const left: size.CellCountInt = if (self.modes.get(.wraparound) and self.screens.active.cursor.pending_wrap) 0 else 1;
+
+ // If we're at cell zero and not pending a wrap, then this is malformed
+ // data and we don't print anything or even store this. Zero-width
+ // characters are ALWAYS attached to some other non-zero-width
+ // character at the time of writing.
+ if (self.screens.active.cursor.x == 0 and left == 1) {
log.warn("zero-width character with no prior character, ignoring", .{});
return;
}
// Find our previous cell
const prev = prev: {
- const immediate = self.screens.active.cursorCellLeft(1);
+ const immediate = self.screens.active.cursorCellLeft(left);
if (immediate.wide != .spacer_tail) break :prev immediate;
- break :prev self.screens.active.cursorCellLeft(2);
+ break :prev self.screens.active.cursorCellLeft(left + 1);
};
// If our previous cell has no text, just ignore the zero-width character
@@ -2693,6 +2720,34 @@ pub fn kittyGraphics(
return kitty.graphics.execute(alloc, self, cmd);
}
+/// Set the storage size limit for Kitty graphics across all screens.
+pub fn setKittyGraphicsSizeLimit(
+ self: *Terminal,
+ alloc: Allocator,
+ limit: usize,
+) !void {
+ if (comptime !build_options.kitty_graphics) return;
+ var it = self.screens.all.iterator();
+ while (it.next()) |entry| {
+ const screen: *Screen = entry.value.*;
+ try screen.kitty_images.setLimit(alloc, screen, limit);
+ }
+}
+
+/// Set the allowed medium types for Kitty graphics image loading
+/// across all screens.
+pub fn setKittyGraphicsLoadingLimits(
+ self: *Terminal,
+ limits: kitty.graphics.LoadingImage.Limits,
+) void {
+ if (comptime !build_options.kitty_graphics) return;
+ var it = self.screens.all.iterator();
+ while (it.next()) |entry| {
+ const screen: *Screen = entry.value.*;
+ screen.kitty_images.image_limits = limits;
+ }
+}
+
/// Set a style attribute.
pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void {
try self.screens.active.setAttribute(attr);
@@ -2941,12 +2996,15 @@ pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen {
.alternate => 0,
},
- // Inherit our Kitty image storage limit from the primary
+ // Inherit our Kitty image settings from the primary
// screen if we have to initialize.
.kitty_image_storage_limit = if (comptime build_options.kitty_graphics)
primary.kitty_images.total_limit
else
0,
+ .kitty_image_loading_limits = if (comptime build_options.kitty_graphics)
+ primary.kitty_images.image_limits
+ else {},
},
);
};
@@ -3260,6 +3318,23 @@ test "Terminal: zero-width character at start" {
try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
}
+// https://github.com/ghostty-org/ghostty/issues/12581
+test "Terminal: zero-width character attaches to pending wrap cell" {
+ var t = try init(testing.allocator, .{ .cols = 2, .rows = 2 });
+ defer t.deinit(testing.allocator);
+
+ // Disable grapheme clustering to exercise the fallback path.
+ t.modes.set(.grapheme_cluster, false);
+
+ try t.print('x');
+ try t.print('å');
+ try t.print(0x0332); // Combining low line.
+
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("xå̲", str);
+}
+
// https://github.com/mitchellh/ghostty/issues/1400
test "Terminal: print single very long line" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 });
diff --git a/vendor/libghostty-vt/src/terminal/apc.zig b/vendor/libghostty-vt/src/terminal/apc.zig
index 3ebacbbf..7e6f08a7 100644
--- a/vendor/libghostty-vt/src/terminal/apc.zig
+++ b/vendor/libghostty-vt/src/terminal/apc.zig
@@ -10,7 +10,15 @@ const log = std.log.scoped(.terminal_apc);
/// The start/feed/end functions are meant to be called from the terminal.Stream
/// apcStart, apcPut, and apcEnd functions, respectively.
pub const Handler = struct {
- state: State = .{ .inactive = {} },
+ state: State = .inactive,
+
+ /// Maximum bytes each APC protocol can buffer. This is to prevent
+ /// malicious input from causing us to allocate too much memory.
+ /// If you want to be lazy and set a single value for all protocols,
+ /// use `.initFull`.
+ max_bytes: std.EnumMap(Protocol, usize) = .initFullWith(.{
+ .kitty = Protocol.defaultMaxBytes(.kitty),
+ }),
pub fn deinit(self: *Handler) void {
self.state.deinit();
@@ -34,19 +42,24 @@ pub const Handler = struct {
switch (byte) {
// Kitty graphics protocol
'G' => self.state = if (comptime build_options.kitty_graphics)
- .{ .kitty = kitty_gfx.CommandParser.init(alloc) }
+ .{ .kitty = .init(
+ alloc,
+ self.max_bytes.get(.kitty) orelse
+ Protocol.defaultMaxBytes(.kitty),
+ ) }
else
- .{ .ignore = {} },
+ .ignore,
// Unknown
- else => self.state = .{ .ignore = {} },
+ else => self.state = .ignore,
}
},
.kitty => |*p| if (comptime build_options.kitty_graphics) {
p.feed(byte) catch |err| {
log.warn("kitty graphics protocol error: {}", .{err});
- self.state = .{ .ignore = {} };
+ p.deinit();
+ self.state = .ignore;
};
} else unreachable,
}
@@ -55,7 +68,7 @@ pub const Handler = struct {
pub fn end(self: *Handler) ?Command {
defer {
self.state.deinit();
- self.state = .{ .inactive = {} };
+ self.state = .inactive;
}
return switch (self.state) {
@@ -106,8 +119,22 @@ pub const State = union(enum) {
}
};
+/// Possible APC command types.
+pub const Protocol = enum {
+ kitty,
+
+ /// Returns the default maximum bytes for the given protocol.
+ pub fn defaultMaxBytes(self: Protocol) usize {
+ return switch (self) {
+ // Kitty graphics payloads can be very large (e.g. full images
+ // encoded as base64), so the default is set to 65 MiB.
+ .kitty => 65 * 1024 * 1024,
+ };
+ }
+};
+
/// Possible APC commands.
-pub const Command = union(enum) {
+pub const Command = union(Protocol) {
kitty: if (build_options.kitty_graphics)
kitty_gfx.Command
else
@@ -169,6 +196,41 @@ test "Kitty command with overflow i32" {
try testing.expect(h.end() == null);
}
+test "kitty feed error deinits parser" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Feed a valid kitty command start to allocate parser state, then
+ // trigger an error during feed via an integer overflow. The testing
+ // allocator will detect leaks if deinit is not called.
+ var h: Handler = .{};
+ defer h.deinit();
+ h.start();
+ for ("Ga=p,i=10000000000;") |c| h.feed(alloc, c);
+ try testing.expect(h.state == .ignore);
+}
+
+test "kitty max bytes exceeded" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{ .max_bytes = .init(.{ .kitty = 4 }) };
+ defer h.deinit();
+ h.start();
+ // 'G' identifies kitty, 'a=t;' moves to data state, then feed exceeds max_bytes.
+ for ("Ga=t;") |c| h.feed(alloc, c);
+ try testing.expect(h.state != .ignore);
+ for ("abcd") |c| h.feed(alloc, c);
+ try testing.expect(h.state != .ignore);
+ // The 5th data byte exceeds the 4-byte limit.
+ h.feed(alloc, 'e');
+ try testing.expect(h.state == .ignore);
+}
+
test "valid Kitty command" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
diff --git a/vendor/libghostty-vt/src/terminal/build_options.zig b/vendor/libghostty-vt/src/terminal/build_options.zig
index 6c0a4df6..136e0f10 100644
--- a/vendor/libghostty-vt/src/terminal/build_options.zig
+++ b/vendor/libghostty-vt/src/terminal/build_options.zig
@@ -2,6 +2,14 @@ const std = @import("std");
/// Options set by Zig build.zig and exposed via `terminal_options`.
pub const Options = struct {
+ pub const Artifact = enum {
+ /// Ghostty application
+ ghostty,
+
+ /// libghostty-vt, Zig module
+ lib,
+ };
+
/// The target artifact to build. This will gate some functionality.
artifact: Artifact,
@@ -47,34 +55,40 @@ pub const Options = struct {
opts.addOption(bool, "simd", self.simd);
opts.addOption(bool, "slow_runtime_safety", self.slow_runtime_safety);
+ // Kitty graphics is almost always true. This used to be conditional on
+ // some other factors but we've since generalized the implementation
+ // to support optional PNG decoding, OS capabilities like filesystems,
+ // etc. So its safe to always enable it and just have the
+ // implementation deal with unsupported features as needed.
+ //
+ // We disable it on wasm32-freestanding because we at the least
+ // require the ability to get timestamps and there is no way to
+ // do that with freestanding targets.
+ const target = m.resolved_target.?.result;
+ opts.addOption(
+ bool,
+ "kitty_graphics",
+ !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding),
+ );
+
// These are synthesized based on other options.
- opts.addOption(bool, "kitty_graphics", self.oniguruma);
opts.addOption(bool, "tmux_control_mode", self.oniguruma);
// Version information.
- var buf: [1024]u8 = undefined;
opts.addOption(
[]const u8,
"version_string",
- std.fmt.bufPrint(
- &buf,
+ b.fmt(
"{f}",
.{self.version},
- ) catch @panic("version string too long"),
+ ),
);
opts.addOption(usize, "version_major", self.version.major);
opts.addOption(usize, "version_minor", self.version.minor);
opts.addOption(usize, "version_patch", self.version.patch);
+ opts.addOption(?[]const u8, "version_pre", self.version.pre);
opts.addOption(?[]const u8, "version_build", self.version.build);
m.addOptions("terminal_options", opts);
}
};
-
-pub const Artifact = enum {
- /// Ghostty application
- ghostty,
-
- /// libghostty-vt, Zig module
- lib,
-};
diff --git a/vendor/libghostty-vt/src/terminal/c/AGENTS.md b/vendor/libghostty-vt/src/terminal/c/AGENTS.md
index 63f7fc6c..c7e9068a 100644
--- a/vendor/libghostty-vt/src/terminal/c/AGENTS.md
+++ b/vendor/libghostty-vt/src/terminal/c/AGENTS.md
@@ -5,7 +5,12 @@
via `lib.TaggedUnion`.
- Any functions must be updated all the way through from here to
`src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers
- in `include/ghostty/vt.h`.
+ in `include/ghostty/vt.h`. Specifically:
+ 1. Define the function in `src/terminal/c/<module>.zig`.
+ 2. Re-export it via a `pub const` in `src/terminal/c/main.zig`.
+ 3. Add an `@export` call in `src/lib_vt.zig` with the
+ `ghostty_` prefixed symbol name.
+ 4. Declare it in the corresponding header under `include/ghostty/vt/`.
- In `include/ghostty/vt.h`, always sort the header contents by:
(1) macros, (2) forward declarations, (3) types, (4) functions
diff --git a/vendor/libghostty-vt/src/terminal/c/build_info.zig b/vendor/libghostty-vt/src/terminal/c/build_info.zig
index bef6760f..8e0cd4d6 100644
--- a/vendor/libghostty-vt/src/terminal/c/build_info.zig
+++ b/vendor/libghostty-vt/src/terminal/c/build_info.zig
@@ -25,7 +25,8 @@ pub const BuildInfo = enum(c_int) {
version_major = 6,
version_minor = 7,
version_patch = 8,
- version_build = 9,
+ version_pre = 9,
+ version_build = 10,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: BuildInfo) type {
@@ -33,7 +34,7 @@ pub const BuildInfo = enum(c_int) {
.invalid => void,
.simd, .kitty_graphics, .tmux_control_mode => bool,
.optimize => OptimizeMode,
- .version_string, .version_build => lib.String,
+ .version_string, .version_pre, .version_build => lib.String,
.version_major, .version_minor, .version_patch => usize,
};
}
@@ -78,6 +79,13 @@ fn getTyped(
.version_major => out.* = build_options.version_major,
.version_minor => out.* = build_options.version_minor,
.version_patch => out.* = build_options.version_patch,
+ .version_pre => {
+ if (build_options.version_pre) |b| {
+ out.* = .{ .ptr = b.ptr, .len = b.len };
+ } else {
+ out.* = .{ .ptr = "", .len = 0 };
+ }
+ },
.version_build => {
if (build_options.version_build) |b| {
out.* = .{ .ptr = b.ptr, .len = b.len };
@@ -151,6 +159,12 @@ test "get version_patch" {
try testing.expectEqual(build_options.version_patch, value);
}
+test "get version_pre" {
+ const testing = std.testing;
+ var value: lib.String = undefined;
+ try testing.expectEqual(Result.success, get(.version_pre, @ptrCast(&value)));
+}
+
test "get version_build" {
const testing = std.testing;
var value: lib.String = undefined;
diff --git a/vendor/libghostty-vt/src/terminal/c/cell.zig b/vendor/libghostty-vt/src/terminal/c/cell.zig
index c9746da7..b59567df 100644
--- a/vendor/libghostty-vt/src/terminal/c/cell.zig
+++ b/vendor/libghostty-vt/src/terminal/c/cell.zig
@@ -120,6 +120,27 @@ pub fn get(
};
}
+pub fn get_multi(
+ cell_: CCell,
+ count: usize,
+ keys: ?[*]const CellData,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = get(cell_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
fn getTyped(
cell_: CCell,
comptime data: CellData,
@@ -176,3 +197,42 @@ test "get wide" {
try testing.expectEqual(Result.success, get(cell, .wide, @ptrCast(&w)));
try testing.expectEqual(Wide.wide, w);
}
+
+test "get_multi success" {
+ const cell: CCell = @bitCast(Cell.init('B'));
+ var cp: u32 = 0;
+ var has_text: bool = false;
+ var written: usize = 0;
+
+ const keys = [_]CellData{ .codepoint, .has_text };
+ var values = [_]?*anyopaque{ @ptrCast(&cp), @ptrCast(&has_text) };
+ try testing.expectEqual(Result.success, get_multi(cell, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(@as(u32, 'B'), cp);
+ try testing.expect(has_text);
+}
+
+test "get_multi error sets out_written" {
+ const cell: CCell = @bitCast(Cell.init('C'));
+ var cp: u32 = 0;
+ var written: usize = 99;
+
+ const keys = [_]CellData{ .codepoint, .invalid };
+ var values = [_]?*anyopaque{ @ptrCast(&cp), @ptrCast(&cp) };
+ try testing.expectEqual(Result.invalid_value, get_multi(cell, keys.len, &keys, &values, &written));
+ try testing.expectEqual(1, written);
+ try testing.expectEqual(@as(u32, 'C'), cp);
+}
+
+test "get_multi null keys returns invalid_value" {
+ const cell: CCell = @bitCast(Cell.init('A'));
+ var cp: u32 = 0;
+ var values = [_]?*anyopaque{@ptrCast(&cp)};
+ try testing.expectEqual(Result.invalid_value, get_multi(cell, 1, null, &values, null));
+}
+
+test "get_multi null values returns invalid_value" {
+ const cell: CCell = @bitCast(Cell.init('A'));
+ const keys = [_]CellData{.codepoint};
+ try testing.expectEqual(Result.invalid_value, get_multi(cell, 1, &keys, null, null));
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/formatter.zig b/vendor/libghostty-vt/src/terminal/c/formatter.zig
index 11717bc2..5b4504c8 100644
--- a/vendor/libghostty-vt/src/terminal/c/formatter.zig
+++ b/vendor/libghostty-vt/src/terminal/c/formatter.zig
@@ -3,6 +3,8 @@ const testing = std.testing;
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const terminal_c = @import("terminal.zig");
+const grid_ref = @import("grid_ref.zig");
+const selection_c = @import("selection.zig");
const ZigTerminal = @import("../Terminal.zig");
const formatterpkg = @import("../formatter.zig");
const Result = @import("result.zig").Result;
@@ -23,6 +25,8 @@ pub const Formatter = ?*FormatterWrapper;
/// C: GhosttyFormatterFormat
pub const Format = formatterpkg.Format;
+const CSelection = selection_c.CSelection;
+
/// C: GhosttyFormatterScreenOptions
pub const ScreenOptions = extern struct {
/// C: GhosttyFormatterScreenExtra
@@ -63,6 +67,10 @@ pub const TerminalOptions = extern struct {
trim: bool,
extra: Extra,
+ /// Optional selection to restrict output to a range.
+ /// If null, the entire screen is formatted.
+ selection: ?*const CSelection = null,
+
/// C: GhosttyFormatterTerminalExtra
pub const Extra = extern struct {
size: usize = @sizeOf(Extra),
@@ -138,6 +146,12 @@ fn terminal_new_(
});
formatter.extra = opts.extra.toZig();
+ // Setup the content that we're formatting
+ if (opts.selection) |sel| formatter.content = .{
+ .selection = sel.toZig() orelse
+ return error.InvalidValue,
+ };
+
ptr.* = .{
.kind = .{ .terminal = formatter },
.alloc = alloc,
@@ -389,6 +403,50 @@ test "format vt" {
try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null);
}
+test "format plain with selection" {
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(t);
+
+ terminal_c.vt_write(t, "Hello World", 11);
+
+ // Get grid refs for "World" (columns 6..10 on row 0)
+ var start_ref: grid_ref.CGridRef = .{};
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 6, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref.CGridRef = .{};
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 10, .y = 0 } },
+ }, &end_ref));
+
+ const sel: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ };
+
+ var f: Formatter = null;
+ try testing.expectEqual(Result.success, terminal_new(
+ &lib.alloc.test_allocator,
+ &f,
+ t,
+ .{ .emit = .plain, .unwrap = false, .trim = true, .selection = &sel, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } },
+ ));
+ defer free(f);
+
+ var buf: [1024]u8 = undefined;
+ var written: usize = 0;
+ try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written));
+ try testing.expectEqualStrings("World", buf[0..written]);
+}
+
test "format html" {
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
index d029c595..db7ff2e8 100644
--- a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
+++ b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
@@ -3,11 +3,13 @@ const testing = std.testing;
const lib = @import("../lib.zig");
const page = @import("../page.zig");
const PageList = @import("../PageList.zig");
+const point = @import("../point.zig");
const size = @import("../size.zig");
const stylepkg = @import("../style.zig");
const cell_c = @import("cell.zig");
const row_c = @import("row.zig");
const style_c = @import("style.zig");
+const terminal_c = @import("terminal.zig");
const Result = @import("result.zig").Result;
/// C: GhosttyGridRef
@@ -29,7 +31,7 @@ pub const CGridRef = extern struct {
};
}
- fn toPin(self: CGridRef) ?PageList.Pin {
+ pub fn toPin(self: CGridRef) ?PageList.Pin {
return .{
.node = self.node orelse return null,
.x = self.x,
@@ -89,6 +91,38 @@ pub fn grid_ref_graphemes(
return .success;
}
+pub fn grid_ref_hyperlink_uri(
+ ref: *const CGridRef,
+ out_buf: ?[*]u8,
+ buf_len: usize,
+ out_len: *usize,
+) callconv(lib.calling_conv) Result {
+ const p = ref.toPin() orelse return .invalid_value;
+ const rac = p.node.data.getRowAndCell(p.x, p.y);
+ const cell = rac.cell;
+
+ if (!cell.hyperlink) {
+ out_len.* = 0;
+ return .success;
+ }
+
+ const link_id = p.node.data.lookupHyperlink(cell) orelse {
+ out_len.* = 0;
+ return .success;
+ };
+ const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id);
+ const uri = entry.uri.slice(p.node.data.memory);
+
+ if (out_buf == null or buf_len < uri.len) {
+ out_len.* = uri.len;
+ return .out_of_space;
+ }
+
+ @memcpy(out_buf.?[0..uri.len], uri);
+ out_len.* = uri.len;
+ return .success;
+}
+
pub fn grid_ref_style(
ref: *const CGridRef,
out: ?*style_c.Style,
@@ -154,3 +188,63 @@ test "grid_ref_style null out" {
const ref = CGridRef{};
try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, null));
}
+
+test "grid_ref_hyperlink_uri null node" {
+ const ref = CGridRef{};
+ var len: usize = undefined;
+ try testing.expectEqual(Result.invalid_value, grid_ref_hyperlink_uri(&ref, null, 0, &len));
+}
+
+test "grid_ref_hyperlink_uri no hyperlink" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ terminal_c.vt_write(terminal, "hello", 5);
+
+ var ref: CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(
+ terminal,
+ point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }),
+ &ref,
+ ));
+
+ var len: usize = undefined;
+ try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, null, 0, &len));
+ try testing.expectEqual(@as(usize, 0), len);
+}
+
+test "grid_ref_hyperlink_uri with hyperlink" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ // Write OSC 8 hyperlink: \e]8;;uri\e\\text\e]8;;\e\\
+ const seq = "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\";
+ terminal_c.vt_write(terminal, seq, seq.len);
+
+ var ref: CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(
+ terminal,
+ point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }),
+ &ref,
+ ));
+
+ // First query length with null buf
+ var len: usize = undefined;
+ try testing.expectEqual(Result.out_of_space, grid_ref_hyperlink_uri(&ref, null, 0, &len));
+ try testing.expectEqual(@as(usize, 19), len); // "https://example.com"
+
+ // Now read with a properly sized buffer
+ var buf: [256]u8 = undefined;
+ try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, &buf, buf.len, &len));
+ try testing.expectEqualStrings("https://example.com", buf[0..len]);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/key_encode.zig b/vendor/libghostty-vt/src/terminal/c/key_encode.zig
index 15fa74dd..f5d459f0 100644
--- a/vendor/libghostty-vt/src/terminal/c/key_encode.zig
+++ b/vendor/libghostty-vt/src/terminal/c/key_encode.zig
@@ -52,6 +52,11 @@ pub const Option = enum(c_int) {
modify_other_keys_state_2 = 4,
kitty_flags = 5,
macos_option_as_alt = 6,
+ /// DEC Backarrow Key Mode (DECBKM)
+ /// See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170
+ /// If `false` (the default), `backspace` emits 0x7f
+ /// If `true`, `backspace` emits 0x08
+ backarrow_key_mode = 7,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -61,6 +66,7 @@ pub const Option = enum(c_int) {
.ignore_keypad_with_numlock,
.alt_esc_prefix,
.modify_other_keys_state_2,
+ .backarrow_key_mode,
=> bool,
.kitty_flags => u8,
.macos_option_as_alt => OptionAsAlt,
@@ -114,6 +120,7 @@ fn setoptTyped(
}
opts.macos_option_as_alt = value.*;
},
+ .backarrow_key_mode => opts.backarrow_key_mode = value.*,
}
}
diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
new file mode 100644
index 00000000..d50b8c4c
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
@@ -0,0 +1,1710 @@
+const std = @import("std");
+const testing = std.testing;
+const build_options = @import("terminal_options");
+const lib = @import("../lib.zig");
+const CAllocator = lib.alloc.Allocator;
+const kitty_storage = @import("../kitty/graphics_storage.zig");
+const kitty_cmd = @import("../kitty/graphics_command.zig");
+const Image = @import("../kitty/graphics_image.zig").Image;
+const grid_ref = @import("grid_ref.zig");
+const selection_c = @import("selection.zig");
+const terminal_c = @import("terminal.zig");
+const Terminal = @import("../Terminal.zig");
+const Result = @import("result.zig").Result;
+
+/// C: GhosttyKittyGraphics
+pub const KittyGraphics = if (build_options.kitty_graphics)
+ *kitty_storage.ImageStorage
+else
+ *anyopaque;
+
+/// C: GhosttyKittyGraphicsImage
+pub const ImageHandle = if (build_options.kitty_graphics)
+ ?*const Image
+else
+ ?*const anyopaque;
+
+/// C: GhosttyKittyGraphicsPlacementIterator
+pub const PlacementIterator = if (build_options.kitty_graphics)
+ ?*PlacementIteratorWrapper
+else
+ ?*anyopaque;
+
+const PlacementMap = if (build_options.kitty_graphics)
+ std.AutoHashMapUnmanaged(
+ kitty_storage.ImageStorage.PlacementKey,
+ kitty_storage.ImageStorage.Placement,
+ )
+else
+ void;
+
+const PlacementIteratorWrapper = if (build_options.kitty_graphics)
+ struct {
+ alloc: std.mem.Allocator,
+ inner: PlacementMap.Iterator = undefined,
+ entry: ?PlacementMap.Entry = null,
+ layer_filter: PlacementLayer = .all,
+ }
+else
+ void;
+
+/// C: GhosttyKittyGraphicsData
+pub const Data = enum(c_int) {
+ invalid = 0,
+ placement_iterator = 1,
+
+ pub fn OutType(comptime self: Data) type {
+ return switch (self) {
+ .invalid => void,
+ .placement_iterator => PlacementIterator,
+ };
+ }
+};
+
+/// C: GhosttyKittyGraphicsPlacementData
+pub const PlacementData = enum(c_int) {
+ invalid = 0,
+ image_id = 1,
+ placement_id = 2,
+ is_virtual = 3,
+ x_offset = 4,
+ y_offset = 5,
+ source_x = 6,
+ source_y = 7,
+ source_width = 8,
+ source_height = 9,
+ columns = 10,
+ rows = 11,
+ z = 12,
+
+ pub fn OutType(comptime self: PlacementData) type {
+ return switch (self) {
+ .invalid => void,
+ .image_id, .placement_id => u32,
+ .is_virtual => bool,
+ .x_offset,
+ .y_offset,
+ .source_x,
+ .source_y,
+ .source_width,
+ .source_height,
+ .columns,
+ .rows,
+ => u32,
+ .z => i32,
+ };
+ }
+};
+
+pub fn get(
+ graphics_: KittyGraphics,
+ data: Data,
+ out: ?*anyopaque,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ return switch (data) {
+ .invalid => .invalid_value,
+ inline else => |comptime_data| getTyped(
+ graphics_,
+ comptime_data,
+ @ptrCast(@alignCast(out)),
+ ),
+ };
+}
+
+fn getTyped(
+ graphics_: KittyGraphics,
+ comptime data: Data,
+ out: *data.OutType(),
+) Result {
+ const storage = graphics_;
+ switch (data) {
+ .invalid => return .invalid_value,
+ .placement_iterator => {
+ const it = out.* orelse return .invalid_value;
+ it.* = .{
+ .alloc = it.alloc,
+ .inner = storage.placements.iterator(),
+ .layer_filter = it.layer_filter,
+ };
+ },
+ }
+ return .success;
+}
+
+/// C: GhosttyKittyPlacementLayer
+pub const PlacementLayer = enum(c_int) {
+ all = 0,
+ below_bg = 1,
+ below_text = 2,
+ above_text = 3,
+
+ fn matches(self: PlacementLayer, z: i32) bool {
+ return switch (self) {
+ .all => true,
+ .below_bg => z < std.math.minInt(i32) / 2,
+ .below_text => z >= std.math.minInt(i32) / 2 and z < 0,
+ .above_text => z >= 0,
+ };
+ }
+};
+
+/// C: GhosttyKittyGraphicsPlacementIteratorOption
+pub const PlacementIteratorOption = enum(c_int) {
+ layer = 0,
+
+ pub fn InType(comptime self: PlacementIteratorOption) type {
+ return switch (self) {
+ .layer => PlacementLayer,
+ };
+ }
+};
+
+/// C: GhosttyKittyImageFormat
+pub const ImageFormat = kitty_cmd.Transmission.Format;
+
+/// C: GhosttyKittyImageCompression
+pub const ImageCompression = kitty_cmd.Transmission.Compression;
+
+/// C: GhosttyKittyGraphicsImageData
+pub const ImageData = enum(c_int) {
+ invalid = 0,
+ id = 1,
+ number = 2,
+ width = 3,
+ height = 4,
+ format = 5,
+ compression = 6,
+ data_ptr = 7,
+ data_len = 8,
+
+ pub fn OutType(comptime self: ImageData) type {
+ return switch (self) {
+ .invalid => void,
+ .id, .number, .width, .height => u32,
+ .format => ImageFormat,
+ .compression => ImageCompression,
+ .data_ptr => [*]const u8,
+ .data_len => usize,
+ };
+ }
+};
+
+pub fn image_get_handle(
+ graphics_: KittyGraphics,
+ image_id: u32,
+) callconv(lib.calling_conv) ImageHandle {
+ if (comptime !build_options.kitty_graphics) return null;
+
+ const storage = graphics_;
+ return storage.images.getPtr(image_id);
+}
+
+pub fn image_get(
+ image_: ImageHandle,
+ data: ImageData,
+ out: ?*anyopaque,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ return switch (data) {
+ .invalid => .invalid_value,
+ inline else => |comptime_data| imageGetTyped(
+ image_,
+ comptime_data,
+ @ptrCast(@alignCast(out)),
+ ),
+ };
+}
+
+pub fn image_get_multi(
+ image_: ImageHandle,
+ count: usize,
+ keys: ?[*]const ImageData,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = image_get(image_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
+fn imageGetTyped(
+ image_: ImageHandle,
+ comptime data: ImageData,
+ out: *data.OutType(),
+) Result {
+ const image = image_ orelse return .invalid_value;
+
+ switch (data) {
+ .invalid => return .invalid_value,
+ .id => out.* = image.id,
+ .number => out.* = image.number,
+ .width => out.* = image.width,
+ .height => out.* = image.height,
+ .format => out.* = image.format,
+ .compression => out.* = image.compression,
+ .data_ptr => out.* = image.data.ptr,
+ .data_len => out.* = image.data.len,
+ }
+
+ return .success;
+}
+
+pub fn placement_iterator_new(
+ alloc_: ?*const CAllocator,
+ out: *PlacementIterator,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) {
+ out.* = null;
+ return .no_value;
+ }
+ const alloc = lib.alloc.default(alloc_);
+ const ptr = alloc.create(PlacementIteratorWrapper) catch {
+ out.* = null;
+ return .out_of_memory;
+ };
+ ptr.* = .{ .alloc = alloc };
+ out.* = ptr;
+ return .success;
+}
+
+pub fn placement_iterator_free(iter_: PlacementIterator) callconv(lib.calling_conv) void {
+ if (comptime !build_options.kitty_graphics) return;
+ const iter = iter_ orelse return;
+ iter.alloc.destroy(iter);
+}
+
+pub fn placement_iterator_set(
+ iter_: PlacementIterator,
+ option: PlacementIteratorOption,
+ value: ?*const anyopaque,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ if (comptime std.debug.runtime_safety) {
+ _ = std.meta.intToEnum(PlacementIteratorOption, @intFromEnum(option)) catch {
+ return .invalid_value;
+ };
+ }
+
+ return switch (option) {
+ inline else => |comptime_option| placementIteratorSetTyped(
+ iter_,
+ comptime_option,
+ @ptrCast(@alignCast(value orelse return .invalid_value)),
+ ),
+ };
+}
+
+fn placementIteratorSetTyped(
+ iter_: PlacementIterator,
+ comptime option: PlacementIteratorOption,
+ value: *const option.InType(),
+) Result {
+ const iter = iter_ orelse return .invalid_value;
+ switch (option) {
+ .layer => iter.layer_filter = value.*,
+ }
+ return .success;
+}
+
+pub fn placement_iterator_next(iter_: PlacementIterator) callconv(lib.calling_conv) bool {
+ if (comptime !build_options.kitty_graphics) return false;
+
+ const iter = iter_ orelse return false;
+ while (iter.inner.next()) |entry| {
+ if (iter.layer_filter.matches(entry.value_ptr.z)) {
+ iter.entry = entry;
+ return true;
+ }
+ }
+ return false;
+}
+
+pub fn placement_get(
+ iter_: PlacementIterator,
+ data: PlacementData,
+ out: ?*anyopaque,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ return switch (data) {
+ .invalid => .invalid_value,
+ inline else => |comptime_data| placementGetTyped(
+ iter_,
+ comptime_data,
+ @ptrCast(@alignCast(out)),
+ ),
+ };
+}
+
+pub fn placement_get_multi(
+ iter_: PlacementIterator,
+ count: usize,
+ keys: ?[*]const PlacementData,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = placement_get(iter_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
+fn placementGetTyped(
+ iter_: PlacementIterator,
+ comptime data: PlacementData,
+ out: *data.OutType(),
+) Result {
+ const iter = iter_ orelse return .invalid_value;
+ const entry = iter.entry orelse return .invalid_value;
+
+ const key = entry.key_ptr;
+ const val = entry.value_ptr;
+
+ switch (data) {
+ .invalid => return .invalid_value,
+ .image_id => out.* = key.image_id,
+ .placement_id => out.* = key.placement_id.id,
+ .is_virtual => out.* = val.location == .virtual,
+ .x_offset => out.* = val.x_offset,
+ .y_offset => out.* = val.y_offset,
+ .source_x => out.* = val.source_x,
+ .source_y => out.* = val.source_y,
+ .source_width => out.* = val.source_width,
+ .source_height => out.* = val.source_height,
+ .columns => out.* = val.columns,
+ .rows => out.* = val.rows,
+ .z => out.* = val.z,
+ }
+
+ return .success;
+}
+
+pub fn placement_rect(
+ iter_: PlacementIterator,
+ image_: ImageHandle,
+ terminal_: terminal_c.Terminal,
+ out: *selection_c.CSelection,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const wrapper = terminal_ orelse return .invalid_value;
+ const image = image_ orelse return .invalid_value;
+ const iter = iter_ orelse return .invalid_value;
+ const entry = iter.entry orelse return .invalid_value;
+ const r = entry.value_ptr.rect(
+ image.*,
+ wrapper.terminal,
+ ) orelse return .no_value;
+
+ out.* = .{
+ .start = grid_ref.CGridRef.fromPin(r.top_left),
+ .end = grid_ref.CGridRef.fromPin(r.bottom_right),
+ .rectangle = true,
+ };
+
+ return .success;
+}
+
+pub fn placement_pixel_size(
+ iter_: PlacementIterator,
+ image_: ImageHandle,
+ terminal_: terminal_c.Terminal,
+ out_width: *u32,
+ out_height: *u32,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const wrapper = terminal_ orelse return .invalid_value;
+ const image = image_ orelse return .invalid_value;
+ const iter = iter_ orelse return .invalid_value;
+ const entry = iter.entry orelse return .invalid_value;
+ const s = entry.value_ptr.pixelSize(image.*, wrapper.terminal);
+
+ out_width.* = s.width;
+ out_height.* = s.height;
+
+ return .success;
+}
+
+pub fn placement_grid_size(
+ iter_: PlacementIterator,
+ image_: ImageHandle,
+ terminal_: terminal_c.Terminal,
+ out_cols: *u32,
+ out_rows: *u32,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const wrapper = terminal_ orelse return .invalid_value;
+ const image = image_ orelse return .invalid_value;
+ const iter = iter_ orelse return .invalid_value;
+ const entry = iter.entry orelse return .invalid_value;
+ const s = entry.value_ptr.gridSize(image.*, wrapper.terminal);
+
+ out_cols.* = s.cols;
+ out_rows.* = s.rows;
+
+ return .success;
+}
+
+pub fn placement_viewport_pos(
+ iter_: PlacementIterator,
+ image_: ImageHandle,
+ terminal_: terminal_c.Terminal,
+ out_col: *i32,
+ out_row: *i32,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const wrapper = terminal_ orelse return .invalid_value;
+ const image = image_ orelse return .invalid_value;
+ const iter = iter_ orelse return .invalid_value;
+ const entry = iter.entry orelse return .invalid_value;
+
+ const vp = computeViewportPos(entry.value_ptr, image, wrapper.terminal);
+ if (!vp.visible) return .no_value;
+
+ out_col.* = vp.col;
+ out_row.* = vp.row;
+
+ return .success;
+}
+
+pub fn placement_source_rect(
+ iter_: PlacementIterator,
+ image_: ImageHandle,
+ out_x: *u32,
+ out_y: *u32,
+ out_width: *u32,
+ out_height: *u32,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const image = image_ orelse return .invalid_value;
+ const iter = iter_ orelse return .invalid_value;
+ const entry = iter.entry orelse return .invalid_value;
+ const p = entry.value_ptr;
+
+ // Apply "0 = full image dimension" convention, then clamp to image bounds.
+ const x = @min(p.source_x, image.width);
+ const y = @min(p.source_y, image.height);
+ const w = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x);
+ const h = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y);
+
+ out_x.* = x;
+ out_y.* = y;
+ out_width.* = w;
+ out_height.* = h;
+
+ return .success;
+}
+
+/// C: GhosttyKittyGraphicsPlacementRenderInfo
+pub const PlacementRenderInfo = extern struct {
+ size: usize = @sizeOf(PlacementRenderInfo),
+ pixel_width: u32 = 0,
+ pixel_height: u32 = 0,
+ grid_cols: u32 = 0,
+ grid_rows: u32 = 0,
+ viewport_col: i32 = 0,
+ viewport_row: i32 = 0,
+ viewport_visible: bool = false,
+ source_x: u32 = 0,
+ source_y: u32 = 0,
+ source_width: u32 = 0,
+ source_height: u32 = 0,
+};
+
+pub fn placement_render_info(
+ iter_: PlacementIterator,
+ image_: ImageHandle,
+ terminal_: terminal_c.Terminal,
+ out_: ?*PlacementRenderInfo,
+) callconv(lib.calling_conv) Result {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+
+ const wrapper = terminal_ orelse return .invalid_value;
+ const image = image_ orelse return .invalid_value;
+ const iter = iter_ orelse return .invalid_value;
+ const entry = iter.entry orelse return .invalid_value;
+ const out = out_ orelse return .invalid_value;
+ if (out.size < @sizeOf(PlacementRenderInfo)) return .invalid_value;
+
+ const p = entry.value_ptr;
+
+ const ps = p.pixelSize(image.*, wrapper.terminal);
+ out.pixel_width = ps.width;
+ out.pixel_height = ps.height;
+
+ const gs = p.gridSize(image.*, wrapper.terminal);
+ out.grid_cols = gs.cols;
+ out.grid_rows = gs.rows;
+
+ const vp = computeViewportPos(p, image, wrapper.terminal);
+ out.viewport_col = vp.col;
+ out.viewport_row = vp.row;
+ out.viewport_visible = vp.visible;
+
+ const x = @min(p.source_x, image.width);
+ const y = @min(p.source_y, image.height);
+ out.source_x = x;
+ out.source_y = y;
+ out.source_width = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x);
+ out.source_height = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y);
+
+ return .success;
+}
+
+/// Compute viewport-relative position of a placement.
+///
+/// Converts the placement's internal pin to viewport-relative column
+/// and row coordinates by getting screen-absolute coordinates for
+/// both the pin and the viewport origin, then subtracting to get
+/// viewport-relative coordinates. The row value can be negative when
+/// the placement's origin has scrolled above the top of the viewport.
+///
+/// A placement is considered not visible if it is a virtual (unicode
+/// placeholder) placement, or if it is fully off-screen (its bottom
+/// edge is above the viewport or its top edge is at or below the
+/// viewport's last row).
+fn computeViewportPos(
+ p: *const kitty_storage.ImageStorage.Placement,
+ image: *const Image,
+ t: *Terminal,
+) struct { col: i32, row: i32, visible: bool } {
+ // Virtual placements use unicode placeholders and don't have a
+ // screen position — they are rendered inline by the text layout.
+ const pin = switch (p.location) {
+ .pin => |pin| pin,
+ .virtual => return .{ .col = 0, .row = 0, .visible = false },
+ };
+
+ // Convert both the placement's pin and the viewport's top-left
+ // corner to screen-absolute coordinates so we can subtract them
+ // to get viewport-relative coordinates.
+ const pages = &t.screens.active.pages;
+ const pin_screen = pages.pointFromPin(.screen, pin.*) orelse
+ return .{ .col = 0, .row = 0, .visible = false };
+ const vp_tl = pages.getTopLeft(.viewport);
+ const vp_screen = pages.pointFromPin(.screen, vp_tl) orelse
+ return .{ .col = 0, .row = 0, .visible = false };
+
+ // Subtracting viewport origin from the pin gives us viewport-
+ // relative coordinates. The row can be negative when the
+ // placement has partially scrolled above the viewport.
+ const vp_row: i32 = @as(i32, @intCast(pin_screen.screen.y)) -
+ @as(i32, @intCast(vp_screen.screen.y));
+ const vp_col: i32 = @intCast(pin_screen.screen.x);
+
+ // A placement is invisible if its bottom edge (row + height)
+ // is above the viewport, or its top edge is at or below the
+ // viewport's last row.
+ const grid_size = p.gridSize(image.*, t);
+ const rows_i32: i32 = @intCast(grid_size.rows);
+ const term_rows: i32 = @intCast(t.rows);
+ const visible = vp_row + rows_i32 > 0 and vp_row < term_rows;
+
+ return .{ .col = vp_col, .row = vp_row, .visible = visible };
+}
+
+test "placement_iterator new/free" {
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ try testing.expect(iter != null);
+ placement_iterator_free(iter);
+}
+
+test "placement_iterator free null" {
+ placement_iterator_free(null);
+}
+
+test "placement_iterator next on empty storage" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(!placement_iterator_next(iter));
+}
+
+test "placement_iterator get before next returns invalid" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+
+ var image_id: u32 = undefined;
+ try testing.expectEqual(Result.invalid_value, placement_get(iter, .image_id, @ptrCast(&image_id)));
+}
+
+test "placement_iterator with transmit and display" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit and display a 1x2 RGB image (image_id=1, placement_id=1).
+ // a=T (transmit+display), t=d (direct), f=24 (RGB), i=1, p=1
+ // s=1,v=2 (1x2 pixels), c=10,r=1 (10 cols, 1 row)
+ // //////// = 8 base64 chars = 6 bytes = 1*2*3 RGB bytes
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+
+ // Should have exactly one placement.
+ try testing.expect(placement_iterator_next(iter));
+
+ var image_id: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id)));
+ try testing.expectEqual(1, image_id);
+
+ var placement_id: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id)));
+ try testing.expectEqual(1, placement_id);
+
+ var is_virtual: bool = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .is_virtual, @ptrCast(&is_virtual)));
+ try testing.expect(!is_virtual);
+
+ // No more placements.
+ try testing.expect(!placement_iterator_next(iter));
+}
+
+test "placement_iterator with multiple placements" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit image 1 then display it twice with different placement IDs.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ const display1 = "\x1b_Ga=p,i=1,p=1,c=10,r=1;\x1b\\";
+ const display2 = "\x1b_Ga=p,i=1,p=2,c=5,r=1;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, display1.ptr, display1.len);
+ terminal_c.vt_write(t, display2.ptr, display2.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+
+ // Count placements and collect image IDs.
+ var count: usize = 0;
+ var seen_p1 = false;
+ var seen_p2 = false;
+ while (placement_iterator_next(iter)) {
+ count += 1;
+
+ var image_id: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id)));
+ try testing.expectEqual(1, image_id);
+
+ var placement_id: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id)));
+ if (placement_id == 1) seen_p1 = true;
+ if (placement_id == 2) seen_p2 = true;
+ }
+
+ try testing.expectEqual(2, count);
+ try testing.expect(seen_p1);
+ try testing.expect(seen_p2);
+}
+
+test "placement_iterator_set layer filter" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit image 1.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+
+ // Display with z=5 (above text), z=-1 (below text), z=-1073741825 (below bg).
+ // INT32_MIN/2 = -1073741824, so -1073741825 < INT32_MIN/2.
+ const d1 = "\x1b_Ga=p,i=1,p=1,z=5;\x1b\\";
+ const d2 = "\x1b_Ga=p,i=1,p=2,z=-1;\x1b\\";
+ const d3 = "\x1b_Ga=p,i=1,p=3,z=-1073741825;\x1b\\";
+ terminal_c.vt_write(t, d1.ptr, d1.len);
+ terminal_c.vt_write(t, d2.ptr, d2.len);
+ terminal_c.vt_write(t, d3.ptr, d3.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ // Filter: above_text (z >= 0) — should yield only p=1.
+ var layer = PlacementLayer.above_text;
+ try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer)));
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+
+ var count: u32 = 0;
+ while (placement_iterator_next(iter)) {
+ var z: i32 = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z)));
+ try testing.expect(z >= 0);
+ count += 1;
+ }
+ try testing.expectEqual(1, count);
+
+ // Filter: below_text (INT32_MIN/2 <= z < 0) — should yield only p=2.
+ layer = .below_text;
+ try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer)));
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+
+ count = 0;
+ while (placement_iterator_next(iter)) {
+ var z: i32 = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z)));
+ try testing.expect(z >= std.math.minInt(i32) / 2 and z < 0);
+ count += 1;
+ }
+ try testing.expectEqual(1, count);
+
+ // Filter: below_bg (z < INT32_MIN/2) — should yield only p=3.
+ layer = .below_bg;
+ try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer)));
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+
+ count = 0;
+ while (placement_iterator_next(iter)) {
+ var z: i32 = undefined;
+ try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z)));
+ try testing.expect(z < std.math.minInt(i32) / 2);
+ count += 1;
+ }
+ try testing.expectEqual(1, count);
+
+ // Filter: all — should yield all 3.
+ layer = .all;
+ try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer)));
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+
+ count = 0;
+ while (placement_iterator_next(iter)) count += 1;
+ try testing.expectEqual(3, count);
+}
+
+test "image_get_handle returns null for missing id" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ try testing.expectEqual(@as(ImageHandle, null), image_get_handle(graphics, 999));
+}
+
+test "image_get_handle and image_get with transmitted image" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit a 1x2 RGB image with image_id=1.
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var id: u32 = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .id, @ptrCast(&id)));
+ try testing.expectEqual(1, id);
+
+ var w: u32 = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w)));
+ try testing.expectEqual(1, w);
+
+ var h: u32 = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h)));
+ try testing.expectEqual(2, h);
+
+ var fmt: ImageFormat = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .format, @ptrCast(&fmt)));
+ try testing.expectEqual(.rgb, fmt);
+
+ var comp: ImageCompression = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp)));
+ try testing.expectEqual(.none, comp);
+
+ var data_len: usize = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len)));
+ try testing.expect(data_len > 0);
+}
+
+test "placement_rect with transmit and display" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Set cell size so grid calculations are deterministic.
+ // 80 cols * 10px = 800px, 24 rows * 20px = 480px.
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ // Transmit and display a 1x2 RGB image at cursor (0,0).
+ // c=10,r=1 => 10 columns, 1 row.
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.success, placement_rect(iter, img, t, &sel));
+
+ // Placement starts at cursor origin (0,0).
+ try testing.expectEqual(0, sel.start.x);
+ try testing.expectEqual(0, sel.start.y);
+
+ // 10 columns wide, 1 row tall => bottom-right is (9, 0).
+ try testing.expectEqual(9, sel.end.x);
+ try testing.expectEqual(0, sel.end.y);
+
+ try testing.expect(sel.rectangle);
+}
+
+test "placement_rect null args return invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.invalid_value, placement_rect(null, null, null, &sel));
+}
+
+test "placement_pixel_size with transmit and display" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // 80 cols * 10px = 800px, 24 rows * 20px = 480px.
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ // Transmit and display a 1x2 RGB image with c=10,r=1.
+ // 10 cols * 10px = 100px width, 1 row * 20px = 20px height.
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var w: u32 = undefined;
+ var h: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_pixel_size(iter, img, t, &w, &h));
+
+ try testing.expectEqual(100, w);
+ try testing.expectEqual(20, h);
+}
+
+test "placement_pixel_size null args return invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var w: u32 = undefined;
+ var h: u32 = undefined;
+ try testing.expectEqual(Result.invalid_value, placement_pixel_size(null, null, null, &w, &h));
+}
+
+test "placement_grid_size with transmit and display" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // 80 cols * 10px = 800px, 24 rows * 20px = 480px.
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ // Transmit and display a 1x2 RGB image with c=10,r=1.
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var cols: u32 = undefined;
+ var rows: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_grid_size(iter, img, t, &cols, &rows));
+
+ try testing.expectEqual(10, cols);
+ try testing.expectEqual(1, rows);
+}
+
+test "placement_grid_size null args return invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var cols: u32 = undefined;
+ var rows: u32 = undefined;
+ try testing.expectEqual(Result.invalid_value, placement_grid_size(null, null, null, &cols, &rows));
+}
+
+test "placement_viewport_pos with transmit and display" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ // Transmit and display at cursor (0,0).
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(
+ t,
+ .kitty_graphics,
+ @ptrCast(&graphics),
+ ));
+
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(
+ &lib.alloc.test_allocator,
+ &iter,
+ ));
+ defer placement_iterator_free(iter);
+
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var col: i32 = undefined;
+ var row: i32 = undefined;
+ try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row));
+
+ try testing.expectEqual(0, col);
+ try testing.expectEqual(0, row);
+}
+
+test "placement_viewport_pos fully off-screen above" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 5, .max_scrollback = 100 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20));
+
+ // Transmit image, then display at cursor (0,0) spanning 1 row.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, display.ptr, display.len);
+
+ // Scroll the image completely off: 10 newlines in a 5-row terminal
+ // scrolls by 5+ rows, so a 1-row image at row 0 is fully gone.
+ const scroll = "\n\n\n\n\n\n\n\n\n\n";
+ terminal_c.vt_write(t, scroll.ptr, scroll.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var col: i32 = undefined;
+ var row: i32 = undefined;
+ try testing.expectEqual(Result.no_value, placement_viewport_pos(iter, img, t, &col, &row));
+}
+
+test "placement_viewport_pos top off-screen" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 5, .max_scrollback = 100 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20));
+
+ // Transmit image, display at cursor (0,0) spanning 4 rows.
+ // C=1 prevents cursor movement after display.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, display.ptr, display.len);
+
+ // Scroll by 2: cursor starts at row 0, 4 newlines to reach bottom,
+ // then 2 more to scroll by 2. Image top-left moves to vp_row=-2,
+ // but bottom rows -2+4=2 > 0 so it's still partially visible.
+ const scroll = "\n\n\n\n\n\n";
+ terminal_c.vt_write(t, scroll.ptr, scroll.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var col: i32 = undefined;
+ var row: i32 = undefined;
+ try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row));
+ try testing.expectEqual(0, col);
+ try testing.expectEqual(-2, row);
+}
+
+test "placement_viewport_pos bottom off-screen" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 5, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20));
+
+ // Transmit image, move cursor to row 3 (1-based: row 4), display spanning 4 rows.
+ // C=1 prevents cursor movement after display.
+ // Image occupies rows 3-6 but viewport only has rows 0-4, so bottom is clipped.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ const cursor = "\x1b[4;1H";
+ const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, cursor.ptr, cursor.len);
+ terminal_c.vt_write(t, display.ptr, display.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var col: i32 = undefined;
+ var row: i32 = undefined;
+ try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row));
+ try testing.expectEqual(0, col);
+ try testing.expectEqual(3, row);
+}
+
+test "placement_viewport_pos top and bottom off-screen" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 5, .max_scrollback = 100 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20));
+
+ // Transmit image, display at cursor (0,0) spanning 10 rows.
+ // C=1 prevents cursor movement after display.
+ // After scrolling by 3, image occupies vp rows -3..6, viewport is 0..4,
+ // so both top and bottom are clipped but center is visible.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ const display = "\x1b_Ga=p,i=1,p=1,c=1,r=10,C=1;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, display.ptr, display.len);
+
+ // Scroll by 3: 4 newlines to reach bottom + 3 more to scroll.
+ const scroll = "\n\n\n\n\n\n\n";
+ terminal_c.vt_write(t, scroll.ptr, scroll.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var col: i32 = undefined;
+ var row: i32 = undefined;
+ try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row));
+ try testing.expectEqual(0, col);
+ try testing.expectEqual(-3, row);
+}
+
+test "placement_viewport_pos null args return invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var col: i32 = undefined;
+ var row: i32 = undefined;
+ try testing.expectEqual(Result.invalid_value, placement_viewport_pos(null, null, null, &col, &row));
+}
+
+test "placement_source_rect defaults to full image" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ // Transmit and display a 1x2 RGB image with no source rect specified.
+ // source_width=0 and source_height=0 should resolve to full image (1x2).
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var x: u32 = undefined;
+ var y: u32 = undefined;
+ var w: u32 = undefined;
+ var h: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h));
+ try testing.expectEqual(0, x);
+ try testing.expectEqual(0, y);
+ try testing.expectEqual(1, w);
+ try testing.expectEqual(2, h);
+}
+
+test "placement_source_rect with explicit source rect" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ // Transmit a 4x4 RGBA image (64 bytes = 4*4*4).
+ // Base64 of 64 zero bytes: 88 chars (21 full groups + AA== padding).
+ const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++
+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++
+ "\x1b\\";
+ // Display with explicit source rect: x=1, y=1, w=2, h=2.
+ const display = "\x1b_Ga=p,i=1,p=1,x=1,y=1,w=2,h=2;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, display.ptr, display.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var x: u32 = undefined;
+ var y: u32 = undefined;
+ var w: u32 = undefined;
+ var h: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h));
+ try testing.expectEqual(1, x);
+ try testing.expectEqual(1, y);
+ try testing.expectEqual(2, w);
+ try testing.expectEqual(2, h);
+}
+
+test "placement_source_rect clamps to image bounds" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ // Transmit a 4x4 RGBA image (64 bytes = 4*4*4).
+ const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++
+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++
+ "\x1b\\";
+ // Display with source rect that exceeds image bounds: x=3, y=3, w=10, h=10.
+ // Should clamp to x=3, y=3, w=1, h=1.
+ const display = "\x1b_Ga=p,i=1,p=1,x=3,y=3,w=10,h=10;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, display.ptr, display.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var x: u32 = undefined;
+ var y: u32 = undefined;
+ var w: u32 = undefined;
+ var h: u32 = undefined;
+ try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h));
+ try testing.expectEqual(3, x);
+ try testing.expectEqual(3, y);
+ try testing.expectEqual(1, w);
+ try testing.expectEqual(1, h);
+}
+
+test "placement_source_rect null args return invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var x: u32 = undefined;
+ var y: u32 = undefined;
+ var w: u32 = undefined;
+ var h: u32 = undefined;
+ try testing.expectEqual(Result.invalid_value, placement_source_rect(null, null, &x, &y, &w, &h));
+}
+
+test "image_get on null returns invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var id: u32 = undefined;
+ try testing.expectEqual(Result.invalid_value, image_get(null, .id, @ptrCast(&id)));
+}
+
+test "placement_render_info returns all fields" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var ri: PlacementRenderInfo = .{};
+ try testing.expectEqual(Result.success, placement_render_info(iter, img, t, &ri));
+ try testing.expect(ri.viewport_visible);
+ try testing.expectEqual(0, ri.viewport_col);
+ try testing.expectEqual(0, ri.viewport_row);
+ try testing.expectEqual(10, ri.grid_cols);
+ try testing.expectEqual(1, ri.grid_rows);
+ try testing.expectEqual(0, ri.source_x);
+ try testing.expectEqual(0, ri.source_y);
+ try testing.expectEqual(1, ri.source_width);
+ try testing.expectEqual(2, ri.source_height);
+}
+
+test "placement_render_info off-screen sets viewport_visible false" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 5, .max_scrollback = 100 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20));
+
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ terminal_c.vt_write(t, display.ptr, display.len);
+
+ // Scroll the image completely off-screen.
+ const scroll = "\n\n\n\n\n\n\n\n\n\n";
+ terminal_c.vt_write(t, scroll.ptr, scroll.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var ri: PlacementRenderInfo = .{};
+ try testing.expectEqual(Result.success, placement_render_info(iter, img, t, &ri));
+ try testing.expect(!ri.viewport_visible);
+ // Other fields should still be populated.
+ try testing.expectEqual(1, ri.grid_cols);
+ try testing.expectEqual(1, ri.grid_rows);
+ try testing.expectEqual(1, ri.source_width);
+ try testing.expectEqual(2, ri.source_height);
+}
+
+test "placement_render_info null returns invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var ri: PlacementRenderInfo = .{};
+ try testing.expectEqual(Result.invalid_value, placement_render_info(null, null, null, &ri));
+}
+
+test "image_get_multi success" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var id: u32 = 0;
+ var width: u32 = 0;
+ var height: u32 = 0;
+ var written: usize = 0;
+
+ const keys = [_]ImageData{ .id, .width, .height };
+ var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&width), @ptrCast(&height) };
+ try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(1, id);
+ try testing.expectEqual(1, width);
+ try testing.expectEqual(2, height);
+}
+
+test "image_get_multi error sets out_written" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var id: u32 = 0;
+ var written: usize = 99;
+
+ const keys = [_]ImageData{ .id, .invalid };
+ var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&id) };
+ try testing.expectEqual(Result.invalid_value, image_get_multi(null, keys.len, &keys, &values, &written));
+ try testing.expectEqual(0, written);
+}
+
+test "image_get_multi null keys returns invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var id: u32 = 0;
+ var values = [_]?*anyopaque{@ptrCast(&id)};
+ try testing.expectEqual(Result.invalid_value, image_get_multi(null, 1, null, &values, null));
+}
+
+test "placement_get_multi success" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+ try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20));
+
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+
+ var iter: PlacementIterator = null;
+ try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter));
+ defer placement_iterator_free(iter);
+ try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
+ try testing.expect(placement_iterator_next(iter));
+
+ var image_id: u32 = 0;
+ var columns: u32 = 0;
+ var z: i32 = 99;
+ var written: usize = 0;
+
+ const keys = [_]PlacementData{ .image_id, .columns, .z };
+ var values = [_]?*anyopaque{ @ptrCast(&image_id), @ptrCast(&columns), @ptrCast(&z) };
+ try testing.expectEqual(Result.success, placement_get_multi(iter, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(1, image_id);
+ try testing.expectEqual(10, columns);
+ try testing.expectEqual(0, z);
+}
+
+test "placement_get_multi error sets out_written" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var id: u32 = 0;
+ var written: usize = 99;
+
+ const keys = [_]PlacementData{ .image_id, .invalid };
+ var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&id) };
+ try testing.expectEqual(Result.invalid_value, placement_get_multi(null, keys.len, &keys, &values, &written));
+ try testing.expectEqual(0, written);
+}
+
+test "placement_get_multi null keys returns invalid_value" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var id: u32 = 0;
+ var values = [_]?*anyopaque{@ptrCast(&id)};
+ try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null));
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig
index 34d1a8f4..126b6640 100644
--- a/vendor/libghostty-vt/src/terminal/c/main.zig
+++ b/vendor/libghostty-vt/src/terminal/c/main.zig
@@ -8,10 +8,28 @@ pub const color = @import("color.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const grid_ref = @import("grid_ref.zig");
+pub const kitty_graphics = @import("kitty_graphics.zig");
+pub const kitty_graphics_get = kitty_graphics.get;
+pub const kitty_graphics_image = kitty_graphics.image_get_handle;
+pub const kitty_graphics_image_get = kitty_graphics.image_get;
+pub const kitty_graphics_image_get_multi = kitty_graphics.image_get_multi;
+pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new;
+pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free;
+pub const kitty_graphics_placement_iterator_set = kitty_graphics.placement_iterator_set;
+pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next;
+pub const kitty_graphics_placement_get = kitty_graphics.placement_get;
+pub const kitty_graphics_placement_get_multi = kitty_graphics.placement_get_multi;
+pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect;
+pub const kitty_graphics_placement_pixel_size = kitty_graphics.placement_pixel_size;
+pub const kitty_graphics_placement_grid_size = kitty_graphics.placement_grid_size;
+pub const kitty_graphics_placement_viewport_pos = kitty_graphics.placement_viewport_pos;
+pub const kitty_graphics_placement_source_rect = kitty_graphics.placement_source_rect;
+pub const kitty_graphics_placement_render_info = kitty_graphics.placement_render_info;
pub const types = @import("types.zig");
pub const modes = @import("modes.zig");
pub const osc = @import("osc.zig");
pub const render = @import("render.zig");
+pub const selection = @import("selection.zig");
pub const key_event = @import("key_event.zig");
pub const key_encode = @import("key_encode.zig");
pub const mouse_event = @import("mouse_event.zig");
@@ -21,6 +39,7 @@ pub const row = @import("row.zig");
pub const sgr = @import("sgr.zig");
pub const size_report = @import("size_report.zig");
pub const style = @import("style.zig");
+pub const sys = @import("sys.zig");
pub const terminal = @import("terminal.zig");
// The full C API, unexported.
@@ -49,17 +68,20 @@ pub const render_state_new = render.new;
pub const render_state_free = render.free;
pub const render_state_update = render.update;
pub const render_state_get = render.get;
+pub const render_state_get_multi = render.get_multi;
pub const render_state_set = render.set;
pub const render_state_colors_get = render.colors_get;
pub const render_state_row_iterator_new = render.row_iterator_new;
pub const render_state_row_iterator_next = render.row_iterator_next;
pub const render_state_row_get = render.row_get;
+pub const render_state_row_get_multi = render.row_get_multi;
pub const render_state_row_set = render.row_set;
pub const render_state_row_iterator_free = render.row_iterator_free;
pub const render_state_row_cells_new = render.row_cells_new;
pub const render_state_row_cells_next = render.row_cells_next;
pub const render_state_row_cells_select = render.row_cells_select;
pub const render_state_row_cells_get = render.row_cells_get;
+pub const render_state_row_cells_get_multi = render.row_cells_get_multi;
pub const render_state_row_cells_free = render.row_cells_free;
pub const sgr_new = sgr.new;
@@ -125,12 +147,17 @@ pub const alloc_free = allocator.free;
pub const size_report_encode = size_report.encode;
pub const cell_get = cell.get;
+pub const cell_get_multi = cell.get_multi;
pub const row_get = row.get;
+pub const row_get_multi = row.get_multi;
pub const style_default = style.default_style;
pub const style_is_default = style.style_is_default;
+pub const sys_log_stderr = sys.logStderr;
+pub const sys_set = sys.set;
+
pub const terminal_new = terminal.new;
pub const terminal_free = terminal.free;
pub const terminal_reset = terminal.reset;
@@ -141,14 +168,16 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport;
pub const terminal_mode_get = terminal.mode_get;
pub const terminal_mode_set = terminal.mode_set;
pub const terminal_get = terminal.get;
+pub const terminal_get_multi = terminal.get_multi;
pub const terminal_grid_ref = terminal.grid_ref;
-pub const terminal_read_text = terminal.read_text;
+pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref;
pub const type_json = types.get_json;
pub const grid_ref_cell = grid_ref.grid_ref_cell;
pub const grid_ref_row = grid_ref.grid_ref_row;
pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes;
+pub const grid_ref_hyperlink_uri = grid_ref.grid_ref_hyperlink_uri;
pub const grid_ref_style = grid_ref.grid_ref_style;
test {
@@ -157,12 +186,14 @@ test {
_ = cell;
_ = color;
_ = grid_ref;
+ _ = kitty_graphics;
_ = row;
_ = focus;
_ = formatter;
_ = modes;
_ = osc;
_ = render;
+ _ = selection;
_ = key_event;
_ = key_encode;
_ = mouse_event;
@@ -171,6 +202,7 @@ test {
_ = sgr;
_ = size_report;
_ = style;
+ _ = sys;
_ = terminal;
_ = types;
diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig
index b95bedd5..af82ddfa 100644
--- a/vendor/libghostty-vt/src/terminal/c/render.zig
+++ b/vendor/libghostty-vt/src/terminal/c/render.zig
@@ -201,6 +201,27 @@ pub fn get(
};
}
+pub fn get_multi(
+ state_: RenderState,
+ count: usize,
+ keys: ?[*]const Data,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = get(state_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
fn getTyped(
state_: RenderState,
comptime data: Data,
@@ -468,6 +489,27 @@ pub fn row_cells_get(
};
}
+pub fn row_cells_get_multi(
+ cells_: RowCells,
+ count: usize,
+ keys: ?[*]const RowCellsData,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = row_cells_get(cells_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
fn rowCellsGetTyped(
cells_: RowCells,
comptime data: RowCellsData,
@@ -568,6 +610,27 @@ pub fn row_get(
};
}
+pub fn row_get_multi(
+ iterator_: RowIterator,
+ count: usize,
+ keys: ?[*]const RowData,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = row_get(iterator_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
fn rowGetTyped(
iterator_: RowIterator,
comptime data: RowData,
@@ -1380,3 +1443,137 @@ test "render: colors get supports truncated sized struct" {
try testing.expectEqual(state_colors.palette[1].cval(), colors.palette[1]);
try testing.expectEqual(sentinel, colors.palette[2]);
}
+
+test "render: get_multi success" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.success, update(state, terminal));
+
+ var cols: u16 = 0;
+ var rows: u16 = 0;
+ var written: usize = 0;
+
+ const keys = [_]Data{ .cols, .rows };
+ var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&rows) };
+ try testing.expectEqual(Result.success, get_multi(state, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(80, cols);
+ try testing.expectEqual(24, rows);
+}
+
+test "render: get_multi null returns invalid_value" {
+ var cols: u16 = 0;
+ var values = [_]?*anyopaque{@ptrCast(&cols)};
+ try testing.expectEqual(Result.invalid_value, get_multi(null, 1, null, &values, null));
+}
+
+test "render: row_get_multi success" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.success, update(state, terminal));
+
+ var it: RowIterator = null;
+ try testing.expectEqual(Result.success, row_iterator_new(
+ &lib.alloc.test_allocator,
+ &it,
+ ));
+ defer row_iterator_free(it);
+
+ try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
+ try testing.expect(row_iterator_next(it));
+
+ var dirty: bool = true;
+ var written: usize = 0;
+
+ const keys = [_]RowData{.dirty};
+ var values = [_]?*anyopaque{@ptrCast(&dirty)};
+ try testing.expectEqual(Result.success, row_get_multi(it, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+}
+
+test "render: row_get_multi null returns invalid_value" {
+ var dirty: bool = false;
+ var values = [_]?*anyopaque{@ptrCast(&dirty)};
+ try testing.expectEqual(Result.invalid_value, row_get_multi(null, 1, null, &values, null));
+}
+
+test "render: row_cells_get_multi success" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ terminal_c.vt_write(terminal, "A", 1);
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.success, update(state, terminal));
+
+ var it: RowIterator = null;
+ try testing.expectEqual(Result.success, row_iterator_new(
+ &lib.alloc.test_allocator,
+ &it,
+ ));
+ defer row_iterator_free(it);
+
+ try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
+ try testing.expect(row_iterator_next(it));
+
+ var cells: RowCells = null;
+ try testing.expectEqual(Result.success, row_cells_new(
+ &lib.alloc.test_allocator,
+ &cells,
+ ));
+ defer row_cells_free(cells);
+
+ try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
+ try testing.expect(row_cells_next(cells));
+
+ var raw: row.CRow = undefined;
+ var written: usize = 0;
+
+ const keys = [_]RowCellsData{.raw};
+ var values = [_]?*anyopaque{@ptrCast(&raw)};
+ try testing.expectEqual(Result.success, row_cells_get_multi(cells, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+}
+
+test "render: row_cells_get_multi null returns invalid_value" {
+ var raw: row.CRow = undefined;
+ var values = [_]?*anyopaque{@ptrCast(&raw)};
+ try testing.expectEqual(Result.invalid_value, row_cells_get_multi(null, 1, null, &values, null));
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/row.zig b/vendor/libghostty-vt/src/terminal/c/row.zig
index d4532706..66c398c8 100644
--- a/vendor/libghostty-vt/src/terminal/c/row.zig
+++ b/vendor/libghostty-vt/src/terminal/c/row.zig
@@ -83,6 +83,27 @@ pub fn get(
};
}
+pub fn get_multi(
+ row_: CRow,
+ count: usize,
+ keys: ?[*]const RowData,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = get(row_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
fn getTyped(
row_: CRow,
comptime data: RowData,
@@ -130,3 +151,43 @@ test "get dirty" {
try testing.expectEqual(Result.success, get(row, .dirty, @ptrCast(&dirty)));
try testing.expect(dirty);
}
+
+test "get_multi success" {
+ var zig_row: Row = @bitCast(@as(u64, 0));
+ zig_row.wrap = true;
+ zig_row.dirty = true;
+ const row_val: CRow = @bitCast(zig_row);
+
+ var wrap: bool = false;
+ var dirty: bool = false;
+ var written: usize = 0;
+
+ const keys = [_]RowData{ .wrap, .dirty };
+ var values = [_]?*anyopaque{ @ptrCast(&wrap), @ptrCast(&dirty) };
+ try testing.expectEqual(Result.success, get_multi(row_val, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expect(wrap);
+ try testing.expect(dirty);
+}
+
+test "get_multi error sets out_written" {
+ var zig_row: Row = @bitCast(@as(u64, 0));
+ zig_row.wrap = true;
+ const row_val: CRow = @bitCast(zig_row);
+
+ var wrap: bool = false;
+ var written: usize = 99;
+
+ const keys = [_]RowData{ .wrap, .invalid };
+ var values = [_]?*anyopaque{ @ptrCast(&wrap), @ptrCast(&wrap) };
+ try testing.expectEqual(Result.invalid_value, get_multi(row_val, keys.len, &keys, &values, &written));
+ try testing.expectEqual(1, written);
+ try testing.expect(wrap);
+}
+
+test "get_multi null keys returns invalid_value" {
+ const row_val: CRow = @bitCast(@as(u64, 0));
+ var wrap: bool = false;
+ var values = [_]?*anyopaque{@ptrCast(&wrap)};
+ try testing.expectEqual(Result.invalid_value, get_multi(row_val, 1, null, &values, null));
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig
new file mode 100644
index 00000000..74e96598
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/selection.zig
@@ -0,0 +1,16 @@
+const grid_ref = @import("grid_ref.zig");
+const Selection = @import("../Selection.zig");
+
+/// C: GhosttySelection
+pub const CSelection = extern struct {
+ size: usize = @sizeOf(CSelection),
+ start: grid_ref.CGridRef,
+ end: grid_ref.CGridRef,
+ rectangle: bool = false,
+
+ pub fn toZig(self: CSelection) ?Selection {
+ const start_pin = self.start.toPin() orelse return null;
+ const end_pin = self.end.toPin() orelse return null;
+ return Selection.init(start_pin, end_pin, self.rectangle);
+ }
+};
diff --git a/vendor/libghostty-vt/src/terminal/c/sys.zig b/vendor/libghostty-vt/src/terminal/c/sys.zig
new file mode 100644
index 00000000..c4b2b17f
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/sys.zig
@@ -0,0 +1,430 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const lib = @import("../lib.zig");
+const CAllocator = lib.alloc.Allocator;
+const terminal_sys = @import("../sys.zig");
+const Result = @import("result.zig").Result;
+
+/// C: GhosttySysImage
+pub const Image = extern struct {
+ width: u32,
+ height: u32,
+ data: ?[*]u8,
+ data_len: usize,
+};
+
+/// C: GhosttySysDecodePngFn
+pub const DecodePngFn = *const fn (
+ ?*anyopaque,
+ *const CAllocator,
+ [*]const u8,
+ usize,
+ *Image,
+) callconv(lib.calling_conv) bool;
+
+/// C: GhosttySysLogLevel
+pub const LogLevel = enum(c_int) {
+ @"error" = 0,
+ warning = 1,
+ info = 2,
+ debug = 3,
+
+ pub fn fromStd(level: std.log.Level) LogLevel {
+ return switch (level) {
+ .err => .@"error",
+ .warn => .warning,
+ .info => .info,
+ .debug => .debug,
+ };
+ }
+};
+
+/// C: GhosttySysLogFn
+pub const LogFn = *const fn (
+ ?*anyopaque,
+ LogLevel,
+ [*]const u8,
+ usize,
+ [*]const u8,
+ usize,
+) callconv(lib.calling_conv) void;
+
+/// C: GhosttySysOption
+pub const Option = enum(c_int) {
+ userdata = 0,
+ decode_png = 1,
+ log = 2,
+
+ pub fn InType(comptime self: Option) type {
+ return switch (self) {
+ .userdata => ?*const anyopaque,
+ .decode_png => ?DecodePngFn,
+ .log => ?LogFn,
+ };
+ }
+};
+
+/// Global state for the sys interface so we can call through to the C
+/// callbacks from Zig.
+const Global = struct {
+ userdata: ?*anyopaque = null,
+ decode_png: ?DecodePngFn = null,
+ log: ?LogFn = null,
+};
+
+/// Global state for the C sys interface.
+var global: Global = .{};
+
+/// Zig-compatible wrapper that calls through to the stored C callback.
+/// The C callback allocates the pixel data through the provided allocator,
+/// so we can take ownership directly.
+fn decodePngWrapper(
+ alloc: std.mem.Allocator,
+ data: []const u8,
+) terminal_sys.DecodeError!terminal_sys.Image {
+ const func = global.decode_png orelse return error.InvalidData;
+
+ const c_alloc = CAllocator.fromZig(&alloc);
+ var out: Image = undefined;
+ if (!func(global.userdata, &c_alloc, data.ptr, data.len, &out)) return error.InvalidData;
+
+ const result_data = out.data orelse return error.InvalidData;
+
+ return .{
+ .width = out.width,
+ .height = out.height,
+ .data = result_data[0..out.data_len],
+ };
+}
+
+pub fn set(
+ option: Option,
+ value: ?*const anyopaque,
+) callconv(lib.calling_conv) Result {
+ if (comptime std.debug.runtime_safety) {
+ _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch {
+ return .invalid_value;
+ };
+ }
+
+ return switch (option) {
+ inline else => |comptime_option| setTyped(
+ comptime_option,
+ @ptrCast(@alignCast(value)),
+ ),
+ };
+}
+
+fn setTyped(
+ comptime option: Option,
+ value: option.InType(),
+) Result {
+ switch (option) {
+ .userdata => global.userdata = @constCast(value),
+ .decode_png => {
+ global.decode_png = value;
+ terminal_sys.decode_png = if (value != null) &decodePngWrapper else null;
+ },
+ .log => global.log = value,
+ }
+ return .success;
+}
+
+/// Dispatch a log message to the installed C callback, if any.
+fn emitLog(level: LogLevel, scope: []const u8, message: []const u8) void {
+ const func = global.log orelse return;
+ func(
+ global.userdata,
+ level,
+ scope.ptr,
+ scope.len,
+ message.ptr,
+ message.len,
+ );
+}
+
+/// Emits logs in chunks. Almost all logs will be less than the chunk size
+/// but this allows emitting larger logs without heap allocation.
+const LogEmitter = struct {
+ c_level: LogLevel,
+ scope_text: []const u8,
+ buf: [2048]u8 = undefined,
+ pos: usize = 0,
+
+ fn write(self: *@This(), bytes: []const u8) error{}!usize {
+ var remaining = bytes;
+ while (remaining.len > 0) {
+ const space = self.buf.len - self.pos;
+ if (space == 0) {
+ self.flush();
+ continue;
+ }
+
+ const n = @min(remaining.len, space);
+ @memcpy(self.buf[self.pos..][0..n], remaining[0..n]);
+ self.pos += n;
+ remaining = remaining[n..];
+ }
+
+ return bytes.len;
+ }
+
+ fn flush(self: *@This()) void {
+ if (self.pos == 0) return;
+ emitLog(
+ self.c_level,
+ self.scope_text,
+ self.buf[0..self.pos],
+ );
+ self.pos = 0;
+ }
+};
+
+/// Custom std.log sink for C ABI builds.
+///
+/// When a log callback is installed via ghostty_sys_set(), messages are
+/// dispatched through it. When no callback is installed, messages are
+/// silently discarded. Large messages that exceed the stack buffer are
+/// delivered across multiple callback invocations.
+pub fn logFn(
+ comptime level: std.log.Level,
+ comptime scope: @TypeOf(.EnumLiteral),
+ comptime format: []const u8,
+ args: anytype,
+) void {
+ if (global.log == null) return;
+
+ const scope_text: []const u8 = if (scope == .default) "" else @tagName(scope);
+ const c_level = LogLevel.fromStd(level);
+
+ var ctx: LogEmitter = .{
+ .c_level = c_level,
+ .scope_text = scope_text,
+ };
+ const writer: std.io.GenericWriter(
+ *LogEmitter,
+ error{},
+ LogEmitter.write,
+ ) = .{ .context = &ctx };
+
+ nosuspend writer.print(format, args) catch {};
+ ctx.flush();
+}
+
+/// Built-in log callback that writes to stderr.
+///
+/// Formats each message as "[level](scope): message\n". Can be passed
+/// directly to ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr).
+///
+/// Uses std.debug.lockStderrWriter for thread-safe, mutex-protected output.
+/// On freestanding/wasm targets this is a no-op (no stderr available).
+pub fn logStderr(
+ _: ?*anyopaque,
+ level: LogLevel,
+ scope_ptr: [*]const u8,
+ scope_len: usize,
+ message_ptr: [*]const u8,
+ message_len: usize,
+) callconv(lib.calling_conv) void {
+ if (comptime builtin.target.cpu.arch.isWasm()) return;
+
+ const scope = scope_ptr[0..scope_len];
+ const message = message_ptr[0..message_len];
+
+ const level_text = switch (level) {
+ .@"error" => "error",
+ .warning => "warning",
+ .info => "info",
+ .debug => "debug",
+ };
+
+ var buffer: [64]u8 = undefined;
+ const writer = std.debug.lockStderrWriter(&buffer);
+ defer std.debug.unlockStderrWriter();
+ nosuspend {
+ if (scope.len > 0) {
+ writer.print("[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch {};
+ } else {
+ writer.print("[{s}]: {s}\n", .{ level_text, message }) catch {};
+ }
+ }
+}
+
+test "set decode_png with null clears" {
+ // Start from a known state.
+ global.decode_png = null;
+ terminal_sys.decode_png = null;
+
+ try std.testing.expectEqual(Result.success, set(.decode_png, null));
+ try std.testing.expect(terminal_sys.decode_png == null);
+}
+
+test "set decode_png installs wrapper" {
+ const S = struct {
+ fn decode(_: ?*anyopaque, _: *const CAllocator, _: [*]const u8, _: usize, out: *Image) callconv(lib.calling_conv) bool {
+ out.* = .{ .width = 1, .height = 1, .data = null, .data_len = 0 };
+ return true;
+ }
+ };
+
+ try std.testing.expectEqual(Result.success, set(
+ .decode_png,
+ @ptrCast(&S.decode),
+ ));
+ try std.testing.expect(terminal_sys.decode_png != null);
+
+ // Clear it again.
+ try std.testing.expectEqual(Result.success, set(.decode_png, null));
+ try std.testing.expect(terminal_sys.decode_png == null);
+}
+
+test "set log with null clears" {
+ global.log = null;
+
+ try std.testing.expectEqual(Result.success, set(.log, null));
+ try std.testing.expect(global.log == null);
+}
+
+test "set log installs callback" {
+ const S = struct {
+ var called: bool = false;
+ fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, _: [*]const u8, _: usize) callconv(lib.calling_conv) void {
+ called = true;
+ }
+ };
+
+ try std.testing.expectEqual(Result.success, set(.log, @ptrCast(&S.logCb)));
+ try std.testing.expect(global.log != null);
+
+ emitLog(.info, "test", "hello");
+ try std.testing.expect(S.called);
+
+ // Clear it again.
+ S.called = false;
+ try std.testing.expectEqual(Result.success, set(.log, null));
+ try std.testing.expect(global.log == null);
+
+ emitLog(.info, "test", "should not arrive");
+ try std.testing.expect(!S.called);
+}
+
+test "logFn small message single chunk" {
+ const S = struct {
+ var call_count: usize = 0;
+ var total_len: usize = 0;
+
+ fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
+ _ = msg;
+ call_count += 1;
+ total_len += msg_len;
+ }
+ };
+
+ S.call_count = 0;
+ S.total_len = 0;
+ global.log = @ptrCast(&S.logCb);
+ defer {
+ global.log = null;
+ }
+
+ logFn(.info, .default, "hello", .{});
+
+ try std.testing.expectEqual(@as(usize, 1), S.call_count);
+ try std.testing.expectEqual(@as(usize, 5), S.total_len);
+}
+
+test "logFn message exceeding chunk size is split" {
+ const S = struct {
+ var call_count: usize = 0;
+ var total_len: usize = 0;
+
+ fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
+ _ = msg;
+ call_count += 1;
+ total_len += msg_len;
+ // Each chunk must not exceed the buffer size.
+ std.debug.assert(msg_len <= 2048);
+ }
+ };
+
+ S.call_count = 0;
+ S.total_len = 0;
+ global.log = @ptrCast(&S.logCb);
+ defer {
+ global.log = null;
+ }
+
+ // Format a message larger than the 2048-byte buffer.
+ // 'A' repeated 3000 times via a fill format.
+ const fill: [3000]u8 = .{0x41} ** 3000;
+ logFn(.info, .default, "{s}", .{@as([]const u8, &fill)});
+
+ try std.testing.expect(S.call_count >= 2);
+ try std.testing.expectEqual(@as(usize, 3000), S.total_len);
+}
+
+test "logFn message exactly at chunk boundary" {
+ const S = struct {
+ var call_count: usize = 0;
+ var total_len: usize = 0;
+
+ fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
+ _ = msg;
+ call_count += 1;
+ total_len += msg_len;
+ std.debug.assert(msg_len <= 2048);
+ }
+ };
+
+ S.call_count = 0;
+ S.total_len = 0;
+ global.log = @ptrCast(&S.logCb);
+ defer {
+ global.log = null;
+ }
+
+ // Exactly 2048 bytes — should emit one full chunk, no remainder.
+ const fill: [2048]u8 = .{0x42} ** 2048;
+ logFn(.info, .default, "{s}", .{@as([]const u8, &fill)});
+
+ try std.testing.expectEqual(@as(usize, 1), S.call_count);
+ try std.testing.expectEqual(@as(usize, 2048), S.total_len);
+}
+
+test "logFn message exactly double chunk size" {
+ const S = struct {
+ var call_count: usize = 0;
+ var total_len: usize = 0;
+
+ fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
+ _ = msg;
+ call_count += 1;
+ total_len += msg_len;
+ std.debug.assert(msg_len <= 2048);
+ }
+ };
+
+ S.call_count = 0;
+ S.total_len = 0;
+ global.log = @ptrCast(&S.logCb);
+ defer {
+ global.log = null;
+ }
+
+ // Exactly 4096 bytes — should emit exactly two full chunks.
+ const fill: [4096]u8 = .{0x43} ** 4096;
+ logFn(.info, .default, "{s}", .{@as([]const u8, &fill)});
+
+ try std.testing.expectEqual(@as(usize, 2), S.call_count);
+ try std.testing.expectEqual(@as(usize, 4096), S.total_len);
+}
+
+test "set userdata" {
+ var data: u32 = 42;
+ try std.testing.expectEqual(Result.success, set(.userdata, @ptrCast(&data)));
+ try std.testing.expect(global.userdata == @as(?*anyopaque, @ptrCast(&data)));
+
+ // Clear it.
+ try std.testing.expectEqual(Result.success, set(.userdata, null));
+ try std.testing.expect(global.userdata == null);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
index fa421f21..d3293f3b 100644
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
@@ -1,20 +1,21 @@
const std = @import("std");
const testing = std.testing;
+const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const ZigTerminal = @import("../Terminal.zig");
const Stream = @import("../stream_terminal.zig").Stream;
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
+const apc = @import("../apc.zig");
const kitty = @import("../kitty/key.zig");
+const kitty_gfx_c = @import("kitty_graphics.zig");
const modes = @import("../modes.zig");
const point = @import("../point.zig");
const size = @import("../size.zig");
const device_attributes = @import("../device_attributes.zig");
const device_status = @import("../device_status.zig");
const size_report = @import("../size_report.zig");
-const formatterpkg = @import("../formatter.zig");
-const selectionpkg = @import("../Selection.zig");
const cell_c = @import("cell.zig");
const row_c = @import("row.zig");
const grid_ref_c = @import("grid_ref.zig");
@@ -306,6 +307,12 @@ pub const Option = enum(c_int) {
color_background = 12,
color_cursor = 13,
color_palette = 14,
+ kitty_image_storage_limit = 15,
+ kitty_image_medium_file = 16,
+ kitty_image_medium_temp_file = 17,
+ kitty_image_medium_shared_mem = 18,
+ apc_max_bytes = 19,
+ apc_max_bytes_kitty = 20,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -322,6 +329,12 @@ pub const Option = enum(c_int) {
.title, .pwd => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
.color_palette => ?*const color.PaletteC,
+ .kitty_image_storage_limit => ?*const u64,
+ .kitty_image_medium_file,
+ .kitty_image_medium_temp_file,
+ .kitty_image_medium_shared_mem,
+ => ?*const bool,
+ .apc_max_bytes, .apc_max_bytes_kitty => ?*const usize,
};
}
};
@@ -390,6 +403,45 @@ fn setTyped(
);
wrapper.terminal.flags.dirty.palette = true;
},
+ .kitty_image_storage_limit => {
+ if (comptime !build_options.kitty_graphics) return .success;
+ const limit: usize = if (value) |v| @intCast(v.*) else 0;
+ var it = wrapper.terminal.screens.all.iterator();
+ while (it.next()) |entry| {
+ const screen = entry.value.*;
+ screen.kitty_images.setLimit(screen.alloc, screen, limit) catch return .out_of_memory;
+ }
+ },
+ .kitty_image_medium_file,
+ .kitty_image_medium_temp_file,
+ .kitty_image_medium_shared_mem,
+ => {
+ if (comptime !build_options.kitty_graphics) return .success;
+ const val = (value orelse return .success).*;
+ var it = wrapper.terminal.screens.all.iterator();
+ while (it.next()) |entry| {
+ const screen = entry.value.*;
+ switch (option) {
+ .kitty_image_medium_file => screen.kitty_images.image_limits.file = val,
+ .kitty_image_medium_temp_file => screen.kitty_images.image_limits.temporary_file = val,
+ .kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val,
+ else => unreachable,
+ }
+ }
+ },
+ .apc_max_bytes => {
+ wrapper.stream.handler.apc_handler.max_bytes = if (value) |ptr|
+ .initFull(ptr.*)
+ else
+ .{};
+ },
+ .apc_max_bytes_kitty => {
+ if (value) |ptr| {
+ wrapper.stream.handler.apc_handler.max_bytes.put(.kitty, ptr.*);
+ } else {
+ wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty);
+ }
+ },
}
return .success;
}
@@ -400,21 +452,6 @@ pub const DeviceAttributes = Effects.CDeviceAttributes;
/// C: GhosttyTerminalScrollViewport
pub const ScrollViewport = ZigTerminal.ScrollViewport.C;
-/// C: GhosttyTerminalSelection
-pub const Selection = extern struct {
- start: point.Point.C,
- end: point.Point.C,
- rectangle: bool,
-
- fn toZig(self: Selection, terminal: *const ZigTerminal) ?selectionpkg {
- return selectionpkg.init(
- pinFromPoint(terminal, self.start) orelse return null,
- pinFromPoint(terminal, self.end) orelse return null,
- self.rectangle,
- );
- }
-};
-
pub fn scroll_viewport(
terminal_: Terminal,
behavior: ScrollViewport,
@@ -496,6 +533,9 @@ pub fn mode_set(
return .success;
}
+/// C: GhosttyKittyGraphics
+pub const KittyGraphics = kitty_gfx_c.KittyGraphics;
+
/// C: GhosttyTerminalScreen
pub const TerminalScreen = ScreenSet.Key;
@@ -530,6 +570,11 @@ pub const TerminalData = enum(c_int) {
color_background_default = 23,
color_cursor_default = 24,
color_palette_default = 25,
+ kitty_image_storage_limit = 26,
+ kitty_image_medium_file = 27,
+ kitty_image_medium_temp_file = 28,
+ kitty_image_medium_shared_mem = 29,
+ kitty_graphics = 30,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: TerminalData) type {
@@ -552,6 +597,12 @@ pub const TerminalData = enum(c_int) {
.color_cursor_default,
=> color.RGB.C,
.color_palette, .color_palette_default => color.PaletteC,
+ .kitty_image_storage_limit => u64,
+ .kitty_image_medium_file,
+ .kitty_image_medium_temp_file,
+ .kitty_image_medium_shared_mem,
+ => bool,
+ .kitty_graphics => KittyGraphics,
};
}
};
@@ -578,6 +629,27 @@ pub fn get(
};
}
+pub fn get_multi(
+ terminal_: Terminal,
+ count: usize,
+ keys: ?[*]const TerminalData,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = get(terminal_, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
fn getTyped(
terminal_: Terminal,
comptime data: TerminalData,
@@ -620,63 +692,60 @@ fn getTyped(
.color_cursor_default => out.* = (t.colors.cursor.default orelse return .no_value).cval(),
.color_palette => out.* = color.paletteCval(&t.colors.palette.current),
.color_palette_default => out.* = color.paletteCval(&t.colors.palette.original),
+ .kitty_image_storage_limit => {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+ out.* = @intCast(t.screens.active.kitty_images.total_limit);
+ },
+ .kitty_image_medium_file => {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+ out.* = t.screens.active.kitty_images.image_limits.file;
+ },
+ .kitty_image_medium_temp_file => {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+ out.* = t.screens.active.kitty_images.image_limits.temporary_file;
+ },
+ .kitty_image_medium_shared_mem => {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+ out.* = t.screens.active.kitty_images.image_limits.shared_memory;
+ },
+ .kitty_graphics => {
+ if (comptime !build_options.kitty_graphics) return .no_value;
+ out.* = &t.screens.active.kitty_images;
+ },
}
return .success;
}
-fn pointToZig(pt: point.Point.C) point.Point {
- return switch (pt.tag) {
- .active => .{ .active = pt.value.active },
- .viewport => .{ .viewport = pt.value.viewport },
- .screen => .{ .screen = pt.value.screen },
- .history => .{ .history = pt.value.history },
- };
-}
-
-fn pinFromPoint(terminal: *const ZigTerminal, pt: point.Point.C) ?PageList.Pin {
- return terminal.screens.active.pages.pin(pointToZig(pt));
-}
-
pub fn grid_ref(
terminal_: Terminal,
pt: point.Point.C,
out_ref: ?*grid_ref_c.CGridRef,
) callconv(lib.calling_conv) Result {
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
- const p = pinFromPoint(t, pt) orelse return .invalid_value;
+ const zig_pt: point.Point = switch (pt.tag) {
+ .active => .{ .active = pt.value.active },
+ .viewport => .{ .viewport = pt.value.viewport },
+ .screen => .{ .screen = pt.value.screen },
+ .history => .{ .history = pt.value.history },
+ };
+ const p = t.screens.active.pages.pin(zig_pt) orelse
+ return .invalid_value;
if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p);
return .success;
}
-pub fn read_text(
+pub fn point_from_grid_ref(
terminal_: Terminal,
- selection: Selection,
- alloc_: ?*const CAllocator,
- out_ptr: *?[*]u8,
- out_len: *usize,
+ ref: *const grid_ref_c.CGridRef,
+ tag: point.Tag,
+ out: ?*point.Coordinate,
) callconv(lib.calling_conv) Result {
- out_ptr.* = null;
- out_len.* = 0;
-
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
- const alloc = lib.alloc.default(alloc_);
- const sel = selection.toZig(t) orelse return .invalid_value;
-
- var formatter: formatterpkg.ScreenFormatter = .init(t.screens.active, .{
- .emit = .plain,
- .unwrap = true,
- .trim = true,
- });
- formatter.content = .{ .selection = sel };
-
- var aw: std.Io.Writer.Allocating = .init(alloc);
- defer aw.deinit();
- formatter.format(&aw.writer) catch return .out_of_memory;
-
- const buf = aw.toOwnedSlice() catch return .out_of_memory;
- out_ptr.* = buf.ptr;
- out_len.* = buf.len;
+ const p = ref.toPin() orelse return .invalid_value;
+ const pt = t.screens.active.pages.pointFromPin(tag, p) orelse
+ return .no_value;
+ if (out) |o| o.* = pt.coord();
return .success;
}
@@ -983,6 +1052,29 @@ test "vt_write split escape sequence" {
try testing.expectEqualStrings("Hello Bold", str);
}
+test "vt_write split combining mark after base at right edge" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 2,
+ .rows = 2,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ // Put "å" in the final column, then send its combining low line in a
+ // separate write so the mark arrives while the cursor has a pending wrap.
+ vt_write(t, "xå", 3);
+ vt_write(t, "\xcc\xb2", 2);
+
+ const str = try t.?.terminal.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("xå̲", str);
+}
+
test "get cols and rows" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
@@ -1244,6 +1336,102 @@ test "grid_ref null terminal" {
}, &out_ref));
}
+test "point_from_grid_ref roundtrip active" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer free(t);
+
+ vt_write(t, "Hello", 5);
+
+ // Get a grid ref at (2, 0) in active coords
+ var ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 2, .y = 0 } },
+ }, &ref));
+
+ // Convert back to active coords
+ var coord: point.Coordinate = undefined;
+ try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .active, &coord));
+ try testing.expectEqual(@as(size.CellCountInt, 2), coord.x);
+ try testing.expectEqual(@as(u32, 0), coord.y);
+}
+
+test "point_from_grid_ref roundtrip viewport" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
+ ));
+ defer free(t);
+
+ vt_write(t, "Hello", 5);
+
+ var ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .viewport,
+ .value = .{ .viewport = .{ .x = 0, .y = 0 } },
+ }, &ref));
+
+ var coord: point.Coordinate = undefined;
+ try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .viewport, &coord));
+ try testing.expectEqual(@as(size.CellCountInt, 0), coord.x);
+ try testing.expectEqual(@as(u32, 0), coord.y);
+}
+
+test "point_from_grid_ref history ref to active returns no_value" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 4, .max_scrollback = 10_000 },
+ ));
+ defer free(t);
+
+ // Write enough lines to push content into scrollback
+ for (0..10) |_| {
+ vt_write(t, "line\n", 5);
+ }
+
+ // Get a ref to the first line (now in scrollback)
+ var ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .screen,
+ .value = .{ .screen = .{ .x = 0, .y = 0 } },
+ }, &ref));
+
+ // Should succeed for screen coords
+ var coord: point.Coordinate = undefined;
+ try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .screen, &coord));
+ try testing.expectEqual(@as(u32, 0), coord.y);
+
+ // Should fail for active coords (it's in scrollback)
+ try testing.expectEqual(Result.no_value, point_from_grid_ref(t, &ref, .active, &coord));
+}
+
+test "point_from_grid_ref null terminal" {
+ var ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.invalid_value, point_from_grid_ref(null, &ref, .active, null));
+}
+
+test "point_from_grid_ref null node" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer free(t);
+
+ const ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.invalid_value, point_from_grid_ref(t, &ref, .active, null));
+}
+
test "set write_pty callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
@@ -2410,3 +2598,49 @@ test "set color sets dirty flag" {
try testing.expectEqual(Result.success, set(t, .color_foreground, @ptrCast(&fg)));
try testing.expect(zt.flags.dirty.palette);
}
+
+test "get_multi success" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer free(t);
+
+ var cols: u16 = 0;
+ var rows: u16 = 0;
+ var written: usize = 0;
+
+ const keys = [_]TerminalData{ .cols, .rows };
+ var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&rows) };
+ try testing.expectEqual(Result.success, get_multi(t, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(80, cols);
+ try testing.expectEqual(24, rows);
+}
+
+test "get_multi error sets out_written" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer free(t);
+
+ var cols: u16 = 0;
+ var written: usize = 99;
+
+ const keys = [_]TerminalData{ .cols, .invalid };
+ var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&cols) };
+ try testing.expectEqual(Result.invalid_value, get_multi(t, keys.len, &keys, &values, &written));
+ try testing.expectEqual(1, written);
+ try testing.expectEqual(80, cols);
+}
+
+test "get_multi null keys returns invalid_value" {
+ var cols: u16 = 0;
+ var values = [_]?*anyopaque{@ptrCast(&cols)};
+ try testing.expectEqual(Result.invalid_value, get_multi(null, 1, null, &values, null));
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig
index 51184b47..500809d9 100644
--- a/vendor/libghostty-vt/src/terminal/c/types.zig
+++ b/vendor/libghostty-vt/src/terminal/c/types.zig
@@ -13,6 +13,7 @@ const size_report = @import("size_report.zig");
const terminal = @import("terminal.zig");
const formatter = @import("formatter.zig");
+const selection = @import("selection.zig");
const render = @import("render.zig");
const style_c = @import("style.zig");
const mouse_encode = @import("mouse_encode.zig");
@@ -26,6 +27,7 @@ pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
.{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) },
.{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
+ .{ "GhosttySelection", StructInfo.init(selection.CSelection) },
.{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
.{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
.{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
@@ -41,7 +43,6 @@ pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
- .{ "GhosttyTerminalSelection", StructInfo.init(terminal.Selection) },
});
/// The comptime-generated JSON string of all structs.
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
index c710f81a..6659cd31 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
@@ -25,6 +25,7 @@ pub const unicode = @import("graphics_unicode.zig");
pub const Command = command.Command;
pub const CommandParser = command.Parser;
pub const Image = image.Image;
+pub const LoadingImage = image.LoadingImage;
pub const ImageStorage = storage.ImageStorage;
pub const RenderPlacement = render.Placement;
pub const Response = command.Response;
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
index dfce56e3..aa35fbeb 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
@@ -3,6 +3,7 @@ const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const simd = @import("../../simd/main.zig");
+const lib = @import("../lib.zig");
const log = std.log.scoped(.kitty_gfx);
@@ -37,6 +38,10 @@ pub const Parser = struct {
/// here instead of a fixed buffer so that we can too.
data: std.ArrayList(u8),
+ /// Maximum bytes the data payload can take. This is to prevent
+ /// malicious input from causing us to allocate too much memory.
+ max_bytes: usize,
+
/// Internal state for parsing.
state: State,
@@ -54,7 +59,7 @@ pub const Parser = struct {
/// Initialize the parser. The allocator given will be used for both
/// temporary data and long-lived values such as the final image blob.
- pub fn init(alloc: Allocator) Parser {
+ pub fn init(alloc: Allocator, max_bytes: usize) Parser {
var arena = ArenaAllocator.init(alloc);
errdefer arena.deinit();
var result: Parser = .{
@@ -63,6 +68,7 @@ pub const Parser = struct {
.kv = .{},
.kv_temp_len = 0,
.kv_current = 0,
+ .max_bytes = max_bytes,
.state = .control_key,
.kv_temp = undefined,
@@ -83,7 +89,7 @@ pub const Parser = struct {
/// Parse a complete command string.
pub fn parseString(alloc: Allocator, data: []const u8) !Command {
- var parser = init(alloc);
+ var parser = init(alloc, 1024 * 1024);
defer parser.deinit();
for (data) |c| try parser.feed(c);
return try parser.complete(alloc);
@@ -136,7 +142,10 @@ pub const Parser = struct {
else => {},
},
- .data => try self.data.append(self.arena.child_allocator, c),
+ .data => {
+ if (self.data.items.len >= self.max_bytes) return error.OutOfMemory;
+ try self.data.append(self.arena.child_allocator, c);
+ },
}
}
@@ -394,39 +403,38 @@ pub const Transmission = struct {
compression: Compression = .none, // o
more_chunks: bool = false, // m
- pub const Format = enum {
- rgb, // 24
- rgba, // 32
- png, // 100
-
+ pub const Format = lib.Enum(lib.target, &.{
+ "rgb", // 24
+ "rgba", // 32
+ "png", // 100
// The following are not supported directly via the protocol
// but they are formats that a png may decode to that we
// support.
- gray_alpha,
- gray,
-
- pub fn bpp(self: Format) u8 {
- return switch (self) {
- .gray => 1,
- .gray_alpha => 2,
- .rgb => 3,
- .rgba => 4,
- .png => unreachable, // Must be validated before
- };
- }
- };
-
- pub const Medium = enum {
- direct, // d
- file, // f
- temporary_file, // t
- shared_memory, // s
- };
-
- pub const Compression = enum {
- none,
- zlib_deflate, // z
- };
+ "gray_alpha",
+ "gray",
+ });
+
+ pub const Medium = lib.Enum(lib.target, &.{
+ "direct", // d
+ "file", // f
+ "temporary_file", // t
+ "shared_memory", // s
+ });
+
+ pub const Compression = lib.Enum(lib.target, &.{
+ "none",
+ "zlib_deflate", // z
+ });
+
+ pub fn formatBpp(format: Format) u8 {
+ return switch (format) {
+ .gray => 1,
+ .gray_alpha => 2,
+ .rgb => 3,
+ .rgba => 4,
+ .png => unreachable, // Must be validated before
+ };
+ }
fn parse(kv: KV) !Transmission {
var result: Transmission = .{};
@@ -964,7 +972,7 @@ pub const CompositionMode = enum {
test "transmission command" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "f=24,s=10,v=20";
@@ -982,7 +990,7 @@ test "transmission command" {
test "transmission ignores 'm' if medium is not direct" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=t,t=t,m=1";
@@ -999,7 +1007,7 @@ test "transmission ignores 'm' if medium is not direct" {
test "transmission respects 'm' if medium is direct" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=t,t=d,m=1";
@@ -1016,7 +1024,7 @@ test "transmission respects 'm' if medium is direct" {
test "query command" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "i=31,s=1,v=1,a=q,t=d,f=24;QUFBQQ";
@@ -1036,7 +1044,7 @@ test "query command" {
test "display command" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=p,U=1,i=31,c=80,r=120";
@@ -1054,7 +1062,7 @@ test "display command" {
test "delete command" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=d,d=p,x=3,y=4";
@@ -1074,7 +1082,7 @@ test "delete command" {
test "no control data" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = ";QUFBQQ";
@@ -1089,7 +1097,7 @@ test "no control data" {
test "ignore unknown keys (long)" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "f=24,s=10,v=20,hello=world";
@@ -1107,7 +1115,7 @@ test "ignore unknown keys (long)" {
test "ignore very long values" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "f=24,s=10,v=2000000000000000000000000000000000000000";
@@ -1125,7 +1133,7 @@ test "ignore very long values" {
test "ensure very large negative values don't get skipped" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=p,i=1,z=-2000000000";
@@ -1142,7 +1150,7 @@ test "ensure very large negative values don't get skipped" {
test "ensure proper overflow error for u32" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=p,i=10000000000";
@@ -1153,7 +1161,7 @@ test "ensure proper overflow error for u32" {
test "ensure proper overflow error for i32" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=p,i=1,z=-9999999999";
@@ -1167,7 +1175,7 @@ test "all i32 values" {
{
// 'z' (usually z-axis values)
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=p,i=1,z=-1";
for (input) |c| try p.feed(c);
@@ -1182,7 +1190,7 @@ test "all i32 values" {
{
// 'H' (relative placement, horizontal offset)
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=p,i=1,H=-1";
for (input) |c| try p.feed(c);
@@ -1197,7 +1205,7 @@ test "all i32 values" {
{
// 'V' (relative placement, vertical offset)
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=p,i=1,V=-1";
for (input) |c| try p.feed(c);
@@ -1254,7 +1262,7 @@ test "response: encode with image ID and number" {
test "delete range command 1" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=d,d=r,x=3,y=4";
@@ -1274,7 +1282,7 @@ test "delete range command 1" {
test "delete range command 2" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=d,d=R,x=5,y=11";
@@ -1294,7 +1302,7 @@ test "delete range command 2" {
test "delete range command 3" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=d,d=R,x=5,y=4";
@@ -1305,7 +1313,7 @@ test "delete range command 3" {
test "delete range command 4" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=d,d=R,x=5";
@@ -1316,7 +1324,7 @@ test "delete range command 4" {
test "delete range command 5" {
const testing = std.testing;
const alloc = testing.allocator;
- var p = Parser.init(alloc);
+ var p = Parser.init(alloc, 1024 * 1024);
defer p.deinit();
const input = "a=d,d=R,y=5";
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
index 5b3ab915..a6a879e5 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
@@ -44,7 +44,7 @@ pub fn execute(
var quiet = cmd.quiet;
const resp_: ?Response = switch (cmd.control) {
- .query => query(alloc, cmd),
+ .query => query(alloc, terminal, cmd),
.display => display(alloc, terminal, cmd),
.delete => delete(alloc, terminal, cmd),
@@ -94,7 +94,11 @@ pub fn execute(
/// This command is used to attempt to load an image and respond with
/// success/error but does not persist any of the command to the terminal
/// state.
-fn query(alloc: Allocator, cmd: *const Command) Response {
+fn query(
+ alloc: Allocator,
+ terminal: *const Terminal,
+ cmd: *const Command,
+) Response {
const t = cmd.control.query;
// Query requires image ID. We can't actually send a response without
@@ -112,7 +116,8 @@ fn query(alloc: Allocator, cmd: *const Command) Response {
};
// Attempt to load the image. If we cannot, then set an appropriate error.
- var loading = LoadingImage.init(alloc, cmd) catch |err| {
+ const storage = &terminal.screens.active.kitty_images;
+ var loading = LoadingImage.init(alloc, cmd, storage.image_limits) catch |err| {
encodeError(&result, err);
return result;
};
@@ -322,7 +327,7 @@ fn loadAndAddImage(
}
break :loading loading.*;
- } else try .init(alloc, cmd);
+ } else try .init(alloc, cmd, storage.image_limits);
// We only want to deinit on error. If we're chunking, then we don't
// want to deinit at all. If we're not chunking, then we'll deinit
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
index d2877cfc..8243a632 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
@@ -8,7 +8,7 @@ const posix = std.posix;
const fastmem = @import("../../fastmem.zig");
const command = @import("graphics_command.zig");
const PageList = @import("../PageList.zig");
-const wuffs = @import("wuffs");
+const sys = @import("../sys.zig");
const temp_dir = struct {
const TempDir = @import("../../os/TempDir.zig");
@@ -44,10 +44,38 @@ pub const LoadingImage = struct {
/// used if q isn't set on subsequent chunks.
quiet: command.Command.Quiet,
+ /// The limits of the Kitty Graphics protocol we should allow.
+ ///
+ /// This can be used to restrict the type of images and other
+ /// parameters for resource or security reasons. Note that depending
+ /// on how libghostty is compiled, some of these may be fully unsupported
+ /// and ignored (e.g. "file" on wasm32-freestanding).
+ pub const Limits = packed struct {
+ file: bool,
+ temporary_file: bool,
+ shared_memory: bool,
+
+ pub const all: Limits = .{
+ .file = true,
+ .temporary_file = true,
+ .shared_memory = true,
+ };
+
+ pub const direct: Limits = .{
+ .file = false,
+ .temporary_file = false,
+ .shared_memory = false,
+ };
+ };
+
/// Initialize a chunked immage from the first image transmission.
/// If this is a multi-chunk image, this should only be the FIRST
/// chunk.
- pub fn init(alloc: Allocator, cmd: *const command.Command) !LoadingImage {
+ pub fn init(
+ alloc: Allocator,
+ cmd: *const command.Command,
+ limits: Limits,
+ ) !LoadingImage {
// Build our initial image from the properties sent via the control.
// These can be overwritten by the data loading process. For example,
// PNG loading sets the width/height from the data.
@@ -72,6 +100,26 @@ pub const LoadingImage = struct {
return result;
}
+ // Verify our capabilities and limits allow this.
+ {
+ // Special case if we don't support decoding PNGs and the format
+ // is a PNG we can save a lot of memory/effort buffering the
+ // data but failing up front.
+ if (t.format == .png and
+ sys.decode_png == null)
+ {
+ return error.UnsupportedMedium;
+ }
+
+ // Verify the medium is allowed
+ switch (t.medium) {
+ .direct => unreachable,
+ .file => if (!limits.file) return error.UnsupportedMedium,
+ .temporary_file => if (!limits.temporary_file) return error.UnsupportedMedium,
+ .shared_memory => if (!limits.shared_memory) return error.UnsupportedMedium,
+ }
+ }
+
// Otherwise, the payload data is guaranteed to be a path.
if (comptime builtin.os.tag != .windows) {
@@ -111,8 +159,9 @@ pub const LoadingImage = struct {
t: command.Transmission,
path: []const u8,
) !void {
+ // android does not support POSIX shared memory.
// windows is currently unsupported, does it support shm?
- if (comptime builtin.target.os.tag == .windows) {
+ if (comptime builtin.abi.isAndroid() or builtin.target.os.tag == .windows) {
return error.UnsupportedMedium;
}
@@ -154,8 +203,8 @@ pub const LoadingImage = struct {
.png => stat_size,
// For these formats we have a size we must have.
- .gray, .gray_alpha, .rgb, .rgba => |f| size: {
- const bpp = f.bpp();
+ .gray, .gray_alpha, .rgb, .rgba => size: {
+ const bpp = command.Transmission.formatBpp(self.image.format);
break :size self.image.width * self.image.height * bpp;
},
};
@@ -281,17 +330,16 @@ pub const LoadingImage = struct {
fn isPathInTempDir(path: []const u8) bool {
if (std.mem.startsWith(u8, path, "/tmp")) return true;
if (std.mem.startsWith(u8, path, "/dev/shm")) return true;
- if (temp_dir.allocTmpDir(std.heap.page_allocator)) |dir| {
- defer temp_dir.freeTmpDir(std.heap.page_allocator, dir);
- if (std.mem.startsWith(u8, path, dir)) return true;
-
- // The temporary dir is sometimes a symlink. On macOS for
- // example /tmp is /private/var/...
- var buf: [std.fs.max_path_bytes]u8 = undefined;
- if (posix.realpath(dir, &buf)) |real_dir| {
- if (std.mem.startsWith(u8, path, real_dir)) return true;
- } else |_| {}
- }
+ const dir = temp_dir.allocTmpDir(std.heap.page_allocator) catch return false;
+ defer temp_dir.freeTmpDir(std.heap.page_allocator, dir);
+ if (std.mem.startsWith(u8, path, dir)) return true;
+
+ // The temporary dir is sometimes a symlink. On macOS for
+ // example /tmp is /private/var/...
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ if (posix.realpath(dir, &buf)) |real_dir| {
+ if (std.mem.startsWith(u8, path, real_dir)) return true;
+ } else |_| {}
return false;
}
@@ -342,7 +390,7 @@ pub const LoadingImage = struct {
if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge;
// Data length must be what we expect
- const bpp = img.format.bpp();
+ const bpp = command.Transmission.formatBpp(img.format);
const expected_len = img.width * img.height * bpp;
const actual_len = self.data.items.len;
if (actual_len != expected_len) {
@@ -426,13 +474,14 @@ pub const LoadingImage = struct {
fn decodePng(self: *LoadingImage, alloc: Allocator) !void {
assert(self.image.format == .png);
- const result = wuffs.png.decode(
+ const decode_png_fn = sys.decode_png orelse
+ return error.UnsupportedFormat;
+ const result = decode_png_fn(
alloc,
self.data.items,
) catch |err| switch (err) {
- error.WuffsError => return error.InvalidData,
+ error.InvalidData => return error.InvalidData,
error.OutOfMemory => return error.OutOfMemory,
- error.Overflow => return error.InvalidData,
};
defer alloc.free(result.data);
@@ -522,7 +571,7 @@ test "image load with invalid RGB data" {
.data = try alloc.dupe(u8, "AAAA"),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
defer loading.deinit(alloc);
}
@@ -540,7 +589,7 @@ test "image load with image too wide" {
.data = try alloc.dupe(u8, "AAAA"),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
defer loading.deinit(alloc);
try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc));
}
@@ -559,7 +608,7 @@ test "image load with image too tall" {
.data = try alloc.dupe(u8, "AAAA"),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
defer loading.deinit(alloc);
try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc));
}
@@ -583,7 +632,7 @@ test "image load: rgb, zlib compressed, direct" {
),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
defer loading.deinit(alloc);
var img = try loading.complete(alloc);
defer img.deinit(alloc);
@@ -611,7 +660,7 @@ test "image load: rgb, not compressed, direct" {
),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
defer loading.deinit(alloc);
var img = try loading.complete(alloc);
defer img.deinit(alloc);
@@ -640,7 +689,7 @@ test "image load: rgb, zlib compressed, direct, chunked" {
.data = try alloc.dupe(u8, data[0..1024]),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
defer loading.deinit(alloc);
// Read our remaining chunks
@@ -676,7 +725,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
} },
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
defer loading.deinit(alloc);
// Read our remaining chunks
@@ -720,7 +769,7 @@ test "image load: temporary file without correct path" {
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
- try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd));
+ try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd, .all));
// Temporary file should still be there
try tmp_dir.dir.access(path, .{});
@@ -753,7 +802,7 @@ test "image load: rgb, not compressed, temporary file" {
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .all);
defer loading.deinit(alloc);
var img = try loading.complete(alloc);
defer img.deinit(alloc);
@@ -790,7 +839,7 @@ test "image load: rgb, not compressed, regular file" {
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .all);
defer loading.deinit(alloc);
var img = try loading.complete(alloc);
defer img.deinit(alloc);
@@ -799,6 +848,8 @@ test "image load: rgb, not compressed, regular file" {
}
test "image load: png, not compressed, regular file" {
+ if (sys.decode_png == null) return error.SkipZigTest;
+
const testing = std.testing;
const alloc = testing.allocator;
@@ -825,7 +876,7 @@ test "image load: png, not compressed, regular file" {
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
- var loading = try LoadingImage.init(alloc, &cmd);
+ var loading = try LoadingImage.init(alloc, &cmd, .all);
defer loading.deinit(alloc);
var img = try loading.complete(alloc);
defer img.deinit(alloc);
@@ -833,3 +884,161 @@ test "image load: png, not compressed, regular file" {
try testing.expect(img.format == .rgba);
try tmp_dir.dir.access(path, .{});
}
+
+test "limits: direct medium always allowed" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var cmd: command.Command = .{
+ .control = .{ .transmit = .{
+ .format = .rgb,
+ .medium = .direct,
+ .width = 1,
+ .height = 1,
+ .image_id = 31,
+ } },
+ .data = try alloc.dupe(u8, "AAAA"),
+ };
+ defer cmd.deinit(alloc);
+
+ // Direct medium should work even with the most restrictive limits
+ var loading = try LoadingImage.init(alloc, &cmd, .direct);
+ defer loading.deinit(alloc);
+}
+
+test "limits: file medium blocked by limits" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var tmp_dir = try temp_dir.TempDir.init();
+ defer tmp_dir.deinit();
+ const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
+ try tmp_dir.dir.writeFile(.{
+ .sub_path = "image.data",
+ .data = data,
+ });
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const path = try tmp_dir.dir.realpath("image.data", &buf);
+
+ var cmd: command.Command = .{
+ .control = .{ .transmit = .{
+ .format = .rgb,
+ .medium = .file,
+ .compression = .none,
+ .width = 20,
+ .height = 15,
+ .image_id = 31,
+ } },
+ .data = try alloc.dupe(u8, path),
+ };
+ defer cmd.deinit(alloc);
+ try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .direct));
+}
+
+test "limits: file medium allowed by limits" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var tmp_dir = try temp_dir.TempDir.init();
+ defer tmp_dir.deinit();
+ const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
+ try tmp_dir.dir.writeFile(.{
+ .sub_path = "image.data",
+ .data = data,
+ });
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const path = try tmp_dir.dir.realpath("image.data", &buf);
+
+ var cmd: command.Command = .{
+ .control = .{ .transmit = .{
+ .format = .rgb,
+ .medium = .file,
+ .compression = .none,
+ .width = 20,
+ .height = 15,
+ .image_id = 31,
+ } },
+ .data = try alloc.dupe(u8, path),
+ };
+ defer cmd.deinit(alloc);
+ var loading = try LoadingImage.init(alloc, &cmd, .{
+ .file = true,
+ .temporary_file = false,
+ .shared_memory = false,
+ });
+ defer loading.deinit(alloc);
+}
+
+test "limits: temporary file medium blocked by limits" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var tmp_dir = try temp_dir.TempDir.init();
+ defer tmp_dir.deinit();
+ const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
+ try tmp_dir.dir.writeFile(.{
+ .sub_path = "tty-graphics-protocol-image.data",
+ .data = data,
+ });
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
+
+ var cmd: command.Command = .{
+ .control = .{ .transmit = .{
+ .format = .rgb,
+ .medium = .temporary_file,
+ .compression = .none,
+ .width = 20,
+ .height = 15,
+ .image_id = 31,
+ } },
+ .data = try alloc.dupe(u8, path),
+ };
+ defer cmd.deinit(alloc);
+ try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .{
+ .file = true,
+ .temporary_file = false,
+ .shared_memory = true,
+ }));
+
+ // File should still exist since we blocked before reading
+ try tmp_dir.dir.access("tty-graphics-protocol-image.data", .{});
+}
+
+test "limits: temporary file medium allowed by limits" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var tmp_dir = try temp_dir.TempDir.init();
+ defer tmp_dir.deinit();
+ const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
+ try tmp_dir.dir.writeFile(.{
+ .sub_path = "tty-graphics-protocol-image.data",
+ .data = data,
+ });
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
+
+ var cmd: command.Command = .{
+ .control = .{ .transmit = .{
+ .format = .rgb,
+ .medium = .temporary_file,
+ .compression = .none,
+ .width = 20,
+ .height = 15,
+ .image_id = 31,
+ } },
+ .data = try alloc.dupe(u8, path),
+ };
+ defer cmd.deinit(alloc);
+ var loading = try LoadingImage.init(alloc, &cmd, .{
+ .file = false,
+ .temporary_file = true,
+ .shared_memory = false,
+ });
+ defer loading.deinit(alloc);
+}
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
index 8ff68e3f..e017d5f7 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
@@ -51,6 +51,9 @@ pub const ImageStorage = struct {
/// Non-null if there is an in-progress loading image.
loading: ?*LoadingImage = null,
+ /// The limits of what medium types are allowed for image loading.
+ image_limits: LoadingImage.Limits = .direct,
+
/// The total bytes of image data that have been loaded and the limit.
/// If the limit is reached, the oldest images will be evicted to make
/// space. Unused images take priority.
@@ -89,8 +92,9 @@ pub const ImageStorage = struct {
) !void {
// Special case disabling by quickly deleting all
if (limit == 0) {
+ const image_limits = self.image_limits;
self.deinit(alloc, s);
- self.* = .{};
+ self.* = .{ .image_limits = image_limits };
}
// If we re lowering our limit, check if we need to evict.
@@ -658,9 +662,10 @@ pub const ImageStorage = struct {
}
}
- /// Calculates the size of this placement's image in pixels,
- /// taking in to account the specified rows and columns.
- pub fn calculatedSize(
+ /// Returns the size of this placement's image in pixels,
+ /// taking into account the source rectangle, specified
+ /// rows/columns, and aspect ratio.
+ pub fn pixelSize(
self: Placement,
image: Image,
t: *const terminal.Terminal,
@@ -755,7 +760,7 @@ pub const ImageStorage = struct {
// Otherwise we calculate the pixel size, divide by
// cell size, and round up to the nearest integer.
- const calc_size = self.calculatedSize(image, t);
+ const calc_size = self.pixelSize(image, t);
return .{
.cols = std.math.divCeil(
u32,
@@ -1334,7 +1339,7 @@ test "storage: aspect ratio calculation when only columns or rows specified" {
// that's 100px width. 100px * (9 / 16) = 56.25, which should round
// to a height of 56px.
- const calc_size = placement.calculatedSize(image, &t);
+ const calc_size = placement.pixelSize(image, &t);
try testing.expectEqual(@as(u32, 100), calc_size.width);
try testing.expectEqual(@as(u32, 56), calc_size.height);
}
@@ -1352,7 +1357,7 @@ test "storage: aspect ratio calculation when only columns or rows specified" {
// 100px height. 100px * (16 / 9) = 177.77..., which should round to
// a width of 178px.
- const calc_size = placement.calculatedSize(image, &t);
+ const calc_size = placement.pixelSize(image, &t);
try testing.expectEqual(@as(u32, 178), calc_size.width);
try testing.expectEqual(@as(u32, 100), calc_size.height);
}
diff --git a/vendor/libghostty-vt/src/terminal/main.zig b/vendor/libghostty-vt/src/terminal/main.zig
index 9f5b65e3..87a9aded 100644
--- a/vendor/libghostty-vt/src/terminal/main.zig
+++ b/vendor/libghostty-vt/src/terminal/main.zig
@@ -23,6 +23,7 @@ pub const search = @import("search.zig");
pub const sgr = @import("sgr.zig");
pub const size = @import("size.zig");
pub const size_report = @import("size_report.zig");
+pub const sys = @import("sys.zig");
pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {};
pub const x11_color = @import("x11_color.zig");
diff --git a/vendor/libghostty-vt/src/terminal/modes.zig b/vendor/libghostty-vt/src/terminal/modes.zig
index 0d8cde5b..c92f97b0 100644
--- a/vendor/libghostty-vt/src/terminal/modes.zig
+++ b/vendor/libghostty-vt/src/terminal/modes.zig
@@ -270,6 +270,11 @@ const entries: []const ModeEntry = &.{
.{ .name = "reverse_wrap", .value = 45 },
.{ .name = "alt_screen_legacy", .value = 47 },
.{ .name = "keypad_keys", .value = 66 },
+ // DEC Backarrow Key Mode (DECBKM)
+ // See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170
+ // If `false` (the default), `backspace` emits 0x7f
+ // If `true`, `backspace` emits 0x08
+ .{ .name = "backarrow_key_mode", .value = 67 },
.{ .name = "enable_left_and_right_margin", .value = 69 },
.{ .name = "mouse_event_normal", .value = 1000 },
.{ .name = "mouse_event_button", .value = 1002 },
diff --git a/vendor/libghostty-vt/src/terminal/stream_terminal.zig b/vendor/libghostty-vt/src/terminal/stream_terminal.zig
index 8e0f9111..f68f088b 100644
--- a/vendor/libghostty-vt/src/terminal/stream_terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/stream_terminal.zig
@@ -1,5 +1,7 @@
const std = @import("std");
+const build_options = @import("terminal_options");
const testing = std.testing;
+const apc = @import("apc.zig");
const csi = @import("csi.zig");
const device_attributes = @import("device_attributes.zig");
const device_status = @import("device_status.zig");
@@ -35,6 +37,12 @@ pub const Handler = struct {
/// effects.
effects: Effects = .readonly,
+ /// The APC command handler maintains the APC state. APC is like
+ /// CSI or OSC, but it is a private escape sequence that is used
+ /// to send commands to the terminal emulator. This is used by
+ /// the kitty graphics protocol.
+ apc_handler: apc.Handler = .{},
+
pub const Effects = struct {
/// Called when the terminal needs to write data back to the pty,
/// e.g. in response to a DECRQM query. The data is only valid
@@ -98,9 +106,7 @@ pub const Handler = struct {
}
pub fn deinit(self: *Handler) void {
- // Currently does nothing but may in the future so callers should
- // call this.
- _ = self;
+ self.apc_handler.deinit();
}
pub fn vt(
@@ -230,6 +236,11 @@ pub const Handler = struct {
.color_operation => try self.colorOperation(value.op, &value.requests),
.kitty_color_report => try self.kittyColorOperation(value),
+ // APC
+ .apc_start => self.apc_handler.start(),
+ .apc_put => self.apc_handler.feed(self.terminal.gpa(), value),
+ .apc_end => self.apcEnd(),
+
// Effect-based handlers
.bell => self.bell(),
.device_attributes => self.reportDeviceAttributes(value),
@@ -249,13 +260,6 @@ pub const Handler = struct {
.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
.report_pwd,
.show_desktop_notification,
@@ -650,6 +654,33 @@ pub const Handler = struct {
}
}
}
+
+ fn apcEnd(self: *Handler) void {
+ const alloc = self.terminal.gpa();
+ var cmd = self.apc_handler.end() orelse return;
+ defer cmd.deinit(alloc);
+
+ switch (cmd) {
+ .kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) {
+ if (self.terminal.kittyGraphics(
+ alloc,
+ kitty_cmd,
+ )) |resp| resp: {
+ // Don't waste time encoding if we can't write responses
+ // anyways.
+ if (self.effects.write_pty == null) break :resp;
+
+ // Encode and write the response if we have one.
+ var buf: [1024]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ resp.encode(&writer) catch return;
+ writer.writeByte(0) catch return;
+ const final = writer.buffered();
+ if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]);
+ }
+ },
+ }
+ }
};
test "basic print" {
@@ -2069,3 +2100,52 @@ test "device attributes: custom response" {
s.nextSlice("\x1B[>c");
try testing.expectEqualStrings("\x1b[>41;100;0c", S.written.?);
}
+
+test "kitty graphics APC response" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var written: ?[]const u8 = null;
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ if (written) |old| testing.allocator.free(old);
+ written = testing.allocator.dupe(u8, data) catch @panic("OOM");
+ }
+ };
+ S.written = null;
+ defer if (S.written) |old| testing.allocator.free(old);
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ // Send a kitty graphics transmit command with image id 1
+ s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\");
+
+ // Should have written a response back
+ try testing.expectEqualStrings("\x1b_Gi=1;OK\x1b\\", S.written.?);
+}
+
+test "kitty graphics via APC" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(testing.allocator);
+
+ const handler: Handler = .init(&t);
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ // Send a kitty graphics transmit command via APC:
+ // ESC _ G <payload> ESC \
+ // a=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;//////// (1x2 RGB direct)
+ s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\");
+
+ const storage = &t.screens.active.kitty_images;
+ const img = storage.imageById(1).?;
+ try testing.expectEqual(.rgb, img.format);
+}
diff --git a/vendor/libghostty-vt/src/terminal/sys.zig b/vendor/libghostty-vt/src/terminal/sys.zig
new file mode 100644
index 00000000..f0c64da5
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/sys.zig
@@ -0,0 +1,54 @@
+//! System interface for the terminal package.
+//!
+//! This provides runtime-swappable function pointers for operations that
+//! depend on external implementations (e.g. image decoding). Each function
+//! pointer is initialized with a default implementation if available.
+//!
+//! This exists so that the terminal package doesn't have hard dependencies
+//! on specific libraries and enables embedders of the terminal package to
+//! swap out implementations as needed at startup to provide their own
+//! implementations.
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const build_options = @import("terminal_options");
+
+/// Decode PNG data into RGBA pixels. If null, PNG decoding is unsupported
+/// and the exact semantics are up to callers. For example, the Kitty Graphics
+/// Protocol will work but cannot accept PNG images.
+pub var decode_png: ?DecodePngFn = png: {
+ if (build_options.artifact == .lib) break :png null;
+ break :png &decodePngWuffs;
+};
+
+pub const DecodeError = Allocator.Error || error{InvalidData};
+pub const DecodePngFn = *const fn (Allocator, []const u8) DecodeError!Image;
+
+/// The result of decoding an image. The caller owns the returned data
+/// and must free it with the same allocator that was passed to the
+/// decode function.
+pub const Image = struct {
+ width: u32,
+ height: u32,
+ data: []u8,
+};
+
+fn decodePngWuffs(
+ alloc: Allocator,
+ data: []const u8,
+) DecodeError!Image {
+ const wuffs = @import("wuffs");
+ const result = wuffs.png.decode(
+ alloc,
+ data,
+ ) catch |err| switch (err) {
+ error.WuffsError => return error.InvalidData,
+ error.OutOfMemory => return error.OutOfMemory,
+ error.Overflow => return error.InvalidData,
+ };
+
+ return .{
+ .width = result.width,
+ .height = result.height,
+ .data = result.data,
+ };
+}
diff --git a/vendor/libghostty-vt/src/terminfo/ghostty.zig b/vendor/libghostty-vt/src/terminfo/ghostty.zig
index ca579147..5788170e 100644
--- a/vendor/libghostty-vt/src/terminfo/ghostty.zig
+++ b/vendor/libghostty-vt/src/terminfo/ghostty.zig
@@ -112,8 +112,8 @@ pub const ghostty: Source = .{
// Cursor styles
.{ .name = "Ss", .value = .{ .string = "\\E[%p1%d q" } },
- // Cursor style reset
- .{ .name = "Se", .value = .{ .string = "\\E[2 q" } },
+ // Cursor style reset (to user configured default)
+ .{ .name = "Se", .value = .{ .string = "\\E[0 q" } },
// OSC 52 Clipboard
.{ .name = "Ms", .value = .{ .string = "\\E]52;%p1%s;%p2%s\\007" } },
diff --git a/vendor/libghostty-vt/src/termio/Exec.zig b/vendor/libghostty-vt/src/termio/Exec.zig
index 0f35b578..87d47807 100644
--- a/vendor/libghostty-vt/src/termio/Exec.zig
+++ b/vendor/libghostty-vt/src/termio/Exec.zig
@@ -237,6 +237,9 @@ pub fn focusGained(
assert(td.backend == .exec);
const execdata = &td.backend.exec;
+ // Windows has no termios, so there is nothing to poll.
+ if (comptime builtin.os.tag == .windows) return;
+
if (!focused) {
// Flag the timer to end on the next iteration. This is
// a lot cheaper than doing full timer cancellation.
@@ -1552,26 +1555,39 @@ fn execCommand(
defer args.deinit(alloc);
if (comptime builtin.os.tag == .windows) {
- // We run our shell wrapped in `cmd.exe` so that we don't have
- // to parse the command line ourselves if it has arguments.
-
+ // On Windows we run the shell value directly rather than
+ // wrapping in `cmd.exe /C <shell>`. An intermediate cmd
+ // process is wasteful for the common case (`wsl ~`,
+ // `pwsh -NoLogo`, etc.) and has visible side effects
+ // (extra process in the tree, per-process cmd AutoRun
+ // state not reaching the user's actual shell).
+ //
+ // Values with arguments are split on whitespace. This
+ // does not honor Windows CLI quoting rules; users who
+ // need quoted arguments should use the direct command
+ // form, which takes an argv array as-is.
+ //
// Note we don't free any of the memory below since it is
// allocated in the arena.
- const windir = std.process.getEnvVarOwned(
- alloc,
- "WINDIR",
- ) catch |err| {
- log.warn("failed to get WINDIR, cannot run shell command err={}", .{err});
- return error.SystemError;
- };
- const cmd = try std.fs.path.joinZ(alloc, &[_][]const u8{
- windir,
- "System32",
- "cmd.exe",
- });
-
- try args.append(alloc, cmd);
- try args.append(alloc, "/C");
+ if (std.mem.indexOfAny(u8, v, " \t") == null) {
+ // No arguments. If the shell is literally "cmd.exe"
+ // (the default), resolve via %COMSPEC% which is the
+ // documented path to the current command processor.
+ // Other values are passed as-is and resolved by
+ // `internal_os.path.expand` in Command.startWindows.
+ const argv0 = if (std.ascii.eqlIgnoreCase(v, "cmd.exe"))
+ std.process.getEnvVarOwned(alloc, "COMSPEC") catch
+ try alloc.dupe(u8, v)
+ else
+ try alloc.dupe(u8, v);
+ try args.append(alloc, try alloc.dupeZ(u8, argv0));
+ } else {
+ var it = std.mem.tokenizeAny(u8, v, " \t");
+ while (it.next()) |tok| {
+ try args.append(alloc, try alloc.dupeZ(u8, tok));
+ }
+ }
+ break :shell try args.toOwnedSlice(alloc);
} else {
// We run our shell wrapped in `/bin/sh` so that we don't have
// to parse the command line ourselves if it has arguments.
@@ -1759,3 +1775,84 @@ test "execCommand: direct command, config freed" {
try testing.expectEqualStrings(result[0], "foo");
try testing.expectEqualStrings(result[1], "bar baz");
}
+
+test "execCommand windows: bare cmd.exe resolves via COMSPEC" {
+ if (comptime builtin.os.tag != .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .shell = "cmd.exe" }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{};
+ }
+ });
+
+ try testing.expectEqual(1, result.len);
+
+ // Expect COMSPEC if available, otherwise the documented fallback.
+ const expected = std.process.getEnvVarOwned(alloc, "COMSPEC") catch
+ try alloc.dupe(u8, "C:\\Windows\\System32\\cmd.exe");
+ try testing.expectEqualStrings(expected, result[0]);
+}
+
+test "execCommand windows: bare non-cmd shell is passed through" {
+ if (comptime builtin.os.tag != .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .shell = "pwsh.exe" }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{};
+ }
+ });
+
+ try testing.expectEqual(1, result.len);
+ try testing.expectEqualStrings("pwsh.exe", result[0]);
+}
+
+test "execCommand windows: shell with args is split on whitespace" {
+ if (comptime builtin.os.tag != .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .shell = "wsl ~" }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{};
+ }
+ });
+
+ try testing.expectEqual(2, result.len);
+ try testing.expectEqualStrings("wsl", result[0]);
+ try testing.expectEqualStrings("~", result[1]);
+}
+
+test "execCommand windows: direct command is passed through unchanged" {
+ if (comptime builtin.os.tag != .windows) return error.SkipZigTest;
+
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const result = try execCommand(alloc, .{ .direct = &.{
+ "C:\\tools\\foo.exe",
+ "arg with spaces",
+ } }, struct {
+ fn get(_: Allocator) !PasswdEntry {
+ return .{};
+ }
+ });
+
+ try testing.expectEqual(2, result.len);
+ try testing.expectEqualStrings("C:\\tools\\foo.exe", result[0]);
+ try testing.expectEqualStrings("arg with spaces", result[1]);
+}
diff --git a/vendor/libghostty-vt/src/termio/Termio.zig b/vendor/libghostty-vt/src/termio/Termio.zig
index 75ccb94b..1d1bfe25 100644
--- a/vendor/libghostty-vt/src/termio/Termio.zig
+++ b/vendor/libghostty-vt/src/termio/Termio.zig
@@ -255,21 +255,12 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
},
.palette = .init(opts.config.palette),
},
+ .kitty_image_storage_limit = opts.config.image_storage_limit,
+ .kitty_image_loading_limits = .all,
};
});
errdefer term.deinit(alloc);
- // Set the image size limits
- var it = term.screens.all.iterator();
- while (it.next()) |entry| {
- const screen: *terminalpkg.Screen = entry.value.*;
- try screen.kitty_images.setLimit(
- alloc,
- screen,
- opts.config.image_storage_limit,
- );
- }
-
// Set our default cursor style
term.screens.active.cursor.cursor_style = opts.config.cursor_style;
@@ -463,16 +454,9 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi
break :cursor color.toTerminalRGB() orelse break :cursor null;
};
- // Set the image size limits
- var it = self.terminal.screens.all.iterator();
- while (it.next()) |entry| {
- const screen: *terminalpkg.Screen = entry.value.*;
- try screen.kitty_images.setLimit(
- self.alloc,
- screen,
- config.image_storage_limit,
- );
- }
+ // Set the image limits
+ try self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit);
+ self.terminal.setKittyGraphicsLoadingLimits(.all);
}
/// Resize the terminal.