From 2f9660c02c04acc33a6e9d7f64a8a58a58f07fea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Jul 2025 20:58:57 -0700 Subject: [PATCH 1/9] apprt/gtk-ng: boilerplate --- src/apprt.zig | 7 ++ src/apprt/gtk-ng.zig | 5 ++ src/apprt/gtk-ng/App.zig | 38 +++++++++++ src/apprt/gtk-ng/Surface.zig | 5 ++ src/build/SharedDeps.zig | 127 +++++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/apprt/gtk-ng.zig create mode 100644 src/apprt/gtk-ng/App.zig create mode 100644 src/apprt/gtk-ng/Surface.zig diff --git a/src/apprt.zig b/src/apprt.zig index fd81d7270..706287302 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -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; diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig new file mode 100644 index 000000000..dea15a3ed --- /dev/null +++ b/src/apprt/gtk-ng.zig @@ -0,0 +1,5 @@ +const internal_os = @import("../os/main.zig"); + +pub const App = @import("gtk-ng/App.zig"); +pub const Surface = @import("gtk-ng/Surface.zig"); +pub const resourcesDir = internal_os.resourcesDir; diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig new file mode 100644 index 000000000..8c12a3b54 --- /dev/null +++ b/src/apprt/gtk-ng/App.zig @@ -0,0 +1,38 @@ +const App = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const apprt = @import("../../apprt.zig"); +const CoreApp = @import("../../App.zig"); + +pub fn init( + self: *App, + core_app: *CoreApp, + opts: struct {}, +) !void { + _ = self; + _ = core_app; + _ = opts; + return; +} + +pub fn run(self: *App) !void { + _ = self; +} + +pub fn terminate(self: *App) void { + _ = self; +} + +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; +} diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig new file mode 100644 index 000000000..df7b04f5f --- /dev/null +++ b/src/apprt/gtk-ng/Surface.zig @@ -0,0 +1,5 @@ +const Surface = @This(); + +pub fn deinit(self: *Surface) void { + _ = self; +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index ea7e696ef..f745a0633 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -553,6 +553,7 @@ pub fn add( switch (self.config.app_runtime) { .none => {}, .gtk => try self.addGTK(step), + .@"gtk-ng" => try self.addGtkNg(step), } } @@ -563,6 +564,132 @@ 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); + } +} + /// 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. From 9c6cf61cd4035821530a8b947543963f61e2f3f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Jul 2025 21:24:19 -0700 Subject: [PATCH 2/9] apprt/gtk-ng: GhosttyApplication and boilerplate to run --- src/apprt/gtk-ng.zig | 4 + src/apprt/gtk-ng/App.zig | 228 +++++++++++++++++++- src/apprt/gtk-ng/Surface.zig | 52 +++++ src/apprt/gtk-ng/adw_version.zig | 122 +++++++++++ src/apprt/gtk-ng/class/application.zig | 283 +++++++++++++++++++++++++ src/apprt/gtk-ng/gtk_version.zig | 140 ++++++++++++ 6 files changed, 825 insertions(+), 4 deletions(-) create mode 100644 src/apprt/gtk-ng/adw_version.zig create mode 100644 src/apprt/gtk-ng/class/application.zig create mode 100644 src/apprt/gtk-ng/gtk_version.zig diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig index dea15a3ed..19d450a54 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk-ng.zig @@ -1,5 +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; diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index 8c12a3b54..428ecc457 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -1,28 +1,236 @@ +/// 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 xev = @import("../../global.zig").xev; +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 { - _ = self; - _ = core_app; _ = opts; + 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. + try internal_os.i18n.initGlobalDomain(); + + // Load our configuration. + const config: *Config = try alloc.create(Config); + errdefer alloc.destroy(config); + config.* = try Config.load(core_app.alloc); + errdefer config.deinit(); + + // If we had configuration errors, then log them. + if (!config._diagnostics.empty()) { + var buf = std.ArrayList(u8).init(alloc); + defer buf.deinit(); + for (config._diagnostics.items()) |diag| { + try diag.write(buf.writer()); + log.warn("configuration error: {s}", .{buf.items}); + buf.clearRetainingCapacity(); + } + + // If we have any CLI errors, exit. + if (config._diagnostics.containsLocation(.cli)) { + log.warn("CLI errors detected, exiting", .{}); + std.posix.exit(1); + } + } + + // Setup our event loop backend + if (config.@"async-backend" != .auto) { + 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 GTK + 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(); + + // Initialize our application class + const app: *GhosttyApplication = .new(core_app, config); + errdefer app.unref(); + + self.* = .{ + .app = app, + }; return; } +/// This sets various GTK-related environment variables as necessary +/// given the runtime environment or configuration. +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]); + } +} + pub fn run(self: *App) !void { - _ = self; + 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( @@ -36,3 +244,15 @@ pub fn performIpc( _ = 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; +} diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index df7b04f5f..094334210 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -1,5 +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; +} diff --git a/src/apprt/gtk-ng/adw_version.zig b/src/apprt/gtk-ng/adw_version.zig new file mode 100644 index 000000000..7ce88f585 --- /dev/null +++ b/src/apprt/gtk-ng/adw_version.zig @@ -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); +} diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig new file mode 100644 index 000000000..5ce6568b8 --- /dev/null +++ b/src/apprt/gtk-ng/class/application.zig @@ -0,0 +1,283 @@ +const std = @import("std"); +const assert = std.debug.assert; +const builtin = @import("builtin"); +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); + +const build_config = @import("../../../build_config.zig"); +const apprt = @import("../../../apprt.zig"); +const CoreApp = @import("../../../App.zig"); +const configpkg = @import("../../../config.zig"); +const Config = configpkg.Config; + +const log = std.log.scoped(.gtk); + +/// 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, + + /// 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. + /// + /// Takes ownership of the `config` argument. + pub fn new(core_app: *CoreApp, config: *Config) *Self { + 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, + }); + + const self = gobject.ext.newInstance(Self, .{ + .application_id = app_id.ptr, + .flags = app_flags, + }); + + 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); + } + + /// 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; + } + + 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 { + // This is where we would initialize the application, but we + // do that in the `run` method instead. + log.debug("GhosttyApplication started", .{}); + + gio.Application.virtual_methods.startup.call( + Class.parent, + self.as(Parent), + ); + } + + 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("GhosttyApplication activated", .{}); + + // Call the parent activate method. + gio.Application.virtual_methods.activate.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *GhosttyApplication) callconv(.C) void { + self.deinit(); + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + 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 { + gio.Application.virtual_methods.activate.implement(class, &activate); + gio.Application.virtual_methods.startup.implement(class, &startup); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + }; +}; diff --git a/src/apprt/gtk-ng/gtk_version.zig b/src/apprt/gtk-ng/gtk_version.zig new file mode 100644 index 000000000..6f3d733a5 --- /dev/null +++ b/src/apprt/gtk-ng/gtk_version.zig @@ -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)); + } +} From ecb77fb8bc25ca52b8b866e9ba25573c17a0d63b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 15 Jul 2025 13:53:48 -0700 Subject: [PATCH 3/9] apprt/gtk-ng: gresource creation, resource registration in Application --- src/apprt/gtk-ng/build/blueprint.zig | 172 +++++++++++++++++++++++ src/apprt/gtk-ng/build/gresource.zig | 182 +++++++++++++++++++++++++ src/apprt/gtk-ng/class/application.zig | 26 ++++ src/apprt/gtk-ng/ui/1.5/window.blp | 8 ++ src/build/SharedDeps.zig | 105 +++++++++++++- 5 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 src/apprt/gtk-ng/build/blueprint.zig create mode 100644 src/apprt/gtk-ng/build/gresource.zig create mode 100644 src/apprt/gtk-ng/ui/1.5/window.blp diff --git a/src/apprt/gtk-ng/build/blueprint.zig b/src/apprt/gtk-ng/build/blueprint.zig new file mode 100644 index 000000000..1e614f972 --- /dev/null +++ b/src/apprt/gtk-ng/build/blueprint.zig @@ -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 +//! +//! 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); + }, + } + } +} diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig new file mode 100644 index 000000000..6f9245de5 --- /dev/null +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -0,0 +1,182 @@ +//! 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 struct { + major: u16, + minor: u16, + name: []const u8, +} = &.{ + .{ .major = 1, .minor = 5, .name = "window" }, +}; + +/// 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; +}; + +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( + \\ + \\ + \\ + ); + + try genIcons(writer); + try genUi(alloc, writer, &ui_files); + + try writer.writeAll( + \\ + \\ + ); +} + +/// 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( + \\ + \\ + , .{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( + \\ {s} + \\ + , + .{ 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( + \\ {s} + \\ + , + .{ alias, app_id, source }, + ); + } + } + + try writer.writeAll( + \\ + \\ + ); +} + +/// 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( + \\ + \\ + , .{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( + " {s}\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( + \\ + \\ + ); +} diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 5ce6568b8..ebd8c514b 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -100,11 +100,20 @@ pub const GhosttyApplication = extern struct { 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; @@ -275,6 +284,23 @@ pub const GhosttyApplication = extern struct { 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); diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp new file mode 100644 index 000000000..22ba886ff --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -0,0 +1,8 @@ +using Gtk 4.0; +using Adw 1; + +Adw.Window { + Label { + label: "Hello"; + } +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index f745a0633..f1a6f80c8 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -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, @@ -688,6 +686,107 @@ fn addGtkNg( 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 @@ -832,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", From ce06eb5f64067abceea98d19855d2e2228252fab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 15 Jul 2025 15:11:27 -0700 Subject: [PATCH 4/9] apprt/gtk-ng: application startup to initialize styles --- src/apprt/gtk-ng/class/application.zig | 60 +++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index ebd8c514b..4d2ab42e4 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -12,7 +12,7 @@ const CoreApp = @import("../../../App.zig"); const configpkg = @import("../../../config.zig"); const Config = configpkg.Config; -const log = std.log.scoped(.gtk); +const log = std.log.scoped(.gtk_ghostty_application); /// The primary entrypoint for the Ghostty GTK application. /// @@ -200,6 +200,8 @@ pub const GhosttyApplication = extern struct { return; } + log.debug("entering runloop", .{}); + defer log.debug("exiting runloop", .{}); priv.running = true; while (priv.running) { _ = glib.MainContext.iteration(ctx, 1); @@ -243,9 +245,13 @@ pub const GhosttyApplication = extern struct { } fn startup(self: *GhosttyApplication) callconv(.C) void { - // This is where we would initialize the application, but we - // do that in the `run` method instead. - log.debug("GhosttyApplication started", .{}); + log.debug("startup", .{}); + const priv = self.private(); + const config = priv.config; + _ = config; + + // Setup our style manager (light/dark mode) + self.startupStyleManager(); gio.Application.virtual_methods.startup.call( Class.parent, @@ -253,11 +259,40 @@ pub const GhosttyApplication = extern struct { ); } + 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" }, + ); + } + 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("GhosttyApplication activated", .{}); + log.debug("activate", .{}); // Call the parent activate method. gio.Application.virtual_methods.activate.call( @@ -274,6 +309,21 @@ pub const GhosttyApplication = extern struct { ); } + 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; } From bb9638890237e4d4faf1a2d14507367408135caa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 15 Jul 2025 15:36:43 -0700 Subject: [PATCH 5/9] apprt/gtk-ng: cgroup base setup --- src/apprt/gtk-ng/cgroup.zig | 213 +++++++++++++++++++++++++ src/apprt/gtk-ng/class/application.zig | 81 ++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/apprt/gtk-ng/cgroup.zig diff --git a/src/apprt/gtk-ng/cgroup.zig b/src/apprt/gtk-ng/cgroup.zig new file mode 100644 index 000000000..23c4d545e --- /dev/null +++ b/src/apprt/gtk-ng/cgroup.zig @@ -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(); +} diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 4d2ab42e4..2c807a8f9 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -8,6 +8,7 @@ const gobject = @import("gobject"); 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 Config = configpkg.Config; @@ -50,6 +51,11 @@ pub const GhosttyApplication = extern struct { /// 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. @@ -131,6 +137,7 @@ pub const GhosttyApplication = extern struct { 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` @@ -253,12 +260,20 @@ pub const GhosttyApplication = extern struct { // 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), ); } + /// 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; @@ -288,6 +303,72 @@ pub const GhosttyApplication = extern struct { ); } + 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 From 3257203b6c0b1e9d415ee6f6c3e9a19fc6b2ae0b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 16 Jul 2025 09:29:33 -0700 Subject: [PATCH 6/9] apprt/gtk-ng: start basic window --- src/apprt/gtk-ng/build/gresource.zig | 35 +++++++++++++-- src/apprt/gtk-ng/class/application.zig | 6 +++ src/apprt/gtk-ng/class/window.zig | 59 ++++++++++++++++++++++++++ src/apprt/gtk-ng/ui/1.5/window.blp | 8 ++-- 4 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 src/apprt/gtk-ng/class/window.zig diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 6f9245de5..db5c2cf6e 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -29,12 +29,14 @@ pub const icon_sizes: []const comptime_int = &.{ 16, 32, 128, 256, 512, 1024 }; /// setup in the build system. /// /// These will be asserted to exist at runtime. -pub const blueprints: []const struct { +pub const blueprints: []const Blueprint = &.{ + .{ .major = 1, .minor = 5, .name = "window" }, +}; + +pub const Blueprint = struct { major: u16, minor: u16, name: []const u8, -} = &.{ - .{ .major = 1, .minor = 5, .name = "window" }, }; /// The list of filepaths that we depend on. Used for the build @@ -60,6 +62,33 @@ pub const file_inputs = deps: { 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(); diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 2c807a8f9..a2fd05a67 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -5,6 +5,7 @@ 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"); @@ -13,6 +14,8 @@ const CoreApp = @import("../../../App.zig"); const configpkg = @import("../../../config.zig"); const Config = configpkg.Config; +const GhosttyWindow = @import("window.zig").GhosttyWindow; + const log = std.log.scoped(.gtk_ghostty_application); /// The primary entrypoint for the Ghostty GTK application. @@ -380,6 +383,9 @@ pub const GhosttyApplication = extern struct { Class.parent, self.as(Parent), ); + + const win = GhosttyWindow.new(self); + gtk.Window.present(win.as(gtk.Window)); } fn finalize(self: *GhosttyApplication) callconv(.C) void { diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig new file mode 100644 index 000000000..df8b99cee --- /dev/null +++ b/src/apprt/gtk-ng/class/window.zig @@ -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); + } + }; +}; diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index 22ba886ff..d6321537e 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -1,8 +1,8 @@ using Gtk 4.0; using Adw 1; -Adw.Window { - Label { - label: "Hello"; - } +template $GhosttyWindow: Adw.ApplicationWindow { + content: Label { + label: "Hello, Ghostty!"; + }; } From bb0ea99d91f067e5472041cf98c0bde9f9f41884 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 16 Jul 2025 09:46:16 -0700 Subject: [PATCH 7/9] ci: test gtk-ng --- .github/workflows/test.yml | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 270d1c3f5..038864168 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 From faa0c36defa6d8ce2c8751e03193a4d2353b3da1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 16 Jul 2025 09:47:35 -0700 Subject: [PATCH 8/9] CODEOWNERS add gtk to gtk-ng --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index 3bb6a4123..0fb60758e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 From 426fa8d8f95a96ccce6ca8a2c3c4059caddf9e83 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 16 Jul 2025 12:20:09 -0700 Subject: [PATCH 9/9] apprt/gtk-ng: move our app initialization all into the App class --- src/apprt/gtk-ng/App.zig | 177 +-------------------- src/apprt/gtk-ng/class/application.zig | 205 ++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 180 deletions(-) diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index 428ecc457..0bd2c9603 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -9,7 +9,6 @@ const gio = @import("gio"); const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); const internal_os = @import("../../os/main.zig"); -const xev = @import("../../global.zig").xev; const Config = configpkg.Config; const CoreApp = @import("../../App.zig"); @@ -31,185 +30,13 @@ pub fn init( opts: struct {}, ) !void { _ = opts; - 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. - try internal_os.i18n.initGlobalDomain(); - - // Load our configuration. - const config: *Config = try alloc.create(Config); - errdefer alloc.destroy(config); - config.* = try Config.load(core_app.alloc); - errdefer config.deinit(); - - // If we had configuration errors, then log them. - if (!config._diagnostics.empty()) { - var buf = std.ArrayList(u8).init(alloc); - defer buf.deinit(); - for (config._diagnostics.items()) |diag| { - try diag.write(buf.writer()); - log.warn("configuration error: {s}", .{buf.items}); - buf.clearRetainingCapacity(); - } - - // If we have any CLI errors, exit. - if (config._diagnostics.containsLocation(.cli)) { - log.warn("CLI errors detected, exiting", .{}); - std.posix.exit(1); - } - } - - // Setup our event loop backend - if (config.@"async-backend" != .auto) { - 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 GTK - 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(); - - // Initialize our application class - const app: *GhosttyApplication = .new(core_app, config); + const app: *GhosttyApplication = try .new(core_app); errdefer app.unref(); - - self.* = .{ - .app = app, - }; + self.* = .{ .app = app }; return; } -/// This sets various GTK-related environment variables as necessary -/// given the runtime environment or configuration. -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]); - } -} - pub fn run(self: *App) !void { try self.app.run(self); } diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index a2fd05a67..df0b3c439 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -1,5 +1,6 @@ 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"); @@ -12,8 +13,12 @@ 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); @@ -69,8 +74,64 @@ pub const GhosttyApplication = extern struct { /// Creates a new GhosttyApplication instance. /// - /// Takes ownership of the `config` argument. - pub fn new(core_app: *CoreApp, config: *Config) *Self { + /// 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, @@ -256,9 +317,9 @@ pub const GhosttyApplication = extern struct { fn startup(self: *GhosttyApplication) callconv(.C) void { log.debug("startup", .{}); - const priv = self.private(); - const config = priv.config; - _ = config; + + // Setup our event loop + self.startupXev(); // Setup our style manager (light/dark mode) self.startupStyleManager(); @@ -274,6 +335,36 @@ pub const GhosttyApplication = extern struct { ); } + /// 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. @@ -444,3 +535,107 @@ pub const GhosttyApplication = extern struct { } }; }; + +/// 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]); + } +}