diff --git a/build.zig.zon b/build.zig.zon index cf16b35ae..5af0e6e99 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -41,6 +41,10 @@ .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", .hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8", }, + .zig_gettext = .{ + .url = "git+https://github.com/pluiedev/zig-gettext/?ref=main#c1ff3a954ba0d9ad369ccf96810437c7e46cfc2a", + .hash = "122046140255a2dadfc3b3f55a68e750776f0ae2330865665f892d381d8d8f438c69", + }, // C libs .cimgui = .{ .path = "./pkg/cimgui" }, @@ -48,6 +52,7 @@ .freetype = .{ .path = "./pkg/freetype" }, .harfbuzz = .{ .path = "./pkg/harfbuzz" }, .highway = .{ .path = "./pkg/highway" }, + .libintl = .{ .path = "./pkg/libintl" }, .libpng = .{ .path = "./pkg/libpng" }, .macos = .{ .path = "./pkg/macos" }, .oniguruma = .{ .path = "./pkg/oniguruma" }, diff --git a/nix/devShell.nix b/nix/devShell.nix index c52afb6c0..16e316309 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -54,6 +54,7 @@ wayland, wayland-scanner, wayland-protocols, + gettext, }: let # See package.nix. Keep in sync. rpathLibs = @@ -84,6 +85,7 @@ gtk4 glib wayland + gettext ]; in mkShell { diff --git a/nix/package.nix b/nix/package.nix index ceb6a7688..eeb2b9c79 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -20,6 +20,7 @@ pkg-config, zig_0_13, pandoc, + gettext, revision ? "dirty", optimize ? "Debug", enableX11 ? true, @@ -56,6 +57,7 @@ ../images ../include ../pkg + ../po ../src ../vendor ../build.zig @@ -125,6 +127,7 @@ in pkg-config zig_hook wrapGAppsHook4 + gettext ] ++ lib.optionals enableWayland [ wayland-scanner diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 66b8eb8b6..1ae4f7c09 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-Bjy31evaKgpRX1mGwAFkai44eiiorTV1gW3VdP9Ins8=" +"sha256-ar4f1+7Mx45K446X2/4/n9gVFZklAcQyJHQ+Ehn2p4U=" diff --git a/pkg/libintl/build.zig b/pkg/libintl/build.zig new file mode 100644 index 000000000..7f3b69db6 --- /dev/null +++ b/pkg/libintl/build.zig @@ -0,0 +1,40 @@ +const std = @import("std"); + +const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, +}; + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const module = b.addModule("intl", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + + if (b.systemIntegrationOption("libintl", .{ + .default = !target.result.isGnuLibC(), + })) { + // On non-glibc platforms we don't have libintl + // built into libc, so we have to do more work. + // In GNU's infinite wisdom, there's no easy pkg-config file for + // you to consume and integrate into build systems other than autoconf. + // Users must rely on system library/include paths, or manually + // add libintl to the Zig search path. + module.linkSystemLibrary("intl", dynamic_link_opts); + } + + // switch (target.result.os.tag) { + // .windows => { + // const msys2 = b.dependency("libintl_msys2", .{}); + // lib.addLibraryPath(msys2.path("usr/bin")); + // module.linkSystemLibrary2("msys-intl-8", .{ + // .preferred_link_mode = .dynamic, + // .search_strategy = .mode_first, + // }); + // }, + // } +} diff --git a/pkg/libintl/build.zig.zon b/pkg/libintl/build.zig.zon new file mode 100644 index 000000000..b7211ce04 --- /dev/null +++ b/pkg/libintl/build.zig.zon @@ -0,0 +1,6 @@ +.{ + .name = "libintl", + .version = "0.0.1", + .paths = .{""}, + .dependencies = .{}, +} diff --git a/pkg/libintl/c.zig b/pkg/libintl/c.zig new file mode 100644 index 000000000..cb5ff9624 --- /dev/null +++ b/pkg/libintl/c.zig @@ -0,0 +1,41 @@ +pub const locale = @cImport(@cInclude("locale.h")); + +pub extern fn gettext( + msgid: [*:0]const u8, +) [*:0]const u8; +pub extern fn dgettext( + domainname: [*:0]const u8, + msgid: [*:0]const u8, +) [*:0]const u8; +pub extern fn dcgettext( + domainname: [*:0]const u8, + msgid: [*:0]const u8, + category: c_int, +) [*:0]const u8; + +pub extern fn ngettext( + msgid1: [*:0]const u8, + msgid2: [*:0]const u8, + n: c_ulong, +) [*:0]const u8; +pub extern fn dngettext( + domainname: [*:0]const u8, + msgid1: [*:0]const u8, + msgid2: [*:0]const u8, + n: c_ulong, +) [*:0]const u8; +pub extern fn dcngettext( + domainname: [*:0]const u8, + msgid1: [*:0]const u8, + msgid2: [*:0]const u8, + n: c_ulong, + category: c_int, +) [*:0]const u8; + +pub extern fn bindtextdomain( + domainname: [*:0]const u8, + dirname: [*:0]const u8, +) ?[*]const u8; +pub extern fn textdomain( + domainname: ?[*:0]const u8, +) ?[*]const u8; diff --git a/pkg/libintl/main.zig b/pkg/libintl/main.zig new file mode 100644 index 000000000..060474467 --- /dev/null +++ b/pkg/libintl/main.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const c = @import("c.zig"); + +pub const Category = enum(c_int) { + messages = c.locale.LC_MESSAGES, + collate = c.locale.LC_COLLATE, + ctype = c.locale.LC_CTYPE, + monetary = c.locale.LC_MONETARY, + numeric = c.locale.LC_NUMERIC, + time = c.locale.LC_TIME, + _, +}; + +pub const Query = struct { + msg: [:0]const u8, + plural: ?struct { + msg: [:0]const u8, + number: c_ulong, + } = null, + domain: ?[:0]const u8 = null, + category: ?Category = null, +}; + +pub const _ = gettext; + +pub fn gettext(comptime msg: [:0]const u8) [:0]const u8 { + return std.mem.span(c.gettext(msg)); +} +pub fn dgettext(comptime msg: [:0]const u8, domain: [:0]const u8) [:0]const u8 { + return std.mem.span(c.dgettext(domain, msg)); +} +pub fn dcgettext(comptime msg: [:0]const u8, domain: [:0]const u8, category: Category) [:0]const u8 { + return std.mem.span(c.dcgettext(domain, msg, category)); +} +pub fn ngettext( + comptime msg: [:0]const u8, + comptime msg_plural: [:0]const u8, + number: c_ulong, +) [:0]const u8 { + return std.mem.span(c.ngettext(msg, msg_plural, number)); +} +pub fn dngettext( + comptime msg: [:0]const u8, + comptime msg_plural: [:0]const u8, + number: c_ulong, + domain: [:0]const u8, +) [:0]const u8 { + return std.mem.span(c.dngettext(domain, msg, msg_plural, number)); +} +pub fn dcngettext( + comptime msg: [:0]const u8, + comptime msg_plural: [:0]const u8, + number: c_ulong, + domain: [:0]const u8, + category: Category, +) [:0]const u8 { + return std.mem.span(c.dcngettext( + domain, + msg, + msg_plural, + number, + category, + )); +} + +pub fn bindTextDomain(domain: [:0]const u8, dir: [:0]const u8) std.mem.Allocator.Error!void { + // ENOMEM is the only possible error + if (c.bindtextdomain(domain, dir) == null) return error.OutOfMemory; +} +pub fn setTextDomain(domain: [:0]const u8) std.mem.Allocator.Error!void { + // ENOMEM is the only possible error + if (c.textdomain(domain) == null) return error.OutOfMemory; +} diff --git a/po/LINGUAS b/po/LINGUAS new file mode 100644 index 000000000..e69de29bb diff --git a/po/messages.pot b/po/messages.pot new file mode 100644 index 000000000..28312dd24 --- /dev/null +++ b/po/messages.pot @@ -0,0 +1,159 @@ +msgid "" +msgstr "" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: src/apprt/gtk/App.zig:1827 src/apprt/gtk/Window.zig:918 +msgid "About Ghostty" +msgstr "" + +#: src/apprt/gtk/Tab.zig:147 +msgid "All terminal sessions in this tab will be terminated." +msgstr "" + +#: src/apprt/gtk/Window.zig:800 +msgid "All terminal sessions in this window will be terminated." +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:170 +msgid "Allow" +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:248 +msgid "" +"An application is attempting to read from the clipboard.\n" +"The current clipboard contents are shown below.\n" +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:252 +msgid "" +"An application is attempting to write to the clipboard.\n" +"The content to write is shown below.\n" +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:237 +msgid "Authorize Clipboard Access" +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:169 +msgid "Cancel" +msgstr "" + +#: src/apprt/gtk/App.zig:1814 +msgid "Close Tab" +msgstr "" + +#: src/apprt/gtk/App.zig:1817 +msgid "Close Window" +msgstr "" + +#: src/apprt/gtk/Tab.zig:143 +msgid "Close this tab?" +msgstr "" + +#: src/apprt/gtk/Surface.zig:728 +msgid "Close this terminal?" +msgstr "" + +#: src/apprt/gtk/Window.zig:796 +msgid "Close this window?" +msgstr "" + +#: src/apprt/gtk/Surface.zig:1141 +msgid "Copied to clipboard" +msgstr "" + +#: src/apprt/gtk/App.zig:1848 +msgid "Copy" +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:170 +msgid "Deny" +msgstr "" + +#: src/apprt/gtk/Window.zig:899 +msgid "Ghostty Developers" +msgstr "" + +#: src/apprt/gtk/inspector.zig:143 +msgid "Ghostty: Terminal Inspector" +msgstr "" + +#: src/apprt/gtk/App.zig:1874 +msgid "Menu" +msgstr "" + +#: src/apprt/gtk/App.zig:1813 src/apprt/gtk/Window.zig:201 +msgid "New Tab" +msgstr "" + +#: src/apprt/gtk/App.zig:1812 +msgid "New Window" +msgstr "" + +#: src/apprt/gtk/App.zig:1825 +msgid "Open Configuration" +msgstr "" + +#: src/apprt/gtk/App.zig:1849 src/apprt/gtk/ClipboardConfirmationWindow.zig:169 +msgid "Paste" +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:245 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.\n" +msgstr "" + +#: src/apprt/gtk/App.zig:1826 +msgid "Reload Configuration" +msgstr "" + +#: src/apprt/gtk/App.zig:1864 +msgid "Reset" +msgstr "" + +#: src/apprt/gtk/App.zig:1816 src/apprt/gtk/App.zig:1857 +msgid "Split Down" +msgstr "" + +#: src/apprt/gtk/App.zig:1815 src/apprt/gtk/App.zig:1856 +msgid "Split Right" +msgstr "" + +#: src/apprt/gtk/App.zig:1824 src/apprt/gtk/App.zig:1865 +msgid "Terminal Inspector" +msgstr "" + +#: src/apprt/gtk/Surface.zig:732 +msgid "" +"There is still a running process in the terminal. Closing the terminal will kill this process.\n" +"Are you sure you want to close the terminal?\n" +"\n" +"Click 'No' to cancel and return to your terminal.\n" +msgstr "" + +#: src/apprt/gtk/Window.zig:174 +msgid "View Open Tabs" +msgstr "" + +#: src/apprt/gtk/ClipboardConfirmationWindow.zig:236 +msgid "Warning: Potentially Unsafe Paste" +msgstr "" + +#: src/apprt/gtk/ResizeOverlay.zig:107 +msgid "c" +msgid_plural "c" +msgstr[0] "" +msgstr[1] "" + +#: src/apprt/gtk/ResizeOverlay.zig:111 +msgid "r" +msgid_plural "r" +msgstr[0] "" +msgstr[1] "" + +#: src/apprt/gtk/Window.zig:220 +msgid "⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index df74cefb2..e86d3bb1d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -38,6 +38,7 @@ const inspector = @import("inspector.zig"); const key = @import("key.zig"); const winproto = @import("winproto.zig"); const testing = std.testing; +const intl = @import("intl"); const log = std.log.scoped(.gtk); @@ -1809,22 +1810,22 @@ fn initMenuContent(menu: *c.GMenu) void { const section = c.g_menu_new(); defer c.g_object_unref(section); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "New Window", "win.new_window"); - c.g_menu_append(section, "New Tab", "win.new_tab"); - c.g_menu_append(section, "Close Tab", "win.close_tab"); - c.g_menu_append(section, "Split Right", "win.split_right"); - c.g_menu_append(section, "Split Down", "win.split_down"); - c.g_menu_append(section, "Close Window", "win.close"); + c.g_menu_append(section, intl._("New Window"), "win.new_window"); + c.g_menu_append(section, intl._("New Tab"), "win.new_tab"); + c.g_menu_append(section, intl._("Close Tab"), "win.close_tab"); + c.g_menu_append(section, intl._("Split Right"), "win.split_right"); + c.g_menu_append(section, intl._("Split Down"), "win.split_down"); + c.g_menu_append(section, intl._("Close Window"), "win.close"); } { const section = c.g_menu_new(); defer c.g_object_unref(section); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); - c.g_menu_append(section, "Open Configuration", "app.open-config"); - c.g_menu_append(section, "Reload Configuration", "app.reload-config"); - c.g_menu_append(section, "About Ghostty", "win.about"); + c.g_menu_append(section, intl._("Terminal Inspector"), "win.toggle_inspector"); + c.g_menu_append(section, intl._("Open Configuration"), "app.open-config"); + c.g_menu_append(section, intl._("Reload Configuration"), "app.reload-config"); + c.g_menu_append(section, intl._("About Ghostty"), "win.about"); } } @@ -1845,24 +1846,24 @@ fn initContextMenu(self: *App) void { const section = c.g_menu_new(); defer c.g_object_unref(section); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Copy", "win.copy"); - c.g_menu_append(section, "Paste", "win.paste"); + c.g_menu_append(section, intl._("Copy"), "win.copy"); + c.g_menu_append(section, intl._("Paste"), "win.paste"); } { const section = c.g_menu_new(); defer c.g_object_unref(section); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Split Right", "win.split_right"); - c.g_menu_append(section, "Split Down", "win.split_down"); + c.g_menu_append(section, intl._("Split Right"), "win.split_right"); + c.g_menu_append(section, intl._("Split Down"), "win.split_down"); } { const section = c.g_menu_new(); defer c.g_object_unref(section); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Reset", "win.reset"); - c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); + c.g_menu_append(section, intl._("Reset"), "win.reset"); + c.g_menu_append(section, intl._("Terminal Inspector"), "win.toggle_inspector"); } const section = c.g_menu_new(); @@ -1871,7 +1872,7 @@ fn initContextMenu(self: *App) void { defer c.g_object_unref(submenu); initMenuContent(@ptrCast(submenu)); - c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); + c.g_menu_append_submenu(section, intl._("Menu"), @ptrCast(@alignCast(submenu))); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); self.context_menu = menu; diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index cf417b668..a90b4b4f9 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -9,6 +9,7 @@ const CoreSurface = @import("../../Surface.zig"); const App = @import("App.zig"); const View = @import("View.zig"); const c = @import("c.zig").c; +const intl = @import("intl"); const log = std.log.scoped(.gtk); @@ -166,8 +167,8 @@ const ButtonsView = struct { pub fn init(root: *ClipboardConfirmation) !ButtonsView { const cancel_text, const confirm_text = switch (root.pending_req) { - .paste => .{ "Cancel", "Paste" }, - .osc_52_read, .osc_52_write => .{ "Deny", "Allow" }, + .paste => .{ intl._("Cancel"), intl._("Paste") }, + .osc_52_read, .osc_52_write => .{ intl._("Deny"), intl._("Allow") }, }; const cancel_button = c.gtk_button_new_with_label(cancel_text); @@ -233,8 +234,8 @@ const ButtonsView = struct { /// The title of the window, based on the reason the prompt is being shown. fn titleText(req: apprt.ClipboardRequest) [:0]const u8 { return switch (req) { - .paste => "Warning: Potentially Unsafe Paste", - .osc_52_read, .osc_52_write => "Authorize Clipboard Access", + .paste => intl._("Warning: Potentially Unsafe Paste"), + .osc_52_read, .osc_52_write => intl._("Authorize Clipboard Access"), }; } @@ -242,16 +243,16 @@ fn titleText(req: apprt.ClipboardRequest) [:0]const u8 { /// is being shown. fn promptText(req: apprt.ClipboardRequest) [:0]const u8 { return switch (req) { - .paste => - \\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed. - , - .osc_52_read => - \\An application is attempting to read from the clipboard. - \\The current clipboard contents are shown below. - , - .osc_52_write => - \\An application is attempting to write to the clipboard. - \\The content to write is shown below. - , + .paste => intl._( + \\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed. + ), + .osc_52_read => intl._( + \\An application is attempting to read from the clipboard. + \\The current clipboard contents are shown below. + ), + .osc_52_write => intl._( + \\An application is attempting to write to the clipboard. + \\The content to write is shown below. + ), }; } diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig index 108dcd676..b52400cd9 100644 --- a/src/apprt/gtk/ResizeOverlay.zig +++ b/src/apprt/gtk/ResizeOverlay.zig @@ -4,6 +4,7 @@ const std = @import("std"); const c = @import("c.zig").c; const configpkg = @import("../../config.zig"); const Surface = @import("Surface.zig"); +const intl = @import("intl"); const log = std.log.scoped(.gtk); @@ -95,13 +96,20 @@ fn gtkUpdate(ud: ?*anyopaque) callconv(.C) c.gboolean { }; const grid_size = surface.core_surface.size.grid(); - var buf: [32]u8 = undefined; + + var buf: [64]u8 = undefined; const text = std.fmt.bufPrintZ( &buf, - "{d}c ⨯ {d}r", + "{d}{s} ⨯ {d}{s}", .{ grid_size.columns, + // Translators: the abbreviation for "column" (of a grid) in your language. + // If the abbreviation cannot be intuitively understood, use the full word. + intl.ngettext("c", "c", grid_size.columns), grid_size.rows, + // Translators: the abbreviation for "row" (of a grid) in your language. + // If the abbreviation cannot be intuitively understood, use the full word. + intl.ngettext("r", "r", grid_size.rows), }, ) catch |err| { log.err("unable to format text: {}", .{err}); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 1ca39425b..420a8d617 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -25,6 +25,7 @@ const ResizeOverlay = @import("ResizeOverlay.zig"); const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig").c; +const intl = @import("intl"); const log = std.log.scoped(.gtk_surface); @@ -725,14 +726,16 @@ pub fn close(self: *Surface, processActive: bool) void { c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_QUESTION, c.GTK_BUTTONS_YES_NO, - "Close this terminal?", + intl._("Close this terminal?"), ); c.gtk_message_dialog_format_secondary_text( @ptrCast(alert), - "There is still a running process in the terminal. " ++ - "Closing the terminal will kill this process. " ++ - "Are you sure you want to close the terminal?\n\n" ++ - "Click 'No' to cancel and return to your terminal.", + intl._( + \\There is still a running process in the terminal. Closing the terminal will kill this process. + \\Are you sure you want to close the terminal? + \\ + \\Click 'No' to cancel and return to your terminal. + ), ); // We want the "yes" to appear destructive. @@ -1136,7 +1139,7 @@ pub fn setClipboardString( self.app.config.@"app-notifications".@"clipboard-copy") { if (self.container.window()) |window| - window.sendToast("Copied to clipboard"); + window.sendToast(intl._("Copied to clipboard")); } return; } diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index d320daa7c..4f2a562ce 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -13,6 +13,7 @@ const CoreSurface = @import("../../Surface.zig"); const Surface = @import("Surface.zig"); const Window = @import("Window.zig"); const c = @import("c.zig").c; +const intl = @import("intl"); const log = std.log.scoped(.gtk); @@ -140,11 +141,11 @@ pub fn closeWithConfirmation(tab: *Tab) void { c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_QUESTION, c.GTK_BUTTONS_YES_NO, - "Close this tab?", + intl._("Close this tab?"), ); c.gtk_message_dialog_format_secondary_text( @ptrCast(alert), - "All terminal sessions in this tab will be terminated.", + intl._("All terminal sessions in this tab will be terminated."), ); // We want the "yes" to appear destructive. diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 3a72e1752..248f94d24 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -26,6 +26,7 @@ const Notebook = @import("notebook.zig").Notebook; const HeaderBar = @import("headerbar.zig").HeaderBar; const version = @import("version.zig"); const winproto = @import("winproto.zig"); +const intl = @import("intl"); const log = std.log.scoped(.gtk); @@ -171,7 +172,7 @@ pub fn init(self: *Window, app: *App) !void { const btn = switch (app.config.@"gtk-tabs-location") { .top, .bottom, .left, .right => 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, intl._("View Open Tabs")); c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic"); _ = c.g_object_bind_property( btn, @@ -198,7 +199,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, intl._("New Tab")); _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(>kTabNewClick), self, null, c.G_CONNECT_DEFAULT); self.headerbar.packStart(btn); } @@ -217,7 +218,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 = intl._("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); if ((comptime adwaita.versionAtLeast(1, 3, 0)) and adwaita.enabled(&app.config) and adwaita.versionAtLeast(1, 3, 0)) @@ -793,11 +794,11 @@ fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_QUESTION, c.GTK_BUTTONS_YES_NO, - "Close this window?", + intl._("Close this window?"), ); c.gtk_message_dialog_format_secondary_text( @ptrCast(alert), - "All terminal sessions in this window will be terminated.", + intl._("All terminal sessions in this window will be terminated."), ); // We want the "yes" to appear destructive. @@ -896,7 +897,7 @@ fn gtkActionAbout( "application-name", name, "developer-name", - "Ghostty Developers", + intl._("Ghostty Developers").ptr, "application-icon", icon, "version", @@ -915,7 +916,7 @@ fn gtkActionAbout( "logo-icon-name", icon, "title", - "About Ghostty", + intl._("About Ghostty").ptr, "version", build_config.version_string.ptr, "website", diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index 558175751..4c618e979 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -9,6 +9,7 @@ const TerminalWindow = @import("Window.zig"); const ImguiWidget = @import("ImguiWidget.zig"); const c = @import("c.zig").c; const CoreInspector = @import("../../inspector/main.zig").Inspector; +const intl = @import("intl"); const log = std.log.scoped(.inspector); @@ -140,7 +141,7 @@ const Window = struct { const gtk_window: *c.GtkWindow = @ptrCast(window); errdefer c.gtk_window_destroy(gtk_window); self.window = gtk_window; - c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); + c.gtk_window_set_title(gtk_window, intl._("Ghostty: Terminal Inspector")); c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index a7ff40cbd..7b775f743 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -14,6 +14,64 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { var steps = std.ArrayList(*std.Build.Step).init(b.allocator); errdefer steps.deinit(); + // Localization files (.pot, .po, .mo) + { + const update = b.step("update-translations", "Update translation files"); + + const gettext = b.dependency("zig_gettext", .{ + // We're running this on the host, so we need to compile it for the host + .target = b.graph.host, + .optimize = cfg.optimize, + }); + + const xgettext = b.addRunArtifact(gettext.artifact("xgettext")); + const pot = pot: { + var src_files = try b.build_root.handle.openDir("src", .{ .iterate = true }); + defer src_files.close(); + + var walk = try src_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.addFileArg(b.path(b.pathJoin(&.{ "src", src.path }))); + } + break :pot xgettext.captureStdOut(); + }; + + // TODO: Use UpdateSourceFiles when Zig 0.14 releases + var wf_update = b.addWriteFiles(); + wf_update.addCopyFileToSource(pot, "po/messages.pot"); + update.dependOn(&wf_update.step); + + var wf_mo = b.addWriteFiles(); + var linguas = try b.build_root.handle.openFile("po/LINGUAS", .{}); + defer linguas.close(); + var reader = linguas.reader(); + var buf: [64]u8 = undefined; + + while (try reader.readUntilDelimiterOrEof(&buf, '\n')) |locale| { + const po = b.fmt("po/{s}.po", .{locale}); + + const mo = b.addRunArtifact(gettext.artifact("msgfmt")); + mo.addFileArg(b.path(po)); + _ = wf_mo.addCopyFile( + mo.captureStdOut(), + b.pathJoin(&.{ "share", "locale", locale, "LC_MESSAGES", "messages.mo" }), + ); + + const msgmerge = b.addSystemCommand(&.{ "msgmerge", "--update", "--quiet" }); + msgmerge.addFileArg(b.path(po)); + msgmerge.addFileArg(pot); + update.dependOn(&msgmerge.step); + } + + try steps.append(&wf_mo.step); + } + // Terminfo terminfo: { // Encode our terminfo diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 64068658d..9c67d3b97 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -358,6 +358,10 @@ pub fn add( .optimize = optimize, .with_tui = false, }).module("zf")); + step.root_module.addImport("intl", b.dependency("libintl", .{ + .target = target, + .optimize = optimize, + }).module("intl")); // Mac Stuff if (step.rootModuleTarget().isDarwin()) { diff --git a/src/global.zig b/src/global.zig index d5a7af630..03d32384f 100644 --- a/src/global.zig +++ b/src/global.zig @@ -10,6 +10,7 @@ const oni = @import("oniguruma"); const crash = @import("crash/main.zig"); const renderer = @import("renderer.zig"); const xev = @import("xev"); +const intl = @import("intl"); /// Global process state. This is initialized in main() for exe artifacts /// and by ghostty_init() for lib artifacts. This should ONLY be used by @@ -162,6 +163,8 @@ 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); + + try self.initI18n(); } /// Cleans up the global state. This doesn't _need_ to be called but @@ -200,6 +203,20 @@ pub const GlobalState = struct { std.log.warn("failed to ignore SIGPIPE err={}", .{err}); }; } + fn initI18n(self: *GlobalState) std.mem.Allocator.Error!void { + const resources = self.resources_dir orelse { + std.log.warn("resources dir not found: not localizing", .{}); + return; + }; + const share = std.fs.path.dirname(resources) orelse return; + const locale = try std.fs.path.joinZ(self.alloc, &.{ share, "locale" }); + defer self.alloc.free(locale); + + std.log.warn("locale={s}", .{locale}); + + try intl.bindTextDomain("messages", locale); + try intl.setTextDomain("messages"); + } }; /// Maintains the Unix resource limits that we set for our process. This diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index 4ef256c1a..7b7c7559e 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -9,14 +9,13 @@ 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. - // 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 we have an environment variable set, we prefer that *only* in release mode. + // + // The reason is that debug builds built by developers may have updated + // resources, and debug Ghostty launched from release Ghostty should not + // inherit old/stale resources of release Ghostty. + if (comptime builtin.mode != .Debug) { + if (try getDirFromEnv(alloc)) |dir| return dir; } // This is the sentinel value we look for in the path to know @@ -52,6 +51,25 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { } } + // If we are in debug mode and we couldn't find freshly-built + // resources for some reason, we fall back to using the env var + if (comptime builtin.mode == .Debug) { + if (try getDirFromEnv(alloc)) |dir| return dir; + } + + return null; +} + +fn getDirFromEnv(alloc: Allocator) !?[]u8 { + // 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, + } + return null; }