opaque C type
This declares an incomplete native type: code may hold GMainLoop pointers but cannot inspect its private fields.
Zig difference: opaque prevents accidental layout assumptions while preserving a distinct pointer type.

chapter-09-first-windowThe large white rectangle is the actual GTK client area. Xvfb has no window manager, so the capture intentionally has no title bar or decorative frame.
That plain dark rectangle is a real GTK window from our program. It is not a mockup. It may not look exciting, but getting a native window to open, run its event loop, and close cleanly is the whole lesson.
chapter-09-first-window$ GDK_BACKEND=x11 zig build run -Dgtk=true
[entry] ghostty
[main] process started
[app] created
[gtk] initialized
[surface 1] created
[gtk] window presented 900x600
[gtk] event loop exited
[surface 1] destroyed
[gtk] terminated
[app] destroyed
[main] process exitingThe deterministic timeout exists for reproducible capture. The default headless runtime remains unchanged.
Chapter 08 has useful Terminal state, but a user still cannot see an application. GTK brings a new chain of things we must own:
operating-system display connection→ GTK initialization→ native window→ event loop→ close/destroyDEPENDENCY FRONTIER
This checkpoint crosses only the window part of the frontier. It draws no terminal cells, rectangle, or glyph.
Ghostty added a GTK backend option on February 20, 2023. The next day it initialized GtkApplication and a manually controlled GLib context while its Window methods were still placeholders. On February 22 it created a GTK application window and GLArea with realize/render callbacks.
The early runtime set title and size, installed realize/render callbacks, and showed a GtkApplicationWindow.
3d8c62cOpen first GTK windowAn opt-in GTK4 runtime creates one core Surface and one 900×600 window, runs GLib, then exits for capture.
chapter-09-first-windowCurrent GTK App and Surface integrate application classes, windows, tabs, actions, input, GLArea rendering, portals, IPC, and platform protocols.
6ad1fe7Open current GTK AppThe first historical GTK window included a GLArea. Our reconstruction stops one step earlier so native lifecycle and graphics initialization remain separate observable milestones.
build.zig: make GTK optionalTo ask for the native runtime, run:
zig build run -Dgtk=trueWithout -Dgtk=true, the headless runtime and pure tests build exactly as before. With the option, build_config.app_runtime selects .gtk and the root module asks pkg-config for GTK4.
Adds one opt-in GTK4 pkg-config dependency while preserving headless default builds.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const gtk = b.option(bool, "gtk", "Build the first GTK runtime") orelse false;
const root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const options = b.addOptions();
options.addOption(bool, "gtk", gtk);
root_module.addOptions("build_options", options);
root_module.link_libc = true;
if (gtk) root_module.linkSystemLibrary("gtk4", .{ .use_pkg_config = .force });
const exe = b.addExecutable(.{
.name = "ghostty-from-scratch",
.root_module = root_module,
});
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
run.step.dependOn(b.getInstallStep());
if (b.args) |args| run.addArgs(args);
const run_step = b.step("run", "Run ghostty-from-scratch");
run_step.dependOn(&run.step);
const tests = b.addTest(.{ .root_module = root_module });
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run the Zig tests");
test_step.dependOn(&run_tests.step);
}Why make it optional? Parser and Terminal tests should not suddenly require a display server or GTK development packages.
apprt.zigCompile-time selection now includes headless and GTK runtimes.
const build_config = @import("build_config.zig");
pub const headless = @import("apprt/headless.zig");
pub const gtk = @import("apprt/gtk.zig");
/// The build-selected application runtime.
pub const runtime = switch (build_config.app_runtime) {
.headless => headless,
.gtk => gtk,
};
pub const App = runtime.App;
pub const Surface = runtime.Surface;
test {
_ = runtime;
}The same apprt.App and apprt.Surface names resolve to different concrete types at compile time. main_ghostty does not change.
apprt/gtk.zig: create, show, run, destroyStart with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceThis declares an incomplete native type: code may hold GMainLoop pointers but cannot inspect its private fields.
Zig difference: opaque prevents accidental layout assumptions while preserving a distinct pointer type.
Why no higher-level tab: TypeScript and Python callbacks do not expose the C calling convention, raw context pointer, native alignment, or explicit GTK ownership contract.
Text version:
opaquepermits typed pointers to a native object without assuming its layout.extern fndeclares a C ABI function. The callback type includescallconv(.c)and receives an untyped borrowed context pointer. The callback checks null, restores alignment, and casts back to*GMainLoop; GTK/GLib still own native resources until explicit destroy or unref.
GTK4 window and GLib loop subset; no application registration, actions, input, graphics area, or terminal-state display.
const std = @import("std");
const CoreApp = @import("../App.zig");
const CoreSurface = @import("../Surface.zig");
const GtkWidget = opaque {};
const GtkWindow = opaque {};
const GMainLoop = opaque {};
extern fn gtk_init() void;
extern fn gtk_window_new() ?*GtkWidget;
extern fn gtk_window_set_title(window: *GtkWindow, title: [*:0]const u8) void;
extern fn gtk_window_set_default_size(window: *GtkWindow, width: c_int, height: c_int) void;
extern fn gtk_window_present(window: *GtkWindow) void;
extern fn gtk_window_destroy(window: *GtkWindow) void;
extern fn g_main_loop_new(context: ?*anyopaque, running: c_int) ?*GMainLoop;
extern fn g_main_loop_run(loop: *GMainLoop) void;
extern fn g_main_loop_quit(loop: *GMainLoop) void;
extern fn g_main_loop_unref(loop: *GMainLoop) void;
extern fn g_timeout_add(
interval: c_uint,
function: *const fn (?*anyopaque) callconv(.c) c_int,
data: ?*anyopaque,
) c_uint;
pub const App = struct {
core_app: *CoreApp,
loop: *GMainLoop,
surface: ?Surface,
pub fn init(self: *App, core_app: *CoreApp) void {
gtk_init();
self.* = .{
.core_app = core_app,
.loop = g_main_loop_new(null, 0).?,
.surface = null,
};
std.debug.print("[gtk] initialized\n", .{});
}
pub fn terminate(self: *App) void {
if (self.surface) |*surface| surface.terminate();
self.surface = null;
g_main_loop_unref(self.loop);
std.debug.print("[gtk] terminated\n", .{});
}
pub fn run(self: *App) !void {
self.surface = .{};
try self.surface.?.init(self.core_app, 1);
const widget = gtk_window_new().?;
const window: *GtkWindow = @ptrCast(widget);
gtk_window_set_title(window, "Ghostty from Scratch");
gtk_window_set_default_size(window, 900, 600);
gtk_window_present(window);
std.debug.print("[gtk] window presented 900x600\n", .{});
_ = g_timeout_add(1800, quitLoop, self.loop);
g_main_loop_run(self.loop);
std.debug.print("[gtk] event loop exited\n", .{});
gtk_window_destroy(window);
}
};
pub const Surface = struct {
core_surface: ?*CoreSurface = null,
pub fn init(self: *Surface, app: *CoreApp, id: u64) !void {
self.core_surface = try CoreSurface.create(app, id);
}
pub fn terminate(self: *Surface) void {
if (self.core_surface) |surface| surface.destroy();
self.core_surface = null;
}
};
fn quitLoop(data: ?*anyopaque) callconv(.c) c_int {
const loop: *GMainLoop = @ptrCast(@alignCast(data.?));
g_main_loop_quit(loop);
return 0;
}Read the runtime as one lifetime story:
gtk_init;One practical bump: importing all of gtk/gtk.h made Zig 0.16 choke on thousands of helper macros. Instead of hiding that, this checkpoint declares only the nine C functions it calls. If you have used a small C shim around a huge library, this is the same idea.
The reproducible script is:
ZIG=/path/to/zig tools/capture/chapter-09-first-window.shIt:
GDK_BACKEND=x11;The screenshot’s SHA-256 is validated during the course build.
| File | Status | Frontier-related difference |
|---|---|---|
src/apprt/gtk.zig |
adapted | one window and loop; no GtkApplication class, actions, input, GLArea, tabs, IPC, or terminal rendering |
src/apprt.zig |
adapted | headless and GTK only |
src/build_config.zig |
adapted | one boolean rather than generated runtime/backend options |
build.zig |
adapted | optional GTK4 pkg-config linkage |
tools/capture/chapter-09-first-window.sh |
project-owned | reproducible Xvfb screenshot support, not upstream runtime code |
src/apprt/gtk/App.zig:1–60Read initialization, run, and termination. Ignore action/IPC methods until interaction exists.src/apprt/gtk/Surface.zig:1–35Preview native Surface ownership only. Do not descend into renderer or input integration yet.commit bc1e2e0143f143c0cf3545d046c68b4c7964cca5tag chapter-09-first-windowThe native window is blank. Chapter 10 must create a graphics surface and draw one known rectangle, with a real before/after screenshot, before fonts or terminal cells enter the renderer.