Introducing gtk-ng, the next evolution of our GTK apprt (#7961)

Surprise, @ghostty-org/gtk! Hopefully a happy one.

This PR introduces the boilerplate for a new apprt I'm calling `gtk-ng`.
The `gtk-ng` apprt is still GTK, but built up from first principles
using the GObject type system, Blueprint files, etc. This will
ultimately replace and become `gtk` (the `-ng` suffix will be stripped
once we fully replace our existing GTK apprt).

In this PR, the `gtk-ng` apprt does nothing more but show a "Hello,
Ghostty" GTK window. It doesn't run a terminal, yet. 😄 I want to
use this PR to introduce the boilerplate and share my motivations.

Since `gtk-ng` and `gtk` are separate apprts, I can PR small,
reviewable, and risky changes into `gtk-ng` rather than opening some
mega-PR that replaces everything all at once. Simultaneously, we can
continue to iterate on and maintain our shipping `gtk` apprt without
dealing with conflicts.

> [!IMPORTANT]
>
> To reiterate, this PR doesn't change anything about our `gtk` apprt.
Builds by default will still use the `gtk` apprt and we can continue to
build both `gtk` and `gtk-ng` side by side (actually, a very important
property until we can be confident we've reached parity).

## A Refactor, Not a Rewrite

The primary goal of this apprt is to _primarily_ be a **refactor, not a
rewrite.**

As much as possible, I'm going to be bringing over a lot of the same
logic from `gtk` as long as it fits and makes sense, but applying it to
our new structure and lifecycle. For example in this PR you can see how
we handle style manager, cgroups, etc. and how that fits within the new
`GhosttyApplication` class.

Our GTK apprt from a business logic standpoint is _pretty damn good_ and
_pretty damn stable_. There's no need to rock that boat and try to
rewrite core logic such as input handling, X11/Wayland stuff, etc. It
just has to be massaged into the new structure.

## Why? 

**Object-oriented, reference-counted systems are good for UI,
actually.** Experience iterating on the non-trivial macOS application
has really reaffirmed that OOP and memory managed systems are really,
really nice for GUI. I'm not a huge OOP fan in general, but it fits GUI
patterns extremely well. And memory management of any form (GC, Ref
Counts, etc.) is important in GUIs where "objects" are handed off to
various owners at different times, the most concrete example being:
splits moving across windows or into an undo management system.

**Blueprint and UI definitions have been a success.** These were
introduced in an incremental way into the `apprt/gtk` (thanks ❤️ )
and have been great. But our existing non-GObject system makes it hard
to go _all in_ on them, e.g. bindings. Moving to a full GObject-based
system will let us fully adopt this.

**`zig-gobject` is good and stable.** This didn't really exist when we
started the GTK apprt (see the long history below). Since adopting it,
its proven to be an excellent, stable dependency. I'm ready to go all-in
on it.

**Memory management has been a challenge.** Our mix of GObject and
non-GObject lifetimes within the GTK apprt has consistently been a
source of memory leaks at best and crashes at worst. For example,
`Window`, `Surface`, `Tab`, etc. have weird lifetimes that we try to
pair alongside their GTK counterparts and its nasty and I don't think
anyone who maintains this will disagree. By representing all of these
concepts as GObject or Widget subclasses, we'll align all their
lifetimes as expected.

**Personally, I've grown a lot, particularly from working on the macOS
side.** I think all of us as programmers can agree that _programming in
multiple languages makes us better programmers_. Similarly, building the
macOS app has shown me patterns and techniques that would make our
GTK-based application better. I'd like to bring those to the GTK side.
(Likewise, I've improved the macOS side from periods of time working on
the GTK side and I suspect that might happen again!)

### Longer Background

It's easy to rewrite. And I think our maintainers know that I'm not a
fan of rewrites. I think its the wrong decision most of the time. It is
easy to look at "legacy" code (especially code you didn't write
yourself), be disgusted, and think you can rewrite it all better. But no
engineer sets out to create technical debt, and I think its worth
respecting how and why some code came to be before embarking on
something new. This section does that.

#### The Beginning

Ghostty started as a pure Zig-based GLFW app, with no concept of
"apprt". It was Linux-only, and X11-only. At some point, I refactored
out the "apprt" system in order to introduce GTK4 (GTK4 came before any
macOS work). For the initial GTK4 work, I decided to just call into the
libgtk C APIs directly. There were various contributing factors for this
decision:

1. Zig was _rapidly_ changing, and we were on nightly Zig. This was
around the Zig 0.11, 0.12 times. Taking on new Zig dependencies was
really dangerous because Zig nightly could break all of us at any
moment.

2. [`zig-gobject`](https://github.com/ianprime0509/zig-gobject) was
brand new and unstable. Given point 1, I discarded it and did straight C
APIs.

3. Ghostty itself was very simple. We didn't support tabs, we didn't
support splits. We were still primarily concerned with making the
terminal stable. We weren't on the "native UI" part of our roadmap quite
yet. This was our initial foray in that direction.

4. On a personal level, I hadn't done real native GUI programming in a
_long_ time (on any platform). Recall the macOS apprt didn't exist yet,
either. Jumping into "plain old Zig" with "plain old C APIs" was a
practical, no-nonsense way for me to get going.

Given all this, I still believe I (it was only me then) made the right
decision for the time.

#### Zig, GTK apprt Stabilization

Eventually, the factors listed above changed: (1) Zig stabilized more
and Ghostty moved to stable Zig for various reasons. (2) `zig-gobject`
became a mature, stable library. (3) Ghostty the application has become
increasingly complex (in a good way, we support a ton of awesome
"platform UI" features).

Socially, the @ghostty-org/gtk subsystem team was created and is filled
with people who are experienced with GTK and Linux in general. This team
introduced more idiomatic GTK concepts into the project such as
blueprint files, a `zig-gobject` migration, and more.

The @ghostty-org/gtk subsystem maintainers have done an awesome job
iterating on this change within the existing `apprt/gtk`. This has been
often frustrating, but it was a pragmatic approach to move us towards
the future and let us ship new features into GTK4 to pursue our platform
UI goals.

#### GUI Maturity

We're now at the point where the core Ghostty terminal (the core,
terminal emulation) is incredibly stable. We don't have exact numbers
but we can confidently assume its used by thousands of people everyday
for real, professional work.

As such, most of the changes within the 1.1 and 1.2 cycle have been at
the apprt/GUI layer, introducing significantly more complexity:
localization, more X11/Wayland integrations, more text to native
elements like the process exit overlay, etc.

I recently rewrote the entire terminal, tab, and split data model in the
macOS app to give us a better foundation for future functionality, and
to improve our memory management story (surface leaks were a common
problem before, and they haven't happened since since the lifetime of a
surface is so much more obvious). This also let me iterate more quickly
on more features such as undo/redo, but will also more easily enable
things like split titles, merging splits into tabs/windows, etc. (not
done yet).

I think its time for this type of change within the GTK apprt as well.
We have the collective real world experience and we've put in the work
in iteration to understand what needs to be done.
This commit is contained in:
Mitchell Hashimoto
2025-07-16 12:28:57 -07:00
committed by GitHub
15 changed files with 2008 additions and 2 deletions

View File

@ -25,6 +25,7 @@ jobs:
- flatpak
- test
- test-gtk
- test-gtk-ng
- test-sentry-linux
- test-macos
- pinact
@ -529,6 +530,9 @@ jobs:
- name: Test GTK Build
run: nix develop -c zig build -Dapp-runtime=gtk -Demit-docs -Demit-webdata
- name: Test GTK-NG Build
run: nix develop -c zig build -Dapp-runtime=gtk-ng -Demit-docs -Demit-webdata
# This relies on the cache being populated by the commands above.
- name: Test System Build
run: nix develop -c zig build --system ${ZIG_GLOBAL_CACHE_DIR}/p
@ -582,6 +586,55 @@ jobs:
-Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }}
test-gtk-ng:
strategy:
fail-fast: false
matrix:
x11: ["true", "false"]
wayland: ["true", "false"]
name: GTK x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }}
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@c343d6c4c2c3268bbec55c542f096f74130eb22c # v1.2.12
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@c134e4c9e34bac6cab09cf239815f9339aaaf84e # v31.5.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Test
run: |
nix develop -c \
zig build \
-Dapp-runtime=gtk-ng \
-Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }} \
test
- name: Build
run: |
nix develop -c \
zig build \
-Dapp-runtime=gtk-ng \
-Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }}
test-sentry-linux:
strategy:
fail-fast: false

View File

@ -119,6 +119,7 @@
# GTK
/src/apprt/gtk/ @ghostty-org/gtk
/src/apprt/gtk-ng/ @ghostty-org/gtk
/src/os/cgroup.zig @ghostty-org/gtk
/src/os/flatpak.zig @ghostty-org/gtk
/dist/linux/ @ghostty-org/gtk

View File

@ -17,6 +17,7 @@ const structs = @import("apprt/structs.zig");
pub const action = @import("apprt/action.zig");
pub const ipc = @import("apprt/ipc.zig");
pub const gtk = @import("apprt/gtk.zig");
pub const gtk_ng = @import("apprt/gtk-ng.zig");
pub const none = @import("apprt/none.zig");
pub const browser = @import("apprt/browser.zig");
pub const embedded = @import("apprt/embedded.zig");
@ -43,6 +44,7 @@ pub const runtime = switch (build_config.artifact) {
.exe => switch (build_config.app_runtime) {
.none => none,
.gtk => gtk,
.@"gtk-ng" => gtk_ng,
},
.lib => embedded,
.wasm_module => browser,
@ -61,6 +63,11 @@ pub const Runtime = enum {
/// GTK-backed. Rich windowed application. GTK is dynamically linked.
gtk,
/// GTK4. The "-ng" variant is a rewrite of the GTK backend using
/// GTK-native technologies such as full GObject classes, Blueprint
/// files, etc.
@"gtk-ng",
pub fn default(target: std.Target) Runtime {
// The Linux default is GTK because it is full featured.
if (target.os.tag == .linux) return .gtk;

9
src/apprt/gtk-ng.zig Normal file
View File

@ -0,0 +1,9 @@
const internal_os = @import("../os/main.zig");
// The required comptime API for any apprt.
pub const App = @import("gtk-ng/App.zig");
pub const Surface = @import("gtk-ng/Surface.zig");
pub const resourcesDir = internal_os.resourcesDir;
// The exported API, custom for the apprt.
pub const GhosttyApplication = @import("gtk-ng/class/application.zig").GhosttyApplication;

85
src/apprt/gtk-ng/App.zig Normal file
View File

@ -0,0 +1,85 @@
/// This is the main entrypoint to the apprt for Ghostty. Ghostty will
/// initialize this in main to start the application..
const App = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
const apprt = @import("../../apprt.zig");
const configpkg = @import("../../config.zig");
const internal_os = @import("../../os/main.zig");
const Config = configpkg.Config;
const CoreApp = @import("../../App.zig");
const GhosttyApplication = @import("class/application.zig").GhosttyApplication;
const Surface = @import("Surface.zig");
const gtk_version = @import("gtk_version.zig");
const adw_version = @import("adw_version.zig");
const log = std.log.scoped(.gtk);
/// The GObject GhosttyApplication instance
app: *GhosttyApplication,
pub fn init(
self: *App,
core_app: *CoreApp,
// Required by the apprt interface but we don't use it.
opts: struct {},
) !void {
_ = opts;
const app: *GhosttyApplication = try .new(core_app);
errdefer app.unref();
self.* = .{ .app = app };
return;
}
pub fn run(self: *App) !void {
try self.app.run(self);
}
pub fn terminate(self: *App) void {
// We force deinitialize the app. We don't unref because other things
// tend to have a reference at this point, so this just forces the
// disposal now.
self.app.deinit();
}
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) !bool {
_ = self;
_ = target;
_ = value;
return false;
}
pub fn performIpc(
alloc: Allocator,
target: apprt.ipc.Target,
comptime action: apprt.ipc.Action.Key,
value: apprt.ipc.Action.Value(action),
) !bool {
_ = alloc;
_ = target;
_ = value;
return false;
}
/// Close the given surface.
pub fn redrawSurface(self: *App, surface: *Surface) void {
_ = self;
_ = surface;
}
/// Redraw the inspector for the given surface.
pub fn redrawInspector(self: *App, surface: *Surface) void {
_ = self;
_ = surface;
}

View File

@ -0,0 +1,57 @@
const Surface = @This();
const apprt = @import("../../apprt.zig");
const CoreSurface = @import("../../Surface.zig");
core_surface: CoreSurface,
pub fn deinit(self: *Surface) void {
_ = self;
}
pub fn close(self: *Surface, process_active: bool) void {
_ = self;
_ = process_active;
}
pub fn shouldClose(self: *Surface) bool {
_ = self;
return false;
}
pub fn getTitle(self: *Surface) ?[:0]const u8 {
_ = self;
return null;
}
pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
_ = self;
return .{ .x = 1, .y = 1 };
}
pub fn getCursorPos(self: *const Surface) !apprt.CursorPos {
_ = self;
return .{ .x = 0, .y = 0 };
}
pub fn clipboardRequest(
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !void {
_ = self;
_ = clipboard_type;
_ = state;
}
pub fn setClipboardString(
self: *Surface,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
confirm: bool,
) !void {
_ = self;
_ = val;
_ = clipboard_type;
_ = confirm;
}

View File

@ -0,0 +1,122 @@
const std = @import("std");
// Until the gobject bindings are built at the same time we are building
// Ghostty, we need to import `adwaita.h` directly to ensure that the version
// macros match the version of `libadwaita` that we are building/linking
// against.
const c = @cImport({
@cInclude("adwaita.h");
});
const adw = @import("adw");
const log = std.log.scoped(.gtk);
pub const comptime_version: std.SemanticVersion = .{
.major = c.ADW_MAJOR_VERSION,
.minor = c.ADW_MINOR_VERSION,
.patch = c.ADW_MICRO_VERSION,
};
pub fn getRuntimeVersion() std.SemanticVersion {
return .{
.major = adw.getMajorVersion(),
.minor = adw.getMinorVersion(),
.patch = adw.getMicroVersion(),
};
}
pub fn logVersion() void {
log.info("libadwaita version build={} runtime={}", .{
comptime_version,
getRuntimeVersion(),
});
}
/// Verifies that the running libadwaita version is at least the given
/// version. This will return false if Ghostty is configured to not build with
/// libadwaita.
///
/// This can be run in both a comptime and runtime context. If it is run in a
/// comptime context, it will only check the version in the headers. If it is
/// run in a runtime context, it will check the actual version of the library we
/// are linked against. So generally you probably want to do both checks!
///
/// This is inlined so that the comptime checks will disable the runtime checks
/// if the comptime checks fail.
pub inline fn atLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// If our header has lower versions than the given version, we can return
// false immediately. This prevents us from compiling against unknown
// symbols and makes runtime checks very slightly faster.
if (comptime comptime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) == .lt) return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
return runtimeAtLeast(major, minor, micro);
}
/// Verifies that the libadwaita version at runtime is at least the given version.
///
/// This function should be used in cases where the only the runtime behavior
/// is affected by the version check. For checks which would affect code
/// generation, use `atLeast`.
pub inline fn runtimeAtLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// We use the functions instead of the constants such as c.GTK_MINOR_VERSION
// because the function gets the actual runtime version.
const runtime_version = getRuntimeVersion();
return runtime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) != .lt;
}
test "versionAtLeast" {
const testing = std.testing;
const funs = &.{ atLeast, runtimeAtLeast };
inline for (funs) |fun| {
try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
try testing.expect(!fun(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1));
}
}
// Whether AdwDialog, AdwAlertDialog, etc. are supported (1.5+)
pub inline fn supportsDialogs() bool {
return atLeast(1, 5, 0);
}
pub inline fn supportsTabOverview() bool {
return atLeast(1, 4, 0);
}
pub inline fn supportsSwitchRow() bool {
return atLeast(1, 4, 0);
}
pub inline fn supportsToolbarView() bool {
return atLeast(1, 4, 0);
}
pub inline fn supportsBanner() bool {
return atLeast(1, 3, 0);
}

View File

@ -0,0 +1,172 @@
//! Compiles a blueprint file using `blueprint-compiler`. This performs
//! additional checks to ensure that various minimum versions are met.
//!
//! Usage: blueprint.zig <major> <minor> <output> <input>
//!
//! Example: blueprint.zig 1 5 output.ui input.blp
const std = @import("std");
pub const c = @cImport({
@cInclude("adwaita.h");
});
const adwaita_version = std.SemanticVersion{
.major = c.ADW_MAJOR_VERSION,
.minor = c.ADW_MINOR_VERSION,
.patch = c.ADW_MICRO_VERSION,
};
const required_blueprint_version = std.SemanticVersion{
.major = 0,
.minor = 16,
.patch = 0,
};
pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const alloc = debug_allocator.allocator();
// Get our args
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
_ = it.next(); // Skip argv0
const arg_major = it.next() orelse return error.NoMajorVersion;
const arg_minor = it.next() orelse return error.NoMinorVersion;
const output = it.next() orelse return error.NoOutput;
const input = it.next() orelse return error.NoInput;
const required_adwaita_version = std.SemanticVersion{
.major = try std.fmt.parseUnsigned(u8, arg_major, 10),
.minor = try std.fmt.parseUnsigned(u8, arg_minor, 10),
.patch = 0,
};
if (adwaita_version.order(required_adwaita_version) == .lt) {
std.debug.print(
\\`libadwaita` is too old.
\\
\\Ghostty requires a version {} or newer of `libadwaita` to
\\compile this blueprint. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
, .{required_adwaita_version});
std.posix.exit(1);
}
// Version checks
{
var stdout: std.ArrayListUnmanaged(u8) = .empty;
defer stdout.deinit(alloc);
var stderr: std.ArrayListUnmanaged(u8) = .empty;
defer stderr.deinit(alloc);
var blueprint_compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"--version",
},
alloc,
);
blueprint_compiler.stdout_behavior = .Pipe;
blueprint_compiler.stderr_behavior = .Pipe;
try blueprint_compiler.spawn();
try blueprint_compiler.collectOutput(
alloc,
&stdout,
&stderr,
std.math.maxInt(u16),
);
const term = blueprint_compiler.wait() catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
std.posix.exit(1);
},
else => return err,
};
switch (term) {
.Exited => |rc| if (rc != 0) std.process.exit(1),
else => std.process.exit(1),
}
const version = try std.SemanticVersion.parse(std.mem.trim(
u8,
stdout.items,
&std.ascii.whitespace,
));
if (version.order(required_blueprint_version) == .lt) {
std.debug.print(
\\`blueprint-compiler` is the wrong version.
\\
\\Ghostty requires version {} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
std.posix.exit(1);
}
}
// Compilation
{
var stdout: std.ArrayListUnmanaged(u8) = .empty;
defer stdout.deinit(alloc);
var stderr: std.ArrayListUnmanaged(u8) = .empty;
defer stderr.deinit(alloc);
var blueprint_compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"compile",
"--output",
output,
input,
},
alloc,
);
blueprint_compiler.stdout_behavior = .Pipe;
blueprint_compiler.stderr_behavior = .Pipe;
try blueprint_compiler.spawn();
try blueprint_compiler.collectOutput(
alloc,
&stdout,
&stderr,
std.math.maxInt(u16),
);
const term = blueprint_compiler.wait() catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
std.posix.exit(1);
},
else => return err,
};
switch (term) {
.Exited => |rc| {
if (rc != 0) {
std.debug.print("{s}", .{stderr.items});
std.process.exit(1);
}
},
else => {
std.debug.print("{s}", .{stderr.items});
std.process.exit(1);
},
}
}
}

View File

@ -0,0 +1,211 @@
//! This file contains a binary helper that builds our gresource XML
//! file that we can then use with `glib-compile-resources`.
//!
//! This binary is expected to be run from the Ghostty source root.
//! Litmus test: `src/apprt/gtk` should exist relative to the pwd.
const std = @import("std");
const Allocator = std.mem.Allocator;
/// Prefix/appid for the gresource file.
pub const prefix = "/com/mitchellh/ghostty";
pub const app_id = "com.mitchellh.ghostty";
/// The path to the Blueprint files. The folder structure is expected to be
/// `{version}/{name}.blp` where `version` is the major and minor
/// minimum adwaita version.
pub const ui_path = "src/apprt/gtk-ng/ui";
/// The possible icon sizes we'll embed into the gresource file.
/// If any size doesn't exist then it will be an error. We could
/// infer this completely from available files but we wouldn't be
/// able to error when they don't exist that way.
pub const icon_sizes: []const comptime_int = &.{ 16, 32, 128, 256, 512, 1024 };
/// The blueprint files that we will embed into the gresource file.
/// We can't look these up at runtime [easily] because we require the
/// compiled UI files as input. We can refactor this lator to maybe do
/// all of this automatically and ensure we have the right dependencies
/// setup in the build system.
///
/// These will be asserted to exist at runtime.
pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "window" },
};
pub const Blueprint = struct {
major: u16,
minor: u16,
name: []const u8,
};
/// The list of filepaths that we depend on. Used for the build
/// system to have proper caching.
pub const file_inputs = deps: {
const total = (icon_sizes.len * 2) + blueprints.len;
var deps: [total][]const u8 = undefined;
var index: usize = 0;
for (icon_sizes) |size| {
deps[index] = std.fmt.comptimePrint("images/icons/icon_{d}.png", .{size});
deps[index + 1] = std.fmt.comptimePrint("images/icons/icon_{d}@2x.png", .{size});
index += 2;
}
for (blueprints) |bp| {
deps[index] = std.fmt.comptimePrint("{s}/{d}.{d}/{s}.blp", .{
ui_path,
bp.major,
bp.minor,
bp.name,
});
index += 1;
}
break :deps deps;
};
/// Returns the matching blueprint resource path for the given blueprint
/// definition. This will fail at compile time if the blueprint is not
/// found.
///
/// Must be called at comptime.
pub fn blueprint(comptime bp: Blueprint) [:0]const u8 {
// The comptime block around this whole thing forces an error if
// the caller attempts to call this function at runtime.
comptime {
for (blueprints) |candidate| {
if (candidate.major == bp.major and
candidate.minor == bp.minor and
std.mem.eql(u8, candidate.name, bp.name))
{
return std.fmt.comptimePrint("{s}/ui/{d}.{d}/{s}.ui", .{
prefix,
candidate.major,
candidate.minor,
candidate.name,
});
}
}
@compileError("invalid blueprint");
}
}
pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const alloc = debug_allocator.allocator();
// Collect the UI files that are passed in as arguments.
var ui_files: std.ArrayListUnmanaged([]const u8) = .empty;
defer {
for (ui_files.items) |item| alloc.free(item);
ui_files.deinit(alloc);
}
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
while (it.next()) |arg| {
if (!std.mem.endsWith(u8, arg, ".ui")) continue;
try ui_files.append(
alloc,
try alloc.dupe(u8, arg),
);
}
const writer = std.io.getStdOut().writer();
try writer.writeAll(
\\<?xml version="1.0" encoding="UTF-8"?>
\\<gresources>
\\
);
try genIcons(writer);
try genUi(alloc, writer, &ui_files);
try writer.writeAll(
\\</gresources>
\\
);
}
/// Generate the icon resources. This works by looking up all the icons
/// specified by `icon_sizes` in `images/icons/`. They are asserted to exist
/// by trying to access the file.
fn genIcons(writer: anytype) !void {
try writer.print(
\\ <gresource prefix="{s}/icons">
\\
, .{prefix});
const cwd = std.fs.cwd();
inline for (icon_sizes) |size| {
// 1x
{
const alias = std.fmt.comptimePrint("{d}x{d}", .{ size, size });
const source = std.fmt.comptimePrint("images/icons/icon_{d}.png", .{size});
try cwd.access(source, .{});
try writer.print(
\\ <file alias="{s}/apps/{s}.png">{s}</file>
\\
,
.{ alias, app_id, source },
);
}
// 2x
{
const alias = std.fmt.comptimePrint("{d}x{d}@2", .{ size, size });
const source = std.fmt.comptimePrint("images/icons/icon_{d}@2x.png", .{size});
try cwd.access(source, .{});
try writer.print(
\\ <file alias="{s}/apps/{s}.png">{s}</file>
\\
,
.{ alias, app_id, source },
);
}
}
try writer.writeAll(
\\ </gresource>
\\
);
}
/// Generate all the UI resources. This works by looking up all the
/// blueprint files in `${ui_path}/{major}.{minor}/{name}.blp` and
/// assuming these will be
fn genUi(
alloc: Allocator,
writer: anytype,
files: *const std.ArrayListUnmanaged([]const u8),
) !void {
try writer.print(
\\ <gresource prefix="{s}/ui">
\\
, .{prefix});
for (files.items) |ui_file| {
for (blueprints) |bp| {
const expected = try std.fmt.allocPrint(
alloc,
"/{d}.{d}/{s}.ui",
.{ bp.major, bp.minor, bp.name },
);
defer alloc.free(expected);
if (!std.mem.endsWith(u8, ui_file, expected)) continue;
try writer.print(
" <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{d}.{d}/{s}.ui\">{s}</file>\n",
.{ bp.major, bp.minor, bp.name, ui_file },
);
break;
} else {
// The for loop never broke which means it didn't find
// a matching blueprint for this input.
return error.BlueprintNotFound;
}
}
try writer.writeAll(
\\ </gresource>
\\
);
}

213
src/apprt/gtk-ng/cgroup.zig Normal file
View File

@ -0,0 +1,213 @@
/// Contains all the logic for putting the Ghostty process and
/// each individual surface into its own cgroup.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const App = @import("App.zig");
const internal_os = @import("../../os/main.zig");
const log = std.log.scoped(.gtk_systemd_cgroup);
pub const Options = struct {
memory_high: ?u64 = null,
pids_max: ?u64 = null,
};
/// Initialize the cgroup for the app. This will create our
/// transient scope, initialize the cgroups we use for the app,
/// configure them, and return the cgroup path for the app.
///
/// Returns the path of the current cgroup for the app, which is
/// allocated with the given allocator.
pub fn init(
alloc: Allocator,
dbus: *gio.DBusConnection,
opts: Options,
) ![]const u8 {
const pid = std.os.linux.getpid();
// Get our initial cgroup. We need this so we can compare
// and detect when we've switched to our transient group.
const original = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
defer alloc.free(original);
// Create our transient scope. If this succeeds then the unit
// was created, but we may not have moved into it yet, so we need
// to do a dumb busy loop to wait for the move to complete.
try createScope(dbus, pid);
const transient = transient: while (true) {
const current = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
if (!std.mem.eql(u8, original, current)) break :transient current;
alloc.free(current);
std.time.sleep(25 * std.time.ns_per_ms);
};
errdefer alloc.free(transient);
log.info("transient scope created cgroup={s}", .{transient});
// Create the app cgroup and put ourselves in it. This is
// required because controllers can't be configured while a
// process is in a cgroup.
try internal_os.cgroup.create(transient, "app", pid);
// Create a cgroup that will contain all our surfaces. We will
// enable the controllers and configure resource limits for surfaces
// only on this cgroup so that it doesn't affect our main app.
try internal_os.cgroup.create(transient, "surfaces", null);
const surfaces = try std.fmt.allocPrint(alloc, "{s}/surfaces", .{transient});
defer alloc.free(surfaces);
// Enable all of our cgroup controllers. If these fail then
// we just log. We can't reasonably undo what we've done above
// so we log the warning and still return the transient group.
// I don't know a scenario where this fails yet.
try enableControllers(alloc, transient);
try enableControllers(alloc, surfaces);
// Configure the "high" memory limit. This limit is used instead
// of "max" because it's a soft limit that can be exceeded and
// can be monitored by things like systemd-oomd to kill if needed,
// versus an instant hard kill.
if (opts.memory_high) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.memory_high = limit,
});
}
// Configure the "max" pids limit. This is a hard limit and cannot be
// exceeded.
if (opts.pids_max) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.pids_max = limit,
});
}
return transient;
}
/// Enable all the cgroup controllers for the given cgroup.
fn enableControllers(alloc: Allocator, cgroup: []const u8) !void {
const raw = try internal_os.cgroup.controllers(alloc, cgroup);
defer alloc.free(raw);
// Build our string builder for enabling all controllers
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
// Controllers are space-separated
var it = std.mem.splitScalar(u8, raw, ' ');
while (it.next()) |controller| {
try builder.append('+');
try builder.appendSlice(controller);
if (it.rest().len > 0) try builder.append(' ');
}
// Enable them all
try internal_os.cgroup.configureControllers(
cgroup,
builder.items,
);
}
/// Create a transient systemd scope unit for the current process and
/// move our process into it.
fn createScope(
dbus: *gio.DBusConnection,
pid_: std.os.linux.pid_t,
) !void {
const pid: u32 = @intCast(pid_);
// The unit name needs to be unique. We use the pid for this.
var name_buf: [256]u8 = undefined;
const name = std.fmt.bufPrintZ(
&name_buf,
"app-ghostty-transient-{}.scope",
.{pid},
) catch unreachable;
const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))");
defer glib.free(builder_type);
// Initialize our builder to build up our parameters
var builder: glib.VariantBuilder = undefined;
builder.init(builder_type);
builder.add("s", name.ptr);
builder.add("s", "fail");
{
// Properties
const properties_type = glib.VariantType.new("a(sv)");
defer glib.free(properties_type);
builder.open(properties_type);
defer builder.close();
// https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html
const pressure_value = glib.Variant.newString("kill");
builder.add("(sv)", "ManagedOOMMemoryPressure", pressure_value);
// Delegate
const delegate_value = glib.Variant.newBoolean(1);
builder.add("(sv)", "Delegate", delegate_value);
// Pid to move into the unit
const pids_value_type = glib.VariantType.new("u");
defer glib.free(pids_value_type);
const pids_value = glib.Variant.newFixedArray(pids_value_type, &pid, 1, @sizeOf(u32));
builder.add("(sv)", "PIDs", pids_value);
}
{
// Aux
const aux_type = glib.VariantType.new("a(sa(sv))");
defer glib.free(aux_type);
builder.open(aux_type);
defer builder.close();
}
var err: ?*glib.Error = null;
defer if (err) |e| e.free();
const reply_type = glib.VariantType.new("(o)");
defer glib.free(reply_type);
const value = builder.end();
const reply = dbus.callSync(
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager",
"StartTransientUnit",
value,
reply_type,
.{},
-1,
null,
&err,
) orelse {
if (err) |e| log.err(
"creating transient cgroup scope failed code={} err={s}",
.{
e.f_code,
if (e.f_message) |msg| msg else "(no message)",
},
);
return error.DbusCallFailed;
};
defer reply.unref();
}

View File

@ -0,0 +1,641 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const adw = @import("adw");
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const build_config = @import("../../../build_config.zig");
const apprt = @import("../../../apprt.zig");
const cgroup = @import("../cgroup.zig");
const CoreApp = @import("../../../App.zig");
const configpkg = @import("../../../config.zig");
const internal_os = @import("../../../os/main.zig");
const xev = @import("../../../global.zig").xev;
const Config = configpkg.Config;
const adw_version = @import("../adw_version.zig");
const gtk_version = @import("../gtk_version.zig");
const GhosttyWindow = @import("window.zig").GhosttyWindow;
const log = std.log.scoped(.gtk_ghostty_application);
/// The primary entrypoint for the Ghostty GTK application.
///
/// This requires a `ghostty.App` and `ghostty.Config` and takes
/// care of the rest. Call `run` to run the application to completion.
pub const GhosttyApplication = extern struct {
/// This type creates a new GObject class. Since the Application is
/// the primary entrypoint I'm going to use this as a place to document
/// how this all works and where you can find resources for it, but
/// this applies to any other GObject class within this apprt.
///
/// The various fields (parent_instance) and constants (Parent,
/// getGObjectType, etc.) are mandatory "interfaces" for zig-gobject
/// to create a GObject class.
///
/// I found these to be the best resources:
///
/// * https://github.com/ianprime0509/zig-gobject/blob/d7f1edaf50193d49b56c60568dfaa9f23195565b/extensions/gobject2.zig
/// * https://github.com/ianprime0509/zig-gobject/blob/d7f1edaf50193d49b56c60568dfaa9f23195565b/example/src/custom_class.zig
///
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Application;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
const Private = struct {
/// The libghostty App instance.
core_app: *CoreApp,
/// The configuration for the application.
config: *Config,
/// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled
/// and initialization was successful.
transient_cgroup_base: ?[]const u8 = null,
/// This is set to false internally when the event loop
/// should exit and the application should quit. This must
/// only be set by the main loop thread.
running: bool = false,
var offset: c_int = 0;
};
/// Creates a new GhosttyApplication instance.
///
/// This does a lot more work than a typical class instantiation,
/// because we expect that this is the main program entrypoint.
///
/// The only failure mode of initializing the application is early OOM.
/// Early OOM can't be recovered from. Every other error is mapped to
/// some degraded state where we can at least show a window with an error.
pub fn new(core_app: *CoreApp) Allocator.Error!*Self {
const alloc = core_app.alloc;
// Log our GTK versions
gtk_version.logVersion();
adw_version.logVersion();
// Set gettext global domain to be our app so that our unqualified
// translations map to our translations.
internal_os.i18n.initGlobalDomain() catch |err| {
// Failures shuldn't stop application startup. Our app may
// not translate correctly but it should still work. In the
// future we may want to add this to the GUI to show.
log.warn("i18n initialization failed error={}", .{err});
};
// Load our configuration.
const config: *Config = try alloc.create(Config);
errdefer alloc.destroy(config);
config.* = Config.load(alloc) catch |err| err: {
// If we fail to load the configuration, then we should log
// the error in the diagnostics so it can be shown to the user.
// We can still load a default which only fails for OOM, allowing
// us to startup.
var default = try Config.default(alloc);
errdefer default.deinit();
const config_arena = default._arena.?.allocator();
try default._diagnostics.append(config_arena, .{
.message = try std.fmt.allocPrintZ(
config_arena,
"error loading user configuration: {}",
.{err},
),
});
break :err default;
};
errdefer config.deinit();
// Setup our GTK init env vars
setGtkEnv(config) catch |err| switch (err) {
error.NoSpaceLeft => {
// If we fail to set GTK environment variables then we still
// try to start the application...
log.warn(
"error setting GTK environment variables err={}",
.{err},
);
},
};
adw.init();
const single_instance = switch (config.@"gtk-single-instance") {
.true => true,
.false => false,
.desktop => switch (config.@"launched-from".?) {
.desktop, .systemd, .dbus => true,
.cli => false,
},
};
// Setup the flags for our application.
const app_flags: gio.ApplicationFlags = app_flags: {
var flags: gio.ApplicationFlags = .flags_default_flags;
if (!single_instance) flags.non_unique = true;
break :app_flags flags;
};
// Our app ID determines uniqueness and maps to our desktop file.
// We append "-debug" to the ID if we're in debug mode so that we
// can develop Ghostty in Ghostty.
const app_id: [:0]const u8 = app_id: {
if (config.class) |class| {
if (gio.Application.idIsValid(class) != 0) {
break :app_id class;
} else {
log.warn("invalid 'class' in config, ignoring", .{});
}
}
const default_id = comptime build_config.bundle_id;
break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id;
};
// Create our GTK Application which encapsulates our process.
log.debug("creating GTK application id={s} single-instance={}", .{
app_id,
single_instance,
});
// Initialize the app.
const self = gobject.ext.newInstance(Self, .{
.application_id = app_id.ptr,
.flags = app_flags,
// Force the resource path to a known value so it doesn't depend
// on the app id (which changes between debug/release and can be
// user-configured) and force it to load in compiled resources.
.resource_base_path = "/com/mitchellh/ghostty",
});
// Setup our private state. More setup is done in the init
// callback that GObject calls, but we can't pass this data through
// to there (and we don't need it there directly) so this is here.
const priv = self.private();
priv.core_app = core_app;
priv.config = config;
return self;
}
/// Force deinitialize the application.
///
/// Normally in a GObject lifecycle, this would be called by the
/// finalizer. But applications are never fully unreferenced so this
/// ensures that our memory is cleaned up properly.
pub fn deinit(self: *Self) void {
const alloc = self.allocator();
const priv = self.private();
priv.config.deinit();
alloc.destroy(priv.config);
if (priv.transient_cgroup_base) |base| alloc.free(base);
}
/// Run the application. This is a replacement for `gio.Application.run`
/// because we want more tight control over our event loop so we can
/// integrate it with libghostty.
pub fn run(self: *Self, rt_app: *apprt.gtk_ng.App) !void {
// Based on the actual `gio.Application.run` implementation:
// https://github.com/GNOME/glib/blob/a8e8b742e7926e33eb635a8edceac74cf239d6ed/gio/gapplication.c#L2533
// Acquire the default context for the application
const ctx = glib.MainContext.default();
if (glib.MainContext.acquire(ctx) == 0) return error.ContextAcquireFailed;
// The final cleanup that is always required at the end of running.
defer {
// Sync any remaining settings
gio.Settings.sync();
// Clear out the event loop, don't block.
while (glib.MainContext.iteration(ctx, 0) != 0) {}
// Release the context so something else can use it.
defer glib.MainContext.release(ctx);
}
// Register the application
var err_: ?*glib.Error = null;
if (self.as(gio.Application).register(
null,
&err_,
) == 0) {
if (err_) |err| {
defer err.free();
log.warn(
"error registering application: {s}",
.{err.f_message orelse "(unknown)"},
);
}
return error.ApplicationRegisterFailed;
}
assert(err_ == null);
// This just calls the `activate` signal but its part of the normal startup
// routine so we just call it, but only if the config allows it (this allows
// for launching Ghostty in the "background" without immediately opening
// a window). An initial window will not be immediately created if we were
// launched by D-Bus activation or systemd. D-Bus activation will send it's
// own `activate` or `new-window` signal later.
//
// https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302
const priv = self.private();
const config = priv.config;
if (config.@"initial-window") switch (config.@"launched-from".?) {
.desktop, .cli => self.as(gio.Application).activate(),
.dbus, .systemd => {},
};
// If we are NOT the primary instance, then we never want to run.
// This means that another instance of the GTK app is running and
// our "activate" call above will open a window.
if (self.as(gio.Application).getIsRemote() != 0) {
log.debug(
"application is remote, exiting run loop after activation",
.{},
);
return;
}
log.debug("entering runloop", .{});
defer log.debug("exiting runloop", .{});
priv.running = true;
while (priv.running) {
_ = glib.MainContext.iteration(ctx, 1);
// Tick the core Ghostty terminal app
try priv.core_app.tick(rt_app);
// Check if we must quit based on the current state.
const must_quit = q: {
// If we are configured to always stay running, don't quit.
if (!config.@"quit-after-last-window-closed") break :q false;
// If the quit timer has expired, quit.
// if (self.quit_timer == .expired) break :q true;
// There's no quit timer running, or it hasn't expired, don't quit.
break :q false;
};
if (must_quit) {
//self.quit();
priv.running = false;
}
}
}
pub fn as(app: *Self, comptime T: type) *T {
return gobject.ext.as(T, app);
}
pub fn unref(self: *Self) void {
gobject.Object.unref(self.as(gobject.Object));
}
fn private(self: *GhosttyApplication) *Private {
return gobject.ext.impl_helpers.getPrivate(
self,
Private,
Private.offset,
);
}
fn startup(self: *GhosttyApplication) callconv(.C) void {
log.debug("startup", .{});
// Setup our event loop
self.startupXev();
// Setup our style manager (light/dark mode)
self.startupStyleManager();
// Setup our cgroup for the application.
self.startupCgroup() catch {
log.warn("TODO", .{});
};
gio.Application.virtual_methods.startup.call(
Class.parent,
self.as(Parent),
);
}
/// Configure libxev to use a specific backend.
///
/// This must be called before any other xev APIs are used.
fn startupXev(self: *GhosttyApplication) void {
const priv = self.private();
const config = priv.config;
// If our backend is auto then we have no setup to do.
if (config.@"async-backend" == .auto) return;
// Setup our event loop backend to the preferred method
const result: bool = switch (config.@"async-backend") {
.auto => unreachable,
.epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false,
.io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false,
};
if (result) {
log.info(
"libxev manual backend={s}",
.{@tagName(xev.backend)},
);
} else {
log.warn(
"libxev manual backend failed, using default={s}",
.{@tagName(xev.backend)},
);
}
}
/// Setup the style manager on startup. The primary task here is to
/// setup our initial light/dark mode based on the configuration and
/// setup listeners for changes to the style manager.
fn startupStyleManager(self: *GhosttyApplication) void {
const priv = self.private();
const config = priv.config;
// Setup our initial light/dark
const style = self.as(adw.Application).getStyleManager();
style.setColorScheme(switch (config.@"window-theme") {
.auto, .ghostty => auto: {
const lum = config.background.toTerminalRGB().perceivedLuminance();
break :auto if (lum > 0.5)
.prefer_light
else
.prefer_dark;
},
.system => .prefer_light,
.dark => .force_dark,
.light => .force_light,
});
// Setup color change notifications
_ = gobject.Object.signals.notify.connect(
style,
*GhosttyApplication,
handleStyleManagerDark,
self,
.{ .detail = "dark" },
);
}
const CgroupError = error{
DbusConnectionFailed,
CgroupInitFailed,
};
/// Setup our cgroup for the application, if enabled.
///
/// The setup for cgroups involves creating the cgroup for our
/// application, moving ourselves into it, and storing the base path
/// so that created surfaces can also have their own cgroups.
fn startupCgroup(self: *GhosttyApplication) CgroupError!void {
const priv = self.private();
const config = priv.config;
// If cgroup isolation isn't enabled then we don't do this.
if (!switch (config.@"linux-cgroup") {
.never => false,
.always => true,
.@"single-instance" => single: {
const flags = self.as(gio.Application).getFlags();
break :single !flags.non_unique;
},
}) {
log.info(
"cgroup isolation disabled via config={}",
.{config.@"linux-cgroup"},
);
return;
}
// We need a dbus connection to do anything else
const dbus = self.as(gio.Application).getDbusConnection() orelse {
if (config.@"linux-cgroup-hard-fail") {
log.err("dbus connection required for cgroup isolation, exiting", .{});
return error.DbusConnectionFailed;
}
return;
};
const alloc = priv.core_app.alloc;
const path = cgroup.init(alloc, dbus, .{
.memory_high = config.@"linux-cgroup-memory-limit",
.pids_max = config.@"linux-cgroup-processes-limit",
}) catch |err| {
// If we can't initialize cgroups then that's okay. We
// want to continue to run so we just won't isolate surfaces.
// NOTE(mitchellh): do we want a config to force it?
log.warn(
"failed to initialize cgroups, terminals will not be isolated err={}",
.{err},
);
// If we have hard fail enabled then we exit now.
if (config.@"linux-cgroup-hard-fail") {
log.err("linux-cgroup-hard-fail enabled, exiting", .{});
return error.CgroupInitFailed;
}
return;
};
log.info("cgroup isolation enabled base={s}", .{path});
priv.transient_cgroup_base = path;
}
fn activate(self: *GhosttyApplication) callconv(.C) void {
// This is called when the application is activated, but we
// don't need to do anything here since we handle activation
// in the `run` method.
log.debug("activate", .{});
// Call the parent activate method.
gio.Application.virtual_methods.activate.call(
Class.parent,
self.as(Parent),
);
const win = GhosttyWindow.new(self);
gtk.Window.present(win.as(gtk.Window));
}
fn finalize(self: *GhosttyApplication) callconv(.C) void {
self.deinit();
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
fn handleStyleManagerDark(
style: *adw.StyleManager,
_: *gobject.ParamSpec,
self: *GhosttyApplication,
) callconv(.c) void {
_ = self;
const color_scheme: apprt.ColorScheme = if (style.getDark() == 0)
.light
else
.dark;
log.debug("style manager changed scheme={}", .{color_scheme});
}
fn allocator(self: *GhosttyApplication) std.mem.Allocator {
return self.private().core_app.alloc;
}
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.C) void {
// Register our compiled resources exactly once.
{
const c = @cImport({
// generated header files
@cInclude("ghostty_resources.h");
});
if (c.ghostty_get_resource()) |ptr| {
gio.resourcesRegister(@ptrCast(@alignCast(ptr)));
} else {
// If we fail to load resources then things will
// probably look really bad but it shouldn't stop our
// app from loading.
log.warn("unable to load resources", .{});
}
}
// Virtual methods
gio.Application.virtual_methods.activate.implement(class, &activate);
gio.Application.virtual_methods.startup.implement(class, &startup);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
};
};
/// This sets various GTK-related environment variables as necessary
/// given the runtime environment or configuration.
///
/// This must be called BEFORE GTK initialization.
fn setGtkEnv(config: *const Config) error{NoSpaceLeft}!void {
var gdk_debug: struct {
/// output OpenGL debug information
opengl: bool = false,
/// disable GLES, Ghostty can't use GLES
@"gl-disable-gles": bool = false,
// GTK's new renderer can cause blurry font when using fractional scaling.
@"gl-no-fractional": bool = false,
/// Disabling Vulkan can improve startup times by hundreds of
/// milliseconds on some systems. We don't use Vulkan so we can just
/// disable it.
@"vulkan-disable": bool = false,
} = .{
.opengl = config.@"gtk-opengl-debug",
};
var gdk_disable: struct {
@"gles-api": bool = false,
/// current gtk implementation for color management is not good enough.
/// see: https://bugs.kde.org/show_bug.cgi?id=495647
/// gtk issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6864
@"color-mgmt": bool = true,
/// Disabling Vulkan can improve startup times by hundreds of
/// milliseconds on some systems. We don't use Vulkan so we can just
/// disable it.
vulkan: bool = false,
} = .{};
environment: {
if (gtk_version.runtimeAtLeast(4, 18, 0)) {
gdk_disable.@"color-mgmt" = false;
}
if (gtk_version.runtimeAtLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
// For the remainder of "why" see the 4.14 comment below.
gdk_disable.@"gles-api" = true;
gdk_disable.vulkan = true;
break :environment;
}
if (gtk_version.runtimeAtLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
// Older versions of GTK do not support these values so it is safe
// to always set this. Forwards versions are uncertain so we'll have
// to reassess...
//
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
gdk_debug.@"gl-disable-gles" = true;
gdk_debug.@"vulkan-disable" = true;
if (gtk_version.runtimeUntil(4, 17, 5)) {
// Removed at GTK v4.17.5
gdk_debug.@"gl-no-fractional" = true;
}
break :environment;
}
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It
// is an environment that isn't tested well and we don't have a
// good understanding of what we may need to do.
gdk_debug.@"vulkan-disable" = true;
}
{
var buf: [1024]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
const writer = fmt.writer();
var first: bool = true;
inline for (@typeInfo(@TypeOf(gdk_debug)).@"struct".fields) |field| {
if (@field(gdk_debug, field.name)) {
if (!first) try writer.writeAll(",");
try writer.writeAll(field.name);
first = false;
}
}
try writer.writeByte(0);
const value = fmt.getWritten();
log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]});
_ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]);
}
{
var buf: [1024]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
const writer = fmt.writer();
var first: bool = true;
inline for (@typeInfo(@TypeOf(gdk_disable)).@"struct".fields) |field| {
if (@field(gdk_disable, field.name)) {
if (!first) try writer.writeAll(",");
try writer.writeAll(field.name);
first = false;
}
}
try writer.writeByte(0);
const value = fmt.getWritten();
log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]});
_ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]);
}
}

View File

@ -0,0 +1,59 @@
const std = @import("std");
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const GhosttyApplication = @import("application.zig").GhosttyApplication;
const log = std.log.scoped(.gtk_ghostty_window);
pub const GhosttyWindow = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.ApplicationWindow;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
const Private = struct {
_todo: u8 = 0,
var offset: c_int = 0;
};
pub fn new(app: *GhosttyApplication) *Self {
return gobject.ext.newInstance(Self, .{ .application = app });
}
fn init(win: *GhosttyWindow, _: *Class) callconv(.C) void {
gtk.Widget.initTemplate(win.as(gtk.Widget));
}
pub fn as(win: *Self, comptime T: type) *T {
return gobject.ext.as(T, win);
}
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.C) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 5,
.name = "window",
}),
);
}
pub fn as(class: *Class, comptime T: type) *T {
return gobject.ext.as(T, class);
}
};
};

View File

@ -0,0 +1,140 @@
const std = @import("std");
// Until the gobject bindings are built at the same time we are building
// Ghostty, we need to import `gtk/gtk.h` directly to ensure that the version
// macros match the version of `gtk4` that we are building/linking against.
const c = @cImport({
@cInclude("gtk/gtk.h");
});
const gtk = @import("gtk");
const log = std.log.scoped(.gtk);
pub const comptime_version: std.SemanticVersion = .{
.major = c.GTK_MAJOR_VERSION,
.minor = c.GTK_MINOR_VERSION,
.patch = c.GTK_MICRO_VERSION,
};
pub fn getRuntimeVersion() std.SemanticVersion {
return .{
.major = gtk.getMajorVersion(),
.minor = gtk.getMinorVersion(),
.patch = gtk.getMicroVersion(),
};
}
pub fn logVersion() void {
log.info("GTK version build={} runtime={}", .{
comptime_version,
getRuntimeVersion(),
});
}
/// Verifies that the GTK version is at least the given version.
///
/// This can be run in both a comptime and runtime context. If it is run in a
/// comptime context, it will only check the version in the headers. If it is
/// run in a runtime context, it will check the actual version of the library we
/// are linked against.
///
/// This function should be used in cases where the version check would affect
/// code generation, such as using symbols that are only available beyond a
/// certain version. For checks which only depend on GTK's runtime behavior,
/// use `runtimeAtLeast`.
///
/// This is inlined so that the comptime checks will disable the runtime checks
/// if the comptime checks fail.
pub inline fn atLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// If our header has lower versions than the given version,
// we can return false immediately. This prevents us from
// compiling against unknown symbols and makes runtime checks
// very slightly faster.
if (comptime comptime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) == .lt) return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
return runtimeAtLeast(major, minor, micro);
}
/// Verifies that the GTK version at runtime is at least the given version.
///
/// This function should be used in cases where the only the runtime behavior
/// is affected by the version check. For checks which would affect code
/// generation, use `atLeast`.
pub inline fn runtimeAtLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// We use the functions instead of the constants such as c.GTK_MINOR_VERSION
// because the function gets the actual runtime version.
const runtime_version = getRuntimeVersion();
return runtime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) != .lt;
}
pub inline fn runtimeUntil(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
const runtime_version = getRuntimeVersion();
return runtime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) == .lt;
}
test "atLeast" {
const testing = std.testing;
const funs = &.{ atLeast, runtimeAtLeast };
inline for (funs) |fun| {
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
}
}
test "runtimeUntil" {
const testing = std.testing;
// This is an array in case we add a comptime variant.
const funs = &.{runtimeUntil};
inline for (funs) |fun| {
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
}
}

View File

@ -0,0 +1,8 @@
using Gtk 4.0;
using Adw 1;
template $GhosttyWindow: Adw.ApplicationWindow {
content: Label {
label: "Hello, Ghostty!";
};
}

View File

@ -8,8 +8,6 @@ const UnicodeTables = @import("UnicodeTables.zig");
const GhosttyFrameData = @import("GhosttyFrameData.zig");
const DistResource = @import("GhosttyDist.zig").Resource;
const gresource = @import("../apprt/gtk/gresource.zig");
config: *const Config,
options: *std.Build.Step.Options,
@ -553,6 +551,7 @@ pub fn add(
switch (self.config.app_runtime) {
.none => {},
.gtk => try self.addGTK(step),
.@"gtk-ng" => try self.addGtkNg(step),
}
}
@ -563,6 +562,233 @@ pub fn add(
return static_libs;
}
/// Setup the dependencies for the GTK apprt build.
fn addGtkNg(
self: *const SharedDeps,
step: *std.Build.Step.Compile,
) !void {
const b = step.step.owner;
const target = step.root_module.resolved_target.?;
const optimize = step.root_module.optimize.?;
const gobject_ = b.lazyDependency("gobject", .{
.target = target,
.optimize = optimize,
});
if (gobject_) |gobject| {
const gobject_imports = .{
.{ "adw", "adw1" },
.{ "gdk", "gdk4" },
.{ "gio", "gio2" },
.{ "glib", "glib2" },
.{ "gobject", "gobject2" },
.{ "gtk", "gtk4" },
.{ "xlib", "xlib2" },
};
inline for (gobject_imports) |import| {
const name, const module = import;
step.root_module.addImport(name, gobject.module(module));
}
}
step.linkSystemLibrary2("gtk4", dynamic_link_opts);
step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
if (self.config.x11) {
step.linkSystemLibrary2("X11", dynamic_link_opts);
if (gobject_) |gobject| {
step.root_module.addImport(
"gdk_x11",
gobject.module("gdkx114"),
);
}
}
if (self.config.wayland) wayland: {
// These need to be all be called to note that we need them.
const wayland_dep_ = b.lazyDependency("wayland", .{});
const wayland_protocols_dep_ = b.lazyDependency(
"wayland_protocols",
.{},
);
const plasma_wayland_protocols_dep_ = b.lazyDependency(
"plasma_wayland_protocols",
.{},
);
// Unwrap or return, there are no more dependencies below.
const wayland_dep = wayland_dep_ orelse break :wayland;
const wayland_protocols_dep = wayland_protocols_dep_ orelse break :wayland;
const plasma_wayland_protocols_dep = plasma_wayland_protocols_dep_ orelse break :wayland;
// Note that zig_wayland cannot be lazy because lazy dependencies
// can't be imported since they don't exist and imports are
// resolved at compile time of the build.
const zig_wayland_dep = b.dependency("zig_wayland", .{});
const Scanner = @import("zig_wayland").Scanner;
const scanner = Scanner.create(zig_wayland_dep.builder, .{
.wayland_xml = wayland_dep.path("protocol/wayland.xml"),
.wayland_protocols = wayland_protocols_dep.path(""),
});
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/blur.xml"),
);
// FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"),
);
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/slide.xml"),
);
scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml");
scanner.generate("wl_compositor", 1);
scanner.generate("org_kde_kwin_blur_manager", 1);
scanner.generate("org_kde_kwin_server_decoration_manager", 1);
scanner.generate("org_kde_kwin_slide_manager", 1);
scanner.generate("xdg_activation_v1", 1);
step.root_module.addImport("wayland", b.createModule(.{
.root_source_file = scanner.result,
}));
if (gobject_) |gobject| step.root_module.addImport(
"gdk_wayland",
gobject.module("gdkwayland4"),
);
if (b.lazyDependency("gtk4_layer_shell", .{
.target = target,
.optimize = optimize,
})) |gtk4_layer_shell| {
const layer_shell_module = gtk4_layer_shell.module("gtk4-layer-shell");
if (gobject_) |gobject| layer_shell_module.addImport(
"gtk",
gobject.module("gtk4"),
);
step.root_module.addImport(
"gtk4-layer-shell",
layer_shell_module,
);
// IMPORTANT: gtk4-layer-shell must be linked BEFORE
// wayland-client, as it relies on shimming libwayland's APIs.
if (b.systemIntegrationOption("gtk4-layer-shell", .{})) {
step.linkSystemLibrary2("gtk4-layer-shell-0", dynamic_link_opts);
} else {
// gtk4-layer-shell *must* be dynamically linked,
// so we don't add it as a static library
const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell");
b.installArtifact(shared_lib);
step.linkLibrary(shared_lib);
}
}
step.linkSystemLibrary2("wayland-client", dynamic_link_opts);
}
{
// Get our gresource c/h files and add them to our build.
const dist = gtkNgDistResources(b);
step.addCSourceFile(.{ .file = dist.resources_c.path(b), .flags = &.{} });
step.addIncludePath(dist.resources_h.path(b).dirname());
}
}
/// Creates the resources that can be prebuilt for our dist build.
pub fn gtkNgDistResources(
b: *std.Build,
) struct {
resources_c: DistResource,
resources_h: DistResource,
} {
const gresource = @import("../apprt/gtk-ng/build/gresource.zig");
const gresource_xml = gresource_xml: {
const xml_exe = b.addExecutable(.{
.name = "generate_gresource_xml",
.root_source_file = b.path("src/apprt/gtk-ng/build/gresource.zig"),
.target = b.graph.host,
});
const xml_run = b.addRunArtifact(xml_exe);
// Run our blueprint compiler across all of our blueprint files.
const blueprint_exe = b.addExecutable(.{
.name = "gtk_blueprint_compiler",
.root_source_file = b.path("src/apprt/gtk-ng/build/blueprint.zig"),
.target = b.graph.host,
});
blueprint_exe.linkLibC();
blueprint_exe.linkSystemLibrary2("gtk4", dynamic_link_opts);
blueprint_exe.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
for (gresource.blueprints) |bp| {
const blueprint_run = b.addRunArtifact(blueprint_exe);
blueprint_run.addArgs(&.{
b.fmt("{d}", .{bp.major}),
b.fmt("{d}", .{bp.minor}),
});
const ui_file = blueprint_run.addOutputFileArg(b.fmt(
"{d}.{d}/{s}.ui",
.{
bp.major,
bp.minor,
bp.name,
},
));
blueprint_run.addFileArg(b.path(b.fmt(
"{s}/{d}.{d}/{s}.blp",
.{
gresource.ui_path,
bp.major,
bp.minor,
bp.name,
},
)));
xml_run.addFileArg(ui_file);
}
break :gresource_xml xml_run.captureStdOut();
};
const generate_c = b.addSystemCommand(&.{
"glib-compile-resources",
"--c-name",
"ghostty",
"--generate-source",
"--target",
});
const resources_c = generate_c.addOutputFileArg("ghostty_resources.c");
generate_c.addFileArg(gresource_xml);
for (gresource.file_inputs) |path| {
generate_c.addFileInput(b.path(path));
}
const generate_h = b.addSystemCommand(&.{
"glib-compile-resources",
"--c-name",
"ghostty",
"--generate-header",
"--target",
});
const resources_h = generate_h.addOutputFileArg("ghostty_resources.h");
generate_h.addFileArg(gresource_xml);
for (gresource.file_inputs) |path| {
generate_h.addFileInput(b.path(path));
}
return .{
.resources_c = .{
.dist = "src/apprt/gtk-ng/ghostty_resources.c",
.generated = resources_c,
},
.resources_h = .{
.dist = "src/apprt/gtk-ng/ghostty_resources.h",
.generated = resources_h,
},
};
}
/// Setup the dependencies for the GTK apprt build. The GTK apprt
/// is particularly involved compared to others so we pull this out
/// into a dedicated function.
@ -705,6 +931,8 @@ pub fn gtkDistResources(
resources_c: DistResource,
resources_h: DistResource,
} {
const gresource = @import("../apprt/gtk/gresource.zig");
const gresource_xml = gresource_xml: {
const xml_exe = b.addExecutable(.{
.name = "generate_gresource_xml",