Groundwork for cross-platform i18n with libintl for libghostty/macOS (#6619)

This builds on @pluiedev's excellent #6004.

## Background: The macOS (and libghostty consumer) Plan

Broadly, the decision I've come to is that for cross-platform
translations (i.e. strings shared across libghostty), we will be using
gettext and libghostty will export helper methods to call those (e.g.
`ghostty_translate` in this PR for singular forms). To be clear, **this
only applies to strings owned by libghostty**. For application-level
strings such as macOS-specific menu items and so on, we still have
choice but will likely using native features.

The reason for this is because converting gettext translations (`po`) to
native formats (Xcode String Catalog, `.strings`/`.stringsdict`) is
nightmare level, in particular for plural forms. I don't see a robust
path to doing it. And if we don't convert and don't use gettext, then
translators would have to maintain an identical translation in multiple
locations. To make matters worse, the macOS translation formats require
Apple-tooling for now unless you want to edit raw JSON.

Leveraging gettext lets us share translations across platforms and take
advantage of proven tech.

## PR Contents

**`pkg/libintl` builds and statically links libintl for macOS.** macOS
doesn't ship libintl with the system while Linux generally does with
libc, so we need to build this ourselves. This makes gettext available
to macOS. libintl is LGPL and we remain in compliance with that despite
static linking because our build process is fully open source, so
downstream consumers can modify our build scripts to replace it if they
wanted to.

~~**`src/os/locale.zig` now sets the `LANGUAGE` environment variable on
macOS based on the app's preferred languages.** macOS lets you configure
the system locale separate from preferred language. We previously relied
solely on `NSLocale.currentLocale`, but this only represents the system
locale. We now also look at `NSLocale.preferredLanguages` (a list in
priority order) and if we support a given language we set `LANGUAGE` so
gettext translates properly. Notably, the above lets us debug
translations in Xcode by setting alternate languages for debug builds
only.~~ Removed this for a future PR since it was problematic.

**`build.zig` unconditionally builds binary `mo` files** since they're
required for all apprts now.

**The macOS app bundles the translation strings.** This includes our
GTK-specific translation strings but the size of these is so small it
isn't worth the complexity of splitting up into multiple `pot`s at this
time, I think.

**i18n APIs moved to `src/os` from `src/apprt/gtk`.** Since these are
now cross-platform/cross-apprt, they're a core API. The only notable
change here is that `_` now maps to `dgettext` and explicitly specifies
our domain so that it's library-friendly. The GTK apprt calls
`initGlobalDomain` so that blueprint translations still work.

## Next Steps

This PR is all groundwork. The macOS app doesn't leverage any of this
yet, although I've verified it all works (e.g. calling the
`ghostty_translate` API from Swift).

For next steps, we need to have a use case for cross-platform
translations and the first one I was looking at was configuration error
messages and other core strings.
This commit is contained in:
Mitchell Hashimoto
2025-03-07 14:51:12 -08:00
committed by GitHub
26 changed files with 3863 additions and 84 deletions

2
.gitattributes vendored
View File

@ -5,5 +5,7 @@ vendor/** linguist-vendored
website/** linguist-documentation
pkg/breakpad/vendor/** linguist-vendored
pkg/cimgui/vendor/** linguist-vendored
pkg/libintl/config.h linguist-generated=true
pkg/libintl/libintl.h linguist-generated=true
pkg/simdutf/vendor/** linguist-vendored
src/terminal/res/** linguist-vendored

View File

@ -60,6 +60,7 @@ pub fn build(b: *std.Build) !void {
// The xcframework build always installs resources because our
// macOS xcode project contains references to them.
resources.install();
i18n.install();
// If we aren't emitting docs we need to emit a placeholder so
// our macOS xcodeproject builds.
@ -82,6 +83,16 @@ pub fn build(b: *std.Build) !void {
{
const run_cmd = b.addRunArtifact(exe.exe);
if (b.args) |args| run_cmd.addArgs(args);
// Set the proper resources dir so things like shell integration
// work correctly. If we're running `zig build run` in Ghostty,
// this also ensures it overwrites the release one with our debug
// build.
run_cmd.setEnvironmentVariable(
"GHOSTTY_RESOURCES_DIR",
b.getInstallPath(.prefix, "share/ghostty"),
);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}

View File

@ -63,6 +63,7 @@
.gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell" },
.harfbuzz = .{ .path = "./pkg/harfbuzz" },
.highway = .{ .path = "./pkg/highway" },
.libintl = .{ .path = "./pkg/libintl" },
.libpng = .{ .path = "./pkg/libpng" },
.macos = .{ .path = "./pkg/macos" },
.oniguruma = .{ .path = "./pkg/oniguruma" },

8
build.zig.zon.nix generated
View File

@ -331,6 +331,14 @@ in
hash = "sha256-NUqLRTm1iOcLmOxwhEJz4/J0EwLEw3e8xOgbPRhm98k=";
};
}
{
name = "1220f870c853529233ea64a108acaaa81f8d06d7ff4b66c76930be7d78d508aff7a2";
path = fetchZigArtifact {
name = "gettext";
url = "https://deps.files.ghostty.org/gettext-0.24.tar.gz";
hash = "sha256-yRhQPVk9cNr0hE0XWhPYFq+stmfAb7oeydzVACwVGLc=";
};
}
{
name = "1220c15e72eadd0d9085a8af134904d9a0f5dfcbed5f606ad60edc60ebeccd9706bb";
path = fetchZigArtifact {

9
build.zig.zon.txt generated
View File

@ -4,6 +4,7 @@ git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e
https://codeberg.org/atman/zg/archive/v0.13.2.tar.gz
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-12208d70ee791d7ef7e16e1c3c9c1127b57f1ed066a24f87d57fc9f730c5dc394b9d.tar.zst
https://deps.files.ghostty.org/harfbuzz-1220b8588f106c996af10249bfa092c6fb2f35fbacb1505ef477a0b04a7dd1063122.tar.gz
@ -22,13 +23,13 @@ https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23c
https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz
https://deps.files.ghostty.org/z2d-12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a.tar.gz
https://deps.files.ghostty.org/zf-1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8.tar.gz
https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz
https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz
https://deps.files.ghostty.org/zig_objc-1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634.tar.gz
https://deps.files.ghostty.org/zig-wayland-fbfe3b4ac0b472a27b1f1a67405436c58cbee12d.tar.gz
https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz
https://deps.files.ghostty.org/zig_objc-1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634.tar.gz
https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
https://github.com/getsentry/breakpad/archive/b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz
https://github.com/GNOME/libxml2/archive/refs/tags/v2.11.5.tar.gz
https://github.com/getsentry/breakpad/archive/b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz
https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e21d5ffd19605741d0e3e19d7c5a8c6c25648673.tar.gz
https://github.com/mitchellh/glfw/archive/b552c6ec47326b94015feddb36058ea567b87159.tar.gz
https://github.com/mitchellh/libxev/archive/8943932a668f338cb2c500f6e1a7396bacd8b55d.tar.gz

View File

@ -154,6 +154,11 @@
"url": "https://deps.files.ghostty.org/highway-12205c83b8311a24b1d5ae6d21640df04f4b0726e314337c043cde1432758cbe165b.tar.gz",
"hash": "sha256-NUqLRTm1iOcLmOxwhEJz4/J0EwLEw3e8xOgbPRhm98k="
},
"1220f870c853529233ea64a108acaaa81f8d06d7ff4b66c76930be7d78d508aff7a2": {
"name": "gettext",
"url": "https://deps.files.ghostty.org/gettext-0.24.tar.gz",
"hash": "sha256-yRhQPVk9cNr0hE0XWhPYFq+stmfAb7oeydzVACwVGLc="
},
"1220c15e72eadd0d9085a8af134904d9a0f5dfcbed5f606ad60edc60ebeccd9706bb": {
"name": "oniguruma",
"url": "https://deps.files.ghostty.org/oniguruma-1220c15e72eadd0d9085a8af134904d9a0f5dfcbed5f606ad60edc60ebeccd9706bb.tar.gz",

View File

@ -669,6 +669,7 @@ typedef struct {
int ghostty_init(void);
void ghostty_cli_main(uintptr_t, char**);
ghostty_info_s ghostty_info(void);
const char* ghostty_translate(const char*);
ghostty_config_t ghostty_config_new();
void ghostty_config_free(ghostty_config_t);

View File

@ -40,6 +40,7 @@
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; };
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */; };
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */; };
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; };
@ -138,6 +139,7 @@
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = "<group>"; };
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = "<group>"; };
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
@ -424,6 +426,7 @@
29C15B1C2CDC3B2000520DD4 /* bat */,
A586167B2B7703CC009BDB1D /* fish */,
55154BDF2B33911F001622DC /* ghostty */,
A546F1132D7B68D7003B11A0 /* locale */,
A5985CE52C33060F00C57AD3 /* man */,
9351BE8E2D22937F003B3499 /* nvim */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
@ -593,20 +596,21 @@
buildActionMask = 2147483647;
files = (
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */,
29C15B1D2CDC3B2900520DD4 /* bat in Resources */,
A586167C2B7703CC009BDB1D /* fish in Resources */,
55154BE02B33911F001622DC /* ghostty in Resources */,
A546F1142D7B68D7003B11A0 /* locale in Resources */,
A5985CE62C33060F00C57AD3 /* man in Resources */,
9351BE8E3D22937F003B3499 /* nvim in Resources */,
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */,
552964E62B34A9B400030505 /* vim in Resources */,
FC5218FA2D10FFCE004C93E0 /* zsh in Resources */,
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */,
A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */,
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */,
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */,
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */,
29C15B1D2CDC3B2900520DD4 /* bat in Resources */,
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */,
A586167C2B7703CC009BDB1D /* fish in Resources */,
FC5218FA2D10FFCE004C93E0 /* zsh in Resources */,
55154BE02B33911F001622DC /* ghostty in Resources */,
A5985CE62C33060F00C57AD3 /* man in Resources */,
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */,
552964E62B34A9B400030505 /* vim in Resources */,
9351BE8E3D22937F003B3499 /* nvim in Resources */,
A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;

101
pkg/libintl/build.zig Normal file
View File

@ -0,0 +1,101 @@
//! Provides libintl for macOS.
//!
//! IMPORTANT: This is only for macOS. We could support other platforms
//! if/when we need to but generally Linux provides libintl in libc.
//! Windows we'll have to figure out when we get there.
//!
//! Since this is only for macOS, there's a lot of hardcoded stuff
//! here that assumes macOS. For example, I generated the config.h
//! on my own machine (a Mac) and then copied it here. This isn't
//! ideal since we should do the same detection that gettext's configure
//! script does, but its quite a bit of work to do that.
//!
//! UPGRADING: If you need to upgrade gettext, then the only thing to
//! really watch out for is the xlocale.h include we added manually
//! at the end of config.h. The comment there notes why. When we upgrade
//! we should audit our config.h and make sure we add that back (if we
//! have to).
const std = @import("std");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const upstream = b.dependency("gettext", .{});
var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit();
try flags.appendSlice(&.{
"-DHAVE_CONFIG_H",
"-DLOCALEDIR=\"\"",
});
{
const lib = b.addStaticLibrary(.{
.name = "intl",
.target = target,
.optimize = optimize,
});
lib.linkLibC();
lib.addIncludePath(b.path(""));
lib.addIncludePath(upstream.path("gettext-runtime/intl"));
lib.addIncludePath(upstream.path("gettext-runtime/intl/gnulib-lib"));
if (target.result.isDarwin()) {
const apple_sdk = @import("apple_sdk");
try apple_sdk.addPaths(b, &lib.root_module);
}
lib.addCSourceFiles(.{
.root = upstream.path("gettext-runtime/intl"),
.files = srcs,
.flags = flags.items,
});
lib.installHeader(b.path("libintl.h"), "libintl.h");
b.installArtifact(lib);
}
}
const srcs: []const []const u8 = &.{
"bindtextdom.c",
"dcgettext.c",
"dcigettext.c",
"dcngettext.c",
"dgettext.c",
"dngettext.c",
"explodename.c",
"finddomain.c",
"gettext.c",
"hash-string.c",
"intl-compat.c",
"l10nflist.c",
"langprefs.c",
"loadmsgcat.c",
"localealias.c",
"log.c",
"ngettext.c",
"plural-exp.c",
"plural.c",
"setlocale.c",
"textdomain.c",
"version.c",
"compat.c",
// There's probably a better way to detect that we need these, but
// these are hardcoded for now for macOS.
"gnulib-lib/getlocalename_l-unsafe.c",
"gnulib-lib/localename.c",
"gnulib-lib/localename-environ.c",
"gnulib-lib/localename-unsafe.c",
"gnulib-lib/setlocale-lock.c",
"gnulib-lib/setlocale_null.c",
"gnulib-lib/setlocale_null-unlocked.c",
// Not needed for macOS, but we might need them for other platforms.
// If we expand this to support other platforms, we should uncomment
// these.
// "osdep.c",
// "printf.c",
};

13
pkg/libintl/build.zig.zon Normal file
View File

@ -0,0 +1,13 @@
.{
.name = "libintl",
.version = "0.24.0",
.paths = .{""},
.dependencies = .{
.gettext = .{
.url = "https://deps.files.ghostty.org/gettext-0.24.tar.gz",
.hash = "1220f870c853529233ea64a108acaaa81f8d06d7ff4b66c76930be7d78d508aff7a2",
},
.apple_sdk = .{ .path = "../apple-sdk" },
},
}

2370
pkg/libintl/config.h generated Normal file

File diff suppressed because it is too large Load Diff

1
pkg/libintl/libgnuintl.h Normal file
View File

@ -0,0 +1 @@
#include "libintl.h"

1168
pkg/libintl/libintl.h generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -131,14 +131,18 @@ which should be filled in accordingly. You can then add your translations
within the newly created translation file.
Afterwards, you need to update the list of known locales within Ghostty's
build system. To do so, open `src/build/GhosttyI18n.zig` and find the list
of locales under the `locale` variable, then append the full locale name
build system. To do so, open `src/os/i18n.zig` and find the list
of locales under the `locales` variable, then add the full locale name
into the list.
The order matters, so make sure to place your locale in the correct position.
Read the comment above the variable for more details on the order. If you're
unsure, place it at the end of the list.
```zig
const locales = [_][]const u8{
"zh_CN.UTF-8",
// <- Add your locale here
// <- Add your locale here (probably)
}
```

View File

@ -40,7 +40,6 @@ const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
const CloseDialog = @import("CloseDialog.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");
@ -101,11 +100,6 @@ 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,
@ -124,6 +118,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
c.adw_get_micro_version(),
});
// Set gettext global domain to be our app so that our unqualified
// translations map to our translations.
try internal_os.i18n.initGlobalDomain();
// Load our configuration
var config = try Config.load(core_app.alloc);
errdefer config.deinit();

View File

@ -6,12 +6,12 @@ const gio = @import("gio");
const adw = @import("adw");
const gtk = @import("gtk");
const i18n = @import("../../os/main.zig").i18n;
const App = @import("App.zig");
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
const Surface = @import("Surface.zig");
const adwaita = @import("adwaita.zig");
const i18n = @import("i18n.zig");
const log = std.log.scoped(.close_dialog);

View File

@ -16,6 +16,7 @@ const build_options = @import("build_options");
const configpkg = @import("../../config.zig");
const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
const i18n = @import("../../os/main.zig").i18n;
const input = @import("../../input.zig");
const renderer = @import("../../renderer.zig");
const terminal = @import("../../terminal/main.zig");
@ -36,7 +37,6 @@ 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);

View File

@ -18,6 +18,7 @@ const gtk = @import("gtk");
const build_config = @import("../../build_config.zig");
const configpkg = @import("../../config.zig");
const font = @import("../../font/main.zig");
const i18n = @import("../../os/main.zig").i18n;
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
@ -34,7 +35,6 @@ const HeaderBar = @import("headerbar.zig");
const CloseDialog = @import("CloseDialog.zig");
const version = @import("version.zig");
const winproto = @import("winproto.zig");
const i18n = @import("i18n.zig");
const log = std.log.scoped(.gtk);

View File

@ -1,37 +0,0 @@
//! 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;

View File

@ -3,13 +3,10 @@ const GhosttyI18n = @This();
const std = @import("std");
const Config = @import("Config.zig");
const gresource = @import("../apprt/gtk/gresource.zig");
const internal_os = @import("../os/main.zig");
const domain = "com.mitchellh.ghostty";
const locales = [_][]const u8{
"zh_CN.UTF-8",
};
owner: *std.Build,
steps: []*std.Build.Step,
@ -18,23 +15,22 @@ steps: []*std.Build.Step,
update_step: *std.Build.Step,
pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n {
_ = cfg;
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"));
inline for (internal_os.i18n.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);
}
try steps.append(&b.addInstallFile(
msgfmt.captureStdOut(),
std.fmt.comptimePrint(
"share/locale/{s}/LC_MESSAGES/{s}.mo",
.{ locale, domain },
),
).step);
}
return .{
@ -107,7 +103,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step {
"po/" ++ domain ++ ".pot",
);
inline for (locales) |locale| {
inline for (internal_os.i18n.locales) |locale| {
const msgmerge = b.addSystemCommand(&.{ "msgmerge", "-q" });
msgmerge.addFileArg(b.path("po/" ++ locale ++ ".po"));
msgmerge.addFileArg(xgettext.captureStdOut());

View File

@ -381,6 +381,17 @@ pub fn add(
if (self.config.renderer == .opengl) {
step.linkFramework("OpenGL");
}
// Apple platforms do not include libc libintl so we bundle it.
// This is LGPL but since our source code is open source we are
// in compliance with the LGPL since end users can modify this
// build script to replace the bundled libintl with their own.
const libintl_dep = b.dependency("libintl", .{
.target = target,
.optimize = optimize,
});
step.linkLibrary(libintl_dep.artifact("intl"));
try static_libs.append(libintl_dep.artifact("intl").getEmittedBin());
}
// cimgui

View File

@ -172,6 +172,11 @@ pub const GlobalState = struct {
// hereafter can use this cached value.
self.resources_dir = try internal_os.resourcesDir(self.alloc);
errdefer if (self.resources_dir) |dir| self.alloc.free(dir);
// Setup i18n
if (self.resources_dir) |v| internal_os.i18n.init(v) catch |err| {
std.log.warn("failed to init i18n, translations will not be available err={}", .{err});
};
}
/// Cleans up the global state. This doesn't _need_ to be called but

View File

@ -15,6 +15,7 @@ const build_config = @import("build_config.zig");
const main = @import("main_ghostty.zig");
const state = &@import("global.zig").state;
const apprt = @import("apprt.zig");
const internal_os = @import("os/main.zig");
// Some comptime assertions that our C API depends on.
comptime {
@ -88,3 +89,13 @@ export fn ghostty_info() Info {
.version_len = build_config.version_string.len,
};
}
/// Translate a string maintained by libghostty into the current
/// application language. This will return the same string (same pointer)
/// if no translation is found, so the pointer must be stable through
/// the function call.
///
/// This should only be used for singular strings maintained by Ghostty.
export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 {
return internal_os.i18n._(msgid);
}

102
src/os/i18n.zig Normal file
View File

@ -0,0 +1,102 @@
const std = @import("std");
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const log = std.log.scoped(.i18n);
/// Supported locales for the application. This must be kept up to date
/// with the translations available in the `po/` directory; this is used
/// by our build process as well runtime libghostty APIs.
///
/// The order also matters. For incomplete locale information (i.e. only
/// a language code available), the first match is used. For example, if
/// we know the user requested `zh` but has no script code, then we'd pick
/// the first locale that matches `zh`.
///
/// For ordering, we prefer:
///
/// 1. The most common locales first, since there are places in the code
/// where we do linear searches for a locale and we want to minimize
/// the number of iterations for the common case.
///
/// 2. Alphabetical for otherwise equally common locales.
///
/// 3. Most preferred locale for a language without a country code.
///
pub const locales = [_][:0]const u8{
"zh_CN.UTF-8",
};
/// Set for faster membership lookup of locales.
pub const locales_map = map: {
var kvs: [locales.len]struct { []const u8 } = undefined;
for (locales, 0..) |locale, i| kvs[i] = .{locale};
break :map std.StaticStringMap(void).initComptime(kvs);
};
pub const InitError = error{
InvalidResourcesDir,
OutOfMemory,
};
/// Initialize i18n support for the application. This should be
/// called automatically by the global state initialization
/// in global.zig.
///
/// This calls `bindtextdomain` for gettext with the proper directory
/// of translations. This does NOT call `textdomain` as we don't
/// want to set the domain for the entire application since this is also
/// used by libghostty.
pub fn init(resources_dir: []const u8) InitError!void {
// i18n is unsupported on Windows
if (builtin.os.tag == .windows) return;
// Our resources dir is always nested below the share dir that
// is standard for translations.
const share_dir = std.fs.path.dirname(resources_dir) orelse
return error.InvalidResourcesDir;
// Build our locale path
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = std.fmt.bufPrintZ(&buf, "{s}/locale", .{share_dir}) catch
return error.OutOfMemory;
// Bind our bundle ID to the given locale path
log.debug("binding domain={s} path={s}", .{ build_config.bundle_id, path });
_ = bindtextdomain(build_config.bundle_id, path.ptr) orelse
return error.OutOfMemory;
}
/// Set the global gettext domain to our bundle ID, allowing unqualified
/// `gettext` (`_`) calls to look up translations for our application.
///
/// This should only be called for apprts that are fully owning the
/// Ghostty application. This should not be called for libghostty users.
pub fn initGlobalDomain() error{OutOfMemory}!void {
_ = textdomain(build_config.bundle_id) orelse return error.OutOfMemory;
}
/// Translate a message for the Ghostty domain.
pub fn _(msgid: [*:0]const u8) [*:0]const u8 {
return dgettext(build_config.bundle_id, msgid);
}
/// This can be called at any point a compile-time-known locale is
/// available. This will use comptime to verify the locale is supported.
pub fn staticLocale(comptime v: [*:0]const u8) [*:0]const u8 {
comptime {
for (locales) |locale| {
if (std.mem.eql(u8, locale, v)) {
return locale;
}
}
@compileError("unsupported locale");
}
}
// 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;
extern fn dgettext(domainname: [*:0]const u8, msgid: [*:0]const u8) [*:0]const u8;

View File

@ -1,10 +1,12 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const macos = @import("macos");
const objc = @import("objc");
const internal_os = @import("main.zig");
const i18n = internal_os.i18n;
const log = std.log.scoped(.os);
const log = std.log.scoped(.os_locale);
/// Ensure that the locale is set.
pub fn ensureLocale(alloc: std.mem.Allocator) !void {
@ -60,7 +62,7 @@ pub fn ensureLocale(alloc: std.mem.Allocator) !void {
_ = internal_os.setenv("LANG", "en_US.UTF-8");
log.info("setlocale default result={s}", .{v});
return;
} else log.err("setlocale failed even with the fallback, uncertain results", .{});
} else log.warn("setlocale failed even with the fallback, uncertain results", .{});
}
/// This sets the LANG environment variable based on the macOS system
@ -71,7 +73,7 @@ fn setLangFromCocoa() void {
// The classes we're going to need.
const NSLocale = objc.getClass("NSLocale") orelse {
log.err("NSLocale class not found. Locale may be incorrect.", .{});
log.warn("NSLocale class not found. Locale may be incorrect.", .{});
return;
};
@ -92,14 +94,14 @@ fn setLangFromCocoa() void {
// Format them into a buffer
var buf: [128]u8 = undefined;
const env_value = std.fmt.bufPrintZ(&buf, "{s}_{s}.UTF-8", .{ z_lang, z_country }) catch |err| {
log.err("error setting locale from system. err={}", .{err});
log.warn("error setting locale from system. err={}", .{err});
return;
};
log.info("detected system locale={s}", .{env_value});
// Set it onto our environment
if (internal_os.setenv("LANG", env_value) < 0) {
log.err("error setting locale env var", .{});
log.warn("error setting locale env var", .{});
return;
}
}

View File

@ -17,6 +17,7 @@ const resourcesdir = @import("resourcesdir.zig");
pub const args = @import("args.zig");
pub const cgroup = @import("cgroup.zig");
pub const hostname = @import("hostname.zig");
pub const i18n = @import("i18n.zig");
pub const passwd = @import("passwd.zig");
pub const xdg = @import("xdg.zig");
pub const windows = @import("windows.zig");