Skip to content

Commit

Permalink
Groundwork for cross-platform i18n with libintl for libghostty/macOS (#…
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
mitchellh authored Mar 7, 2025
2 parents 4a215a9 + dcb8440 commit e03e98e
Show file tree
Hide file tree
Showing 26 changed files with 3,863 additions and 84 deletions.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
8 changes: 8 additions & 0 deletions build.zig.zon.nix

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions build.zig.zon.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions build.zig.zon2json-lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 12 additions & 8 deletions macos/Ghostty.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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>"; };
Expand Down Expand Up @@ -424,6 +426,7 @@
29C15B1C2CDC3B2000520DD4 /* bat */,
A586167B2B7703CC009BDB1D /* fish */,
55154BDF2B33911F001622DC /* ghostty */,
A546F1132D7B68D7003B11A0 /* locale */,
A5985CE52C33060F00C57AD3 /* man */,
9351BE8E2D22937F003B3499 /* nvim */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
Expand Down Expand Up @@ -593,20 +596,21 @@
buildActionMask = 2147483647;
files = (
FC9ABA9C2D0F53F80020D4C8 /* bash-completion 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 */,
A546F1142D7B68D7003B11A0 /* locale in Resources */,
A5985CE62C33060F00C57AD3 /* man in Resources */,
9351BE8E3D22937F003B3499 /* nvim in Resources */,
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */,
552964E62B34A9B400030505 /* vim in Resources */,
9351BE8E3D22937F003B3499 /* nvim 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 */,
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */,
A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
101 changes: 101 additions & 0 deletions pkg/libintl/build.zig
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions pkg/libintl/build.zig.zon
Original file line number Diff line number Diff line change
@@ -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" },
},
}
Loading

0 comments on commit e03e98e

Please sign in to comment.