diff --git a/src/core/explorer_context_menu.rs b/src/core/explorer_context_menu.rs index 4b5384e3..70174995 100644 --- a/src/core/explorer_context_menu.rs +++ b/src/core/explorer_context_menu.rs @@ -17,6 +17,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; +use crate::ui::i18n::{L10nKey, t}; + const DIRECTORY_KEY: &str = r"Software\Classes\Directory\shell\tty7"; const BACKGROUND_KEY: &str = r"Software\Classes\Directory\Background\shell\tty7"; @@ -34,11 +36,19 @@ impl Location { } } + /// The wording Explorer shows, in the language the app is set to. + /// + /// Explorer reads this string from the registry, not from tty7, so what is + /// written here is what a user sees until something writes it again — an + /// install-time snapshot of the locale. Two things keep that snapshot + /// honest: `register` sets the locale from the config before building the + /// entries, and [`refresh_labels`] restates them when the language changes + /// in Settings. fn label(self) -> &'static str { - match self { - Self::Directory => "Open in tty7", - Self::Background => "Open tty7 here", - } + t(match self { + Self::Directory => L10nKey::ExplorerMenuOpenIn, + Self::Background => L10nKey::ExplorerMenuOpenHere, + }) } fn placeholder(self) -> &'static str { @@ -90,6 +100,25 @@ pub fn unregister() -> Result<()> { platform_unregister() } +/// Restate the verb labels in the language the UI now runs in. +/// +/// The registry holds whatever wording was current when the installer ran, so +/// without this a user who switches tty7 to Chinese keeps English entries in +/// Explorer for the life of the install — the one place in the product where +/// the language setting would not reach. +/// +/// Only keys that already exist are rewritten. Offering the menu is the +/// installer's checkbox and declining it is the user's decision; changing a +/// language must never be what puts the verbs back. +/// +/// Best-effort by design: a failure here costs a log line, never a language +/// change the user asked for. +pub fn refresh_labels() { + if let Err(error) = platform_refresh_labels() { + log::warn!("could not restate the Explorer context-menu labels: {error}"); + } +} + /// Build a command line without converting the executable path through UTF-8. /// /// Quotes are unconditional: both the executable and the Explorer-substituted @@ -251,6 +280,48 @@ mod windows { notify_explorer(); Ok(()) } + + /// The verb key, or `None` when tty7's menu is not installed. + /// + /// Deliberately open rather than create: this is the call that makes + /// [`refresh_labels`] unable to resurrect a menu the user removed. + fn open_existing(path: &str) -> Result> { + let path = wide(OsStr::new(path)); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: `path` is a live, NUL-terminated UTF-16 string and `key` is a + // live local the API fills in only on success. + let code = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + path.as_ptr(), + 0, + KEY_READ | KEY_WRITE, + &mut key, + ) + }; + match code { + ERROR_SUCCESS => Ok(Some(RegistryKey(key))), + ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(None), + other => Err(io_error("opening the tty7 Explorer registry key", other)), + } + } + + pub(super) fn refresh_labels() -> Result<()> { + let mut restated = false; + for location in [Location::Directory, Location::Background] { + let Some(key) = open_existing(location.key())? else { + continue; + }; + set_string(&key, None, OsStr::new(location.label()))?; + restated = true; + } + // Only worth waking the shell when something actually moved; a user who + // never installed the menu changes languages for free. + if restated { + notify_explorer(); + } + Ok(()) + } } #[cfg(windows)] @@ -263,6 +334,11 @@ fn platform_unregister() -> Result<()> { windows::unregister() } +#[cfg(windows)] +fn platform_refresh_labels() -> Result<()> { + windows::refresh_labels() +} + #[cfg(not(windows))] fn platform_register() -> Result<()> { anyhow::bail!("Windows Explorer integration is only available on Windows") @@ -273,12 +349,23 @@ fn platform_unregister() -> Result<()> { anyhow::bail!("Windows Explorer integration is only available on Windows") } +/// Nothing to restate: the verbs exist only on Windows. +/// +/// Silent rather than an error like the two above, because this one is called +/// on every language change on every platform. Refusing here would put a +/// warning in the log of every macOS and Linux user who picks a language. +#[cfg(not(windows))] +fn platform_refresh_labels() -> Result<()> { + Ok(()) +} + #[cfg(test)] mod tests { use super::*; #[test] fn registration_targets_both_directory_surfaces() { + crate::ui::i18n::set_locale("en"); let app = Path::new(r"C:\Program Files\tty7\tty7-app.exe"); let [directory, background] = registrations(app); @@ -299,6 +386,25 @@ mod tests { ); } + /// The entries Explorer shows were English whatever language tty7 ran in, + /// because the labels were string literals. They are the only wording in + /// the product that outlives the process that wrote it, so the guard is on + /// the label rather than on the registry write it feeds. + #[test] + fn the_verb_labels_follow_the_ui_language() { + crate::ui::i18n::set_locale("zh-CN"); + assert_eq!(Location::Directory.label(), "在 tty7 中打开"); + assert_eq!(Location::Background.label(), "在此处打开 tty7"); + + crate::ui::i18n::set_locale("ja-JP"); + assert_eq!(Location::Directory.label(), "tty7 で開く"); + assert_eq!(Location::Background.label(), "ここで tty7 を開く"); + + crate::ui::i18n::set_locale("en"); + assert_eq!(Location::Directory.label(), "Open in tty7"); + assert_eq!(Location::Background.label(), "Open tty7 here"); + } + #[test] fn commands_quote_even_paths_without_spaces() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index fd018576..5a306c15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -466,6 +466,11 @@ fn main() { // the log is the only place the reason can survive. if let Some(register) = explorer_menu_action_from(&args) { let result = if register { + // The verb labels are localized, and this process stops at the + // `return` below — it never reaches the `set_locale` on the GUI + // path. Without this read every install would write English + // entries, whatever language the user runs tty7 in. + crate::ui::i18n::set_locale(&Config::load().gui_language); crate::core::explorer_context_menu::register() } else { crate::core::explorer_context_menu::unregister() diff --git a/src/ui/app.rs b/src/ui/app.rs index f7bf6f5d..7c3d91d5 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5434,6 +5434,10 @@ impl Tty7App { set_locale(code); cx.global::().save(); set_menus(cx); + // Explorer reads its menu wording from the registry, so it is the one + // surface a language change does not reach on its own. No-op unless + // the user installed the context menu, and off Windows entirely. + crate::core::explorer_context_menu::refresh_labels(); self.refresh_locale_state(window, cx); crate::ui::windows::WindowRegistry::refresh_locale(cx, Some(self.workspace)); } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index ae2c1cfb..5df37e30 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -128,7 +128,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { "How opaque the window background is, for every theme. Below 100% the desktop shows through." } L10nKey::SettingsBlur => "Blur", - L10nKey::SettingsBlurDesc => "Blur whatever is behind a translucent window (macOS).", + L10nKey::SettingsBlurDesc => { + if cfg!(target_os = "macos") { + "Blur whatever is behind a translucent window." + } else { + "Blur whatever is behind a translucent window. Needs a compositor that offers it — KDE Plasma does; GNOME and plain X11 leave the window merely transparent." + } + } L10nKey::SettingsBlurAutoDesc => { "Blur whatever is behind a translucent window. Only applies while Background material is Auto." } @@ -172,6 +178,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ThemeDuplicateFailed => "Could not duplicate the theme", L10nKey::ThemeSaveFailed => "Could not save the theme", L10nKey::OpenInFileManagerFailed => "Could not open {path}", + L10nKey::ExplorerMenuOpenIn => "Open in tty7", + L10nKey::ExplorerMenuOpenHere => "Open tty7 here", L10nKey::SettingsCustomThemesIntro => { "Duplicate a theme to edit its colors, or drop a tty7 YAML theme or iTerm2 .itermcolors file in the themes folder." } @@ -344,7 +352,15 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsConnectTimeout => "Connect timeout (s)", L10nKey::SettingsConnectTimeoutDesc => "Blank = library default.", L10nKey::SettingsX11Forwarding => "X11 forwarding", - L10nKey::SettingsX11ForwardingDesc => "Request X11 forwarding (needs XQuartz on macOS).", + L10nKey::SettingsX11ForwardingDesc => { + if cfg!(target_os = "macos") { + "Request X11 forwarding (needs XQuartz)." + } else if cfg!(target_os = "windows") { + "Request X11 forwarding (needs an X server running, such as VcXsrv or X410)." + } else { + "Request X11 forwarding." + } + } L10nKey::SettingsShellIntegration => "Shell integration", L10nKey::SettingsShellIntegrationDesc => { "Let the remote shell report prompts, exit codes, and the working directory." @@ -477,7 +493,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsCopyOnSelect => "Copy on select", L10nKey::SettingsCopyOnSelectDesc => { - "Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed." + if cfg!(target_os = "macos") { + "Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed." + } else { + "Selecting text with the mouse copies it to the clipboard right away, no Ctrl+Shift+C needed." + } } L10nKey::SettingsTrimTrailingSpaces => "Trim trailing spaces on copy", L10nKey::SettingsTrimTrailingSpacesDesc => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 56f6f416..507a8ac6 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -135,7 +135,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "すべてのテーマにおけるウィンドウ背景の不透明度。100% 未満ではデスクトップが透けて見えます" } L10nKey::SettingsBlur => "背景のぼかし", - L10nKey::SettingsBlurDesc => "半透明ウィンドウの背後にあるものをぼかす(macOS)", + L10nKey::SettingsBlurDesc => { + if cfg!(target_os = "macos") { + "半透明ウィンドウの背後にあるものをぼかす" + } else { + "半透明ウィンドウの背後にあるものをぼかす。対応するコンポジターが必要です(KDE Plasma は対応、GNOME と素の X11 ではウィンドウが透けるだけです)" + } + } L10nKey::SettingsBlurAutoDesc => { "半透明ウィンドウの背後にあるものをぼかす。背景マテリアルが「自動」のときのみ有効です" } @@ -175,6 +181,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ThemeDuplicateFailed => "テーマを複製できませんでした", L10nKey::ThemeSaveFailed => "テーマを保存できませんでした", L10nKey::OpenInFileManagerFailed => "{path} を開けませんでした", + L10nKey::ExplorerMenuOpenIn => "tty7 で開く", + L10nKey::ExplorerMenuOpenHere => "ここで tty7 を開く", L10nKey::SettingsCustomThemesIntro => { "テーマを複製して色を編集するか、tty7 の YAML テーマや iTerm2 の .itermcolors をテーマフォルダに置いてください" } @@ -349,7 +357,15 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsConnectTimeout => "接続タイムアウト(秒)", L10nKey::SettingsConnectTimeoutDesc => "空欄 = ライブラリのデフォルト", L10nKey::SettingsX11Forwarding => "X11 転送", - L10nKey::SettingsX11ForwardingDesc => "X11 転送を要求(macOS では XQuartz が必要)", + L10nKey::SettingsX11ForwardingDesc => { + if cfg!(target_os = "macos") { + "X11 転送を要求(XQuartz が必要)" + } else if cfg!(target_os = "windows") { + "X11 転送を要求(VcXsrv や X410 などの X サーバーの起動が必要)" + } else { + "X11 転送を要求" + } + } L10nKey::SettingsShellIntegration => "シェル統合", L10nKey::SettingsShellIntegrationDesc => { "リモートシェルにプロンプト・終了コード・作業ディレクトリを報告させる" @@ -488,7 +504,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsCopyOnSelect => "選択時に自動コピー", L10nKey::SettingsCopyOnSelectDesc => { - "マウスでテキストを選択するとすぐにクリップボードへコピーされます。⌘C は不要です" + if cfg!(target_os = "macos") { + "マウスでテキストを選択するとすぐにクリップボードへコピーされます。⌘C は不要です" + } else { + "マウスでテキストを選択するとすぐにクリップボードへコピーされます。Ctrl+Shift+C は不要です" + } } L10nKey::SettingsTrimTrailingSpaces => "コピー時に末尾の空白を除去", L10nKey::SettingsTrimTrailingSpacesDesc => "コピーした各行の末尾の空白を除去する", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4e012720..f9d8e5af 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -207,6 +207,8 @@ l10n_keys! { ThemeDuplicateFailed, ThemeSaveFailed, OpenInFileManagerFailed, + ExplorerMenuOpenIn, + ExplorerMenuOpenHere, SettingsCustomThemesIntro, SettingsDuplicateToEdit, SettingsHosts, @@ -1581,6 +1583,39 @@ mod tests { } } + /// Three settings rows talked about macOS as if it were the only platform + /// they were ever shown on: "(macOS)" on a blur switch Linux honors too, + /// "⌘C" on a shortcut that is Ctrl+Shift+C everywhere else, and XQuartz as + /// the only X server anyone could need. All three were wrong in all three + /// languages at once — each translation had faithfully carried the English + /// text's assumption across — which is why this walks every locale rather + /// than trusting en to stand for them. + #[test] + fn wording_that_names_a_platform_names_this_one() { + for lang in SUPPORTED_LANGUAGES { + set_locale(lang.code); + let code = lang.code; + + let copy = t(L10nKey::SettingsCopyOnSelectDesc); + let x11 = t(L10nKey::SettingsX11ForwardingDesc); + + // This row is shown on macOS and Linux alike (Windows gets the + // backdrop picker instead), and both honor the switch, so naming + // either one of them is wrong wherever it is read. + let blur = t(L10nKey::SettingsBlurDesc); + assert!(!blur.contains("macOS"), "{code} blur desc: {blur:?}"); + + if cfg!(target_os = "macos") { + assert!(copy.contains('⌘'), "{code} copy-on-select: {copy:?}"); + assert!(x11.contains("XQuartz"), "{code} x11: {x11:?}"); + } else { + assert!(!copy.contains('⌘'), "{code} copy-on-select: {copy:?}"); + assert!(!x11.contains("XQuartz"), "{code} x11: {x11:?}"); + } + } + set_locale(default_language_code()); + } + #[test] fn explicit_languages_select_the_right_locale() { set_locale("zh-CN"); diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 65463832..2adc7f53 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -117,7 +117,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "窗口背景的不透明度,适用于所有主题。低于 100% 时可以看到桌面。" } L10nKey::SettingsBlur => "模糊", - L10nKey::SettingsBlurDesc => "模糊半透明窗口背后的内容(macOS)。", + L10nKey::SettingsBlurDesc => { + if cfg!(target_os = "macos") { + "模糊半透明窗口背后的内容。" + } else { + "模糊半透明窗口背后的内容。需要合成器支持——KDE Plasma 可以;GNOME 和裸 X11 下窗口只会变透明。" + } + } L10nKey::SettingsBlurAutoDesc => { "模糊半透明窗口背后的内容。仅在「背景材质」为「自动」时生效。" } @@ -155,6 +161,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ThemeDuplicateFailed => "无法复制主题", L10nKey::ThemeSaveFailed => "无法保存主题", L10nKey::OpenInFileManagerFailed => "无法打开 {path}", + L10nKey::ExplorerMenuOpenIn => "在 tty7 中打开", + L10nKey::ExplorerMenuOpenHere => "在此处打开 tty7", L10nKey::SettingsCustomThemesIntro => { "复制一个主题即可在此编辑颜色,或把 tty7 YAML 主题、iTerm2 .itermcolors 文件放进主题文件夹。" } @@ -307,7 +315,15 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsConnectTimeout => "连接超时(秒)", L10nKey::SettingsConnectTimeoutDesc => "留空 = 库默认值。", L10nKey::SettingsX11Forwarding => "X11 转发", - L10nKey::SettingsX11ForwardingDesc => "请求 X11 转发(macOS 上需要 XQuartz)。", + L10nKey::SettingsX11ForwardingDesc => { + if cfg!(target_os = "macos") { + "请求 X11 转发(需要 XQuartz)。" + } else if cfg!(target_os = "windows") { + "请求 X11 转发(需要运行 X 服务端,如 VcXsrv 或 X410)。" + } else { + "请求 X11 转发。" + } + } L10nKey::SettingsShellIntegration => "Shell 集成", L10nKey::SettingsShellIntegrationDesc => "让远程 shell 报告提示符、退出码和工作目录。", L10nKey::SettingsLoginScripts => "登录脚本", @@ -420,7 +436,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "双击选择光标下的完整 URL、文件路径、邮箱或成对的括号。" } L10nKey::SettingsCopyOnSelect => "选中即复制", - L10nKey::SettingsCopyOnSelectDesc => "用鼠标选中文本时立即复制到剪贴板,无需按 ⌘C。", + L10nKey::SettingsCopyOnSelectDesc => { + if cfg!(target_os = "macos") { + "用鼠标选中文本时立即复制到剪贴板,无需按 ⌘C。" + } else { + "用鼠标选中文本时立即复制到剪贴板,无需按 Ctrl+Shift+C。" + } + } L10nKey::SettingsTrimTrailingSpaces => "复制时去除末尾空格", L10nKey::SettingsTrimTrailingSpacesDesc => "去除每行复制文本末尾的空白。", L10nKey::SettingsKeyboard => "键盘",