diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-07 18:29:21 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-07 18:29:21 +0300 |
| commit | dcfe11a7588fcd42471159453810abc9b7720a57 (patch) | |
| tree | c32a77266028e73ca2382b96c96f8438a5fcd621 /vendor/libghostty-vt/src | |
| parent | 2dfed860103c4e4505ac7f25def9fe37da1e1e6a (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')
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. |
