gtk: add localization support, take 3 (#6004)

This is my third (!) attempt at implementing localization support. By
leveraging GTK builder to do most of the `gettext` calls, I can avoid
the whole mess about missing symbols on non-glibc platforms.

Added some documentation too for contributors and translators, just for
good measure.

Supersedes #5214, resolves the GTK half of #2357
This commit is contained in:
Leah Amelia Chen
2025-03-05 20:12:52 +01:00
committed by GitHub
18 changed files with 925 additions and 15 deletions

View File

@@ -39,6 +39,7 @@ const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig");
const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
const Split = @import("Split.zig");
const c = @import("c.zig").c;
const i18n = @import("i18n.zig");
const version = @import("version.zig");
const inspector = @import("inspector.zig");
const key = @import("key.zig");
@@ -98,6 +99,11 @@ quit_timer: union(enum) {
pub fn init(core_app: *CoreApp, opts: Options) !App {
_ = opts;
// This can technically be placed *anywhere* because we don't have any
// localized log messages. It just has to be placed before any localized
// widgets are drawn.
try i18n.init(core_app.alloc);
// Log our GTK version
log.info("GTK version build={d}.{d}.{d} runtime={d}.{d}.{d}", .{
c.GTK_MAJOR_VERSION,

View File

@@ -35,6 +35,7 @@ const gtk_key = @import("key.zig");
const c = @import("c.zig").c;
const Builder = @import("Builder.zig");
const adwaita = @import("adwaita.zig");
const i18n = @import("i18n.zig");
const log = std.log.scoped(.gtk_surface);
@@ -1152,7 +1153,7 @@ pub fn setClipboardString(
self.app.config.@"app-notifications".@"clipboard-copy")
{
if (self.container.window()) |window|
window.sendToast("Copied to clipboard");
window.sendToast(i18n._("Copied to clipboard"));
}
return;
}

View File

@@ -33,6 +33,7 @@ const TabView = @import("TabView.zig");
const HeaderBar = @import("headerbar.zig");
const version = @import("version.zig");
const winproto = @import("winproto.zig");
const i18n = @import("i18n.zig");
const log = std.log.scoped(.gtk);
@@ -192,7 +193,7 @@ pub fn init(self: *Window, app: *App) !void {
{
const btn = c.gtk_menu_button_new();
c.gtk_widget_set_tooltip_text(btn, "Main Menu");
c.gtk_widget_set_tooltip_text(btn, i18n._("Main Menu"));
c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic");
c.gtk_menu_button_set_popover(@ptrCast(btn), @ptrCast(@alignCast(self.titlebar_menu.asWidget())));
_ = c.g_signal_connect_data(
@@ -212,7 +213,7 @@ pub fn init(self: *Window, app: *App) !void {
const btn = switch (self.config.gtk_tabs_location) {
.top, .bottom => btn: {
const btn = c.gtk_toggle_button_new();
c.gtk_widget_set_tooltip_text(btn, "View Open Tabs");
c.gtk_widget_set_tooltip_text(btn, i18n._("View Open Tabs"));
c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic");
_ = c.g_object_bind_property(
btn,
@@ -239,7 +240,7 @@ pub fn init(self: *Window, app: *App) !void {
{
const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic");
c.gtk_widget_set_tooltip_text(btn, "New Tab");
c.gtk_widget_set_tooltip_text(btn, i18n._("New Tab"));
_ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(&gtkTabNewClick), self, null, c.G_CONNECT_DEFAULT);
self.headerbar.packStart(btn);
}
@@ -257,7 +258,7 @@ pub fn init(self: *Window, app: *App) !void {
// This is a really common issue where people build from source in debug and performance is really bad.
if (comptime std.debug.runtime_safety) {
const warning_box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
const warning_text = "⚠️ You're running a debug build of Ghostty! Performance will be degraded.";
const warning_text = i18n._("⚠️ You're running a debug build of Ghostty! Performance will be degraded.");
if (adwaita.versionAtLeast(1, 3, 0)) {
const banner = c.adw_banner_new(warning_text);
c.adw_banner_set_revealed(@ptrCast(banner), 1);
@@ -674,10 +675,10 @@ pub fn focusCurrentTab(self: *Window) void {
}
pub fn onConfigReloaded(self: *Window) void {
self.sendToast("Reloaded the configuration");
self.sendToast(i18n._("Reloaded the configuration"));
}
pub fn sendToast(self: *Window, title: [:0]const u8) void {
pub fn sendToast(self: *Window, title: [*:0]const u8) void {
const toast = c.adw_toast_new(title);
c.adw_toast_set_timeout(toast, 3);
c.adw_toast_overlay_add_toast(@ptrCast(self.toast_overlay), toast);
@@ -930,7 +931,7 @@ fn gtkActionAbout(
"application-name",
name,
"developer-name",
"Ghostty Developers",
i18n._("Ghostty Developers"),
"application-icon",
icon,
"version",
@@ -949,7 +950,7 @@ fn gtkActionAbout(
"logo-icon-name",
icon,
"title",
"About Ghostty",
i18n._("About Ghostty"),
"version",
build_config.version_string.ptr,
"website",

37
src/apprt/gtk/i18n.zig Normal file
View File

@@ -0,0 +1,37 @@
//! I18n support for the GTK frontend based on gettext/libintl
//!
//! This is normally built into the C standard library for the *vast* majority
//! of users who use glibc, but for musl users we fall back to the `gettext-tiny`
//! stub implementation which provides all of the necessary interfaces.
//! Musl users who do want to use localization should know what they need to do.
const std = @import("std");
const global = &@import("../../global.zig").state;
const build_config = @import("../../build_config.zig");
const log = std.log.scoped(.gtk_i18n);
pub fn init(alloc: std.mem.Allocator) !void {
const resources_dir = global.resources_dir orelse {
log.warn("resource dir not found; not localizing", .{});
return;
};
const share_dir = std.fs.path.dirname(resources_dir) orelse {
log.warn("resource dir not placed in a share/ directory; not localizing", .{});
return;
};
const locale_dir = try std.fs.path.joinZ(alloc, &.{ share_dir, "locale" });
defer alloc.free(locale_dir);
// The only way these calls can fail is if we're out of memory
_ = bindtextdomain(build_config.bundle_id, locale_dir.ptr) orelse return error.OutOfMemory;
_ = textdomain(build_config.bundle_id) orelse return error.OutOfMemory;
}
// Manually include function definitions for the gettext functions
// as libintl.h isn't always easily available (e.g. in musl)
extern fn bindtextdomain(domainname: [*:0]const u8, dirname: [*:0]const u8) ?[*:0]const u8;
extern fn textdomain(domainname: [*:0]const u8) ?[*:0]const u8;
pub extern fn gettext(msgid: [*:0]const u8) [*:0]const u8;
pub const _ = gettext;

118
src/build/GhosttyI18n.zig Normal file
View File

@@ -0,0 +1,118 @@
const GhosttyI18n = @This();
const std = @import("std");
const Config = @import("Config.zig");
const gresource = @import("../apprt/gtk/gresource.zig");
const domain = "com.mitchellh.ghostty";
const locales = [_][]const u8{
"zh_CN.UTF-8",
};
owner: *std.Build,
steps: []*std.Build.Step,
/// This step updates the translation files on disk that should be
/// committed to the repo.
update_step: *std.Build.Step,
pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n {
var steps = std.ArrayList(*std.Build.Step).init(b.allocator);
defer steps.deinit();
if (cfg.app_runtime == .gtk) {
// Output the .mo files used by the GTK apprt
inline for (locales) |locale| {
const msgfmt = b.addSystemCommand(&.{ "msgfmt", "-o", "-" });
msgfmt.addFileArg(b.path("po/" ++ locale ++ ".po"));
try steps.append(&b.addInstallFile(
msgfmt.captureStdOut(),
std.fmt.comptimePrint(
"share/locale/{s}/LC_MESSAGES/{s}.mo",
.{ locale, domain },
),
).step);
}
}
return .{
.owner = b,
.update_step = try createUpdateStep(b),
.steps = try steps.toOwnedSlice(),
};
}
pub fn install(self: *const GhosttyI18n) void {
for (self.steps) |step| self.owner.getInstallStep().dependOn(step);
}
fn createUpdateStep(b: *std.Build) !*std.Build.Step {
const xgettext = b.addSystemCommand(&.{
"xgettext",
"--language=C", // Silence the "unknown extension" errors
"--from-code=UTF-8",
"--add-comments=Translators",
"--keyword=_",
"--keyword=C_:1c,2",
"--package-name=" ++ domain,
"--msgid-bugs-address=m@mitchellh.com",
"--copyright-holder=Mitchell Hashimoto",
"-o",
"-",
});
// Not cacheable due to the gresource files
xgettext.has_side_effects = true;
inline for (gresource.blueprint_files) |blp| {
// We avoid using addFileArg here since the full, absolute file path
// would be added to the file as its location, which differs for
// everyone's checkout of the repository.
// This comes at a cost of losing per-file caching, of course.
xgettext.addArg(std.fmt.comptimePrint(
"src/apprt/gtk/ui/{[major]}.{[minor]}/{[name]s}.blp",
blp,
));
}
{
var gtk_files = try b.build_root.handle.openDir(
"src/apprt/gtk",
.{ .iterate = true },
);
defer gtk_files.close();
var walk = try gtk_files.walk(b.allocator);
defer walk.deinit();
while (try walk.next()) |src| {
switch (src.kind) {
.file => if (!std.mem.endsWith(
u8,
src.basename,
".zig",
)) continue,
else => continue,
}
xgettext.addArg((b.pathJoin(&.{ "src/apprt/gtk", src.path })));
}
}
const wf = b.addWriteFiles();
wf.addCopyFileToSource(
xgettext.captureStdOut(),
"po/" ++ domain ++ ".pot",
);
inline for (locales) |locale| {
const msgmerge = b.addSystemCommand(&.{ "msgmerge", "-q" });
msgmerge.addFileArg(b.path("po/" ++ locale ++ ".po"));
msgmerge.addFileArg(xgettext.captureStdOut());
wf.addCopyFileToSource(msgmerge.captureStdOut(), "po/" ++ locale ++ ".po");
}
return &wf.step;
}

View File

@@ -40,6 +40,7 @@ COPY ./dist/linux /src/dist/linux
COPY ./images /src/images
COPY ./include /src/include
COPY ./pkg /src/pkg
COPY ./po /src/po
COPY ./nix /src/nix
COPY ./vendor /src/vendor
COPY ./build.zig /src/build.zig

View File

@@ -13,6 +13,7 @@ pub const GhosttyExe = @import("GhosttyExe.zig");
pub const GhosttyFrameData = @import("GhosttyFrameData.zig");
pub const GhosttyLib = @import("GhosttyLib.zig");
pub const GhosttyResources = @import("GhosttyResources.zig");
pub const GhosttyI18n = @import("GhosttyI18n.zig");
pub const GhosttyXCFramework = @import("GhosttyXCFramework.zig");
pub const GhosttyWebdata = @import("GhosttyWebdata.zig");
pub const HelpStrings = @import("HelpStrings.zig");

View File

@@ -9,14 +9,22 @@ const Allocator = std.mem.Allocator;
/// This is highly Ghostty-specific and can likely be generalized at
/// some point but we can cross that bridge if we ever need to.
pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 {
// If we have an environment variable set, we always use that.
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
//
// In debug builds we try using terminfo detection first instead, since
// if debug Ghostty is launched by an older version of Ghostty, it
// would inherit the old, stale resources of older Ghostty instead of the
// freshly built ones under zig-out/share/ghostty.
//
// Note: we ALWAYS want to allocate here because the result is always
// freed, do not try to use internal_os.getenv or posix getenv.
if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| {
if (dir.len > 0) return dir;
} else |err| switch (err) {
error.EnvironmentVariableNotFound => {},
else => return err,
if (comptime builtin.mode != .Debug) {
if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| {
if (dir.len > 0) return dir;
} else |err| switch (err) {
error.EnvironmentVariableNotFound => {},
else => return err,
}
}
// This is the sentinel value we look for in the path to know
@@ -52,6 +60,17 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 {
}
}
// If terminfo detection failed in debug builds (somehow),
// fallback and use the provided resources dir.
if (comptime builtin.mode == .Debug) {
if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| {
if (dir.len > 0) return dir;
} else |err| switch (err) {
error.EnvironmentVariableNotFound => {},
else => return err,
}
}
return null;
}