Skip to content

Chapter 09: First native GTK window

REAL CAPTURE · CHAPTER 09The first native GTK window
A real 1280 by 800 Xvfb display containing a blank 900 by 600 GTK window positioned at 190 by 100. The white window surface is surrounded by the black root display.
revision
chapter-09-first-window
platform
Linux · GTK 4.6.9 · X11/Xvfb
display
1280×800
window
900×600 at 190×100

The 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.

VISIBLE RESULTGTK initialization, presentation, loop, and teardown
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 exiting

The 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/destroy

DEPENDENCY FRONTIER

Build only what the current result needs

  1. 00Process entryReconstructed
  2. 01App lifecycleReconstructed
  3. 02Runtime + SurfaceReconstructed
  4. 03Child process + PTYReconstructed
  5. 04I/O + parserReconstructed
  6. 05Terminal stateReconstructed
  7. 06Window + GPU + fontsYou are here

This checkpoint crosses only the window part of the frontier. It draws no terminal cells, rectangle, or glyph.

Real Ghostty also built the GTK shell before the full renderer

Section titled “Real Ghostty also built the GTK shell before the full renderer”

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.

SOURCE ARCHAEOLOGYThen, reconstruction, and now solve different-sized problems
  1. 01
    THEN · 2023-02-22Create a GTK window and GL area

    The early runtime set title and size, installed realize/render callbacks, and showed a GtkApplicationWindow.

    3d8c62cOpen first GTK window
  2. 02
    RECONSTRUCTION · CHAPTER 09Present a blank native surface

    An opt-in GTK4 runtime creates one core Surface and one 900×600 window, runs GLib, then exits for capture.

    chapter-09-first-window
  3. 03
    NOW · PINNED MAINOwn a full native product

    Current GTK App and Surface integrate application classes, windows, tabs, actions, input, GLArea rendering, portals, IPC, and platform protocols.

    6ad1fe7Open current GTK App

The first historical GTK window included a GLArea. Our reconstruction stops one step earlier so native lifecycle and graphics initialization remain separate observable milestones.

To ask for the native runtime, run:

Terminal window
zig build run -Dgtk=true

Without -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.

adaptedbuild.zig

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.

adaptedsrc/apprt.zig

Compile-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.

Open apprt/gtk.zig: create, show, run, destroy

Section titled “Open apprt/gtk.zig: create, show, run, destroy”
ZIG SYNTAX BRIDGE

Describe native GTK types and one C callback

Start with the C model, then inspect each highlighted Zig line.

Open in the cumulative Zig/C reference
C MENTAL MODEL

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.

MEMORY / LIFETIME FLOW
  1. GTK / GLibnative window + loop allocations
  2. runtime Appborrowed typed pointers; explicit unref/destroy
  3. GLib callback queuefunction pointer + borrowed data pointer

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: opaque permits typed pointers to a native object without assuming its layout. extern fn declares a C ABI function. The callback type includes callconv(.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.

adaptedsrc/apprt/gtk.zig

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:

  1. calls gtk_init;
  2. creates a GLib main loop;
  3. creates its runtime/core Surface pair;
  4. creates a GTK window;
  5. assigns title and 900×600 default size;
  6. presents the window;
  7. enters the loop;
  8. exits through a capture timeout;
  9. destroys the window and surfaces.

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.

Capture the window so we know it really happened

Section titled “Capture the window so we know it really happened”

The reproducible script is:

Terminal window
ZIG=/path/to/zig tools/capture/chapter-09-first-window.sh

It:

  • starts a 1280×800 Xvfb X11 display;
  • runs the GTK build with GDK_BACKEND=x11;
  • finds the real window by title;
  • moves it to 190×100;
  • captures the X root window;
  • waits for clean application exit;
  • writes exact output and screenshot artifacts.

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
</>
Current GTK App lifecyclesrc/apprt/gtk/App.zig:1–60Read initialization, run, and termination. Ignore action/IPC methods until interaction exists.
Read excerptGitHub
</>
Current GTK Surface boundarysrc/apprt/gtk/Surface.zig:1–35Preview native Surface ownership only. Do not descend into renderer or input integration yet.
Read excerptGitHub
commit bc1e2e0143f143c0cf3545d046c68b4c7964cca5
tag chapter-09-first-window

We have a window—what can we draw in it?

Section titled “We have a window—what can we draw in it?”

The 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.