gtk: add support for using GTK Builder UI files and Blueprints (#5714)

Adds buildtime and comptime checks to make sure that Blueprints/UI files
are availble and correctly formed. Will also compile Blueprints to UI
files so that they are available to GTK code.
This commit is contained in:
Jeffrey C. Ollie
2025-02-12 19:10:50 -06:00
committed by GitHub
6 changed files with 200 additions and 26 deletions

View File

@ -32,6 +32,7 @@
gtk4,
gobject-introspection,
libadwaita,
blueprint-compiler,
adwaita-icon-theme,
hicolor-icon-theme,
harfbuzz,
@ -159,6 +160,7 @@ in
libXrandr
# Only needed for GTK builds
blueprint-compiler
libadwaita
gtk4
glib

View File

@ -15,6 +15,7 @@
gtk4,
gobject-introspection,
libadwaita,
blueprint-compiler,
wrapGAppsHook4,
gsettings-desktop-schemas,
git,
@ -82,6 +83,7 @@ in
zig_hook
gobject-introspection
wrapGAppsHook4
blueprint-compiler
]
++ lib.optionals enableWayland [
wayland-scanner

61
src/apprt/gtk/Builder.zig Normal file
View File

@ -0,0 +1,61 @@
/// Wrapper around GTK's builder APIs that perform some comptime checks.
const Builder = @This();
const std = @import("std");
const gtk = @import("gtk");
const gobject = @import("gobject");
resource_name: [:0]const u8,
pub fn init(comptime name: []const u8, comptime kind: enum { blp, ui }) Builder {
comptime {
switch (kind) {
.blp => {
// Use @embedFile to make sure that the file exists at compile
// time. Zig _should_ discard the data so that it doesn't end
// up in the final executable. At runtime we will load the data
// from a GResource.
_ = @embedFile("ui/" ++ name ++ ".blp");
// Check to make sure that our file is listed as a
// `blueprint_file` in `gresource.zig`. If it isn't Ghostty
// could crash at runtime when we try and load a nonexistent
// GResource.
const gresource = @import("gresource.zig");
for (gresource.blueprint_files) |blueprint_file| {
if (std.mem.eql(u8, blueprint_file, name)) break;
} else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig");
},
.ui => {
// Use @embedFile to make sure that the file exists at compile
// time. Zig _should_ discard the data so that it doesn't end
// up in the final executable. At runtime we will load the data
// from a GResource.
_ = @embedFile("ui/" ++ name ++ ".ui");
// Check to make sure that our file is listed as a `ui_file` in
// `gresource.zig`. If it isn't Ghostty could crash at runtime
// when we try and load a nonexistent GResource.
const gresource = @import("gresource.zig");
for (gresource.ui_files) |ui_file| {
if (std.mem.eql(u8, ui_file, name)) break;
} else @compileError("missing ui file '" ++ name ++ "' in gresource.zig");
},
}
}
return .{
.resource_name = "/com/mitchellh/ghostty/ui/" ++ name ++ ".ui",
};
}
pub fn setWidgetClassTemplate(self: *const Builder, class: *gtk.WidgetClass) void {
class.setTemplateFromResource(self.resource_name);
}
pub fn getObject(self: *const Builder, name: [:0]const u8) ?gobject.Object {
const builder = gtk.Builder.newFromResource(self.resource_name);
defer builder.unref();
return builder.getObject(name);
}

View File

@ -0,0 +1,39 @@
const std = @import("std");
const build_options = @import("build_options");
const gtk = @import("gtk");
const adw = if (build_options.adwaita) @import("adw") else void;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
const filename = filename: {
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
_ = it.next() orelse return error.NoFilename;
break :filename try alloc.dupeZ(u8, it.next() orelse return error.NoFilename);
};
defer alloc.free(filename);
const data = try std.fs.cwd().readFileAlloc(alloc, filename, std.math.maxInt(u16));
defer alloc.free(data);
if ((comptime !build_options.adwaita) and std.mem.indexOf(u8, data, "lib=\"Adw\"") != null) {
std.debug.print("{s}: skipping builder check because Adwaita is not enabled!\n", .{filename});
return;
}
if (gtk.initCheck() == 0) {
std.debug.print("{s}: skipping builder check because we can't connect to display!\n", .{filename});
return;
}
if (comptime build_options.adwaita) {
adw.init();
}
const builder = gtk.Builder.newFromString(data.ptr, @intCast(data.len));
defer builder.unref();
}

View File

@ -53,29 +53,33 @@ const icons = [_]struct {
},
};
pub const gresource_xml = comptimeGenerateGResourceXML();
pub const ui_files = [_][]const u8{};
pub const blueprint_files = [_][]const u8{};
fn comptimeGenerateGResourceXML() []const u8 {
comptime {
@setEvalBranchQuota(13000);
var counter = std.io.countingWriter(std.io.null_writer);
try writeGResourceXML(&counter.writer());
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
var buf: [counter.bytes_written]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try writeGResourceXML(stream.writer());
const final = buf;
return final[0..stream.getWritten().len];
var extra_ui_files = std.ArrayList([]const u8).init(alloc);
defer {
for (extra_ui_files.items) |item| alloc.free(item);
extra_ui_files.deinit();
}
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
while (it.next()) |filename| {
if (std.mem.eql(u8, std.fs.path.extension(filename), ".ui")) {
try extra_ui_files.append(try alloc.dupe(u8, filename));
}
}
fn writeGResourceXML(writer: anytype) !void {
const writer = std.io.getStdOut().writer();
try writer.writeAll(
\\<?xml version="1.0" encoding="UTF-8"?>
\\<gresources>
\\
);
try writer.writeAll(
\\ <gresource prefix="/com/mitchellh/ghostty">
\\
);
@ -87,9 +91,6 @@ fn writeGResourceXML(writer: anytype) !void {
}
try writer.writeAll(
\\ </gresource>
\\
);
try writer.writeAll(
\\ <gresource prefix="/com/mitchellh/ghostty/icons">
\\
);
@ -99,6 +100,23 @@ fn writeGResourceXML(writer: anytype) !void {
.{ icon.alias, icon.source },
);
}
try writer.writeAll(
\\ </gresource>
\\ <gresource prefix="/com/mitchellh/ghostty/ui">
\\
);
for (ui_files) |ui_file| {
try writer.print(
" <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{0s}.ui\">src/apprt/gtk/ui/{0s}.ui</file>\n",
.{ui_file},
);
}
for (extra_ui_files.items) |ui_file| {
try writer.print(
" <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{s}\">{s}</file>\n",
.{ std.fs.path.basename(ui_file), ui_file },
);
}
try writer.writeAll(
\\ </gresource>
\\</gresources>
@ -107,12 +125,24 @@ fn writeGResourceXML(writer: anytype) !void {
}
pub const dependencies = deps: {
var deps: [css_files.len + icons.len][]const u8 = undefined;
for (css_files, 0..) |css_file, i| {
deps[i] = std.fmt.comptimePrint("src/apprt/gtk/{s}", .{css_file});
const total = css_files.len + icons.len + ui_files.len + blueprint_files.len;
var deps: [total][]const u8 = undefined;
var index: usize = 0;
for (css_files) |css_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/{s}", .{css_file});
index += 1;
}
for (icons, css_files.len..) |icon, i| {
deps[i] = std.fmt.comptimePrint("images/icons/icon_{s}.png", .{icon.source});
for (icons) |icon| {
deps[index] = std.fmt.comptimePrint("images/icons/icon_{s}.png", .{icon.source});
index += 1;
}
for (ui_files) |ui_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{s}.ui", .{ui_file});
index += 1;
}
for (blueprint_files) |blueprint_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{s}.blp", .{blueprint_file});
index += 1;
}
break :deps deps;
};

View File

@ -490,8 +490,48 @@ pub fn add(
{
const gresource = @import("../apprt/gtk/gresource.zig");
const wf = b.addWriteFiles();
const gresource_xml = wf.add("gresource.xml", gresource.gresource_xml);
const gresource_xml = gresource_xml: {
const generate_gresource_xml = b.addExecutable(.{
.name = "generate_gresource_xml",
.root_source_file = b.path("src/apprt/gtk/gresource.zig"),
.target = b.host,
});
const generate = b.addRunArtifact(generate_gresource_xml);
for (gresource.blueprint_files) |blueprint_file| {
const blueprint_compiler = b.addSystemCommand(&.{
"blueprint-compiler",
"compile",
"--output",
});
const ui_file = blueprint_compiler.addOutputFileArg(b.fmt("{s}.ui", .{blueprint_file}));
blueprint_compiler.addFileArg(b.path(b.fmt("src/apprt/gtk/ui/{s}.blp", .{blueprint_file})));
generate.addFileArg(ui_file);
}
break :gresource_xml generate.captureStdOut();
};
{
const gtk_builder_check = b.addExecutable(.{
.name = "gtk_builder_check",
.root_source_file = b.path("src/apprt/gtk/builder_check.zig"),
.target = b.host,
});
gtk_builder_check.root_module.addOptions("build_options", self.options);
gtk_builder_check.linkSystemLibrary2("gtk4", dynamic_link_opts);
if (self.config.adwaita) gtk_builder_check.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
gtk_builder_check.linkLibC();
for (gresource.dependencies) |pathname| {
const extension = std.fs.path.extension(pathname);
if (!std.mem.eql(u8, extension, ".ui")) continue;
const check = b.addRunArtifact(gtk_builder_check);
check.addFileArg(b.path(pathname));
step.step.dependOn(&check.step);
}
}
const generate_resources_c = b.addSystemCommand(&.{
"glib-compile-resources",