From 04b5dc733243d85f0bbaa3aea25d65a19649cd64 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 08:58:56 -0700 Subject: [PATCH 1/8] terminal: guard ghostty.h checks on building the app --- src/lib/enum.zig | 5 ++++- src/lib_vt.zig | 10 +++++----- src/terminal/mouse.zig | 1 + src/terminal/osc.zig | 1 + 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/enum.zig b/src/lib/enum.zig index bdec2ab88..f40a40b54 100644 --- a/src/lib/enum.zig +++ b/src/lib/enum.zig @@ -95,7 +95,10 @@ test "abi by removing a key" { /// Verify that for every key in enum T, there is a matching declaration in /// `ghostty.h` with the correct value. This should only ever be called inside a `test` /// because the `ghostty.h` module is only available then. -pub fn checkGhosttyHEnum(comptime T: type, comptime prefix: []const u8) !void { +pub fn checkGhosttyHEnum( + comptime T: type, + comptime prefix: []const u8, +) !void { const info = @typeInfo(T); try std.testing.expect(info == .@"enum"); diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 7a75bb92a..01cea6bcd 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -260,9 +260,9 @@ pub const std_options: std.Options = options: { test { _ = terminal; - _ = @import("lib/main.zig"); - @import("std").testing.refAllDecls(input); - if (comptime terminal.options.c_abi) { - _ = terminal.c_api; - } + // _ = @import("lib/main.zig"); + // @import("std").testing.refAllDecls(input); + // if (comptime terminal.options.c_abi) { + // _ = terminal.c_api; + // } } diff --git a/src/terminal/mouse.zig b/src/terminal/mouse.zig index e72e166f7..5286ab856 100644 --- a/src/terminal/mouse.zig +++ b/src/terminal/mouse.zig @@ -92,6 +92,7 @@ pub const Shape = enum(c_int) { }; test "ghostty.h MouseShape" { + if (comptime build_options.artifact == .lib) return error.SkipZigTest; try lib.checkGhosttyHEnum(Shape, "GHOSTTY_MOUSE_SHAPE_"); } }; diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 0086e48bc..920ce3f7e 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -205,6 +205,7 @@ pub const Command = union(Key) { pause, test "ghostty.h Command.ProgressReport.State" { + if (comptime build_options.artifact == .lib) return error.SkipZigTest; try lib.checkGhosttyHEnum(State, "GHOSTTY_PROGRESS_STATE_"); } }; From 7253668ec20b0a79e41930ec4ba9e189d605cc7e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 09:11:18 -0700 Subject: [PATCH 2/8] config: move file formatter to dedicated file to prevent import bloat --- src/config.zig | 3 +- src/config/formatter.zig | 103 +------------------------------ src/config/formatter_file.zig | 110 ++++++++++++++++++++++++++++++++++ src/input/key.zig | 1 - 4 files changed, 113 insertions(+), 104 deletions(-) create mode 100644 src/config/formatter_file.zig diff --git a/src/config.zig b/src/config.zig index 314fb49ee..289b6a811 100644 --- a/src/config.zig +++ b/src/config.zig @@ -2,6 +2,7 @@ const builtin = @import("builtin"); const file_load = @import("config/file_load.zig"); const formatter = @import("config/formatter.zig"); +const formatter_file = @import("config/formatter_file.zig"); pub const Config = @import("config/Config.zig"); pub const conditional = @import("config/conditional.zig"); pub const io = @import("config/io.zig"); @@ -10,7 +11,7 @@ pub const edit = @import("config/edit.zig"); pub const url = @import("config/url.zig"); pub const ConditionalState = conditional.State; -pub const FileFormatter = formatter.FileFormatter; +pub const FileFormatter = formatter_file.FileFormatter; pub const entryFormatter = formatter.entryFormatter; pub const formatEntry = formatter.formatEntry; pub const preferredDefaultFilePath = file_load.preferredDefaultFilePath; diff --git a/src/config/formatter.zig b/src/config/formatter.zig index dcf99167d..00d946f88 100644 --- a/src/config/formatter.zig +++ b/src/config/formatter.zig @@ -1,8 +1,7 @@ const formatter = @This(); + const std = @import("std"); const Allocator = std.mem.Allocator; -const help_strings = @import("help_strings"); -const Config = @import("Config.zig"); const Key = @import("key.zig").Key; /// Returns a single entry formatter for the given field name and writer. @@ -125,106 +124,6 @@ pub fn formatEntry( @compileError("missing case for type"); } -/// FileFormatter is a formatter implementation that outputs the -/// config in a file-like format. This uses more generous whitespace, -/// can include comments, etc. -pub const FileFormatter = struct { - alloc: Allocator, - config: *const Config, - - /// Include comments for documentation of each key - docs: bool = false, - - /// Only include changed values from the default. - changed: bool = false, - - /// Implements std.fmt so it can be used directly with std.fmt. - pub fn format( - self: FileFormatter, - writer: *std.Io.Writer, - ) std.Io.Writer.Error!void { - @setEvalBranchQuota(10_000); - - // If we're change-tracking then we need the default config to - // compare against. - var default: ?Config = if (self.changed) - Config.default(self.alloc) catch return error.WriteFailed - else - null; - defer if (default) |*v| v.deinit(); - - inline for (@typeInfo(Config).@"struct".fields) |field| { - if (field.name[0] == '_') continue; - - const value = @field(self.config, field.name); - const do_format = if (default) |d| format: { - const key = @field(Key, field.name); - break :format d.changed(self.config, key); - } else true; - - if (do_format) { - const do_docs = self.docs and @hasDecl(help_strings.Config, field.name); - if (do_docs) { - const help = @field(help_strings.Config, field.name); - var lines = std.mem.splitScalar(u8, help, '\n'); - while (lines.next()) |line| { - try writer.print("# {s}\n", .{line}); - } - } - - formatEntry( - field.type, - field.name, - value, - writer, - ) catch return error.WriteFailed; - - if (do_docs) try writer.print("\n", .{}); - } - } - } -}; - -test "format default config" { - const testing = std.testing; - const alloc = testing.allocator; - var cfg = try Config.default(alloc); - defer cfg.deinit(); - - var buf: std.Io.Writer.Allocating = .init(alloc); - defer buf.deinit(); - - // We just make sure this works without errors. We aren't asserting output. - const fmt: FileFormatter = .{ - .alloc = alloc, - .config = &cfg, - }; - try fmt.format(&buf.writer); - - //std.log.warn("{s}", .{buf.written()}); -} - -test "format default config changed" { - const testing = std.testing; - const alloc = testing.allocator; - var cfg = try Config.default(alloc); - defer cfg.deinit(); - cfg.@"font-size" = 42; - - var buf: std.Io.Writer.Allocating = .init(alloc); - defer buf.deinit(); - - // We just make sure this works without errors. We aren't asserting output. - const fmt: FileFormatter = .{ - .alloc = alloc, - .config = &cfg, - .changed = true, - }; - try fmt.format(&buf.writer); - - //std.log.warn("{s}", .{buf.written()}); -} - test "formatEntry bool" { const testing = std.testing; diff --git a/src/config/formatter_file.zig b/src/config/formatter_file.zig new file mode 100644 index 000000000..4bc44c0e5 --- /dev/null +++ b/src/config/formatter_file.zig @@ -0,0 +1,110 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Config = @import("Config.zig"); +const Key = @import("key.zig").Key; +const help_strings = @import("help_strings"); +const formatter = @import("formatter.zig"); + +// IMPORTANT: This is in a seperate file from formatter.zig because it +// puts a build-time dependency on Config.zig which brings in too much +// into libghostty-vt tests which reference some formattable types. + +/// FileFormatter is a formatter implementation that outputs the +/// config in a file-like format. This uses more generous whitespace, +/// can include comments, etc. +pub const FileFormatter = struct { + alloc: Allocator, + config: *const Config, + + /// Include comments for documentation of each key + docs: bool = false, + + /// Only include changed values from the default. + changed: bool = false, + + /// Implements std.fmt so it can be used directly with std.fmt. + pub fn format( + self: FileFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + @setEvalBranchQuota(10_000); + + // If we're change-tracking then we need the default config to + // compare against. + var default: ?Config = if (self.changed) + Config.default(self.alloc) catch return error.WriteFailed + else + null; + defer if (default) |*v| v.deinit(); + + inline for (@typeInfo(Config).@"struct".fields) |field| { + if (field.name[0] == '_') continue; + + const value = @field(self.config, field.name); + const do_format = if (default) |d| format: { + const key = @field(Key, field.name); + break :format d.changed(self.config, key); + } else true; + + if (do_format) { + const do_docs = self.docs and @hasDecl(help_strings.Config, field.name); + if (do_docs) { + const help = @field(help_strings.Config, field.name); + var lines = std.mem.splitScalar(u8, help, '\n'); + while (lines.next()) |line| { + try writer.print("# {s}\n", .{line}); + } + } + + formatter.formatEntry( + field.type, + field.name, + value, + writer, + ) catch return error.WriteFailed; + + if (do_docs) try writer.print("\n", .{}); + } + } + } +}; + +test "format default config" { + const testing = std.testing; + const alloc = testing.allocator; + var cfg = try Config.default(alloc); + defer cfg.deinit(); + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + + // We just make sure this works without errors. We aren't asserting output. + const fmt: FileFormatter = .{ + .alloc = alloc, + .config = &cfg, + }; + try fmt.format(&buf.writer); + + //std.log.warn("{s}", .{buf.written()}); +} + +test "format default config changed" { + const testing = std.testing; + const alloc = testing.allocator; + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.@"font-size" = 42; + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + + // We just make sure this works without errors. We aren't asserting output. + const fmt: FileFormatter = .{ + .alloc = alloc, + .config = &cfg, + .changed = true, + }; + try fmt.format(&buf.writer); + + //std.log.warn("{s}", .{buf.written()}); +} diff --git a/src/input/key.zig b/src/input/key.zig index a929a0323..9c04f01e0 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -2,7 +2,6 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const cimgui = @import("dcimgui"); -const OptionAsAlt = @import("config.zig").OptionAsAlt; pub const Mods = @import("key_mods.zig").Mods; From f60587ffcc15886ee0dd2f24764da4e69f83a9d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 09:14:26 -0700 Subject: [PATCH 3/8] renderer/size: move PaddingBalance enum out of Config Previously WindowPaddingBalance was defined inside Config.zig, which meant tests for renderer sizing had to pull in the full config dependency. Move the enum into renderer/size.zig as PaddingBalance and re-export it from Config so the public API is unchanged. This lets size.zig tests run without depending on Config. --- src/config/Config.zig | 7 +------ src/renderer/size.zig | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index d32740783..facbdb21d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -39,6 +39,7 @@ pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; +pub const WindowPaddingBalance = @import("../renderer/size.zig").PaddingBalance; const string = @import("string.zig"); // We do this instead of importing all of terminal/main.zig to @@ -5245,12 +5246,6 @@ pub const Fullscreen = enum(c_int) { @"non-native-padded-notch", }; -pub const WindowPaddingBalance = enum { - false, - true, - equal, -}; - pub const WindowPaddingColor = enum { background, extend, diff --git a/src/renderer/size.zig b/src/renderer/size.zig index 7a022cdb4..6cf05f58a 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -1,10 +1,22 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const configpkg = @import("../config.zig"); const terminal_size = @import("../terminal/size.zig"); const log = std.log.scoped(.renderer_size); +/// Controls how extra whitespace around the terminal grid is distributed. +pub const PaddingBalance = enum { + /// No balancing; padding is applied as specified explicitly. + false, + /// Balances padding but caps the top padding so the first row doesn't + /// drift too far from the top of the window. Excess vertical space is + /// shifted to the bottom. + true, + /// Distributes leftover space equally on all sides so the grid is + /// centered within the screen. + equal, +}; + /// All relevant sizes for a rendered terminal. These are all the sizes that /// any functionality should need to know about the terminal in order to /// convert between any coordinate systems. @@ -37,7 +49,7 @@ pub const Size = struct { pub fn balancePadding( self: *Size, explicit: Padding, - mode: configpkg.Config.WindowPaddingBalance, + mode: PaddingBalance, ) void { // This ensure grid() does the right thing self.padding = explicit; From 51f878417fede56716a3931b8a55fa4b0cbe15aa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 09:15:32 -0700 Subject: [PATCH 4/8] reenable tests --- src/lib_vt.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 01cea6bcd..7a75bb92a 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -260,9 +260,9 @@ pub const std_options: std.Options = options: { test { _ = terminal; - // _ = @import("lib/main.zig"); - // @import("std").testing.refAllDecls(input); - // if (comptime terminal.options.c_abi) { - // _ = terminal.c_api; - // } + _ = @import("lib/main.zig"); + @import("std").testing.refAllDecls(input); + if (comptime terminal.options.c_abi) { + _ = terminal.c_api; + } } From 409f05c92750e94c376b0c42ae7c70b2d9fbd62c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 09:17:34 -0700 Subject: [PATCH 5/8] typos --- src/config/formatter_file.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/formatter_file.zig b/src/config/formatter_file.zig index 4bc44c0e5..37beb7b54 100644 --- a/src/config/formatter_file.zig +++ b/src/config/formatter_file.zig @@ -5,7 +5,7 @@ const Key = @import("key.zig").Key; const help_strings = @import("help_strings"); const formatter = @import("formatter.zig"); -// IMPORTANT: This is in a seperate file from formatter.zig because it +// IMPORTANT: This is in a separate file from formatter.zig because it // puts a build-time dependency on Config.zig which brings in too much // into libghostty-vt tests which reference some formattable types. From 58283528c79de8f9d17bc5f4e280e43835696c67 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 09:19:32 -0700 Subject: [PATCH 6/8] vt: handle invalid enum before pointer cast in getters The inline else switch in each C API getter expands the .invalid case, which has OutType void. When called with .invalid and a null out pointer, the @ptrCast(@alignCast(out)) panics before getTyped can return early. Handle .invalid explicitly in the outer switch of every getter to short-circuit before the pointer cast. This affects build_info, cell, row, terminal, osc, and render (three getters). --- src/terminal/c/build_info.zig | 1 + src/terminal/c/cell.zig | 1 + src/terminal/c/osc.zig | 1 + src/terminal/c/render.zig | 3 +++ src/terminal/c/row.zig | 1 + src/terminal/c/terminal.zig | 1 + 6 files changed, 8 insertions(+) diff --git a/src/terminal/c/build_info.zig b/src/terminal/c/build_info.zig index d65a3b562..312d94fec 100644 --- a/src/terminal/c/build_info.zig +++ b/src/terminal/c/build_info.zig @@ -43,6 +43,7 @@ pub fn get( } return switch (data) { + .invalid => .invalid_value, inline else => |comptime_data| getTyped( comptime_data, @ptrCast(@alignCast(out)), diff --git a/src/terminal/c/cell.zig b/src/terminal/c/cell.zig index 2ff9b35f9..171867048 100644 --- a/src/terminal/c/cell.zig +++ b/src/terminal/c/cell.zig @@ -110,6 +110,7 @@ pub fn get( } return switch (data) { + .invalid => .invalid_value, inline else => |comptime_data| getTyped( cell_, comptime_data, diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index c4cdaad3b..648e448fd 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -76,6 +76,7 @@ pub fn commandData( } return switch (data) { + .invalid => false, inline else => |comptime_data| commandDataTyped( command_, comptime_data, diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index f8a107979..398fe4ece 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -193,6 +193,7 @@ pub fn get( } return switch (data) { + .invalid => .invalid_value, inline else => |comptime_data| getTyped( state_, comptime_data, @@ -467,6 +468,7 @@ pub fn row_cells_get( } return switch (data) { + .invalid => .invalid_value, inline else => |comptime_data| rowCellsGetTyped( cells_, comptime_data, @@ -566,6 +568,7 @@ pub fn row_get( } return switch (data) { + .invalid => .invalid_value, inline else => |comptime_data| rowGetTyped( iterator_, comptime_data, diff --git a/src/terminal/c/row.zig b/src/terminal/c/row.zig index 6614d922e..7b765b56a 100644 --- a/src/terminal/c/row.zig +++ b/src/terminal/c/row.zig @@ -73,6 +73,7 @@ pub fn get( } return switch (data) { + .invalid => .invalid_value, inline else => |comptime_data| getTyped( row_, comptime_data, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 97b76985d..2dd56a519 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -196,6 +196,7 @@ pub fn get( } return switch (data) { + .invalid => .invalid_value, inline else => |comptime_data| getTyped( terminal_, comptime_data, From f92bb7419692bfce765aa6571ec1d72ac5095a2c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 09:21:33 -0700 Subject: [PATCH 7/8] ci: add test-lib-vt job Add a new CI job that runs `zig build test-lib-vt` to test the lib-vt build step. The job mirrors the existing test job structure with the same nix/cachix setup and skip conditions. It is also added to the required checks list. --- .github/workflows/test.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f59a5bd67..b49fd3b4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -105,6 +105,7 @@ jobs: - test-sentry-linux - test-i18n - test-fuzz-libghostty + - test-lib-vt - test-macos - pinact - prettier @@ -911,6 +912,36 @@ jobs: - name: Test System Build run: nix develop -c zig build --system ${ZIG_GLOBAL_CACHE_DIR}/p + test-lib-vt: + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip + runs-on: namespace-profile-ghostty-md + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test + run: nix develop -c zig build test-lib-vt + test-gtk: strategy: fail-fast: false From 3c8d0a9c25493091b82bed88f2c6c7c171a51c11 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 09:23:11 -0700 Subject: [PATCH 8/8] vt: fix test failures in render and key_encode The colors_get function used structSizedFieldFits to guard the palette copy, which required the entire palette array to fit in the provided size. This prevented partial palette writes when the caller passed a truncated sized struct, since the guard failed even though the inner code already handled partial copies correctly. Remove the outer guard so the existing partial-copy logic applies. The setopt_from_terminal test expected alt_esc_prefix to be false on a fresh terminal, but the mode definition in modes.zig sets its default to true. Update the test expectation to match. --- src/terminal/c/key_encode.zig | 2 +- src/terminal/c/render.zig | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index 2cdb765af..616a438d9 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -259,7 +259,7 @@ test "setopt_from_terminal" { // Options should reflect defaults from a fresh terminal try testing.expect(!e.?.opts.cursor_key_application); - try testing.expect(!e.?.opts.alt_esc_prefix); + try testing.expect(e.?.opts.alt_esc_prefix); try testing.expectEqual(KittyFlags.disabled, e.?.opts.kitty_flags); try testing.expectEqual(OptionAsAlt.false, e.?.opts.macos_option_as_alt); } diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index 398fe4ece..020dbc202 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -337,11 +337,7 @@ pub fn colors_get( out_colors.cursor_has_value = colors.cursor != null; } - if (lib.structSizedFieldFits( - Colors, - out_size, - "palette", - )) { + { const palette_offset = @offsetOf(Colors, "palette"); if (out_size > palette_offset) { const available = out_size - palette_offset;