From ecb77fb8bc25ca52b8b866e9ba25573c17a0d63b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 15 Jul 2025 13:53:48 -0700 Subject: [PATCH] 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",