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 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 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..19d450a54 --- /dev/null +++ b/src/apprt/gtk-ng.zig @@ -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; diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig new file mode 100644 index 000000000..0bd2c9603 --- /dev/null +++ b/src/apprt/gtk-ng/App.zig @@ -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; +} diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig new file mode 100644 index 000000000..094334210 --- /dev/null +++ b/src/apprt/gtk-ng/Surface.zig @@ -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; +} 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/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..db5c2cf6e --- /dev/null +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -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( + \\ + \\ + \\ + ); + + 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/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 new file mode 100644 index 000000000..df0b3c439 --- /dev/null +++ b/src/apprt/gtk-ng/class/application.zig @@ -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]); + } +} 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/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)); + } +} 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..d6321537e --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -0,0 +1,8 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyWindow: Adw.ApplicationWindow { + content: Label { + label: "Hello, Ghostty!"; + }; +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index ea7e696ef..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, @@ -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",