Files
ghostty-rs/crates/ghostty-os/src/i18n.rs
T
ddidderr 452b782149 feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin)
Ports the macOS-specific Zig code behind cfg(target_os = "macos"):
- renderer: a GraphicsApi trait with generic::Renderer<G>; Metal backend
  (all renderer/metal/* files, shaders.metal verbatim, runtime library
  compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga;
  OpenGL remains the backend elsewhere.
- font: CoreText face, shaper and discovery with build.rs backend
  selection (coretext default; coretext-freetype / coretext-harfbuzz /
  coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple.
- input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes.
- os: macos, mach, cf_release_thread, NSProcessInfo args and macOS
  branches; config: macOS defaults, keybinds, Application Support paths.
- termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit
  handling, os_log logging.

Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings
clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot
run here. The embedded runtime/C API (embedded.zig) is the next step.

Also includes the cross-platform integration edits shared by the crash,
verification, Windows and macOS work (Cargo target-gating, thread
crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg
gates), a cfg fix so ghostty-config builds on wasm32, and an allow for
the intentional recursion in the crash overflow test.

Test Plan:
- cargo test --workspace (3224 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check --target aarch64-apple-darwin -p ghostty-renderer (with
  the documented cross env)
2026-10-12 06:02:39 +02:00

209 lines
6.5 KiB
Rust

#[cfg(not(windows))]
use std::path::Path;
use crate::i18n_locales::LOCALES;
/// Application bundle ID, used as the gettext domain
/// (`build_config.bundle_id`).
// TODO(integration): take from a shared build-config crate.
pub const BUNDLE_ID: &str = "com.mitchellh.ghostty";
/// Membership test for supported locales (Zig: `locales_map`).
pub fn is_supported_locale(locale: &str) -> bool {
LOCALES.contains(&locale)
}
#[derive(Debug, thiserror::Error)]
pub enum InitError {
#[error("invalid resources dir")]
InvalidResourcesDir,
#[error("out of memory")]
OutOfMemory,
}
/// Initialize i18n support for the application. This should be
/// called automatically by the global state initialization.
///
/// 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.
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)]
pub fn init(_resources_dir: &str) -> Result<(), InitError> {
// i18n is unsupported on Windows
Ok(())
}
/// See the Windows variant above.
#[cfg(not(windows))]
pub fn init(resources_dir: &str) -> Result<(), InitError> {
// Our resources dir is always nested below the share dir that
// is standard for translations.
// (`std.fs.path.dirname` returns null for a bare relative name, where
// `Path::parent` returns an empty path.)
let share_dir = Path::new(resources_dir)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or(InitError::InvalidResourcesDir)?;
// Build our locale path
let path = format!("{}/locale", share_dir.display());
// Bind our bundle ID to the given locale path
log::debug!("binding domain={BUNDLE_ID} path={path}");
gettextrs::bindtextdomain(BUNDLE_ID, path).map_err(|_| InitError::OutOfMemory)?;
Ok(())
}
/// 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 init_global_domain() -> Result<(), InitError> {
#[cfg(not(windows))]
gettextrs::textdomain(BUNDLE_ID).map_err(|_| InitError::OutOfMemory)?;
Ok(())
}
/// Translate a message for the Ghostty domain (Zig: `_`).
pub fn tr(msgid: &str) -> String {
#[cfg(windows)]
return msgid.to_owned();
#[cfg(not(windows))]
gettextrs::dgettext(BUNDLE_ID, msgid)
}
/// Canonicalize a locale name from a platform-specific value to
/// a POSIX-compliant value.
///
/// The Zig version is a thin layer over gnulib's
/// `_libintl_locale_name_canonicalize` (only linked when building libintl
/// from source, i.e. macOS). That function isn't available here, so this is
/// a pure-Rust approximation of the cases that matter: BCP-47 style tags
/// have `-` replaced with `_` and the Chinese script tags are mapped to
/// their POSIX region equivalents. Encodings are NOT parsed out (like the
/// original, `en_US.UTF-8` becomes `en_US.UTF_8`).
pub fn canonicalize_locale(locale: &str) -> String {
// Fix zh locales
if let Some(fixed) = fix_zh_locale(locale) {
return fixed.to_owned();
}
// Emulate gnulib's mapping of bare zh script tags.
match locale {
"zh-Hans" => return "zh_CN".to_owned(),
"zh-Hant" => return "zh_TW".to_owned(),
_ => {}
}
locale.replace('-', "_")
}
/// Handles some zh locales canonicalization because internal libintl
/// canonicalization function doesn't handle correctly in these cases.
fn fix_zh_locale(locale: &str) -> Option<&'static str> {
let mut it = locale.split('-');
let name = it.next()?;
if name != "zh" {
return None;
}
let script = it.next()?;
let region = it.next()?;
if script == "Hans" {
if region == "SG" {
return Some("zh_SG");
}
return Some("zh_CN");
}
if script == "Hant" {
if region == "MO" {
return Some("zh_MO");
}
if region == "HK" {
return Some("zh_HK");
}
return Some("zh_TW");
}
None
}
/// This can be called at any point a compile-time-known locale is
/// available. In a `const` context this verifies the locale is supported
/// (panics at compile time otherwise).
pub const fn static_locale(v: &'static str) -> &'static str {
let vb = v.as_bytes();
let mut i = 0;
while i < LOCALES.len() {
let lb = LOCALES[i].as_bytes();
if lb.len() == vb.len() {
let mut j = 0;
let mut eq = true;
while j < lb.len() {
if lb[j] != vb[j] {
eq = false;
break;
}
j += 1;
}
if eq {
return LOCALES[i];
}
}
i += 1;
}
panic!("unsupported locale");
}
#[cfg(test)]
mod tests {
use super::*;
// The Zig test is darwin-only; the canonicalization here is portable.
#[test]
fn canonicalize_locale_darwin() {
assert_eq!("en_US", canonicalize_locale("en_US"));
assert_eq!("zh_CN", canonicalize_locale("zh-Hans"));
assert_eq!("zh_TW", canonicalize_locale("zh-Hant"));
assert_eq!("zh_CN", canonicalize_locale("zh-Hans-CN"));
assert_eq!("zh_SG", canonicalize_locale("zh-Hans-SG"));
assert_eq!("zh_TW", canonicalize_locale("zh-Hant-TW"));
assert_eq!("zh_HK", canonicalize_locale("zh-Hant-HK"));
assert_eq!("zh_MO", canonicalize_locale("zh-Hant-MO"));
// This is just an edge case I want to make sure we're aware of:
// canonicalizeLocale does not handle encodings and will turn them into
// underscores. We should parse them out before calling this function.
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
}
#[test]
fn locales_map_and_static_locale() {
assert!(is_supported_locale("zh_CN"));
assert!(is_supported_locale("kk"));
assert!(!is_supported_locale("xx"));
const L: &str = static_locale("de");
assert_eq!(L, "de");
}
#[test]
fn init_rejects_dirless_resources_dir() {
assert!(matches!(
init("ghostty"),
Err(InitError::InvalidResourcesDir)
));
assert!(matches!(init("/"), Err(InitError::InvalidResourcesDir)));
}
#[test]
fn tr_falls_back_to_msgid() {
assert_eq!(tr("some untranslated message"), "some untranslated message");
}
}