From 00273a1abb8b4df512db0fbe84d733202eb9b2d4 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 16 Aug 2026 12:05:46 +0900 Subject: [PATCH 1/5] feat(i18n): add interface localization with Simplified Chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a localization layer for the application UI and ships an English and a Simplified Chinese catalog. Previously every user-facing string was a hardcoded English literal spread across view, type, and error modules, so the interface could not be translated at all. Translations live in JSON files under `otty/src/i18n/locales/` and are embedded with `include_str!`, keeping the binary self-contained. `Key` stays a Rust enum so call sites remain type-checked, and serde deserializes each catalog into `HashMap`; an unknown key in a JSON file fails deserialization, and tests assert that both catalogs cover every `Key` and contain no extras. No new dependency is required — `serde` and `serde_json` were already used by the crate. The active locale is process-global rather than threaded through every widget signature, which would have touched every view function for no benefit. `Settings > General > Language` offers System / English / 简体中文, persisted as `general.language`; `System` resolves through the POSIX variables `LC_ALL`, `LC_MESSAGES`, `LANG`. Settings written by older builds simply lack the field and fall back to `System`. Section titles double as `TreePath` segments in the settings navigation tree, so a language change invalidates every stored path. `SettingsState::replace_with_settings` now rebuilds the tree and the selected path, and the locale is applied before that happens — both at startup in `load_initial_settings_state` and on save in the reducer. The settings tests read their paths from the tree instead of hardcoding English titles, so they no longer depend on which locale is active. Theme preset names (One Dark, Solarized Dark, Dracula) are left untranslated as proper nouns, as are sample placeholder values such as `example.com` and `~/.ssh/id_ed25519`. Log messages and infrastructure error types stay in English; only strings that reach the UI are localized. --- app/src/events/quick_launch.rs | 5 +- app/src/i18n/key.rs | 288 ++++++++++++++ app/src/i18n/locales/en.json | 127 ++++++ app/src/i18n/locales/zh-CN.json | 127 ++++++ app/src/i18n/mod.rs | 372 ++++++++++++++++++ app/src/main.rs | 1 + app/src/view.rs | 23 +- app/src/widgets/explorer/view/sidebar_tree.rs | 3 +- app/src/widgets/quick_launch/errors.rs | 24 +- app/src/widgets/quick_launch/reducer.rs | 3 +- .../widgets/quick_launch/view/context_menu.rs | 23 +- .../quick_launch/view/sidebar_panel.rs | 3 +- .../widgets/quick_launch/view/wizard_form.rs | 83 ++-- app/src/widgets/settings/event.rs | 6 +- app/src/widgets/settings/reducer.rs | 44 ++- app/src/widgets/settings/state.rs | 63 ++- app/src/widgets/settings/storage.rs | 8 + app/src/widgets/settings/types.rs | 211 ++++++++-- .../widgets/settings/view/settings_form.rs | 63 ++- app/src/widgets/tabs/reducer.rs | 7 +- app/src/widgets/tabs/view/tab_content.rs | 27 +- app/src/widgets/terminal_workspace/reducer.rs | 6 +- .../view/pane_context_menu.rs | 17 +- .../terminal_workspace/view/pane_grid.rs | 3 +- 24 files changed, 1370 insertions(+), 167 deletions(-) create mode 100644 app/src/i18n/key.rs create mode 100644 app/src/i18n/locales/en.json create mode 100644 app/src/i18n/locales/zh-CN.json create mode 100644 app/src/i18n/mod.rs diff --git a/app/src/events/quick_launch.rs b/app/src/events/quick_launch.rs index 0413a47..a60073c 100644 --- a/app/src/events/quick_launch.rs +++ b/app/src/events/quick_launch.rs @@ -3,6 +3,7 @@ use iced::Task; use super::AppEvent; use crate::app::App; use crate::domain::quick_launch::WizardTabInit; +use crate::i18n::{self, Key}; use crate::widgets::quick_launch::{ QuickLaunchCtx, QuickLaunchEffect, QuickLaunchEvent, QuickLaunchIntent, }; @@ -41,13 +42,13 @@ fn handle_effect(effect: QuickLaunchEffect) -> Task { match effect { QuickLaunchEffect::OpenWizardCreateTab { parent_path } => Task::done( AppEvent::Tabs(TabsEvent::Intent(TabsIntent::OpenWizardTab { - title: String::from("Create Quick Launch"), + title: i18n::t(Key::QuickLaunchCreateTabTitle).to_string(), init: WizardTabInit::Create { parent_path }, })), ), QuickLaunchEffect::OpenWizardEditTab { path, command } => Task::done( AppEvent::Tabs(TabsEvent::Intent(TabsIntent::OpenWizardTab { - title: format!("Edit: {}", command.title()), + title: i18n::edit_tab_title(command.title()), init: WizardTabInit::Edit { path, command }, })), ), diff --git a/app/src/i18n/key.rs b/app/src/i18n/key.rs new file mode 100644 index 0000000..a26861e --- /dev/null +++ b/app/src/i18n/key.rs @@ -0,0 +1,288 @@ +use serde::Deserialize; + +/// Identifier for a user-facing string in the translation catalogs. +/// +/// Variants map to `snake_case` keys in the locale JSON files under +/// `locales/`. Every variant must be present in every catalog; the +/// `catalogs_cover_every_key` test enforces that, and an unknown key in a JSON +/// file fails deserialization at startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Key { + // Sidebar add menu. + /// "Create tab" entry in the sidebar add menu. + MenuCreateTab, + /// "Create folder" entry in the sidebar add menu. + MenuCreateFolder, + /// "Create quick launch" entry in the sidebar add menu. + MenuCreateQuickLaunch, + + // Placeholders shown when tab state is missing. + /// Shown when a terminal tab has no backing state. + TerminalTabNotInitialized, + /// Shown when a quick launch wizard tab has no editor state. + QuickLaunchEditorNotInitialized, + /// Shown when a quick launch error tab has no payload. + QuickLaunchErrorPayloadMissing, + /// Shown when the terminal widget cannot be rendered. + TerminalUnavailable, + + // Tab bar and tab content. + /// Shown when no tab is open. + NoTabs, + /// Placeholder body for tabs without dedicated content. + TabContentPlaceholder, + /// Title of the settings tab. + SettingsTabTitle, + /// Title of the quick launch creation wizard tab. + QuickLaunchCreateTabTitle, + + // Terminal pane context menu. + /// Copy the current text selection. + CopySelection, + /// Paste clipboard contents into the terminal. + Paste, + /// Copy the selected block's output. + CopyContent, + /// Copy the selected block's prompt. + CopyPrompt, + /// Copy the selected block's command line. + CopyCommand, + /// Split the pane along the horizontal axis. + SplitHorizontally, + /// Split the pane along the vertical axis. + SplitVertically, + /// Close the pane. + ClosePane, + + // Quick launch context menu. + /// Terminate a running quick launch. + Kill, + /// Edit the selected quick launch. + Edit, + /// Rename the selected entry. + Rename, + /// Duplicate the selected quick launch. + Duplicate, + /// Remove the selected quick launch. + Remove, + /// Create a folder in the quick launch tree. + CreateFolder, + /// Create a quick launch entry. + CreateQuickLaunch, + /// Delete the selected folder. + Delete, + /// Create a launch entry inside a folder. + CreateLaunch, + + // Quick launch sidebar panel. + /// Header of the quick launch sidebar panel. + QuickLaunchPanelTitle, + + // Explorer sidebar. + /// Shown when the explorer has no active folder. + NoActiveFolder, + + // Quick launch wizard. + /// Wizard section header. + WizardHeader, + /// Entry title field label. + FieldTitle, + /// Command type field label. + FieldType, + /// Custom command type option. + TypeCustom, + /// SSH command type option. + TypeSsh, + /// Custom command section header. + SectionCustomCommand, + /// Program path field label. + FieldProgram, + /// Program arguments field label. + FieldArguments, + /// Working directory field label. + FieldWorkdir, + /// SSH connection section header. + SectionSshConnection, + /// SSH host field label. + FieldHost, + /// SSH port field label. + FieldPort, + /// SSH user field label. + FieldUser, + /// SSH identity file field label. + FieldIdentityFile, + /// Additional SSH argument field label. + FieldExtraArgs, + /// Environment variable list label. + FieldEnvironment, + /// Environment variable name placeholder. + EnvKeyPlaceholder, + /// Environment variable value placeholder. + EnvValuePlaceholder, + /// Shown when the custom editor state is inconsistent. + InvalidCustomEditorState, + /// Shown when the SSH editor state is inconsistent. + InvalidSshEditorState, + + // Shared buttons. + /// Persist pending changes. + ButtonSave, + /// Discard pending changes. + ButtonCancel, + /// Remove a list entry. + ButtonRemove, + /// Append a list entry. + ButtonAdd, + /// Append an environment variable entry. + ButtonAddEnv, + /// Restore settings to the persisted baseline. + ButtonReset, + + // Settings sections and fields. + /// General settings section. + SectionGeneral, + /// Terminal settings section. + SectionTerminal, + /// Appearance settings section. + SectionAppearance, + /// Interface language field label. + FieldLanguage, + /// Shell command field label. + FieldShell, + /// Default editor field label. + FieldDefaultEditor, + /// Pane layout field label. + FieldPaneLayout, + /// Option that keeps sibling pane widths even after layout changes. + OptionEqualizePanes, + /// Theme preset field label. + FieldPreset, + /// Placeholder shown when no theme preset matches the palette. + PresetPlaceholderCustom, + /// Language option that follows the operating system locale. + LanguageSystem, + + // Quick launch errors. + /// Prefix for I/O error messages. + ErrIoPrefix, + /// Prefix for JSON error messages. + ErrJsonPrefix, + /// Rejected because the title was blank. + ErrTitleEmpty, + /// Rejected because a sibling already uses the title. + ErrTitleDuplicate, + /// Wizard validation: title missing. + ErrTitleRequired, + /// Wizard validation: program missing. + ErrProgramRequired, + /// Wizard validation: host missing. + ErrHostRequired, + /// Wizard validation: port is not numeric. + ErrInvalidPort, + /// Wizard validation: custom draft absent. + ErrMissingCustomDraft, + /// Wizard validation: SSH draft absent. + ErrMissingSshDraft, + + // Templates. These carry `{placeholder}` markers substituted at call time. + /// Tab title for editing an existing quick launch. Placeholder: `{title}`. + TplEditTabTitle, + /// Error tab title for a failed launch. Placeholder: `{title}`. + TplLaunchFailedTitle, + /// Error tab body for a failed launch. Placeholders: `{command}`, `{error}`. + TplLaunchFailedBody, + /// Message for a terminal that failed to start. Placeholder: `{error}`. + TplTerminalInitFailed, + /// Label for a palette color with no name. Placeholder: `{index}`. + TplPaletteFallbackLabel, +} + +impl Key { + /// Every key in the catalogs, used by tests to verify full coverage. + #[cfg(test)] + pub(super) const ALL: [Self; 82] = [ + Self::MenuCreateTab, + Self::MenuCreateFolder, + Self::MenuCreateQuickLaunch, + Self::TerminalTabNotInitialized, + Self::QuickLaunchEditorNotInitialized, + Self::QuickLaunchErrorPayloadMissing, + Self::TerminalUnavailable, + Self::NoTabs, + Self::TabContentPlaceholder, + Self::SettingsTabTitle, + Self::QuickLaunchCreateTabTitle, + Self::CopySelection, + Self::Paste, + Self::CopyContent, + Self::CopyPrompt, + Self::CopyCommand, + Self::SplitHorizontally, + Self::SplitVertically, + Self::ClosePane, + Self::Kill, + Self::Edit, + Self::Rename, + Self::Duplicate, + Self::Remove, + Self::CreateFolder, + Self::CreateQuickLaunch, + Self::Delete, + Self::CreateLaunch, + Self::QuickLaunchPanelTitle, + Self::NoActiveFolder, + Self::WizardHeader, + Self::FieldTitle, + Self::FieldType, + Self::TypeCustom, + Self::TypeSsh, + Self::SectionCustomCommand, + Self::FieldProgram, + Self::FieldArguments, + Self::FieldWorkdir, + Self::SectionSshConnection, + Self::FieldHost, + Self::FieldPort, + Self::FieldUser, + Self::FieldIdentityFile, + Self::FieldExtraArgs, + Self::FieldEnvironment, + Self::EnvKeyPlaceholder, + Self::EnvValuePlaceholder, + Self::InvalidCustomEditorState, + Self::InvalidSshEditorState, + Self::ButtonSave, + Self::ButtonCancel, + Self::ButtonRemove, + Self::ButtonAdd, + Self::ButtonAddEnv, + Self::ButtonReset, + Self::SectionGeneral, + Self::SectionTerminal, + Self::SectionAppearance, + Self::FieldLanguage, + Self::FieldShell, + Self::FieldDefaultEditor, + Self::FieldPaneLayout, + Self::OptionEqualizePanes, + Self::FieldPreset, + Self::PresetPlaceholderCustom, + Self::LanguageSystem, + Self::ErrIoPrefix, + Self::ErrJsonPrefix, + Self::ErrTitleEmpty, + Self::ErrTitleDuplicate, + Self::ErrTitleRequired, + Self::ErrProgramRequired, + Self::ErrHostRequired, + Self::ErrInvalidPort, + Self::ErrMissingCustomDraft, + Self::ErrMissingSshDraft, + Self::TplEditTabTitle, + Self::TplLaunchFailedTitle, + Self::TplLaunchFailedBody, + Self::TplTerminalInitFailed, + Self::TplPaletteFallbackLabel, + ]; +} diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json new file mode 100644 index 0000000..4f44634 --- /dev/null +++ b/app/src/i18n/locales/en.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "Foreground", + "Background", + "Black", + "Red", + "Green", + "Yellow", + "Blue", + "Magenta", + "Cyan", + "White", + "Bright Black", + "Bright Red", + "Bright Green", + "Bright Yellow", + "Bright Blue", + "Bright Magenta", + "Bright Cyan", + "Bright White", + "Bright Foreground", + "Dim Black", + "Dim Red", + "Dim Green", + "Dim Yellow", + "Dim Blue", + "Dim Magenta", + "Dim Cyan", + "Dim White", + "Dim Foreground", + "Overlay" + ], + "strings": { + "menu_create_tab": "Create tab", + "menu_create_folder": "Create folder", + "menu_create_quick_launch": "Create quick launch", + + "terminal_tab_not_initialized": "Terminal tab is not initialized.", + "quick_launch_editor_not_initialized": "Quick launch editor is not initialized.", + "quick_launch_error_payload_missing": "Quick launch error payload is missing.", + "terminal_unavailable": "Terminal unavailable", + + "no_tabs": "No tabs", + "tab_content_placeholder": "Tab content placeholder", + "settings_tab_title": "Settings", + "quick_launch_create_tab_title": "Create Quick Launch", + + "copy_selection": "Copy selection", + "paste": "Paste", + "copy_content": "Copy content", + "copy_prompt": "Copy prompt", + "copy_command": "Copy command", + "split_horizontally": "Split horizontally", + "split_vertically": "Split vertically", + "close_pane": "Close", + + "kill": "Kill", + "edit": "Edit", + "rename": "Rename", + "duplicate": "Duplicate", + "remove": "Remove", + "create_folder": "Create Folder", + "create_quick_launch": "Create quick launch", + "delete": "Delete", + "create_launch": "Create Launch", + + "quick_launch_panel_title": "SHELL", + "no_active_folder": "No active folder", + + "wizard_header": "Quick launch", + "field_title": "Title", + "field_type": "Type", + "type_custom": "Custom", + "type_ssh": "SSH", + "section_custom_command": "Custom command", + "field_program": "Program", + "field_arguments": "Arguments", + "field_workdir": "Workdir (cwd)", + "section_ssh_connection": "SSH connection", + "field_host": "Host", + "field_port": "Port", + "field_user": "User", + "field_identity_file": "Identity file", + "field_extra_args": "Extra args", + "field_environment": "Environment", + "env_key_placeholder": "KEY", + "env_value_placeholder": "value", + "invalid_custom_editor_state": "Invalid custom editor state", + "invalid_ssh_editor_state": "Invalid SSH editor state", + + "button_save": "Save", + "button_cancel": "Cancel", + "button_remove": "Remove", + "button_add": "Add", + "button_add_env": "Add env", + "button_reset": "Reset", + + "section_general": "General", + "section_terminal": "Terminal", + "section_appearance": "Appearance", + "field_language": "Language", + "field_shell": "Shell", + "field_default_editor": "Default editor", + "field_pane_layout": "Pane layout", + "option_equalize_panes": "Even out sibling pane widths on split and close", + "field_preset": "Preset", + "preset_placeholder_custom": "Custom", + "language_system": "System", + + "err_io_prefix": "I/O error: ", + "err_json_prefix": "JSON error: ", + "err_title_empty": "Title must not be empty.", + "err_title_duplicate": "A sibling with this title already exists.", + "err_title_required": "Title is required.", + "err_program_required": "Program is required.", + "err_host_required": "Host is required.", + "err_invalid_port": "Port must be a number.", + "err_missing_custom_draft": "Custom command draft is missing.", + "err_missing_ssh_draft": "SSH command draft is missing.", + + "tpl_edit_tab_title": "Edit: {title}", + "tpl_launch_failed_title": "Failed to launch \"{title}\"", + "tpl_launch_failed_body": "Command: {command}\nError: {error}", + "tpl_terminal_init_failed": "Terminal tab initialization failed: {error}", + "tpl_palette_fallback_label": "Color {index}" + } +} diff --git a/app/src/i18n/locales/zh-CN.json b/app/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..6649d99 --- /dev/null +++ b/app/src/i18n/locales/zh-CN.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "前景色", + "背景色", + "黑色", + "红色", + "绿色", + "黄色", + "蓝色", + "品红", + "青色", + "白色", + "亮黑", + "亮红", + "亮绿", + "亮黄", + "亮蓝", + "亮品红", + "亮青", + "亮白", + "亮前景色", + "暗黑", + "暗红", + "暗绿", + "暗黄", + "暗蓝", + "暗品红", + "暗青", + "暗白", + "暗前景色", + "浮层" + ], + "strings": { + "menu_create_tab": "新建标签页", + "menu_create_folder": "新建文件夹", + "menu_create_quick_launch": "新建快捷启动", + + "terminal_tab_not_initialized": "终端标签页尚未初始化。", + "quick_launch_editor_not_initialized": "快捷启动编辑器尚未初始化。", + "quick_launch_error_payload_missing": "快捷启动的错误信息缺失。", + "terminal_unavailable": "终端不可用", + + "no_tabs": "没有标签页", + "tab_content_placeholder": "标签页内容占位", + "settings_tab_title": "设置", + "quick_launch_create_tab_title": "新建快捷启动", + + "copy_selection": "复制选中内容", + "paste": "粘贴", + "copy_content": "复制输出", + "copy_prompt": "复制提示符", + "copy_command": "复制命令", + "split_horizontally": "水平拆分", + "split_vertically": "垂直拆分", + "close_pane": "关闭", + + "kill": "终止", + "edit": "编辑", + "rename": "重命名", + "duplicate": "创建副本", + "remove": "移除", + "create_folder": "新建文件夹", + "create_quick_launch": "新建快捷启动", + "delete": "删除", + "create_launch": "新建启动项", + + "quick_launch_panel_title": "快捷启动", + "no_active_folder": "无活动文件夹", + + "wizard_header": "快捷启动", + "field_title": "名称", + "field_type": "类型", + "type_custom": "自定义", + "type_ssh": "SSH", + "section_custom_command": "自定义命令", + "field_program": "程序", + "field_arguments": "参数", + "field_workdir": "工作目录 (cwd)", + "section_ssh_connection": "SSH 连接", + "field_host": "主机", + "field_port": "端口", + "field_user": "用户", + "field_identity_file": "私钥文件", + "field_extra_args": "额外参数", + "field_environment": "环境变量", + "env_key_placeholder": "变量名", + "env_value_placeholder": "变量值", + "invalid_custom_editor_state": "自定义命令编辑器状态无效", + "invalid_ssh_editor_state": "SSH 编辑器状态无效", + + "button_save": "保存", + "button_cancel": "取消", + "button_remove": "移除", + "button_add": "添加", + "button_add_env": "添加环境变量", + "button_reset": "重置", + + "section_general": "通用", + "section_terminal": "终端", + "section_appearance": "外观", + "field_language": "界面语言", + "field_shell": "Shell", + "field_default_editor": "默认编辑器", + "field_pane_layout": "窗格布局", + "option_equalize_panes": "拆分和关闭时均衡同级窗格宽度", + "field_preset": "配色预设", + "preset_placeholder_custom": "自定义", + "language_system": "跟随系统", + + "err_io_prefix": "I/O 错误:", + "err_json_prefix": "JSON 错误:", + "err_title_empty": "名称不能为空。", + "err_title_duplicate": "同级中已存在同名条目。", + "err_title_required": "名称为必填项。", + "err_program_required": "程序为必填项。", + "err_host_required": "主机为必填项。", + "err_invalid_port": "端口必须是数字。", + "err_missing_custom_draft": "自定义命令草稿缺失。", + "err_missing_ssh_draft": "SSH 命令草稿缺失。", + + "tpl_edit_tab_title": "编辑:{title}", + "tpl_launch_failed_title": "启动“{title}”失败", + "tpl_launch_failed_body": "命令:{command}\n错误:{error}", + "tpl_terminal_init_failed": "终端标签页初始化失败:{error}", + "tpl_palette_fallback_label": "颜色 {index}" + } +} diff --git a/app/src/i18n/mod.rs b/app/src/i18n/mod.rs new file mode 100644 index 0000000..5ab506a --- /dev/null +++ b/app/src/i18n/mod.rs @@ -0,0 +1,372 @@ +//! Interface localization. +//! +//! Translations live in JSON files under `locales/` and are embedded into the +//! binary at build time. The active locale is process-global so that view +//! functions can call [`t`] without threading a locale parameter through every +//! widget signature. + +mod key; + +use std::collections::HashMap; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU8, Ordering}; + +pub(crate) use key::Key; +use serde::Deserialize; + +/// Embedded English catalog. +const EN_JSON: &str = include_str!("locales/en.json"); +/// Embedded Simplified Chinese catalog. +const ZH_CN_JSON: &str = include_str!("locales/zh-CN.json"); + +/// Number of configurable palette colors, mirroring `PALETTE_FIELDS`. +const PALETTE_LABEL_COUNT: usize = 29; + +/// Active locale, encoded as [`Locale::as_u8`]. +static CURRENT_LOCALE: AtomicU8 = AtomicU8::new(Locale::En.as_u8()); + +/// Parsed contents of one locale JSON file. +#[derive(Debug, Deserialize)] +struct Catalog { + /// Palette color labels in palette field order. + palette_labels: Vec, + /// All translatable strings, addressed by [`Key`]. + strings: HashMap, +} + +impl Catalog { + /// Parse an embedded catalog, panicking on malformed input. + /// + /// The inputs are compile-time constants, so a failure here is a build + /// defect rather than a runtime condition. + fn parse(name: &str, json: &str) -> Self { + serde_json::from_str(json).unwrap_or_else(|err| { + panic!("locale catalog {name} is invalid: {err}") + }) + } +} + +/// Interface locales shipped with the application. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Locale { + /// English. + En, + /// Simplified Chinese. + ZhCn, +} + +impl Locale { + /// BCP 47 tag persisted in the settings file. + pub(crate) fn tag(self) -> &'static str { + match self { + Self::ZhCn => "zh-CN", + _ => "en", + } + } + + /// Resolve a locale from a BCP 47 tag or POSIX locale string. + /// + /// Anything that is not recognizably Chinese falls back to English, which + /// keeps unknown or malformed values usable instead of failing. + pub(crate) fn from_tag(tag: &str) -> Self { + let normalized = tag.to_ascii_lowercase().replace('_', "-"); + if normalized.starts_with("zh") { + return Self::ZhCn; + } + + Self::En + } + + /// Encode the locale for storage in the global atomic. + const fn as_u8(self) -> u8 { + match self { + Self::En => 0, + Self::ZhCn => 1, + } + } + + /// Decode a locale previously encoded by [`Locale::as_u8`]. + fn from_u8(value: u8) -> Self { + match value { + 1 => Self::ZhCn, + _ => Self::En, + } + } +} + +/// Return the active interface locale. +pub(crate) fn current_locale() -> Locale { + Locale::from_u8(CURRENT_LOCALE.load(Ordering::Relaxed)) +} + +/// Set the active interface locale. +/// +/// Callers must re-render afterwards; this only swaps the catalog used by +/// subsequent [`t`] calls. +pub(crate) fn set_locale(locale: Locale) { + CURRENT_LOCALE.store(locale.as_u8(), Ordering::Relaxed); +} + +/// Detect the preferred locale from the environment. +/// +/// Checks the POSIX locale variables in the order the C library uses; an unset +/// or unrecognized environment yields [`Locale::En`]. +pub(crate) fn detect_system_locale() -> Locale { + for name in ["LC_ALL", "LC_MESSAGES", "LANG"] { + let Ok(value) = std::env::var(name) else { + continue; + }; + if !value.is_empty() { + return Locale::from_tag(&value); + } + } + + Locale::En +} + +/// Return the translation of `key` in the active locale. +pub(crate) fn t(key: Key) -> &'static str { + text_in(current_locale(), key) +} + +/// Return the label for the palette color at `index`. +pub(crate) fn palette_label(index: usize) -> String { + palette_label_in(current_locale(), index) +} + +/// Build the tab title shown when editing an existing quick launch. +pub(crate) fn edit_tab_title(command_title: &str) -> String { + fill_in( + current_locale(), + Key::TplEditTabTitle, + &[("title", command_title)], + ) +} + +/// Build the error tab title shown when a command fails to launch. +pub(crate) fn launch_failed_title(command_title: &str) -> String { + fill_in( + current_locale(), + Key::TplLaunchFailedTitle, + &[("title", command_title)], + ) +} + +/// Build the error tab body shown when a command fails to launch. +pub(crate) fn launch_failed_body(command_title: &str, error: &str) -> String { + fill_in( + current_locale(), + Key::TplLaunchFailedBody, + &[("command", command_title), ("error", error)], + ) +} + +/// Build the message shown when a terminal tab fails to initialize. +pub(crate) fn terminal_init_failed(error: &str) -> String { + fill_in( + current_locale(), + Key::TplTerminalInitFailed, + &[("error", error)], + ) +} + +/// Return the translation of `key` in `locale`. +/// +/// Falls back to English when a key is absent, so a partial catalog degrades +/// to readable text instead of blank UI. +fn text_in(locale: Locale, key: Key) -> &'static str { + if let Some(value) = catalog(locale).strings.get(&key) { + return value.as_str(); + } + + catalog(Locale::En) + .strings + .get(&key) + .map_or("", String::as_str) +} + +/// Return the label for the palette color at `index` in `locale`. +fn palette_label_in(locale: Locale, index: usize) -> String { + if let Some(label) = catalog(locale).palette_labels.get(index) { + return label.clone(); + } + + let display_index = (index + 1).to_string(); + fill_in( + locale, + Key::TplPaletteFallbackLabel, + &[("index", &display_index)], + ) +} + +/// Substitute `{name}` placeholders in the `locale` template behind `key`. +fn fill_in(locale: Locale, key: Key, arguments: &[(&str, &str)]) -> String { + let mut text = text_in(locale, key).to_string(); + for (name, value) in arguments { + text = text.replace(&format!("{{{name}}}"), value); + } + + text +} + +/// Return the parsed catalog for `locale`, parsing it on first use. +fn catalog(locale: Locale) -> &'static Catalog { + static EN: OnceLock = OnceLock::new(); + static ZH_CN: OnceLock = OnceLock::new(); + + match locale { + Locale::ZhCn => { + ZH_CN.get_or_init(|| Catalog::parse("zh-CN", ZH_CN_JSON)) + }, + _ => EN.get_or_init(|| Catalog::parse("en", EN_JSON)), + } +} + +#[cfg(test)] +mod tests { + use super::{ + Catalog, Key, Locale, PALETTE_LABEL_COUNT, catalog, fill_in, + palette_label_in, text_in, + }; + + #[test] + fn given_every_key_when_catalogs_are_read_then_all_are_translated() { + for locale in [Locale::En, Locale::ZhCn] { + let catalog = catalog(locale); + for key in Key::ALL { + assert!( + catalog.strings.contains_key(&key), + "{} is missing translation for {key:?}", + locale.tag() + ); + } + } + } + + #[test] + fn given_catalogs_when_counted_then_they_contain_no_extra_keys() { + for locale in [Locale::En, Locale::ZhCn] { + assert_eq!( + catalog(locale).strings.len(), + Key::ALL.len(), + "{} has entries not listed in Key::ALL", + locale.tag() + ); + } + } + + #[test] + fn given_catalogs_when_palette_labels_read_then_counts_match_palette() { + for locale in [Locale::En, Locale::ZhCn] { + assert_eq!( + catalog(locale).palette_labels.len(), + PALETTE_LABEL_COUNT, + "{} has the wrong palette label count", + locale.tag() + ); + } + } + + #[test] + fn given_locale_tags_when_parsed_then_chinese_variants_map_to_zh_cn() { + assert_eq!(Locale::from_tag("zh_CN.UTF-8"), Locale::ZhCn); + assert_eq!(Locale::from_tag("zh-Hans"), Locale::ZhCn); + assert_eq!(Locale::from_tag("ZH-TW"), Locale::ZhCn); + assert_eq!(Locale::from_tag("en_US.UTF-8"), Locale::En); + assert_eq!(Locale::from_tag("de_DE"), Locale::En); + assert_eq!(Locale::from_tag(""), Locale::En); + } + + #[test] + fn given_locale_when_round_tripped_through_tag_then_value_is_preserved() { + for locale in [Locale::En, Locale::ZhCn] { + assert_eq!(Locale::from_tag(locale.tag()), locale); + } + } + + #[test] + fn given_a_locale_when_translating_then_that_locale_text_is_returned() { + assert_eq!(text_in(Locale::ZhCn, Key::ButtonSave), "保存"); + assert_eq!(text_in(Locale::ZhCn, Key::SectionAppearance), "外观"); + assert_eq!(text_in(Locale::En, Key::ButtonSave), "Save"); + assert_eq!(text_in(Locale::En, Key::SectionAppearance), "Appearance"); + } + + #[test] + fn given_templates_when_filled_then_placeholders_are_substituted() { + assert_eq!( + fill_in(Locale::En, Key::TplEditTabTitle, &[("title", "deploy")]), + "Edit: deploy" + ); + assert_eq!( + fill_in( + Locale::En, + Key::TplLaunchFailedTitle, + &[("title", "deploy")] + ), + "Failed to launch \"deploy\"" + ); + assert_eq!( + fill_in( + Locale::En, + Key::TplLaunchFailedBody, + &[("command", "deploy"), ("error", "boom")] + ), + "Command: deploy\nError: boom" + ); + assert_eq!( + fill_in( + Locale::En, + Key::TplTerminalInitFailed, + &[("error", "boom")] + ), + "Terminal tab initialization failed: boom" + ); + + assert_eq!( + fill_in(Locale::ZhCn, Key::TplEditTabTitle, &[("title", "deploy")]), + "编辑:deploy" + ); + assert_eq!( + fill_in( + Locale::ZhCn, + Key::TplLaunchFailedTitle, + &[("title", "deploy")] + ), + "启动“deploy”失败" + ); + assert_eq!( + fill_in( + Locale::ZhCn, + Key::TplLaunchFailedBody, + &[("command", "deploy"), ("error", "boom")] + ), + "命令:deploy\n错误:boom" + ); + } + + #[test] + fn given_palette_index_when_labelled_then_named_and_fallback_labels_apply() + { + assert_eq!(palette_label_in(Locale::ZhCn, 0), "前景色"); + assert_eq!( + palette_label_in(Locale::ZhCn, PALETTE_LABEL_COUNT), + "颜色 30" + ); + + assert_eq!(palette_label_in(Locale::En, 0), "Foreground"); + assert_eq!( + palette_label_in(Locale::En, PALETTE_LABEL_COUNT), + "Color 30" + ); + } + + #[test] + fn given_embedded_catalogs_when_parsed_then_they_deserialize() { + let en = Catalog::parse("en", super::EN_JSON); + let zh = Catalog::parse("zh-CN", super::ZH_CN_JSON); + + assert!(!en.strings.is_empty()); + assert!(!zh.strings.is_empty()); + } +} diff --git a/app/src/main.rs b/app/src/main.rs index 0b7d466..fa533c2 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -9,6 +9,7 @@ mod events; mod fonts; mod geometry; mod guards; +mod i18n; mod icons; mod layout; mod services; diff --git a/app/src/view.rs b/app/src/view.rs index b5b7b59..e7f930c 100644 --- a/app/src/view.rs +++ b/app/src/view.rs @@ -7,6 +7,7 @@ use super::{App, AppEvent}; use crate::components::primitive::resize_grips; use crate::components::primitive::{menu_item, sidebar_workspace_panel}; use crate::geometry::{anchor_position, menu_height_for_items}; +use crate::i18n::{self, Key}; use crate::layout::BUTTON_SIZE_COMPACT; use crate::style::menu_panel_style; use crate::theme::ThemeProps; @@ -324,7 +325,9 @@ fn view_tab_content<'a>( event, )) }), - None => missing_tab_state("Terminal tab is not initialized."), + None => { + missing_tab_state(i18n::t(Key::TerminalTabNotInitialized)) + }, } }, (Some(_tab_id), Some(TabContent::Settings)) => { @@ -346,9 +349,9 @@ fn view_tab_content<'a>( AppEvent::QuickLaunch(QuickLaunchEvent::Intent(event)) }) }, - None => { - missing_tab_state("Quick launch editor is not initialized.") - }, + None => missing_tab_state(i18n::t( + Key::QuickLaunchEditorNotInitialized, + )), } }, (Some(tab_id), Some(TabContent::QuickLaunchError)) => { @@ -360,9 +363,9 @@ fn view_tab_content<'a>( .map(|event| { AppEvent::QuickLaunch(QuickLaunchEvent::Intent(event)) }), - None => { - missing_tab_state("Quick launch error payload is missing.") - }, + None => missing_tab_state(i18n::t( + Key::QuickLaunchErrorPayloadMissing, + )), } }, _ => container(Space::new()) @@ -426,17 +429,17 @@ fn view_add_menu_overlay<'a>( ) -> Element<'a, SidebarIntent, Theme, iced::Renderer> { let menu_items = [ add_menu_item( - "Create tab", + i18n::t(Key::MenuCreateTab), theme_props, SidebarIntent::AddMenuCreateTab, ), add_menu_item( - "Create folder", + i18n::t(Key::MenuCreateFolder), theme_props, SidebarIntent::AddMenuCreateFolder, ), add_menu_item( - "Create quick launch", + i18n::t(Key::MenuCreateQuickLaunch), theme_props, SidebarIntent::AddMenuCreateCommand, ), diff --git a/app/src/widgets/explorer/view/sidebar_tree.rs b/app/src/widgets/explorer/view/sidebar_tree.rs index 9ba7daf..a0607f9 100644 --- a/app/src/widgets/explorer/view/sidebar_tree.rs +++ b/app/src/widgets/explorer/view/sidebar_tree.rs @@ -6,6 +6,7 @@ use otty_ui_tree::{TreeRowContext, TreeView}; use super::super::event::ExplorerIntent; use super::super::model::ExplorerTreeViewModel; use super::super::types::FileNode; +use crate::i18n::{self, Key}; use crate::icons::{FILE, FOLDER, FOLDER_OPENED}; use crate::layout::BUTTON_SIZE_COMPACT; use crate::style::{thin_scroll_style, tree_row_style}; @@ -58,7 +59,7 @@ fn current_directory_bar<'a>( root_label: Option<&'a str>, palette: &'a IcedColorPalette, ) -> Element<'a, ExplorerIntent, iced::Theme, iced::Renderer> { - let label = root_label.unwrap_or("No active folder"); + let label = root_label.unwrap_or(i18n::t(Key::NoActiveFolder)); let overlay = palette.overlay; let foreground = palette.foreground; diff --git a/app/src/widgets/quick_launch/errors.rs b/app/src/widgets/quick_launch/errors.rs index 6df3ad3..a18e8e0 100644 --- a/app/src/widgets/quick_launch/errors.rs +++ b/app/src/widgets/quick_launch/errors.rs @@ -1,15 +1,17 @@ use thiserror::Error; +use crate::i18n::{self, Key}; + /// Errors emitted during quick launch operations. #[derive(Debug, Error)] pub(crate) enum QuickLaunchError { - #[error("I/O error: {0}")] + #[error("{}{}", i18n::t(Key::ErrIoPrefix), .0)] Io(#[from] std::io::Error), - #[error("JSON error: {0}")] + #[error("{}{}", i18n::t(Key::ErrJsonPrefix), .0)] Json(#[from] serde_json::Error), - #[error("Title must not be empty.")] + #[error("{}", i18n::t(Key::ErrTitleEmpty))] TitleEmpty, - #[error("A sibling with this title already exists.")] + #[error("{}", i18n::t(Key::ErrTitleDuplicate))] TitleDuplicate, #[error("{message}")] Validation { message: String }, @@ -18,17 +20,17 @@ pub(crate) enum QuickLaunchError { /// Errors emitted by quick launch wizard validation. #[derive(Debug, Error)] pub(crate) enum QuickLaunchWizardError { - #[error("Title is required.")] + #[error("{}", i18n::t(Key::ErrTitleRequired))] TitleRequired, - #[error("Program is required.")] + #[error("{}", i18n::t(Key::ErrProgramRequired))] ProgramRequired, - #[error("Host is required.")] + #[error("{}", i18n::t(Key::ErrHostRequired))] HostRequired, - #[error("Port must be a number.")] + #[error("{}", i18n::t(Key::ErrInvalidPort))] InvalidPort, - #[error("Custom command draft is missing.")] + #[error("{}", i18n::t(Key::ErrMissingCustomDraft))] MissingCustomDraft, - #[error("SSH command draft is missing.")] + #[error("{}", i18n::t(Key::ErrMissingSshDraft))] MissingSshDraft, } @@ -37,5 +39,5 @@ pub(crate) fn quick_launch_error_message( command: &super::types::QuickLaunch, error: &dyn std::fmt::Display, ) -> String { - format!("Command: {}\nError: {error}", command.title(),) + i18n::launch_failed_body(command.title(), &error.to_string()) } diff --git a/app/src/widgets/quick_launch/reducer.rs b/app/src/widgets/quick_launch/reducer.rs index bc31df8..25da4c7 100644 --- a/app/src/widgets/quick_launch/reducer.rs +++ b/app/src/widgets/quick_launch/reducer.rs @@ -19,6 +19,7 @@ use super::types::{ QuickLaunchFolder, QuickLaunchNode, QuickLaunchSetupOutcome, QuickLaunchWizardSaveRequest, QuickLaunchWizardSaveTarget, WizardMode, }; +use crate::i18n; /// Runtime context for the quick launch reducer. pub(crate) struct QuickLaunchCtx<'a> { @@ -385,7 +386,7 @@ fn reduce_setup_completed( } let command_title = command.title().to_string(); emit_effect(QuickLaunchEffect::OpenErrorTab { - title: format!("Failed to launch \"{command_title}\""), + title: i18n::launch_failed_title(&command_title), message: quick_launch_error_message(&command, error.as_ref()), }) }, diff --git a/app/src/widgets/quick_launch/view/context_menu.rs b/app/src/widgets/quick_launch/view/context_menu.rs index 090af88..edb5800 100644 --- a/app/src/widgets/quick_launch/view/context_menu.rs +++ b/app/src/widgets/quick_launch/view/context_menu.rs @@ -8,6 +8,7 @@ use super::super::types::{ }; use crate::components::primitive::menu_item; use crate::geometry::anchor_position; +use crate::i18n::{self, Key}; use crate::layout::BUTTON_SIZE_COMPACT; use crate::style::menu_panel_style; use crate::theme::ThemeProps; @@ -38,62 +39,62 @@ pub(crate) fn view( let is_launching = props.launching.contains_key(path); if is_launching { items.push(menu_item_element( - "Kill", + i18n::t(Key::Kill), ContextMenuAction::Kill, props.theme, )); } items.push(menu_item_element( - "Edit", + i18n::t(Key::Edit), ContextMenuAction::Edit, props.theme, )); items.push(menu_item_element( - "Rename", + i18n::t(Key::Rename), ContextMenuAction::Rename, props.theme, )); items.push(menu_item_element( - "Duplicate", + i18n::t(Key::Duplicate), ContextMenuAction::Duplicate, props.theme, )); items.push(menu_item_element( - "Remove", + i18n::t(Key::Remove), ContextMenuAction::Remove, props.theme, )); }, ContextMenuTarget::Folder(_) => { items.push(menu_item_element( - "Create Folder", + i18n::t(Key::CreateFolder), ContextMenuAction::CreateFolder, props.theme, )); items.push(menu_item_element( - "Create quick launch", + i18n::t(Key::CreateQuickLaunch), ContextMenuAction::CreateCommand, props.theme, )); items.push(menu_item_element( - "Rename", + i18n::t(Key::Rename), ContextMenuAction::Rename, props.theme, )); items.push(menu_item_element( - "Delete", + i18n::t(Key::Delete), ContextMenuAction::Delete, props.theme, )); }, ContextMenuTarget::Background => { items.push(menu_item_element( - "Create Folder", + i18n::t(Key::CreateFolder), ContextMenuAction::CreateFolder, props.theme, )); items.push(menu_item_element( - "Create Launch", + i18n::t(Key::CreateLaunch), ContextMenuAction::CreateCommand, props.theme, )); diff --git a/app/src/widgets/quick_launch/view/sidebar_panel.rs b/app/src/widgets/quick_launch/view/sidebar_panel.rs index 3cdf323..994f804 100644 --- a/app/src/widgets/quick_launch/view/sidebar_panel.rs +++ b/app/src/widgets/quick_launch/view/sidebar_panel.rs @@ -7,6 +7,7 @@ use super::sidebar_tree; use crate::components::primitive::icon_button::{ self, IconButtonHorizontalAlignment, IconButtonProps, IconButtonVariant, }; +use crate::i18n::{self, Key}; use crate::icons; use crate::layout::BUTTON_SIZE_COMPACT; use crate::theme::ThemeProps; @@ -41,7 +42,7 @@ pub(crate) fn view( let header = container( row![ - text("SHELL").size(TITLE_SIZE), + text(i18n::t(Key::QuickLaunchPanelTitle)).size(TITLE_SIZE), Space::new().width(Length::Fill), add_button, ] diff --git a/app/src/widgets/quick_launch/view/wizard_form.rs b/app/src/widgets/quick_launch/view/wizard_form.rs index f34549d..9b9013c 100644 --- a/app/src/widgets/quick_launch/view/wizard_form.rs +++ b/app/src/widgets/quick_launch/view/wizard_form.rs @@ -5,6 +5,7 @@ use iced::{Element, Length, Theme, alignment}; use super::super::event::QuickLaunchIntent; use super::super::state::WizardEditorState; use super::super::types::QuickLaunchType; +use crate::i18n::{self, Key}; use crate::layout::{BUTTON_RADIUS_ROUNDED, BUTTON_SIZE_REGULAR}; use crate::theme::{IcedColorPalette, ThemeProps}; @@ -37,9 +38,9 @@ pub(crate) fn view( let mut content = column![].spacing(SECTION_SPACING).width(Length::Fill); - content = content.push(section_header("Quick launch", theme)); + content = content.push(section_header(i18n::t(Key::WizardHeader), theme)); content = content.push(text_input_row( - "Title", + i18n::t(Key::FieldTitle), "codex: review", editor.title(), move |value| QuickLaunchIntent::WizardUpdateTitle { tab_id, value }, @@ -54,7 +55,7 @@ pub(crate) fn view( )); } else { content = content.push(row![ - field_label("Type"), + field_label(i18n::t(Key::FieldType)), text(quick_launch_type_label(editor.command_type())) .size(LABEL_SIZE), ]); @@ -63,15 +64,18 @@ pub(crate) fn view( match editor.command_type() { QuickLaunchType::Custom => { let Some(custom) = editor.custom() else { - return container(text("Invalid custom editor state")) + return container(text(i18n::t(Key::InvalidCustomEditorState))) .width(Length::Fill) .height(Length::Fill) .into(); }; - content = content.push(section_header("Custom command", theme)); + content = content.push(section_header( + i18n::t(Key::SectionCustomCommand), + theme, + )); content = content.push(text_input_row( - "Program", + i18n::t(Key::FieldProgram), "/usr/bin/bash", custom.program(), move |value| QuickLaunchIntent::WizardUpdateProgram { @@ -81,7 +85,7 @@ pub(crate) fn view( theme, )); content = content.push(list_editor( - "Arguments", + i18n::t(Key::FieldArguments), "--flag", custom.args(), tab_id, @@ -92,13 +96,13 @@ pub(crate) fn view( )); content = content.push(env_editor( custom.env(), - "KEY", - "value", + i18n::t(Key::EnvKeyPlaceholder), + i18n::t(Key::EnvValuePlaceholder), tab_id, theme, )); content = content.push(text_input_row( - "Workdir (cwd)", + i18n::t(Key::FieldWorkdir), "/path/to/project", custom.working_directory(), move |value| QuickLaunchIntent::WizardUpdateWorkingDirectory { @@ -110,15 +114,18 @@ pub(crate) fn view( }, QuickLaunchType::Ssh => { let Some(ssh) = editor.ssh() else { - return container(text("Invalid SSH editor state")) + return container(text(i18n::t(Key::InvalidSshEditorState))) .width(Length::Fill) .height(Length::Fill) .into(); }; - content = content.push(section_header("SSH connection", theme)); + content = content.push(section_header( + i18n::t(Key::SectionSshConnection), + theme, + )); content = content.push(text_input_row( - "Host", + i18n::t(Key::FieldHost), "example.com", ssh.host(), move |value| QuickLaunchIntent::WizardUpdateHost { @@ -128,7 +135,7 @@ pub(crate) fn view( theme, )); content = content.push(text_input_row( - "Port", + i18n::t(Key::FieldPort), "22", ssh.port(), move |value| QuickLaunchIntent::WizardUpdatePort { @@ -138,7 +145,7 @@ pub(crate) fn view( theme, )); content = content.push(text_input_row( - "User", + i18n::t(Key::FieldUser), "ubuntu", ssh.user(), move |value| QuickLaunchIntent::WizardUpdateUser { @@ -148,7 +155,7 @@ pub(crate) fn view( theme, )); content = content.push(text_input_row( - "Identity file", + i18n::t(Key::FieldIdentityFile), "~/.ssh/id_ed25519", ssh.identity_file(), move |value| QuickLaunchIntent::WizardUpdateIdentityFile { @@ -158,7 +165,7 @@ pub(crate) fn view( theme, )); content = content.push(list_editor( - "Extra args", + i18n::t(Key::FieldExtraArgs), "-A", ssh.extra_args(), tab_id, @@ -180,9 +187,13 @@ pub(crate) fn view( } let action_row = row![ - editor_button("Save", QuickLaunchIntent::WizardSave { tab_id }, theme,), editor_button( - "Cancel", + i18n::t(Key::ButtonSave), + QuickLaunchIntent::WizardSave { tab_id }, + theme, + ), + editor_button( + i18n::t(Key::ButtonCancel), QuickLaunchIntent::WizardCancel { tab_id }, theme, ), @@ -210,8 +221,8 @@ pub(crate) fn view( fn quick_launch_type_label(command_type: QuickLaunchType) -> &'static str { match command_type { - QuickLaunchType::Custom => "Custom", - QuickLaunchType::Ssh => "SSH", + QuickLaunchType::Ssh => i18n::t(Key::TypeSsh), + _ => i18n::t(Key::TypeCustom), } } @@ -258,7 +269,7 @@ fn command_type_selector<'a>( theme: ThemeProps<'a>, ) -> Element<'a, QuickLaunchIntent, Theme, iced::Renderer> { let custom = toggle_button( - "Custom", + i18n::t(Key::TypeCustom), selected == QuickLaunchType::Custom, QuickLaunchIntent::WizardSelectCommandType { tab_id, @@ -267,7 +278,7 @@ fn command_type_selector<'a>( theme, ); let ssh = toggle_button( - "SSH", + i18n::t(Key::TypeSsh), selected == QuickLaunchType::Ssh, QuickLaunchIntent::WizardSelectCommandType { tab_id, @@ -277,7 +288,7 @@ fn command_type_selector<'a>( ); row![ - field_label("Type"), + field_label(i18n::t(Key::FieldType)), row![custom, ssh] .spacing(FIELD_SPACING) .align_y(alignment::Vertical::Center), @@ -309,12 +320,21 @@ fn list_editor<'a>( .padding([INPUT_PADDING_Y, INPUT_PADDING_X]) .size(INPUT_SIZE) .width(Length::Fill); - let remove = editor_button("Remove", on_remove(tab_id, index), theme); + let remove = editor_button( + i18n::t(Key::ButtonRemove), + on_remove(tab_id, index), + theme, + ); col = col.push(row![input, remove].spacing(FIELD_SPACING)); } - col.push(editor_button("Add", on_add(tab_id), theme)).into() + col.push(editor_button( + i18n::t(Key::ButtonAdd), + on_add(tab_id), + theme, + )) + .into() } fn env_editor<'a>( @@ -324,9 +344,10 @@ fn env_editor<'a>( tab_id: u64, theme: ThemeProps<'a>, ) -> Element<'a, QuickLaunchIntent, Theme, iced::Renderer> { - let mut col = column![text("Environment").size(LABEL_SIZE)] - .spacing(FIELD_SPACING) - .width(Length::Fill); + let mut col = + column![text(i18n::t(Key::FieldEnvironment)).size(LABEL_SIZE)] + .spacing(FIELD_SPACING) + .width(Length::Fill); for (index, (key, value)) in env.iter().enumerate() { let key_input = text_input(key_placeholder, key) @@ -348,7 +369,7 @@ fn env_editor<'a>( .size(INPUT_SIZE) .width(Length::Fill); let remove = editor_button( - "Remove", + i18n::t(Key::ButtonRemove), QuickLaunchIntent::WizardRemoveEnv { tab_id, index }, theme, ); @@ -358,7 +379,7 @@ fn env_editor<'a>( } col.push(editor_button( - "Add env", + i18n::t(Key::ButtonAddEnv), QuickLaunchIntent::WizardAddEnv { tab_id }, theme, )) diff --git a/app/src/widgets/settings/event.rs b/app/src/widgets/settings/event.rs index 11aa723..835b985 100644 --- a/app/src/widgets/settings/event.rs +++ b/app/src/widgets/settings/event.rs @@ -1,4 +1,6 @@ -use super::types::{SettingsData, SettingsLoad, SettingsPreset}; +use super::types::{ + LanguageSetting, SettingsData, SettingsLoad, SettingsPreset, +}; /// Intent events handled by the settings presentation layer. #[derive(Debug, Clone)] @@ -21,6 +23,8 @@ pub(crate) enum SettingsIntent { NodePressed { path: Vec }, /// A tree node was hovered (or unhovered when `path` is `None`). NodeHovered { path: Option> }, + /// The interface language selection changed. + LanguageChanged(LanguageSetting), /// The shell text input changed. ShellChanged(String), /// The editor text input changed. diff --git a/app/src/widgets/settings/reducer.rs b/app/src/widgets/settings/reducer.rs index fb4ea8d..cbae92e 100644 --- a/app/src/widgets/settings/reducer.rs +++ b/app/src/widgets/settings/reducer.rs @@ -4,6 +4,7 @@ use super::event::{SettingsEffect, SettingsEvent, SettingsIntent}; use super::state::SettingsState; use super::storage::{load_settings, save_settings}; use super::types::{SettingsData, SettingsLoad, SettingsLoadStatus}; +use crate::i18n; /// Reduce a settings intent event into state updates and effect tasks. pub(crate) fn reduce( @@ -24,6 +25,9 @@ pub(crate) fn reduce( }, SettingsIntent::Save => request_save_settings(state), SettingsIntent::SaveCompleted(settings) => { + // The locale must be active before the state rebuilds its tree, + // whose paths are made of localized section titles. + i18n::set_locale(settings.language().resolve()); state.mark_saved(settings.clone()); Task::done(SettingsEvent::Effect(SettingsEffect::ApplyTheme( settings, @@ -45,6 +49,10 @@ pub(crate) fn reduce( state.set_hovered_path(path); Task::none() }, + SettingsIntent::LanguageChanged(value) => { + state.set_language(value); + Task::none() + }, SettingsIntent::ShellChanged(value) => { state.set_shell(value); Task::none() @@ -106,6 +114,9 @@ fn apply_loaded_settings( log::warn!("settings file invalid: {message}"); } + // The locale must be active before the state rebuilds its tree, whose + // paths are made of localized section titles. + i18n::set_locale(settings.language().resolve()); state.replace_with_settings(settings.clone()); settings } @@ -115,8 +126,8 @@ mod tests { use super::*; use crate::widgets::settings::state::SettingsState; use crate::widgets::settings::types::{ - SettingsData, SettingsLoad, SettingsLoadStatus, SettingsPreset, - SettingsSection, + LanguageSetting, SettingsData, SettingsLoad, SettingsLoadStatus, + SettingsPreset, SettingsSection, }; fn default_state() -> SettingsState { @@ -246,14 +257,35 @@ mod tests { #[test] fn given_node_pressed_with_section_then_selection_updated() { let mut state = default_state(); + // Read the path from the tree so the test does not depend on which + // locale rendered the section titles. + let path = state + .tree() + .iter() + .find(|node| { + node.section_kind() == Some(SettingsSection::Appearance) + }) + .map(|node| vec![node.title().to_string()]) + .expect("appearance section should be present in the tree"); + + let _task = reduce(&mut state, SettingsIntent::NodePressed { path }); + + assert_eq!(state.selected_section(), SettingsSection::Appearance); + } + + #[test] + fn given_language_changed_when_reduced_then_draft_language_updated() { + let mut state = default_state(); let _task = reduce( &mut state, - SettingsIntent::NodePressed { - path: vec![String::from("Appearance")], - }, + SettingsIntent::LanguageChanged(LanguageSetting::SimplifiedChinese), ); - assert_eq!(state.selected_section(), SettingsSection::Appearance); + assert_eq!( + state.draft().language(), + LanguageSetting::SimplifiedChinese + ); + assert!(state.is_dirty()); } } diff --git a/app/src/widgets/settings/state.rs b/app/src/widgets/settings/state.rs index 002030c..246052a 100644 --- a/app/src/widgets/settings/state.rs +++ b/app/src/widgets/settings/state.rs @@ -1,6 +1,7 @@ use super::services::{is_hex_color_prefix, is_valid_hex_color}; use super::types::{ - SettingsData, SettingsNode, SettingsPreset, SettingsSection, + LanguageSetting, SettingsData, SettingsNode, SettingsPreset, + SettingsSection, }; /// Stored and draft settings state for the settings widget. @@ -66,12 +67,7 @@ impl SettingsState { /// Create state from a persisted settings payload. pub(crate) fn from_settings(settings: SettingsData) -> Self { - let tree = vec![ - SettingsNode::section(SettingsSection::Terminal), - SettingsNode::section(SettingsSection::Appearance), - ]; - let selected_section = SettingsSection::Terminal; - let selected_path = vec![selected_section.title().to_string()]; + let selected_section = SettingsSection::General; let palette_inputs = settings.theme_palette().to_vec(); let selected_preset = SettingsPreset::from_palette(&palette_inputs); @@ -80,21 +76,28 @@ impl SettingsState { draft: settings, palette_inputs, selected_preset, - tree, + tree: build_tree(), selected_section, - selected_path, + selected_path: vec![selected_section.title().to_string()], hovered_path: None, dirty: false, } } /// Replace persisted and draft values using freshly loaded settings. + /// + /// Rebuilds the navigation tree because section titles are localized and + /// double as tree path segments: a language change invalidates every + /// previously stored path. pub(super) fn replace_with_settings(&mut self, settings: SettingsData) { self.baseline = settings.clone(); self.draft = settings; self.palette_inputs = self.draft.theme_palette().to_vec(); self.selected_preset = SettingsPreset::from_palette(&self.palette_inputs); + + self.tree = build_tree(); + self.selected_path = vec![self.selected_section.title().to_string()]; self.hovered_path = None; self.dirty = false; } @@ -115,6 +118,12 @@ impl SettingsState { self.replace_with_settings(baseline); } + /// Update the interface language in the draft. + pub(super) fn set_language(&mut self, value: LanguageSetting) { + self.draft.set_language(value); + self.update_dirty(); + } + /// Update the shell field in the draft. pub(super) fn set_shell(&mut self, value: String) { self.draft.set_terminal_shell(value); @@ -197,6 +206,14 @@ impl Default for SettingsState { } } +/// Build the navigation tree using the active locale's section titles. +fn build_tree() -> Vec { + SettingsSection::ALL + .into_iter() + .map(SettingsNode::section) + .collect() +} + fn find_node_mut<'a>( nodes: &'a mut [SettingsNode], path: &[String], @@ -222,7 +239,7 @@ fn find_node_mut<'a>( mod tests { use super::SettingsState; use crate::widgets::settings::types::{ - SettingsData, SettingsPreset, SettingsSection, + LanguageSetting, SettingsData, SettingsPreset, SettingsSection, }; #[test] @@ -311,11 +328,35 @@ mod tests { #[test] fn given_section_path_when_select_path_then_updates_selected_section() { let mut state = SettingsState::default(); - let path = vec![String::from("Appearance")]; + let path = section_path(&state, SettingsSection::Appearance); state.select_path(&path); assert_eq!(state.selected_section, SettingsSection::Appearance); assert_eq!(state.selected_path, path); } + + #[test] + fn given_language_change_when_applied_then_draft_updates_and_marks_dirty() { + let mut state = SettingsState::default(); + + state.set_language(LanguageSetting::SimplifiedChinese); + + assert_eq!(state.draft.language(), LanguageSetting::SimplifiedChinese); + assert!(state.dirty); + } + + /// Read a section's tree path from the state so the assertion does not + /// depend on which locale rendered the titles. + fn section_path( + state: &SettingsState, + section: SettingsSection, + ) -> Vec { + state + .tree() + .iter() + .find(|node| node.section_kind() == Some(section)) + .map(|node| vec![node.title().to_string()]) + .expect("section should be present in the settings tree") + } } diff --git a/app/src/widgets/settings/storage.rs b/app/src/widgets/settings/storage.rs index 1e36740..9b78812 100644 --- a/app/src/widgets/settings/storage.rs +++ b/app/src/widgets/settings/storage.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use super::errors::SettingsError; use super::state::SettingsState; use super::types::{SettingsData, SettingsLoad, SettingsLoadStatus}; +use crate::i18n; /// Load settings from disk. pub(super) fn load_settings() -> Result { @@ -32,6 +33,11 @@ pub(super) fn load_initial_settings_state() -> SettingsState { SettingsData::default() }, }; + + // The locale must be active before the state builds its tree, whose paths + // are made of localized section titles. + i18n::set_locale(data.language().resolve()); + SettingsState::from_settings(data) } @@ -104,6 +110,7 @@ mod tests { SettingsData, SettingsLoadStatus, load_settings_from_path, save_settings_to_path, }; + use crate::widgets::settings::types::LanguageSetting; #[test] fn given_valid_settings_when_save_and_load_then_round_trip_matches() { @@ -113,6 +120,7 @@ mod tests { settings.set_terminal_shell(String::from("/bin/zsh")); settings.set_terminal_editor(String::from("vim")); settings.set_theme_palette_entry(0, String::from("#112233")); + settings.set_language(LanguageSetting::SimplifiedChinese); save_settings_to_path(&path, &settings) .expect("settings should save successfully"); diff --git a/app/src/widgets/settings/types.rs b/app/src/widgets/settings/types.rs index 63ac00e..6899d53 100644 --- a/app/src/widgets/settings/types.rs +++ b/app/src/widgets/settings/types.rs @@ -3,12 +3,79 @@ use std::fmt; use otty_ui_tree::TreeNode; use serde::Serialize; +use crate::i18n::{self, Key, Locale}; use crate::theme::ColorPalette; use crate::widgets::settings::services::is_valid_hex_color; const DEFAULT_EDITOR: &str = "nano"; const FALLBACK_SHELL: &str = "/bin/bash"; +/// Interface language choice stored in the settings file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +pub(crate) enum LanguageSetting { + /// Follow the operating system locale. + #[default] + #[serde(rename = "system")] + System, + /// Always use English. + #[serde(rename = "en")] + English, + /// Always use Simplified Chinese. + #[serde(rename = "zh-CN")] + SimplifiedChinese, +} + +impl LanguageSetting { + /// All choices offered by the language selector. + pub(crate) const ALL: [Self; 3] = + [Self::System, Self::English, Self::SimplifiedChinese]; + + /// Resolve the setting to the locale that should be rendered. + pub(crate) fn resolve(self) -> Locale { + match self { + Self::English => Locale::En, + Self::SimplifiedChinese => Locale::ZhCn, + _ => i18n::detect_system_locale(), + } + } + + /// Value persisted in the settings file. + fn tag(self) -> &'static str { + match self { + Self::English => "en", + Self::SimplifiedChinese => "zh-CN", + _ => "system", + } + } + + /// Parse a persisted value, falling back to [`LanguageSetting::System`]. + fn from_tag(tag: &str) -> Self { + match tag.to_ascii_lowercase().replace('_', "-").as_str() { + "en" => Self::English, + "zh-cn" => Self::SimplifiedChinese, + _ => Self::System, + } + } +} + +impl fmt::Display for LanguageSetting { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Language names stay in their own language; only the + // follow-the-system option is translated. + match self { + Self::English => f.write_str("English"), + Self::SimplifiedChinese => f.write_str("简体中文"), + _ => f.write_str(i18n::t(Key::LanguageSystem)), + } + } +} + +/// General settings that are not specific to the terminal or the theme. +#[derive(Debug, Clone, PartialEq, Serialize, Default)] +pub(crate) struct GeneralSettingsData { + language: LanguageSetting, +} + /// Terminal-related settings. #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct TerminalSettingsData { @@ -52,11 +119,22 @@ impl ThemeSettingsData { /// Typed settings payload used for persistence and UI state. #[derive(Debug, Clone, PartialEq, Serialize, Default)] pub(crate) struct SettingsData { + general: GeneralSettingsData, terminal: TerminalSettingsData, theme: ThemeSettingsData, } impl SettingsData { + /// Return the configured interface language. + pub(crate) fn language(&self) -> LanguageSetting { + self.general.language + } + + /// Update the interface language. + pub(crate) fn set_language(&mut self, value: LanguageSetting) { + self.general.language = value; + } + /// Return shell command for terminal sessions. pub(crate) fn terminal_shell(&self) -> &str { &self.terminal.shell @@ -119,6 +197,12 @@ impl SettingsData { pub(crate) fn from_json(value: &serde_json::Value) -> Self { let mut settings = SettingsData::default(); + if let Some(general) = value.get("general") + && let Some(language) = read_string_field(general, "language") + { + settings.general.language = LanguageSetting::from_tag(&language); + } + if let Some(terminal) = value.get("terminal") { if let Some(shell) = read_string_field(terminal, "shell") .filter(|value| is_non_empty(value)) @@ -172,6 +256,7 @@ impl SettingsData { }; Self { + general: self.general.clone(), terminal: TerminalSettingsData { shell, editor, @@ -185,16 +270,25 @@ impl SettingsData { /// Top-level settings pages shown in the settings tree. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsSection { + General, Terminal, Appearance, } impl SettingsSection { - /// Human-readable section title. + /// All sections, in the order they appear in the navigation tree. + pub(crate) const ALL: [Self; 3] = + [Self::General, Self::Terminal, Self::Appearance]; + + /// Human-readable section title in the active locale. + /// + /// The title doubles as the tree path segment, so callers must rebuild + /// paths whenever the locale changes. pub(crate) fn title(&self) -> &'static str { match self { - Self::Terminal => "Terminal", - Self::Appearance => "Appearance", + Self::General => i18n::t(Key::SectionGeneral), + Self::Appearance => i18n::t(Key::SectionAppearance), + _ => i18n::t(Key::SectionTerminal), } } } @@ -364,38 +458,6 @@ fn default_shell() -> String { std::env::var("SHELL").unwrap_or_else(|_| FALLBACK_SHELL.to_string()) } -pub(super) const PALETTE_LABELS: [&str; 29] = [ - "Foreground", - "Background", - "Black", - "Red", - "Green", - "Yellow", - "Blue", - "Magenta", - "Cyan", - "White", - "Bright Black", - "Bright Red", - "Bright Green", - "Bright Yellow", - "Bright Blue", - "Bright Magenta", - "Bright Cyan", - "Bright White", - "Bright Foreground", - "Dim Black", - "Dim Red", - "Dim Green", - "Dim Yellow", - "Dim Blue", - "Dim Magenta", - "Dim Cyan", - "Dim White", - "Dim Foreground", - "Overlay", -]; - #[derive(Debug, Clone, Copy)] enum PaletteField { Foreground, @@ -651,7 +713,10 @@ impl SettingsLoad { mod tests { use serde_json::json; - use super::{SettingsData, SettingsPreset, is_valid_hex_color}; + use super::{ + LanguageSetting, Locale, SettingsData, SettingsPreset, + is_valid_hex_color, + }; #[test] fn given_valid_palette_when_from_json_then_palette_is_loaded() { @@ -730,4 +795,78 @@ mod tests { assert_eq!(preset, Some(SettingsPreset::Dracula)); } + + #[test] + fn given_language_tags_when_parsed_then_known_values_are_recognized() { + assert_eq!(LanguageSetting::from_tag("en"), LanguageSetting::English); + assert_eq!( + LanguageSetting::from_tag("zh-CN"), + LanguageSetting::SimplifiedChinese + ); + assert_eq!( + LanguageSetting::from_tag("zh_cn"), + LanguageSetting::SimplifiedChinese + ); + assert_eq!( + LanguageSetting::from_tag("system"), + LanguageSetting::System + ); + assert_eq!( + LanguageSetting::from_tag("klingon"), + LanguageSetting::System + ); + } + + #[test] + fn given_language_setting_when_round_tripped_then_value_is_preserved() { + for language in LanguageSetting::ALL { + assert_eq!(LanguageSetting::from_tag(language.tag()), language); + } + } + + #[test] + fn given_explicit_language_when_resolved_then_locale_matches() { + assert_eq!(LanguageSetting::English.resolve(), Locale::En); + assert_eq!(LanguageSetting::SimplifiedChinese.resolve(), Locale::ZhCn); + } + + #[test] + fn given_language_in_json_when_parsed_then_settings_carry_it() { + let value = serde_json::json!({ "general": { "language": "zh-CN" } }); + + let settings = SettingsData::from_json(&value); + + assert_eq!(settings.language(), LanguageSetting::SimplifiedChinese); + } + + #[test] + fn given_json_without_general_when_parsed_then_language_defaults_to_system() + { + let value = serde_json::json!({ "terminal": { "shell": "/bin/sh" } }); + + let settings = SettingsData::from_json(&value); + + assert_eq!(settings.language(), LanguageSetting::System); + } + + #[test] + fn given_language_when_normalized_then_selection_is_preserved() { + let mut settings = SettingsData::default(); + settings.set_language(LanguageSetting::SimplifiedChinese); + + let normalized = settings.normalized(); + + assert_eq!(normalized.language(), LanguageSetting::SimplifiedChinese); + } + + #[test] + fn given_language_when_serialized_then_tag_is_written() { + let mut settings = SettingsData::default(); + settings.set_language(LanguageSetting::SimplifiedChinese); + + let value = + serde_json::to_value(&settings).expect("settings should serialize"); + + assert_eq!(value["general"]["language"], "zh-CN"); + } } diff --git a/app/src/widgets/settings/view/settings_form.rs b/app/src/widgets/settings/view/settings_form.rs index 7a4b83b..5f516ed 100644 --- a/app/src/widgets/settings/view/settings_form.rs +++ b/app/src/widgets/settings/view/settings_form.rs @@ -12,11 +12,13 @@ use otty_ui_tree::{TreeRowContext, TreeView}; use super::super::event::SettingsIntent; use super::super::model::SettingsViewModel; use super::super::services::is_valid_hex_color; -use super::super::types::{SettingsNode, SettingsPreset, SettingsSection}; +use super::super::types::{ + LanguageSetting, SettingsNode, SettingsPreset, SettingsSection, +}; +use crate::i18n::{self, Key}; use crate::layout::{BUTTON_RADIUS_ROUNDED, BUTTON_SIZE_COMPACT}; use crate::style::{thin_scroll_style, tree_row_style}; use crate::theme::{IcedColorPalette, ThemeProps}; -use crate::widgets::settings::types::PALETTE_LABELS; const HEADER_HEIGHT: f32 = 32.0; const HEADER_PADDING_X: f32 = 12.0; @@ -71,13 +73,13 @@ fn settings_header<'a>( props: &SettingsFormProps<'a>, ) -> Element<'a, SettingsIntent, Theme, iced::Renderer> { let save_button = action_button( - "Save", + i18n::t(Key::ButtonSave), props.vm.is_dirty, SettingsIntent::Save, props.theme, ); let reset_button = action_button( - "Reset", + i18n::t(Key::ButtonReset), props.vm.is_dirty, SettingsIntent::Reset, props.theme, @@ -186,8 +188,9 @@ fn settings_form<'a>( ) -> Element<'a, SettingsIntent, Theme, iced::Renderer> { let content: Element<'a, SettingsIntent, Theme, iced::Renderer> = match props.vm.selected_section { - SettingsSection::Terminal => terminal_form(props), + SettingsSection::General => general_form(props), SettingsSection::Appearance => theme_form(props), + _ => terminal_form(props), }; let palette = props.theme.theme.iced_palette().clone(); @@ -209,6 +212,31 @@ fn settings_form<'a>( .into() } +fn general_form<'a>( + props: &SettingsFormProps<'a>, +) -> Element<'a, SettingsIntent, Theme, iced::Renderer> { + let language_selector = pick_list( + LanguageSetting::ALL, + Some(props.vm.draft.language()), + SettingsIntent::LanguageChanged, + ) + .width(Length::Fill) + .padding([FORM_INPUT_PADDING_Y, FORM_INPUT_PADDING_X]) + .text_size(FORM_INPUT_FONT_SIZE) + .menu_height(Length::Fixed(PRESET_MENU_MAX_HEIGHT)) + .style(pick_list_style(props.theme)) + .menu_style(pick_list_menu_style(props.theme)); + + let content = column![ + section_title(i18n::t(Key::SectionGeneral), props.theme), + form_row_content_height(i18n::t(Key::FieldLanguage), language_selector), + ] + .spacing(FORM_SECTION_SPACING) + .padding(FORM_PADDING); + + content.into() +} + fn terminal_form<'a>( props: &SettingsFormProps<'a>, ) -> Element<'a, SettingsIntent, Theme, iced::Renderer> { @@ -227,17 +255,17 @@ fn terminal_form<'a>( .style(text_input_style(props.theme)); let equalize_panes_toggle = checkbox(props.vm.draft.equalize_panes()) - .label("Even out sibling pane widths on split and close") + .label(i18n::t(Key::OptionEqualizePanes)) .on_toggle(SettingsIntent::EqualizePanesToggled) .size(FORM_INPUT_FONT_SIZE) .text_size(FORM_INPUT_FONT_SIZE) .style(checkbox_style(props.theme)); let content = column![ - section_title("Terminal", props.theme), - form_row("Shell", shell_input), - form_row("Default editor", editor_input), - form_row("Pane layout", equalize_panes_toggle), + section_title(i18n::t(Key::SectionTerminal), props.theme), + form_row(i18n::t(Key::FieldShell), shell_input), + form_row(i18n::t(Key::FieldDefaultEditor), editor_input), + form_row(i18n::t(Key::FieldPaneLayout), equalize_panes_toggle), ] .spacing(FORM_SECTION_SPACING) .padding(FORM_PADDING); @@ -253,7 +281,7 @@ fn theme_form<'a>( props.vm.selected_preset, SettingsIntent::ApplyPreset, ) - .placeholder("Custom") + .placeholder(i18n::t(Key::PresetPlaceholderCustom)) .width(Length::Fill) .padding([FORM_INPUT_PADDING_Y, FORM_INPUT_PADDING_X]) .text_size(FORM_INPUT_FONT_SIZE) @@ -263,14 +291,7 @@ fn theme_form<'a>( let mut palette_column = Column::new().spacing(PALETTE_ROW_SPACING); for (index, value) in props.vm.palette_inputs.iter().enumerate() { - let label_text = PALETTE_LABELS.get(index).copied().map_or_else( - || { - let index_display = index + 1; - format!("Color {index_display}") - }, - |label| label.to_string(), - ); - let label = text(label_text) + let label = text(i18n::palette_label(index)) .size(FORM_INPUT_FONT_SIZE) .width(Length::Fixed(FORM_LABEL_WIDTH)) .align_x(alignment::Horizontal::Left) @@ -313,8 +334,8 @@ fn theme_form<'a>( } let content = column![ - section_title("Appearance", props.theme), - form_row_content_height("Preset", preset_selector), + section_title(i18n::t(Key::SectionAppearance), props.theme), + form_row_content_height(i18n::t(Key::FieldPreset), preset_selector), palette_column ] .spacing(FORM_SECTION_SPACING) diff --git a/app/src/widgets/tabs/reducer.rs b/app/src/widgets/tabs/reducer.rs index 3345797..83576ee 100644 --- a/app/src/widgets/tabs/reducer.rs +++ b/app/src/widgets/tabs/reducer.rs @@ -5,6 +5,7 @@ use super::event::{TabsEffect, TabsEvent, TabsIntent}; use super::state::TabsState; use super::types::{TabContent, TabItem}; use crate::domain::quick_launch::WizardTabInit; +use crate::i18n::{self, Key}; /// Reduce a tabs intent event into state mutation and effect tasks. pub(crate) fn reduce( @@ -165,7 +166,11 @@ fn open_settings_tab(state: &mut TabsState) -> Task { state.insert( tab_id, - TabItem::new(tab_id, String::from("Settings"), TabContent::Settings), + TabItem::new( + tab_id, + i18n::t(Key::SettingsTabTitle).to_string(), + TabContent::Settings, + ), ); state.activate(Some(tab_id)); diff --git a/app/src/widgets/tabs/view/tab_content.rs b/app/src/widgets/tabs/view/tab_content.rs index 8f22f53..7c7f8b8 100644 --- a/app/src/widgets/tabs/view/tab_content.rs +++ b/app/src/widgets/tabs/view/tab_content.rs @@ -2,6 +2,7 @@ use iced::widget::{container, text}; use iced::{Element, Length, Theme, alignment}; use super::super::model::TabsViewModel; +use crate::i18n::{self, Key}; use crate::theme::ThemeProps; /// Props for rendering tab content area. @@ -21,19 +22,23 @@ pub(crate) fn view<'a, Message: 'a>( let palette = props.theme.theme.iced_palette(); if !props.vm.has_tabs { - return container(text("No tabs").color(palette.dim_foreground)) - .width(Length::Fill) - .height(Length::Fill) - .align_x(alignment::Horizontal::Center) - .align_y(alignment::Vertical::Center) - .into(); - } - - // TODO: render active tab content based on tab content type (Phase 4) - container(text("Tab content placeholder").color(palette.dim_foreground)) + return container( + text(i18n::t(Key::NoTabs)).color(palette.dim_foreground), + ) .width(Length::Fill) .height(Length::Fill) .align_x(alignment::Horizontal::Center) .align_y(alignment::Vertical::Center) - .into() + .into(); + } + + // TODO: render active tab content based on tab content type (Phase 4) + container( + text(i18n::t(Key::TabContentPlaceholder)).color(palette.dim_foreground), + ) + .width(Length::Fill) + .height(Length::Fill) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + .into() } diff --git a/app/src/widgets/terminal_workspace/reducer.rs b/app/src/widgets/terminal_workspace/reducer.rs index 9f8f71f..adc4b83 100644 --- a/app/src/widgets/terminal_workspace/reducer.rs +++ b/app/src/widgets/terminal_workspace/reducer.rs @@ -10,6 +10,7 @@ use super::event::{ use super::shortcuts::TerminalWorkspaceAction; use super::state::{StateCommand, TerminalTabState, TerminalWorkspaceState}; use super::types::{TerminalEvent, TerminalKind}; +use crate::i18n; /// Runtime context injected into each reduce call. pub(crate) struct TerminalWorkspaceCtx { @@ -214,10 +215,9 @@ fn reduce_open_tab( )); if kind == TerminalKind::Command { - let failed_title = - format!("Failed to launch \"{failed_tab_title}\""); + let failed_title = i18n::launch_failed_title(&failed_tab_title); let failed_message = - format!("Terminal tab initialization failed: {err}"); + i18n::terminal_init_failed(&err.to_string()); let error_task = Task::done(TerminalWorkspaceEvent::Effect( TerminalWorkspaceEffect::CommandTabOpenFailed { tab_id, diff --git a/app/src/widgets/terminal_workspace/view/pane_context_menu.rs b/app/src/widgets/terminal_workspace/view/pane_context_menu.rs index 693475d..5a39da7 100644 --- a/app/src/widgets/terminal_workspace/view/pane_context_menu.rs +++ b/app/src/widgets/terminal_workspace/view/pane_context_menu.rs @@ -5,6 +5,7 @@ use crate::components::primitive::menu_item::{ MenuItemEvent, MenuItemProps, view as menu_item_view, }; use crate::geometry::{anchor_position, menu_height_for_items}; +use crate::i18n::{self, Key}; use crate::layout::BUTTON_SIZE_COMPACT; use crate::style::menu_panel_style; use crate::theme::ThemeProps; @@ -36,7 +37,7 @@ pub(crate) fn view<'a>( let mut buttons: Vec> = Vec::new(); buttons.push(menu_item( - "Copy selection", + i18n::t(Key::CopySelection), props.theme, TerminalWorkspaceIntent::CopySelection { tab_id: props.tab_id, @@ -44,7 +45,7 @@ pub(crate) fn view<'a>( }, )); buttons.push(menu_item( - "Paste", + i18n::t(Key::Paste), props.theme, TerminalWorkspaceIntent::PasteIntoPrompt { tab_id: props.tab_id, @@ -54,7 +55,7 @@ pub(crate) fn view<'a>( if props.has_block_selection { buttons.push(menu_item( - "Copy content", + i18n::t(Key::CopyContent), props.theme, TerminalWorkspaceIntent::CopySelectedBlockContent { tab_id: props.tab_id, @@ -62,7 +63,7 @@ pub(crate) fn view<'a>( }, )); buttons.push(menu_item( - "Copy prompt", + i18n::t(Key::CopyPrompt), props.theme, TerminalWorkspaceIntent::CopySelectedBlockPrompt { tab_id: props.tab_id, @@ -70,7 +71,7 @@ pub(crate) fn view<'a>( }, )); buttons.push(menu_item( - "Copy command", + i18n::t(Key::CopyCommand), props.theme, TerminalWorkspaceIntent::CopySelectedBlockCommand { tab_id: props.tab_id, @@ -80,7 +81,7 @@ pub(crate) fn view<'a>( } buttons.push(menu_item( - "Split horizontally", + i18n::t(Key::SplitHorizontally), props.theme, TerminalWorkspaceIntent::SplitPane { tab_id: props.tab_id, @@ -89,7 +90,7 @@ pub(crate) fn view<'a>( }, )); buttons.push(menu_item( - "Split vertically", + i18n::t(Key::SplitVertically), props.theme, TerminalWorkspaceIntent::SplitPane { tab_id: props.tab_id, @@ -98,7 +99,7 @@ pub(crate) fn view<'a>( }, )); buttons.push(menu_item( - "Close", + i18n::t(Key::ClosePane), props.theme, TerminalWorkspaceIntent::ClosePane { tab_id: props.tab_id, diff --git a/app/src/widgets/terminal_workspace/view/pane_grid.rs b/app/src/widgets/terminal_workspace/view/pane_grid.rs index 28786f1..318e077 100644 --- a/app/src/widgets/terminal_workspace/view/pane_grid.rs +++ b/app/src/widgets/terminal_workspace/view/pane_grid.rs @@ -8,6 +8,7 @@ use otty_ui_term::TerminalView; use super::super::event::TerminalWorkspaceIntent; use super::super::model::TerminalTabViewModel; use super::super::types::TerminalEntry; +use crate::i18n::{self, Key}; const PANE_GRID_SPACING: f32 = 1.0; const PANE_RESIZE_GRAB: f32 = 12.0; @@ -93,7 +94,7 @@ fn view_single_pane<'a>( is_focused: bool, ) -> Element<'a, TerminalWorkspaceIntent> { let Some(terminal_entry) = terminals.get(&terminal_id) else { - return container(text("Terminal unavailable")) + return container(text(i18n::t(Key::TerminalUnavailable))) .width(Length::Fill) .height(Length::Fill) .into(); From 97e69362b02faaf62eed19d25c19422b287f258d Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 16 Aug 2026 12:29:08 +0900 Subject: [PATCH 2/5] feat(i18n): ship eight more languages and make locales data-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Traditional Chinese, Japanese, Korean, French, German, Spanish, Brazilian Portuguese, and Russian, bringing the shipped set to ten languages. Adding a language previously meant touching three places: a `Locale` variant, a hand-written `OnceLock` in `catalog`, and a matching `LanguageSetting` variant. That does not scale past a couple of languages, so both types are now data-driven. `Locale::ALL` is the single list, `catalog` caches into an array indexed by `Locale::index`, and `LanguageSetting` collapsed from one variant per language into `System | Fixed(Locale)` with its selector derived from `Locale::ALL`. Adding a language is now a JSON file plus one enum variant, with no view or settings changes. Locale detection handles the cases that matter for these languages. Chinese resolves by script rather than by primary subtag, so `zh-TW`, `zh-HK`, `zh-MO`, and an explicit `Hant` select Traditional while everything else Chinese selects Simplified. POSIX strings are normalized before matching, so `de_AT@euro` and `ja_JP.UTF-8` resolve correctly. A stored `general.language` value, in contrast, must match a shipped tag exactly, so an unrecognized or partial tag keeps following the system instead of silently picking a language. Three tests were added that matter specifically for hand-written translations: every locale keeps the `{title}`, `{command}`, `{error}`, and `{index}` placeholders in its templates; no catalog entry or palette label is blank; and every `Locale::index` matches its position in `ALL`, which is the invariant the catalog array indexing relies on. The existing completeness tests now iterate `Locale::ALL` rather than a hardcoded pair, so a new language is covered the moment it is listed. Language names in the selector are endonyms (日本語, Русский, Português (Brasil)) so a user can find their language without already being able to read the current interface language. --- app/src/i18n/locales/de.json | 127 ++++++++ app/src/i18n/locales/es.json | 127 ++++++++ app/src/i18n/locales/fr.json | 127 ++++++++ app/src/i18n/locales/ja.json | 127 ++++++++ app/src/i18n/locales/ko.json | 127 ++++++++ app/src/i18n/locales/pt-BR.json | 127 ++++++++ app/src/i18n/locales/ru.json | 127 ++++++++ app/src/i18n/locales/zh-TW.json | 127 ++++++++ app/src/i18n/mod.rs | 291 +++++++++++++++--- app/src/widgets/settings/reducer.rs | 7 +- app/src/widgets/settings/state.rs | 8 +- app/src/widgets/settings/storage.rs | 3 +- app/src/widgets/settings/types.rs | 117 +++++-- .../widgets/settings/view/settings_form.rs | 2 +- 14 files changed, 1362 insertions(+), 82 deletions(-) create mode 100644 app/src/i18n/locales/de.json create mode 100644 app/src/i18n/locales/es.json create mode 100644 app/src/i18n/locales/fr.json create mode 100644 app/src/i18n/locales/ja.json create mode 100644 app/src/i18n/locales/ko.json create mode 100644 app/src/i18n/locales/pt-BR.json create mode 100644 app/src/i18n/locales/ru.json create mode 100644 app/src/i18n/locales/zh-TW.json diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json new file mode 100644 index 0000000..e802d33 --- /dev/null +++ b/app/src/i18n/locales/de.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "Vordergrund", + "Hintergrund", + "Schwarz", + "Rot", + "Grün", + "Gelb", + "Blau", + "Magenta", + "Cyan", + "Weiß", + "Helles Schwarz", + "Helles Rot", + "Helles Grün", + "Helles Gelb", + "Helles Blau", + "Helles Magenta", + "Helles Cyan", + "Helles Weiß", + "Heller Vordergrund", + "Gedämpftes Schwarz", + "Gedämpftes Rot", + "Gedämpftes Grün", + "Gedämpftes Gelb", + "Gedämpftes Blau", + "Gedämpftes Magenta", + "Gedämpftes Cyan", + "Gedämpftes Weiß", + "Gedämpfter Vordergrund", + "Overlay" + ], + "strings": { + "menu_create_tab": "Tab erstellen", + "menu_create_folder": "Ordner erstellen", + "menu_create_quick_launch": "Schnellstart erstellen", + + "terminal_tab_not_initialized": "Der Terminal-Tab ist nicht initialisiert.", + "quick_launch_editor_not_initialized": "Der Schnellstart-Editor ist nicht initialisiert.", + "quick_launch_error_payload_missing": "Die Fehlerdaten des Schnellstarts fehlen.", + "terminal_unavailable": "Terminal nicht verfügbar", + + "no_tabs": "Keine Tabs", + "tab_content_placeholder": "Platzhalter für Tab-Inhalt", + "settings_tab_title": "Einstellungen", + "quick_launch_create_tab_title": "Schnellstart erstellen", + + "copy_selection": "Auswahl kopieren", + "paste": "Einfügen", + "copy_content": "Ausgabe kopieren", + "copy_prompt": "Prompt kopieren", + "copy_command": "Befehl kopieren", + "split_horizontally": "Horizontal teilen", + "split_vertically": "Vertikal teilen", + "close_pane": "Schließen", + + "kill": "Beenden", + "edit": "Bearbeiten", + "rename": "Umbenennen", + "duplicate": "Duplizieren", + "remove": "Entfernen", + "create_folder": "Ordner erstellen", + "create_quick_launch": "Schnellstart erstellen", + "delete": "Löschen", + "create_launch": "Starteintrag erstellen", + + "quick_launch_panel_title": "Schnellstart", + "no_active_folder": "Kein aktiver Ordner", + + "wizard_header": "Schnellstart", + "field_title": "Name", + "field_type": "Typ", + "type_custom": "Benutzerdefiniert", + "type_ssh": "SSH", + "section_custom_command": "Benutzerdefinierter Befehl", + "field_program": "Programm", + "field_arguments": "Argumente", + "field_workdir": "Arbeitsverzeichnis (cwd)", + "section_ssh_connection": "SSH-Verbindung", + "field_host": "Host", + "field_port": "Port", + "field_user": "Benutzer", + "field_identity_file": "Private-Key-Datei", + "field_extra_args": "Zusätzliche Argumente", + "field_environment": "Umgebungsvariablen", + "env_key_placeholder": "NAME", + "env_value_placeholder": "Wert", + "invalid_custom_editor_state": "Ungültiger Zustand des benutzerdefinierten Editors", + "invalid_ssh_editor_state": "Ungültiger Zustand des SSH-Editors", + + "button_save": "Speichern", + "button_cancel": "Abbrechen", + "button_remove": "Entfernen", + "button_add": "Hinzufügen", + "button_add_env": "Variable hinzufügen", + "button_reset": "Zurücksetzen", + + "section_general": "Allgemein", + "section_terminal": "Terminal", + "section_appearance": "Darstellung", + "field_language": "Anzeigesprache", + "field_shell": "Shell", + "field_default_editor": "Standard-Editor", + "field_pane_layout": "Fensteraufteilung", + "option_equalize_panes": "Breiten benachbarter Fenster beim Teilen und Schließen ausgleichen", + "field_preset": "Voreinstellung", + "preset_placeholder_custom": "Benutzerdefiniert", + "language_system": "System folgen", + + "err_io_prefix": "E/A-Fehler: ", + "err_json_prefix": "JSON-Fehler: ", + "err_title_empty": "Der Name darf nicht leer sein.", + "err_title_duplicate": "Auf dieser Ebene existiert bereits ein Eintrag mit diesem Namen.", + "err_title_required": "Der Name ist erforderlich.", + "err_program_required": "Das Programm ist erforderlich.", + "err_host_required": "Der Host ist erforderlich.", + "err_invalid_port": "Der Port muss eine Zahl sein.", + "err_missing_custom_draft": "Entwurf des benutzerdefinierten Befehls nicht gefunden.", + "err_missing_ssh_draft": "Entwurf des SSH-Befehls nicht gefunden.", + + "tpl_edit_tab_title": "Bearbeiten: {title}", + "tpl_launch_failed_title": "Starten von „{title}“ fehlgeschlagen", + "tpl_launch_failed_body": "Befehl: {command}\nFehler: {error}", + "tpl_terminal_init_failed": "Initialisierung des Terminal-Tabs fehlgeschlagen: {error}", + "tpl_palette_fallback_label": "Farbe {index}" + } +} diff --git a/app/src/i18n/locales/es.json b/app/src/i18n/locales/es.json new file mode 100644 index 0000000..0657326 --- /dev/null +++ b/app/src/i18n/locales/es.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "Primer plano", + "Fondo", + "Negro", + "Rojo", + "Verde", + "Amarillo", + "Azul", + "Magenta", + "Cian", + "Blanco", + "Negro brillante", + "Rojo brillante", + "Verde brillante", + "Amarillo brillante", + "Azul brillante", + "Magenta brillante", + "Cian brillante", + "Blanco brillante", + "Primer plano brillante", + "Negro atenuado", + "Rojo atenuado", + "Verde atenuado", + "Amarillo atenuado", + "Azul atenuado", + "Magenta atenuado", + "Cian atenuado", + "Blanco atenuado", + "Primer plano atenuado", + "Superposición" + ], + "strings": { + "menu_create_tab": "Crear pestaña", + "menu_create_folder": "Crear carpeta", + "menu_create_quick_launch": "Crear inicio rápido", + + "terminal_tab_not_initialized": "La pestaña de terminal no está inicializada.", + "quick_launch_editor_not_initialized": "El editor de inicio rápido no está inicializado.", + "quick_launch_error_payload_missing": "Faltan los datos de error del inicio rápido.", + "terminal_unavailable": "Terminal no disponible", + + "no_tabs": "Sin pestañas", + "tab_content_placeholder": "Marcador de contenido de pestaña", + "settings_tab_title": "Ajustes", + "quick_launch_create_tab_title": "Crear inicio rápido", + + "copy_selection": "Copiar selección", + "paste": "Pegar", + "copy_content": "Copiar salida", + "copy_prompt": "Copiar prompt", + "copy_command": "Copiar comando", + "split_horizontally": "Dividir horizontalmente", + "split_vertically": "Dividir verticalmente", + "close_pane": "Cerrar", + + "kill": "Terminar", + "edit": "Editar", + "rename": "Cambiar nombre", + "duplicate": "Duplicar", + "remove": "Quitar", + "create_folder": "Crear carpeta", + "create_quick_launch": "Crear inicio rápido", + "delete": "Eliminar", + "create_launch": "Crear elemento de inicio", + + "quick_launch_panel_title": "Inicio rápido", + "no_active_folder": "Ninguna carpeta activa", + + "wizard_header": "Inicio rápido", + "field_title": "Nombre", + "field_type": "Tipo", + "type_custom": "Personalizado", + "type_ssh": "SSH", + "section_custom_command": "Comando personalizado", + "field_program": "Programa", + "field_arguments": "Argumentos", + "field_workdir": "Directorio de trabajo (cwd)", + "section_ssh_connection": "Conexión SSH", + "field_host": "Host", + "field_port": "Puerto", + "field_user": "Usuario", + "field_identity_file": "Archivo de clave privada", + "field_extra_args": "Argumentos adicionales", + "field_environment": "Variables de entorno", + "env_key_placeholder": "NOMBRE", + "env_value_placeholder": "valor", + "invalid_custom_editor_state": "Estado del editor personalizado no válido", + "invalid_ssh_editor_state": "Estado del editor SSH no válido", + + "button_save": "Guardar", + "button_cancel": "Cancelar", + "button_remove": "Quitar", + "button_add": "Añadir", + "button_add_env": "Añadir variable", + "button_reset": "Restablecer", + + "section_general": "General", + "section_terminal": "Terminal", + "section_appearance": "Apariencia", + "field_language": "Idioma de la interfaz", + "field_shell": "Shell", + "field_default_editor": "Editor predeterminado", + "field_pane_layout": "Diseño de paneles", + "option_equalize_panes": "Igualar el ancho de los paneles adyacentes al dividir y cerrar", + "field_preset": "Preajuste", + "preset_placeholder_custom": "Personalizado", + "language_system": "Seguir al sistema", + + "err_io_prefix": "Error de E/S: ", + "err_json_prefix": "Error de JSON: ", + "err_title_empty": "El nombre no puede estar vacío.", + "err_title_duplicate": "Ya existe un elemento con este nombre en el mismo nivel.", + "err_title_required": "El nombre es obligatorio.", + "err_program_required": "El programa es obligatorio.", + "err_host_required": "El host es obligatorio.", + "err_invalid_port": "El puerto debe ser un número.", + "err_missing_custom_draft": "No se encuentra el borrador del comando personalizado.", + "err_missing_ssh_draft": "No se encuentra el borrador del comando SSH.", + + "tpl_edit_tab_title": "Editar: {title}", + "tpl_launch_failed_title": "No se pudo iniciar «{title}»", + "tpl_launch_failed_body": "Comando: {command}\nError: {error}", + "tpl_terminal_init_failed": "No se pudo inicializar la pestaña de terminal: {error}", + "tpl_palette_fallback_label": "Color {index}" + } +} diff --git a/app/src/i18n/locales/fr.json b/app/src/i18n/locales/fr.json new file mode 100644 index 0000000..854e429 --- /dev/null +++ b/app/src/i18n/locales/fr.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "Premier plan", + "Arrière-plan", + "Noir", + "Rouge", + "Vert", + "Jaune", + "Bleu", + "Magenta", + "Cyan", + "Blanc", + "Noir vif", + "Rouge vif", + "Vert vif", + "Jaune vif", + "Bleu vif", + "Magenta vif", + "Cyan vif", + "Blanc vif", + "Premier plan vif", + "Noir atténué", + "Rouge atténué", + "Vert atténué", + "Jaune atténué", + "Bleu atténué", + "Magenta atténué", + "Cyan atténué", + "Blanc atténué", + "Premier plan atténué", + "Superposition" + ], + "strings": { + "menu_create_tab": "Nouvel onglet", + "menu_create_folder": "Nouveau dossier", + "menu_create_quick_launch": "Nouveau lancement rapide", + + "terminal_tab_not_initialized": "L'onglet de terminal n'est pas initialisé.", + "quick_launch_editor_not_initialized": "L'éditeur de lancement rapide n'est pas initialisé.", + "quick_launch_error_payload_missing": "Les informations d'erreur du lancement rapide sont absentes.", + "terminal_unavailable": "Terminal indisponible", + + "no_tabs": "Aucun onglet", + "tab_content_placeholder": "Contenu d'onglet réservé", + "settings_tab_title": "Paramètres", + "quick_launch_create_tab_title": "Nouveau lancement rapide", + + "copy_selection": "Copier la sélection", + "paste": "Coller", + "copy_content": "Copier la sortie", + "copy_prompt": "Copier l'invite", + "copy_command": "Copier la commande", + "split_horizontally": "Diviser horizontalement", + "split_vertically": "Diviser verticalement", + "close_pane": "Fermer", + + "kill": "Arrêter", + "edit": "Modifier", + "rename": "Renommer", + "duplicate": "Dupliquer", + "remove": "Retirer", + "create_folder": "Nouveau dossier", + "create_quick_launch": "Nouveau lancement rapide", + "delete": "Supprimer", + "create_launch": "Nouvel élément de lancement", + + "quick_launch_panel_title": "Lancement rapide", + "no_active_folder": "Aucun dossier actif", + + "wizard_header": "Lancement rapide", + "field_title": "Nom", + "field_type": "Type", + "type_custom": "Personnalisé", + "type_ssh": "SSH", + "section_custom_command": "Commande personnalisée", + "field_program": "Programme", + "field_arguments": "Arguments", + "field_workdir": "Répertoire de travail (cwd)", + "section_ssh_connection": "Connexion SSH", + "field_host": "Hôte", + "field_port": "Port", + "field_user": "Utilisateur", + "field_identity_file": "Fichier de clé privée", + "field_extra_args": "Arguments supplémentaires", + "field_environment": "Variables d'environnement", + "env_key_placeholder": "NOM", + "env_value_placeholder": "valeur", + "invalid_custom_editor_state": "État de l'éditeur personnalisé invalide", + "invalid_ssh_editor_state": "État de l'éditeur SSH invalide", + + "button_save": "Enregistrer", + "button_cancel": "Annuler", + "button_remove": "Retirer", + "button_add": "Ajouter", + "button_add_env": "Ajouter une variable", + "button_reset": "Réinitialiser", + + "section_general": "Général", + "section_terminal": "Terminal", + "section_appearance": "Apparence", + "field_language": "Langue de l'interface", + "field_shell": "Shell", + "field_default_editor": "Éditeur par défaut", + "field_pane_layout": "Disposition des volets", + "option_equalize_panes": "Uniformiser la largeur des volets adjacents lors de la division et de la fermeture", + "field_preset": "Préréglage", + "preset_placeholder_custom": "Personnalisé", + "language_system": "Suivre le système", + + "err_io_prefix": "Erreur d'E/S : ", + "err_json_prefix": "Erreur JSON : ", + "err_title_empty": "Le nom ne peut pas être vide.", + "err_title_duplicate": "Un élément du même niveau porte déjà ce nom.", + "err_title_required": "Le nom est obligatoire.", + "err_program_required": "Le programme est obligatoire.", + "err_host_required": "L'hôte est obligatoire.", + "err_invalid_port": "Le port doit être un nombre.", + "err_missing_custom_draft": "Brouillon de commande personnalisée introuvable.", + "err_missing_ssh_draft": "Brouillon de commande SSH introuvable.", + + "tpl_edit_tab_title": "Modifier : {title}", + "tpl_launch_failed_title": "Échec du lancement de « {title} »", + "tpl_launch_failed_body": "Commande : {command}\nErreur : {error}", + "tpl_terminal_init_failed": "Échec de l'initialisation de l'onglet de terminal : {error}", + "tpl_palette_fallback_label": "Couleur {index}" + } +} diff --git a/app/src/i18n/locales/ja.json b/app/src/i18n/locales/ja.json new file mode 100644 index 0000000..946cd52 --- /dev/null +++ b/app/src/i18n/locales/ja.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "前景色", + "背景色", + "黒", + "赤", + "緑", + "黄", + "青", + "マゼンタ", + "シアン", + "白", + "明るい黒", + "明るい赤", + "明るい緑", + "明るい黄", + "明るい青", + "明るいマゼンタ", + "明るいシアン", + "明るい白", + "明るい前景色", + "暗い黒", + "暗い赤", + "暗い緑", + "暗い黄", + "暗い青", + "暗いマゼンタ", + "暗いシアン", + "暗い白", + "暗い前景色", + "オーバーレイ" + ], + "strings": { + "menu_create_tab": "タブを作成", + "menu_create_folder": "フォルダーを作成", + "menu_create_quick_launch": "クイック起動を作成", + + "terminal_tab_not_initialized": "ターミナルタブが初期化されていません。", + "quick_launch_editor_not_initialized": "クイック起動エディターが初期化されていません。", + "quick_launch_error_payload_missing": "クイック起動のエラー情報が見つかりません。", + "terminal_unavailable": "ターミナルを利用できません", + + "no_tabs": "タブがありません", + "tab_content_placeholder": "タブコンテンツのプレースホルダー", + "settings_tab_title": "設定", + "quick_launch_create_tab_title": "クイック起動を作成", + + "copy_selection": "選択範囲をコピー", + "paste": "貼り付け", + "copy_content": "出力をコピー", + "copy_prompt": "プロンプトをコピー", + "copy_command": "コマンドをコピー", + "split_horizontally": "水平に分割", + "split_vertically": "垂直に分割", + "close_pane": "閉じる", + + "kill": "強制終了", + "edit": "編集", + "rename": "名前を変更", + "duplicate": "複製", + "remove": "削除", + "create_folder": "フォルダーを作成", + "create_quick_launch": "クイック起動を作成", + "delete": "削除", + "create_launch": "起動項目を作成", + + "quick_launch_panel_title": "クイック起動", + "no_active_folder": "アクティブなフォルダーがありません", + + "wizard_header": "クイック起動", + "field_title": "名前", + "field_type": "種類", + "type_custom": "カスタム", + "type_ssh": "SSH", + "section_custom_command": "カスタムコマンド", + "field_program": "プログラム", + "field_arguments": "引数", + "field_workdir": "作業ディレクトリ (cwd)", + "section_ssh_connection": "SSH 接続", + "field_host": "ホスト", + "field_port": "ポート", + "field_user": "ユーザー", + "field_identity_file": "秘密鍵ファイル", + "field_extra_args": "追加の引数", + "field_environment": "環境変数", + "env_key_placeholder": "変数名", + "env_value_placeholder": "値", + "invalid_custom_editor_state": "カスタムエディターの状態が不正です", + "invalid_ssh_editor_state": "SSH エディターの状態が不正です", + + "button_save": "保存", + "button_cancel": "キャンセル", + "button_remove": "削除", + "button_add": "追加", + "button_add_env": "環境変数を追加", + "button_reset": "リセット", + + "section_general": "全般", + "section_terminal": "ターミナル", + "section_appearance": "外観", + "field_language": "表示言語", + "field_shell": "シェル", + "field_default_editor": "既定のエディター", + "field_pane_layout": "ペインのレイアウト", + "option_equalize_panes": "分割・終了時に同じ階層のペイン幅を均等化", + "field_preset": "プリセット", + "preset_placeholder_custom": "カスタム", + "language_system": "システムに従う", + + "err_io_prefix": "I/O エラー: ", + "err_json_prefix": "JSON エラー: ", + "err_title_empty": "名前は空にできません。", + "err_title_duplicate": "同じ階層に同名の項目が既に存在します。", + "err_title_required": "名前は必須です。", + "err_program_required": "プログラムは必須です。", + "err_host_required": "ホストは必須です。", + "err_invalid_port": "ポートには数値を指定してください。", + "err_missing_custom_draft": "カスタムコマンドの下書きが見つかりません。", + "err_missing_ssh_draft": "SSH コマンドの下書きが見つかりません。", + + "tpl_edit_tab_title": "編集: {title}", + "tpl_launch_failed_title": "「{title}」の起動に失敗しました", + "tpl_launch_failed_body": "コマンド: {command}\nエラー: {error}", + "tpl_terminal_init_failed": "ターミナルタブの初期化に失敗しました: {error}", + "tpl_palette_fallback_label": "色 {index}" + } +} diff --git a/app/src/i18n/locales/ko.json b/app/src/i18n/locales/ko.json new file mode 100644 index 0000000..845a2ce --- /dev/null +++ b/app/src/i18n/locales/ko.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "전경색", + "배경색", + "검정", + "빨강", + "초록", + "노랑", + "파랑", + "마젠타", + "시안", + "흰색", + "밝은 검정", + "밝은 빨강", + "밝은 초록", + "밝은 노랑", + "밝은 파랑", + "밝은 마젠타", + "밝은 시안", + "밝은 흰색", + "밝은 전경색", + "어두운 검정", + "어두운 빨강", + "어두운 초록", + "어두운 노랑", + "어두운 파랑", + "어두운 마젠타", + "어두운 시안", + "어두운 흰색", + "어두운 전경색", + "오버레이" + ], + "strings": { + "menu_create_tab": "탭 만들기", + "menu_create_folder": "폴더 만들기", + "menu_create_quick_launch": "빠른 실행 만들기", + + "terminal_tab_not_initialized": "터미널 탭이 초기화되지 않았습니다.", + "quick_launch_editor_not_initialized": "빠른 실행 편집기가 초기화되지 않았습니다.", + "quick_launch_error_payload_missing": "빠른 실행 오류 정보가 없습니다.", + "terminal_unavailable": "터미널을 사용할 수 없음", + + "no_tabs": "탭 없음", + "tab_content_placeholder": "탭 콘텐츠 자리 표시자", + "settings_tab_title": "설정", + "quick_launch_create_tab_title": "빠른 실행 만들기", + + "copy_selection": "선택 영역 복사", + "paste": "붙여넣기", + "copy_content": "출력 복사", + "copy_prompt": "프롬프트 복사", + "copy_command": "명령 복사", + "split_horizontally": "가로로 분할", + "split_vertically": "세로로 분할", + "close_pane": "닫기", + + "kill": "강제 종료", + "edit": "편집", + "rename": "이름 바꾸기", + "duplicate": "복제", + "remove": "제거", + "create_folder": "폴더 만들기", + "create_quick_launch": "빠른 실행 만들기", + "delete": "삭제", + "create_launch": "실행 항목 만들기", + + "quick_launch_panel_title": "빠른 실행", + "no_active_folder": "활성 폴더 없음", + + "wizard_header": "빠른 실행", + "field_title": "이름", + "field_type": "유형", + "type_custom": "사용자 지정", + "type_ssh": "SSH", + "section_custom_command": "사용자 지정 명령", + "field_program": "프로그램", + "field_arguments": "인수", + "field_workdir": "작업 디렉터리 (cwd)", + "section_ssh_connection": "SSH 연결", + "field_host": "호스트", + "field_port": "포트", + "field_user": "사용자", + "field_identity_file": "개인 키 파일", + "field_extra_args": "추가 인수", + "field_environment": "환경 변수", + "env_key_placeholder": "변수 이름", + "env_value_placeholder": "값", + "invalid_custom_editor_state": "사용자 지정 편집기 상태가 올바르지 않습니다", + "invalid_ssh_editor_state": "SSH 편집기 상태가 올바르지 않습니다", + + "button_save": "저장", + "button_cancel": "취소", + "button_remove": "제거", + "button_add": "추가", + "button_add_env": "환경 변수 추가", + "button_reset": "초기화", + + "section_general": "일반", + "section_terminal": "터미널", + "section_appearance": "모양", + "field_language": "표시 언어", + "field_shell": "셸", + "field_default_editor": "기본 편집기", + "field_pane_layout": "창 분할 레이아웃", + "option_equalize_panes": "분할하거나 닫을 때 같은 계층의 창 너비 균등화", + "field_preset": "사전 설정", + "preset_placeholder_custom": "사용자 지정", + "language_system": "시스템 설정 따르기", + + "err_io_prefix": "I/O 오류: ", + "err_json_prefix": "JSON 오류: ", + "err_title_empty": "이름은 비워 둘 수 없습니다.", + "err_title_duplicate": "같은 위치에 같은 이름의 항목이 이미 있습니다.", + "err_title_required": "이름은 필수입니다.", + "err_program_required": "프로그램은 필수입니다.", + "err_host_required": "호스트는 필수입니다.", + "err_invalid_port": "포트는 숫자여야 합니다.", + "err_missing_custom_draft": "사용자 지정 명령 초안을 찾을 수 없습니다.", + "err_missing_ssh_draft": "SSH 명령 초안을 찾을 수 없습니다.", + + "tpl_edit_tab_title": "편집: {title}", + "tpl_launch_failed_title": "'{title}' 실행에 실패했습니다", + "tpl_launch_failed_body": "명령: {command}\n오류: {error}", + "tpl_terminal_init_failed": "터미널 탭 초기화에 실패했습니다: {error}", + "tpl_palette_fallback_label": "색 {index}" + } +} diff --git a/app/src/i18n/locales/pt-BR.json b/app/src/i18n/locales/pt-BR.json new file mode 100644 index 0000000..814e68c --- /dev/null +++ b/app/src/i18n/locales/pt-BR.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "Primeiro plano", + "Plano de fundo", + "Preto", + "Vermelho", + "Verde", + "Amarelo", + "Azul", + "Magenta", + "Ciano", + "Branco", + "Preto brilhante", + "Vermelho brilhante", + "Verde brilhante", + "Amarelo brilhante", + "Azul brilhante", + "Magenta brilhante", + "Ciano brilhante", + "Branco brilhante", + "Primeiro plano brilhante", + "Preto esmaecido", + "Vermelho esmaecido", + "Verde esmaecido", + "Amarelo esmaecido", + "Azul esmaecido", + "Magenta esmaecido", + "Ciano esmaecido", + "Branco esmaecido", + "Primeiro plano esmaecido", + "Sobreposição" + ], + "strings": { + "menu_create_tab": "Criar aba", + "menu_create_folder": "Criar pasta", + "menu_create_quick_launch": "Criar início rápido", + + "terminal_tab_not_initialized": "A aba de terminal não foi inicializada.", + "quick_launch_editor_not_initialized": "O editor de início rápido não foi inicializado.", + "quick_launch_error_payload_missing": "Os dados de erro do início rápido estão ausentes.", + "terminal_unavailable": "Terminal indisponível", + + "no_tabs": "Nenhuma aba", + "tab_content_placeholder": "Espaço reservado para o conteúdo da aba", + "settings_tab_title": "Configurações", + "quick_launch_create_tab_title": "Criar início rápido", + + "copy_selection": "Copiar seleção", + "paste": "Colar", + "copy_content": "Copiar saída", + "copy_prompt": "Copiar prompt", + "copy_command": "Copiar comando", + "split_horizontally": "Dividir horizontalmente", + "split_vertically": "Dividir verticalmente", + "close_pane": "Fechar", + + "kill": "Encerrar", + "edit": "Editar", + "rename": "Renomear", + "duplicate": "Duplicar", + "remove": "Remover", + "create_folder": "Criar pasta", + "create_quick_launch": "Criar início rápido", + "delete": "Excluir", + "create_launch": "Criar item de início", + + "quick_launch_panel_title": "Início rápido", + "no_active_folder": "Nenhuma pasta ativa", + + "wizard_header": "Início rápido", + "field_title": "Nome", + "field_type": "Tipo", + "type_custom": "Personalizado", + "type_ssh": "SSH", + "section_custom_command": "Comando personalizado", + "field_program": "Programa", + "field_arguments": "Argumentos", + "field_workdir": "Diretório de trabalho (cwd)", + "section_ssh_connection": "Conexão SSH", + "field_host": "Host", + "field_port": "Porta", + "field_user": "Usuário", + "field_identity_file": "Arquivo de chave privada", + "field_extra_args": "Argumentos adicionais", + "field_environment": "Variáveis de ambiente", + "env_key_placeholder": "NOME", + "env_value_placeholder": "valor", + "invalid_custom_editor_state": "Estado do editor personalizado inválido", + "invalid_ssh_editor_state": "Estado do editor SSH inválido", + + "button_save": "Salvar", + "button_cancel": "Cancelar", + "button_remove": "Remover", + "button_add": "Adicionar", + "button_add_env": "Adicionar variável", + "button_reset": "Redefinir", + + "section_general": "Geral", + "section_terminal": "Terminal", + "section_appearance": "Aparência", + "field_language": "Idioma da interface", + "field_shell": "Shell", + "field_default_editor": "Editor padrão", + "field_pane_layout": "Layout dos painéis", + "option_equalize_panes": "Igualar a largura dos painéis adjacentes ao dividir e fechar", + "field_preset": "Predefinição", + "preset_placeholder_custom": "Personalizado", + "language_system": "Seguir o sistema", + + "err_io_prefix": "Erro de E/S: ", + "err_json_prefix": "Erro de JSON: ", + "err_title_empty": "O nome não pode ficar vazio.", + "err_title_duplicate": "Já existe um item com este nome no mesmo nível.", + "err_title_required": "O nome é obrigatório.", + "err_program_required": "O programa é obrigatório.", + "err_host_required": "O host é obrigatório.", + "err_invalid_port": "A porta deve ser um número.", + "err_missing_custom_draft": "Rascunho do comando personalizado não encontrado.", + "err_missing_ssh_draft": "Rascunho do comando SSH não encontrado.", + + "tpl_edit_tab_title": "Editar: {title}", + "tpl_launch_failed_title": "Falha ao iniciar “{title}”", + "tpl_launch_failed_body": "Comando: {command}\nErro: {error}", + "tpl_terminal_init_failed": "Falha ao inicializar a aba de terminal: {error}", + "tpl_palette_fallback_label": "Cor {index}" + } +} diff --git a/app/src/i18n/locales/ru.json b/app/src/i18n/locales/ru.json new file mode 100644 index 0000000..514d09a --- /dev/null +++ b/app/src/i18n/locales/ru.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "Передний план", + "Фон", + "Чёрный", + "Красный", + "Зелёный", + "Жёлтый", + "Синий", + "Пурпурный", + "Голубой", + "Белый", + "Яркий чёрный", + "Яркий красный", + "Яркий зелёный", + "Яркий жёлтый", + "Яркий синий", + "Яркий пурпурный", + "Яркий голубой", + "Яркий белый", + "Яркий передний план", + "Приглушённый чёрный", + "Приглушённый красный", + "Приглушённый зелёный", + "Приглушённый жёлтый", + "Приглушённый синий", + "Приглушённый пурпурный", + "Приглушённый голубой", + "Приглушённый белый", + "Приглушённый передний план", + "Наложение" + ], + "strings": { + "menu_create_tab": "Создать вкладку", + "menu_create_folder": "Создать папку", + "menu_create_quick_launch": "Создать быстрый запуск", + + "terminal_tab_not_initialized": "Вкладка терминала не инициализирована.", + "quick_launch_editor_not_initialized": "Редактор быстрого запуска не инициализирован.", + "quick_launch_error_payload_missing": "Данные об ошибке быстрого запуска отсутствуют.", + "terminal_unavailable": "Терминал недоступен", + + "no_tabs": "Нет вкладок", + "tab_content_placeholder": "Заполнитель содержимого вкладки", + "settings_tab_title": "Настройки", + "quick_launch_create_tab_title": "Создать быстрый запуск", + + "copy_selection": "Копировать выделение", + "paste": "Вставить", + "copy_content": "Копировать вывод", + "copy_prompt": "Копировать приглашение", + "copy_command": "Копировать команду", + "split_horizontally": "Разделить по горизонтали", + "split_vertically": "Разделить по вертикали", + "close_pane": "Закрыть", + + "kill": "Завершить", + "edit": "Изменить", + "rename": "Переименовать", + "duplicate": "Дублировать", + "remove": "Убрать", + "create_folder": "Создать папку", + "create_quick_launch": "Создать быстрый запуск", + "delete": "Удалить", + "create_launch": "Создать элемент запуска", + + "quick_launch_panel_title": "Быстрый запуск", + "no_active_folder": "Нет активной папки", + + "wizard_header": "Быстрый запуск", + "field_title": "Название", + "field_type": "Тип", + "type_custom": "Пользовательский", + "type_ssh": "SSH", + "section_custom_command": "Пользовательская команда", + "field_program": "Программа", + "field_arguments": "Аргументы", + "field_workdir": "Рабочий каталог (cwd)", + "section_ssh_connection": "SSH-подключение", + "field_host": "Хост", + "field_port": "Порт", + "field_user": "Пользователь", + "field_identity_file": "Файл закрытого ключа", + "field_extra_args": "Дополнительные аргументы", + "field_environment": "Переменные окружения", + "env_key_placeholder": "ИМЯ", + "env_value_placeholder": "значение", + "invalid_custom_editor_state": "Некорректное состояние редактора команды", + "invalid_ssh_editor_state": "Некорректное состояние редактора SSH", + + "button_save": "Сохранить", + "button_cancel": "Отмена", + "button_remove": "Убрать", + "button_add": "Добавить", + "button_add_env": "Добавить переменную", + "button_reset": "Сбросить", + + "section_general": "Общие", + "section_terminal": "Терминал", + "section_appearance": "Внешний вид", + "field_language": "Язык интерфейса", + "field_shell": "Оболочка", + "field_default_editor": "Редактор по умолчанию", + "field_pane_layout": "Расположение панелей", + "option_equalize_panes": "Выравнивать ширину соседних панелей при разделении и закрытии", + "field_preset": "Предустановка", + "preset_placeholder_custom": "Пользовательская", + "language_system": "Как в системе", + + "err_io_prefix": "Ошибка ввода-вывода: ", + "err_json_prefix": "Ошибка JSON: ", + "err_title_empty": "Название не может быть пустым.", + "err_title_duplicate": "На этом уровне уже есть элемент с таким названием.", + "err_title_required": "Укажите название.", + "err_program_required": "Укажите программу.", + "err_host_required": "Укажите хост.", + "err_invalid_port": "Порт должен быть числом.", + "err_missing_custom_draft": "Черновик пользовательской команды не найден.", + "err_missing_ssh_draft": "Черновик команды SSH не найден.", + + "tpl_edit_tab_title": "Изменение: {title}", + "tpl_launch_failed_title": "Не удалось запустить «{title}»", + "tpl_launch_failed_body": "Команда: {command}\nОшибка: {error}", + "tpl_terminal_init_failed": "Не удалось инициализировать вкладку терминала: {error}", + "tpl_palette_fallback_label": "Цвет {index}" + } +} diff --git a/app/src/i18n/locales/zh-TW.json b/app/src/i18n/locales/zh-TW.json new file mode 100644 index 0000000..eb73054 --- /dev/null +++ b/app/src/i18n/locales/zh-TW.json @@ -0,0 +1,127 @@ +{ + "palette_labels": [ + "前景色", + "背景色", + "黑色", + "紅色", + "綠色", + "黃色", + "藍色", + "洋紅", + "青色", + "白色", + "亮黑", + "亮紅", + "亮綠", + "亮黃", + "亮藍", + "亮洋紅", + "亮青", + "亮白", + "亮前景色", + "暗黑", + "暗紅", + "暗綠", + "暗黃", + "暗藍", + "暗洋紅", + "暗青", + "暗白", + "暗前景色", + "覆蓋層" + ], + "strings": { + "menu_create_tab": "新增分頁", + "menu_create_folder": "新增資料夾", + "menu_create_quick_launch": "新增快速啟動", + + "terminal_tab_not_initialized": "終端機分頁尚未初始化。", + "quick_launch_editor_not_initialized": "快速啟動編輯器尚未初始化。", + "quick_launch_error_payload_missing": "快速啟動的錯誤資訊遺失。", + "terminal_unavailable": "終端機無法使用", + + "no_tabs": "沒有分頁", + "tab_content_placeholder": "分頁內容佔位", + "settings_tab_title": "設定", + "quick_launch_create_tab_title": "新增快速啟動", + + "copy_selection": "複製選取內容", + "paste": "貼上", + "copy_content": "複製輸出", + "copy_prompt": "複製提示字元", + "copy_command": "複製指令", + "split_horizontally": "水平分割", + "split_vertically": "垂直分割", + "close_pane": "關閉", + + "kill": "終止", + "edit": "編輯", + "rename": "重新命名", + "duplicate": "建立複本", + "remove": "移除", + "create_folder": "新增資料夾", + "create_quick_launch": "新增快速啟動", + "delete": "刪除", + "create_launch": "新增啟動項目", + + "quick_launch_panel_title": "快速啟動", + "no_active_folder": "無使用中的資料夾", + + "wizard_header": "快速啟動", + "field_title": "名稱", + "field_type": "類型", + "type_custom": "自訂", + "type_ssh": "SSH", + "section_custom_command": "自訂指令", + "field_program": "程式", + "field_arguments": "參數", + "field_workdir": "工作目錄 (cwd)", + "section_ssh_connection": "SSH 連線", + "field_host": "主機", + "field_port": "連接埠", + "field_user": "使用者", + "field_identity_file": "私密金鑰檔案", + "field_extra_args": "額外參數", + "field_environment": "環境變數", + "env_key_placeholder": "變數名稱", + "env_value_placeholder": "變數值", + "invalid_custom_editor_state": "自訂指令編輯器狀態無效", + "invalid_ssh_editor_state": "SSH 編輯器狀態無效", + + "button_save": "儲存", + "button_cancel": "取消", + "button_remove": "移除", + "button_add": "新增", + "button_add_env": "新增環境變數", + "button_reset": "重設", + + "section_general": "一般", + "section_terminal": "終端機", + "section_appearance": "外觀", + "field_language": "介面語言", + "field_shell": "Shell", + "field_default_editor": "預設編輯器", + "field_pane_layout": "窗格配置", + "option_equalize_panes": "分割和關閉時平均同層窗格寬度", + "field_preset": "配色預設", + "preset_placeholder_custom": "自訂", + "language_system": "跟隨系統", + + "err_io_prefix": "I/O 錯誤:", + "err_json_prefix": "JSON 錯誤:", + "err_title_empty": "名稱不得為空。", + "err_title_duplicate": "同層中已有相同名稱的項目。", + "err_title_required": "名稱為必填。", + "err_program_required": "程式為必填。", + "err_host_required": "主機為必填。", + "err_invalid_port": "連接埠必須是數字。", + "err_missing_custom_draft": "找不到自訂指令草稿。", + "err_missing_ssh_draft": "找不到 SSH 指令草稿。", + + "tpl_edit_tab_title": "編輯:{title}", + "tpl_launch_failed_title": "啟動「{title}」失敗", + "tpl_launch_failed_body": "指令:{command}\n錯誤:{error}", + "tpl_terminal_init_failed": "終端機分頁初始化失敗:{error}", + "tpl_palette_fallback_label": "色彩 {index}" + } +} diff --git a/app/src/i18n/mod.rs b/app/src/i18n/mod.rs index 5ab506a..c229db5 100644 --- a/app/src/i18n/mod.rs +++ b/app/src/i18n/mod.rs @@ -14,16 +14,11 @@ use std::sync::atomic::{AtomicU8, Ordering}; pub(crate) use key::Key; use serde::Deserialize; -/// Embedded English catalog. -const EN_JSON: &str = include_str!("locales/en.json"); -/// Embedded Simplified Chinese catalog. -const ZH_CN_JSON: &str = include_str!("locales/zh-CN.json"); - /// Number of configurable palette colors, mirroring `PALETTE_FIELDS`. const PALETTE_LABEL_COUNT: usize = 29; -/// Active locale, encoded as [`Locale::as_u8`]. -static CURRENT_LOCALE: AtomicU8 = AtomicU8::new(Locale::En.as_u8()); +/// Active locale, encoded as [`Locale::index`]. +static CURRENT_LOCALE: AtomicU8 = AtomicU8::new(Locale::En.index() as u8); /// Parsed contents of one locale JSON file. #[derive(Debug, Deserialize)] @@ -47,48 +42,154 @@ impl Catalog { } /// Interface locales shipped with the application. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// Adding a language means adding a variant here, a JSON file under +/// `locales/`, and an entry in [`Locale::ALL`]; no view code changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Locale { /// English. En, /// Simplified Chinese. ZhCn, + /// Traditional Chinese. + ZhTw, + /// Japanese. + Ja, + /// Korean. + Ko, + /// French. + Fr, + /// German. + De, + /// Spanish. + Es, + /// Brazilian Portuguese. + PtBr, + /// Russian. + Ru, } impl Locale { + /// Every shipped locale, in the order shown by the language selector. + pub(crate) const ALL: [Self; 10] = [ + Self::En, + Self::ZhCn, + Self::ZhTw, + Self::Ja, + Self::Ko, + Self::Fr, + Self::De, + Self::Es, + Self::PtBr, + Self::Ru, + ]; + /// BCP 47 tag persisted in the settings file. pub(crate) fn tag(self) -> &'static str { match self { Self::ZhCn => "zh-CN", + Self::ZhTw => "zh-TW", + Self::Ja => "ja", + Self::Ko => "ko", + Self::Fr => "fr", + Self::De => "de", + Self::Es => "es", + Self::PtBr => "pt-BR", + Self::Ru => "ru", _ => "en", } } + /// Language name written in that language, as shown in the selector. + /// + /// Endonyms are used so a user can find their language without already + /// reading the current interface language. + pub(crate) fn native_name(self) -> &'static str { + match self { + Self::ZhCn => "简体中文", + Self::ZhTw => "繁體中文", + Self::Ja => "日本語", + Self::Ko => "한국어", + Self::Fr => "Français", + Self::De => "Deutsch", + Self::Es => "Español", + Self::PtBr => "Português (Brasil)", + Self::Ru => "Русский", + _ => "English", + } + } + /// Resolve a locale from a BCP 47 tag or POSIX locale string. /// - /// Anything that is not recognizably Chinese falls back to English, which - /// keeps unknown or malformed values usable instead of failing. + /// Accepts forms such as `zh_TW.UTF-8` and `pt-BR`. Unknown or malformed + /// values fall back to English rather than failing. pub(crate) fn from_tag(tag: &str) -> Self { - let normalized = tag.to_ascii_lowercase().replace('_', "-"); + let normalized = tag + .split(['.', '@']) + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .replace('_', "-"); + + // Chinese splits by script rather than by primary subtag, so the + // traditional-script regions and an explicit `Hant` map to zh-TW. if normalized.starts_with("zh") { - return Self::ZhCn; + let traditional = normalized.contains("hant") + || normalized.contains("-tw") + || normalized.contains("-hk") + || normalized.contains("-mo"); + return if traditional { Self::ZhTw } else { Self::ZhCn }; } - Self::En + match normalized.split('-').next().unwrap_or_default() { + "ja" => Self::Ja, + "ko" => Self::Ko, + "fr" => Self::Fr, + "de" => Self::De, + "es" => Self::Es, + "pt" => Self::PtBr, + "ru" => Self::Ru, + _ => Self::En, + } } - /// Encode the locale for storage in the global atomic. - const fn as_u8(self) -> u8 { + /// Embedded catalog source for this locale. + fn json(self) -> &'static str { + match self { + Self::ZhCn => include_str!("locales/zh-CN.json"), + Self::ZhTw => include_str!("locales/zh-TW.json"), + Self::Ja => include_str!("locales/ja.json"), + Self::Ko => include_str!("locales/ko.json"), + Self::Fr => include_str!("locales/fr.json"), + Self::De => include_str!("locales/de.json"), + Self::Es => include_str!("locales/es.json"), + Self::PtBr => include_str!("locales/pt-BR.json"), + Self::Ru => include_str!("locales/ru.json"), + _ => include_str!("locales/en.json"), + } + } + + /// Position in [`Locale::ALL`], used to index the catalog cache and to + /// encode the locale into the global atomic. + const fn index(self) -> usize { match self { - Self::En => 0, Self::ZhCn => 1, + Self::ZhTw => 2, + Self::Ja => 3, + Self::Ko => 4, + Self::Fr => 5, + Self::De => 6, + Self::Es => 7, + Self::PtBr => 8, + Self::Ru => 9, + _ => 0, } } - /// Decode a locale previously encoded by [`Locale::as_u8`]. - fn from_u8(value: u8) -> Self { - match value { - 1 => Self::ZhCn, + /// Decode a locale previously encoded by [`Locale::index`]. + fn from_index(value: usize) -> Self { + match Self::ALL.get(value) { + Some(locale) => *locale, _ => Self::En, } } @@ -96,7 +197,7 @@ impl Locale { /// Return the active interface locale. pub(crate) fn current_locale() -> Locale { - Locale::from_u8(CURRENT_LOCALE.load(Ordering::Relaxed)) + Locale::from_index(CURRENT_LOCALE.load(Ordering::Relaxed) as usize) } /// Set the active interface locale. @@ -104,7 +205,7 @@ pub(crate) fn current_locale() -> Locale { /// Callers must re-render afterwards; this only swaps the catalog used by /// subsequent [`t`] calls. pub(crate) fn set_locale(locale: Locale) { - CURRENT_LOCALE.store(locale.as_u8(), Ordering::Relaxed); + CURRENT_LOCALE.store(locale.index() as u8, Ordering::Relaxed); } /// Detect the preferred locale from the environment. @@ -210,16 +311,17 @@ fn fill_in(locale: Locale, key: Key, arguments: &[(&str, &str)]) -> String { } /// Return the parsed catalog for `locale`, parsing it on first use. +/// +/// Catalogs are cached per locale, so only the languages actually displayed +/// are ever parsed. fn catalog(locale: Locale) -> &'static Catalog { - static EN: OnceLock = OnceLock::new(); - static ZH_CN: OnceLock = OnceLock::new(); - - match locale { - Locale::ZhCn => { - ZH_CN.get_or_init(|| Catalog::parse("zh-CN", ZH_CN_JSON)) - }, - _ => EN.get_or_init(|| Catalog::parse("en", EN_JSON)), - } + static CATALOGS: [OnceLock; Locale::ALL.len()] = + [const { OnceLock::new() }; Locale::ALL.len()]; + + // `index` is defined as the position in `ALL`, which + // `locale_indices_match_all_positions` verifies. + CATALOGS[locale.index()] + .get_or_init(|| Catalog::parse(locale.tag(), locale.json())) } #[cfg(test)] @@ -231,7 +333,7 @@ mod tests { #[test] fn given_every_key_when_catalogs_are_read_then_all_are_translated() { - for locale in [Locale::En, Locale::ZhCn] { + for locale in Locale::ALL { let catalog = catalog(locale); for key in Key::ALL { assert!( @@ -245,7 +347,7 @@ mod tests { #[test] fn given_catalogs_when_counted_then_they_contain_no_extra_keys() { - for locale in [Locale::En, Locale::ZhCn] { + for locale in Locale::ALL { assert_eq!( catalog(locale).strings.len(), Key::ALL.len(), @@ -257,7 +359,7 @@ mod tests { #[test] fn given_catalogs_when_palette_labels_read_then_counts_match_palette() { - for locale in [Locale::En, Locale::ZhCn] { + for locale in Locale::ALL { assert_eq!( catalog(locale).palette_labels.len(), PALETTE_LABEL_COUNT, @@ -268,18 +370,72 @@ mod tests { } #[test] - fn given_locale_tags_when_parsed_then_chinese_variants_map_to_zh_cn() { + fn given_chinese_tags_when_parsed_then_script_selects_the_variant() { assert_eq!(Locale::from_tag("zh_CN.UTF-8"), Locale::ZhCn); assert_eq!(Locale::from_tag("zh-Hans"), Locale::ZhCn); - assert_eq!(Locale::from_tag("ZH-TW"), Locale::ZhCn); + assert_eq!(Locale::from_tag("zh"), Locale::ZhCn); + assert_eq!(Locale::from_tag("ZH-TW"), Locale::ZhTw); + assert_eq!(Locale::from_tag("zh_HK"), Locale::ZhTw); + assert_eq!(Locale::from_tag("zh-Hant"), Locale::ZhTw); + } + + #[test] + fn given_posix_locale_strings_when_parsed_then_language_is_recognized() { + assert_eq!(Locale::from_tag("ja_JP.UTF-8"), Locale::Ja); + assert_eq!(Locale::from_tag("ko_KR.UTF-8"), Locale::Ko); + assert_eq!(Locale::from_tag("fr_CA"), Locale::Fr); + assert_eq!(Locale::from_tag("de_AT@euro"), Locale::De); + assert_eq!(Locale::from_tag("es_MX.UTF-8"), Locale::Es); + assert_eq!(Locale::from_tag("pt_PT"), Locale::PtBr); + assert_eq!(Locale::from_tag("ru_RU.UTF-8"), Locale::Ru); assert_eq!(Locale::from_tag("en_US.UTF-8"), Locale::En); - assert_eq!(Locale::from_tag("de_DE"), Locale::En); + } + + #[test] + fn given_unknown_tags_when_parsed_then_english_is_used() { + assert_eq!(Locale::from_tag("tlh"), Locale::En); + assert_eq!(Locale::from_tag("C"), Locale::En); assert_eq!(Locale::from_tag(""), Locale::En); } + #[test] + fn given_locales_when_indexed_then_positions_match_all_order() { + for (position, locale) in Locale::ALL.into_iter().enumerate() { + assert_eq!( + locale.index(), + position, + "{} has an index that does not match its position in ALL", + locale.tag() + ); + } + } + + #[test] + fn given_locales_when_encoded_then_the_atomic_round_trips() { + for locale in Locale::ALL { + assert_eq!(Locale::from_index(locale.index()), locale); + } + } + + #[test] + fn given_locales_when_listed_then_tags_and_names_are_unique() { + let mut tags: Vec<&str> = Locale::ALL.iter().map(|l| l.tag()).collect(); + tags.sort_unstable(); + let unique_tags = tags.len(); + tags.dedup(); + assert_eq!(tags.len(), unique_tags, "duplicate locale tag"); + + let mut names: Vec<&str> = + Locale::ALL.iter().map(|l| l.native_name()).collect(); + names.sort_unstable(); + let unique_names = names.len(); + names.dedup(); + assert_eq!(names.len(), unique_names, "duplicate locale name"); + } + #[test] fn given_locale_when_round_tripped_through_tag_then_value_is_preserved() { - for locale in [Locale::En, Locale::ZhCn] { + for locale in Locale::ALL { assert_eq!(Locale::from_tag(locale.tag()), locale); } } @@ -361,12 +517,63 @@ mod tests { ); } + #[test] + fn given_every_locale_when_templates_read_then_placeholders_survive() { + let required: [(Key, &[&str]); 5] = [ + (Key::TplEditTabTitle, &["{title}"]), + (Key::TplLaunchFailedTitle, &["{title}"]), + (Key::TplLaunchFailedBody, &["{command}", "{error}"]), + (Key::TplTerminalInitFailed, &["{error}"]), + (Key::TplPaletteFallbackLabel, &["{index}"]), + ]; + + for locale in Locale::ALL { + for (key, placeholders) in required { + let template = text_in(locale, key); + for placeholder in placeholders { + assert!( + template.contains(placeholder), + "{} template {key:?} lost {placeholder}", + locale.tag() + ); + } + } + } + } + + #[test] + fn given_every_locale_when_catalog_read_then_no_entry_is_blank() { + for locale in Locale::ALL { + let catalog = catalog(locale); + + for (key, value) in &catalog.strings { + assert!( + !value.trim().is_empty(), + "{} has a blank translation for {key:?}", + locale.tag() + ); + } + + for (index, label) in catalog.palette_labels.iter().enumerate() { + assert!( + !label.trim().is_empty(), + "{} has a blank palette label at {index}", + locale.tag() + ); + } + } + } + #[test] fn given_embedded_catalogs_when_parsed_then_they_deserialize() { - let en = Catalog::parse("en", super::EN_JSON); - let zh = Catalog::parse("zh-CN", super::ZH_CN_JSON); + for locale in Locale::ALL { + let catalog = Catalog::parse(locale.tag(), locale.json()); - assert!(!en.strings.is_empty()); - assert!(!zh.strings.is_empty()); + assert!( + !catalog.strings.is_empty(), + "{} parsed to an empty catalog", + locale.tag() + ); + } } } diff --git a/app/src/widgets/settings/reducer.rs b/app/src/widgets/settings/reducer.rs index cbae92e..c7789a9 100644 --- a/app/src/widgets/settings/reducer.rs +++ b/app/src/widgets/settings/reducer.rs @@ -124,6 +124,7 @@ fn apply_loaded_settings( #[cfg(test)] mod tests { use super::*; + use crate::i18n::Locale; use crate::widgets::settings::state::SettingsState; use crate::widgets::settings::types::{ LanguageSetting, SettingsData, SettingsLoad, SettingsLoadStatus, @@ -279,12 +280,14 @@ mod tests { let _task = reduce( &mut state, - SettingsIntent::LanguageChanged(LanguageSetting::SimplifiedChinese), + SettingsIntent::LanguageChanged(LanguageSetting::Fixed( + Locale::ZhCn, + )), ); assert_eq!( state.draft().language(), - LanguageSetting::SimplifiedChinese + LanguageSetting::Fixed(Locale::ZhCn) ); assert!(state.is_dirty()); } diff --git a/app/src/widgets/settings/state.rs b/app/src/widgets/settings/state.rs index 246052a..30b5750 100644 --- a/app/src/widgets/settings/state.rs +++ b/app/src/widgets/settings/state.rs @@ -238,6 +238,7 @@ fn find_node_mut<'a>( #[cfg(test)] mod tests { use super::SettingsState; + use crate::i18n::Locale; use crate::widgets::settings::types::{ LanguageSetting, SettingsData, SettingsPreset, SettingsSection, }; @@ -340,9 +341,12 @@ mod tests { fn given_language_change_when_applied_then_draft_updates_and_marks_dirty() { let mut state = SettingsState::default(); - state.set_language(LanguageSetting::SimplifiedChinese); + state.set_language(LanguageSetting::Fixed(Locale::ZhCn)); - assert_eq!(state.draft.language(), LanguageSetting::SimplifiedChinese); + assert_eq!( + state.draft.language(), + LanguageSetting::Fixed(Locale::ZhCn) + ); assert!(state.dirty); } diff --git a/app/src/widgets/settings/storage.rs b/app/src/widgets/settings/storage.rs index 9b78812..958bd3d 100644 --- a/app/src/widgets/settings/storage.rs +++ b/app/src/widgets/settings/storage.rs @@ -110,6 +110,7 @@ mod tests { SettingsData, SettingsLoadStatus, load_settings_from_path, save_settings_to_path, }; + use crate::i18n::Locale; use crate::widgets::settings::types::LanguageSetting; #[test] @@ -120,7 +121,7 @@ mod tests { settings.set_terminal_shell(String::from("/bin/zsh")); settings.set_terminal_editor(String::from("vim")); settings.set_theme_palette_entry(0, String::from("#112233")); - settings.set_language(LanguageSetting::SimplifiedChinese); + settings.set_language(LanguageSetting::Fixed(Locale::ZhCn)); save_settings_to_path(&path, &settings) .expect("settings should save successfully"); diff --git a/app/src/widgets/settings/types.rs b/app/src/widgets/settings/types.rs index 6899d53..56f5928 100644 --- a/app/src/widgets/settings/types.rs +++ b/app/src/widgets/settings/types.rs @@ -11,30 +11,32 @@ const DEFAULT_EDITOR: &str = "nano"; const FALLBACK_SHELL: &str = "/bin/bash"; /// Interface language choice stored in the settings file. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +/// +/// Adding a language requires no change here: the selector is derived from +/// [`Locale::ALL`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum LanguageSetting { /// Follow the operating system locale. #[default] - #[serde(rename = "system")] System, - /// Always use English. - #[serde(rename = "en")] - English, - /// Always use Simplified Chinese. - #[serde(rename = "zh-CN")] - SimplifiedChinese, + /// Always use one specific locale. + Fixed(Locale), } impl LanguageSetting { - /// All choices offered by the language selector. - pub(crate) const ALL: [Self; 3] = - [Self::System, Self::English, Self::SimplifiedChinese]; + /// All choices offered by the language selector, system first. + pub(crate) fn all() -> Vec { + let mut options = Vec::with_capacity(Locale::ALL.len() + 1); + options.push(Self::System); + options.extend(Locale::ALL.map(Self::Fixed)); + + options + } /// Resolve the setting to the locale that should be rendered. pub(crate) fn resolve(self) -> Locale { match self { - Self::English => Locale::En, - Self::SimplifiedChinese => Locale::ZhCn, + Self::Fixed(locale) => locale, _ => i18n::detect_system_locale(), } } @@ -42,19 +44,21 @@ impl LanguageSetting { /// Value persisted in the settings file. fn tag(self) -> &'static str { match self { - Self::English => "en", - Self::SimplifiedChinese => "zh-CN", + Self::Fixed(locale) => locale.tag(), _ => "system", } } /// Parse a persisted value, falling back to [`LanguageSetting::System`]. + /// + /// Only exact tag matches select a language, so an unrecognized value + /// keeps following the system rather than silently picking a language. fn from_tag(tag: &str) -> Self { - match tag.to_ascii_lowercase().replace('_', "-").as_str() { - "en" => Self::English, - "zh-cn" => Self::SimplifiedChinese, - _ => Self::System, - } + let normalized = tag.to_ascii_lowercase().replace('_', "-"); + Locale::ALL + .into_iter() + .find(|locale| locale.tag().to_ascii_lowercase() == normalized) + .map_or(Self::System, Self::Fixed) } } @@ -63,13 +67,21 @@ impl fmt::Display for LanguageSetting { // Language names stay in their own language; only the // follow-the-system option is translated. match self { - Self::English => f.write_str("English"), - Self::SimplifiedChinese => f.write_str("简体中文"), + Self::Fixed(locale) => f.write_str(locale.native_name()), _ => f.write_str(i18n::t(Key::LanguageSystem)), } } } +impl Serialize for LanguageSetting { + fn serialize( + &self, + serializer: S, + ) -> Result { + serializer.serialize_str(self.tag()) + } +} + /// General settings that are not specific to the terminal or the theme. #[derive(Debug, Clone, PartialEq, Serialize, Default)] pub(crate) struct GeneralSettingsData { @@ -798,14 +810,21 @@ mod tests { #[test] fn given_language_tags_when_parsed_then_known_values_are_recognized() { - assert_eq!(LanguageSetting::from_tag("en"), LanguageSetting::English); + assert_eq!( + LanguageSetting::from_tag("en"), + LanguageSetting::Fixed(Locale::En) + ); assert_eq!( LanguageSetting::from_tag("zh-CN"), - LanguageSetting::SimplifiedChinese + LanguageSetting::Fixed(Locale::ZhCn) ); assert_eq!( - LanguageSetting::from_tag("zh_cn"), - LanguageSetting::SimplifiedChinese + LanguageSetting::from_tag("zh_tw"), + LanguageSetting::Fixed(Locale::ZhTw) + ); + assert_eq!( + LanguageSetting::from_tag("pt-br"), + LanguageSetting::Fixed(Locale::PtBr) ); assert_eq!( LanguageSetting::from_tag("system"), @@ -817,17 +836,37 @@ mod tests { ); } + #[test] + fn given_partial_language_tag_when_parsed_then_system_is_kept() { + // Unlike locale detection, a stored setting must match exactly: + // a bare "zh" is not a choice the selector can produce. + assert_eq!(LanguageSetting::from_tag("zh"), LanguageSetting::System); + assert_eq!(LanguageSetting::from_tag("en-GB"), LanguageSetting::System); + } + #[test] fn given_language_setting_when_round_tripped_then_value_is_preserved() { - for language in LanguageSetting::ALL { + for language in LanguageSetting::all() { assert_eq!(LanguageSetting::from_tag(language.tag()), language); } } + #[test] + fn given_selector_options_when_listed_then_system_leads_every_locale() { + let options = LanguageSetting::all(); + + assert_eq!(options.len(), Locale::ALL.len() + 1); + assert_eq!(options.first(), Some(&LanguageSetting::System)); + for locale in Locale::ALL { + assert!(options.contains(&LanguageSetting::Fixed(locale))); + } + } + #[test] fn given_explicit_language_when_resolved_then_locale_matches() { - assert_eq!(LanguageSetting::English.resolve(), Locale::En); - assert_eq!(LanguageSetting::SimplifiedChinese.resolve(), Locale::ZhCn); + for locale in Locale::ALL { + assert_eq!(LanguageSetting::Fixed(locale).resolve(), locale); + } } #[test] @@ -836,7 +875,7 @@ mod tests { let settings = SettingsData::from_json(&value); - assert_eq!(settings.language(), LanguageSetting::SimplifiedChinese); + assert_eq!(settings.language(), LanguageSetting::Fixed(Locale::ZhCn)); } #[test] @@ -852,21 +891,31 @@ mod tests { #[test] fn given_language_when_normalized_then_selection_is_preserved() { let mut settings = SettingsData::default(); - settings.set_language(LanguageSetting::SimplifiedChinese); + settings.set_language(LanguageSetting::Fixed(Locale::ZhTw)); let normalized = settings.normalized(); - assert_eq!(normalized.language(), LanguageSetting::SimplifiedChinese); + assert_eq!(normalized.language(), LanguageSetting::Fixed(Locale::ZhTw)); } #[test] fn given_language_when_serialized_then_tag_is_written() { let mut settings = SettingsData::default(); - settings.set_language(LanguageSetting::SimplifiedChinese); + settings.set_language(LanguageSetting::Fixed(Locale::PtBr)); + + let value = + serde_json::to_value(&settings).expect("settings should serialize"); + + assert_eq!(value["general"]["language"], "pt-BR"); + } + + #[test] + fn given_system_language_when_serialized_then_system_tag_is_written() { + let settings = SettingsData::default(); let value = serde_json::to_value(&settings).expect("settings should serialize"); - assert_eq!(value["general"]["language"], "zh-CN"); + assert_eq!(value["general"]["language"], "system"); } } diff --git a/app/src/widgets/settings/view/settings_form.rs b/app/src/widgets/settings/view/settings_form.rs index 5f516ed..cd2917f 100644 --- a/app/src/widgets/settings/view/settings_form.rs +++ b/app/src/widgets/settings/view/settings_form.rs @@ -216,7 +216,7 @@ fn general_form<'a>( props: &SettingsFormProps<'a>, ) -> Element<'a, SettingsIntent, Theme, iced::Renderer> { let language_selector = pick_list( - LanguageSetting::ALL, + LanguageSetting::all(), Some(props.vm.draft.language()), SettingsIntent::LanguageChanged, ) From 546a15c0afd387fe08843e855c33169d12cab4d1 Mon Sep 17 00:00:00 2001 From: vam Date: Tue, 8 Sep 2026 11:56:27 +0800 Subject: [PATCH 3/5] docs: document interface localization --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 04aa2b2..e70e37d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Developers already spend much of their time in the terminal, yet modern terminal - **Quick Launch for saved commands and SSH targets** Save frequently used commands or SSH connections and launch them without retyping the same input every time. +- **Interface localization in ten languages** + Follow the system locale or choose a language from **Settings > General > Language**. - **Block-based terminal UI** OTTY structures terminal output activity around atomic command blocks, making it easier to control each command and its output. From 182ff5e423e39d8c0882dd07e3839c30531ae60f Mon Sep 17 00:00:00 2001 From: vam Date: Thu, 10 Sep 2026 11:41:46 +0800 Subject: [PATCH 4/5] fix(i18n): harden locale selection and diagnostics Use the native system locale provider, honor explicit Chinese scripts, preserve literal template arguments, refresh the settings tab title, and localize visible quick-launch diagnostics. --- Cargo.lock | 1 + app/Cargo.toml | 1 + app/src/events/settings.rs | 21 ++- app/src/i18n/key.rs | 41 ++++- app/src/i18n/locales/de.json | 15 +- app/src/i18n/locales/en.json | 15 +- app/src/i18n/locales/es.json | 15 +- app/src/i18n/locales/fr.json | 15 +- app/src/i18n/locales/ja.json | 15 +- app/src/i18n/locales/ko.json | 15 +- app/src/i18n/locales/pt-BR.json | 15 +- app/src/i18n/locales/ru.json | 15 +- app/src/i18n/locales/zh-CN.json | 15 +- app/src/i18n/locales/zh-TW.json | 15 +- app/src/i18n/mod.rs | 218 ++++++++++++++++++++--- app/src/widgets/quick_launch/errors.rs | 38 +++- app/src/widgets/quick_launch/reducer.rs | 20 ++- app/src/widgets/quick_launch/services.rs | 93 +++++----- app/src/widgets/tabs/event.rs | 4 + app/src/widgets/tabs/reducer.rs | 44 +++++ app/src/widgets/tabs/state.rs | 13 +- 21 files changed, 551 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c832371..8c53e7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3037,6 +3037,7 @@ dependencies = [ "serde", "serde_json", "shell-words", + "sys-locale", "thiserror 2.0.18", ] diff --git a/app/Cargo.toml b/app/Cargo.toml index ade5983..02c9e8f 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -22,6 +22,7 @@ thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } shell-words = "1.1.0" +sys-locale = "0.3.2" notify = "8.2.0" [package.metadata.generate-rpm] diff --git a/app/src/events/settings.rs b/app/src/events/settings.rs index 8ebbd65..9a28956 100644 --- a/app/src/events/settings.rs +++ b/app/src/events/settings.rs @@ -6,8 +6,10 @@ use otty_ui_term::settings::{ use super::AppEvent; use crate::app::App; +use crate::i18n::{self, Key}; use crate::widgets::settings::types::SettingsData; use crate::widgets::settings::{SettingsEffect, SettingsEvent, SettingsIntent}; +use crate::widgets::tabs::{TabsEvent, TabsIntent}; use crate::widgets::terminal_workspace::services::{ fallback_shell_session_with_shell, setup_shell_session_with_shell, }; @@ -74,9 +76,18 @@ fn apply_theme(app: &mut App, data: &SettingsData) -> Task { let palette = data.to_color_palette(); let terminal_palette: otty_ui_term::ColorPalette = palette.into(); - Task::done(AppEvent::TerminalWorkspace(TerminalWorkspaceEvent::Intent( - TerminalWorkspaceIntent::ApplyTheme { - palette: Box::new(terminal_palette), - }, - ))) + Task::batch(vec![ + Task::done(AppEvent::Tabs(TabsEvent::Intent( + TabsIntent::RefreshSettingsTitle { + title: i18n::t(Key::SettingsTabTitle).to_string(), + }, + ))), + Task::done(AppEvent::TerminalWorkspace( + TerminalWorkspaceEvent::Intent( + TerminalWorkspaceIntent::ApplyTheme { + palette: Box::new(terminal_palette), + }, + ), + )), + ]) } diff --git a/app/src/i18n/key.rs b/app/src/i18n/key.rs index a26861e..bc9fb74 100644 --- a/app/src/i18n/key.rs +++ b/app/src/i18n/key.rs @@ -184,6 +184,14 @@ pub(crate) enum Key { ErrMissingCustomDraft, /// Wizard validation: SSH draft absent. ErrMissingSshDraft, + /// SSH validation: port is not positive. + ErrSshPortPositive, + /// Wizard save target folder is missing. + ErrMissingTargetFolder, + /// Wizard save parent folder is missing. + ErrMissingParentFolder, + /// Wizard edit target no longer exists. + ErrCommandNoLongerExists, // Templates. These carry `{placeholder}` markers substituted at call time. /// Tab title for editing an existing quick launch. Placeholder: `{title}`. @@ -196,12 +204,30 @@ pub(crate) enum Key { TplTerminalInitFailed, /// Label for a palette color with no name. Placeholder: `{index}`. TplPaletteFallbackLabel, + /// SSH connection failure. Placeholder: `{error}`. + TplSshConnectionFailed, + /// Missing working directory. Placeholder: `{path}`. + TplWorkingDirectoryNotFound, + /// Working path is not a directory. Placeholder: `{path}`. + TplWorkingDirectoryNotDirectory, + /// Missing SSH identity file. Placeholder: `{path}`. + TplIdentityFileNotFound, + /// SSH identity path is not a file. Placeholder: `{path}`. + TplIdentityFileNotFile, + /// Program is absent from `PATH`. Placeholder: `{program}`. + TplProgramNotFoundInPath, + /// Explicit program path is missing. Placeholder: `{program}`. + TplProgramNotFound, + /// Program path is a directory. Placeholder: `{program}`. + TplProgramIsDirectory, + /// Program path is not executable. Placeholder: `{program}`. + TplProgramNotExecutable, } impl Key { /// Every key in the catalogs, used by tests to verify full coverage. #[cfg(test)] - pub(super) const ALL: [Self; 82] = [ + pub(super) const ALL: [Self; 95] = [ Self::MenuCreateTab, Self::MenuCreateFolder, Self::MenuCreateQuickLaunch, @@ -279,10 +305,23 @@ impl Key { Self::ErrInvalidPort, Self::ErrMissingCustomDraft, Self::ErrMissingSshDraft, + Self::ErrSshPortPositive, + Self::ErrMissingTargetFolder, + Self::ErrMissingParentFolder, + Self::ErrCommandNoLongerExists, Self::TplEditTabTitle, Self::TplLaunchFailedTitle, Self::TplLaunchFailedBody, Self::TplTerminalInitFailed, Self::TplPaletteFallbackLabel, + Self::TplSshConnectionFailed, + Self::TplWorkingDirectoryNotFound, + Self::TplWorkingDirectoryNotDirectory, + Self::TplIdentityFileNotFound, + Self::TplIdentityFileNotFile, + Self::TplProgramNotFoundInPath, + Self::TplProgramNotFound, + Self::TplProgramIsDirectory, + Self::TplProgramNotExecutable, ]; } diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json index e802d33..52d60ed 100644 --- a/app/src/i18n/locales/de.json +++ b/app/src/i18n/locales/de.json @@ -117,11 +117,24 @@ "err_invalid_port": "Der Port muss eine Zahl sein.", "err_missing_custom_draft": "Entwurf des benutzerdefinierten Befehls nicht gefunden.", "err_missing_ssh_draft": "Entwurf des SSH-Befehls nicht gefunden.", + "err_ssh_port_positive": "Der SSH-Port muss größer als 0 sein.", + "err_missing_target_folder": "Der Zielordner wurde nicht gefunden.", + "err_missing_parent_folder": "Der übergeordnete Ordner wurde nicht gefunden.", + "err_command_no_longer_exists": "Der Befehl existiert nicht mehr.", "tpl_edit_tab_title": "Bearbeiten: {title}", "tpl_launch_failed_title": "Starten von „{title}“ fehlgeschlagen", "tpl_launch_failed_body": "Befehl: {command}\nFehler: {error}", "tpl_terminal_init_failed": "Initialisierung des Terminal-Tabs fehlgeschlagen: {error}", - "tpl_palette_fallback_label": "Farbe {index}" + "tpl_palette_fallback_label": "Farbe {index}", + "tpl_ssh_connection_failed": "SSH-Verbindung fehlgeschlagen: {error}", + "tpl_working_directory_not_found": "Arbeitsverzeichnis nicht gefunden: {path}", + "tpl_working_directory_not_directory": "Das Arbeitsverzeichnis ist kein Verzeichnis: {path}", + "tpl_identity_file_not_found": "Identitätsdatei nicht gefunden: {path}", + "tpl_identity_file_not_file": "Die Identitätsdatei ist keine Datei: {path}", + "tpl_program_not_found_in_path": "Programm nicht in PATH gefunden: {program}", + "tpl_program_not_found": "Programm nicht gefunden: {program}", + "tpl_program_is_directory": "Das Programm ist ein Verzeichnis: {program}", + "tpl_program_not_executable": "Das Programm ist nicht ausführbar: {program}" } } diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json index 4f44634..ec6b3a1 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -117,11 +117,24 @@ "err_invalid_port": "Port must be a number.", "err_missing_custom_draft": "Custom command draft is missing.", "err_missing_ssh_draft": "SSH command draft is missing.", + "err_ssh_port_positive": "SSH port must be greater than 0.", + "err_missing_target_folder": "Target folder is missing.", + "err_missing_parent_folder": "Parent folder is missing.", + "err_command_no_longer_exists": "Command no longer exists.", "tpl_edit_tab_title": "Edit: {title}", "tpl_launch_failed_title": "Failed to launch \"{title}\"", "tpl_launch_failed_body": "Command: {command}\nError: {error}", "tpl_terminal_init_failed": "Terminal tab initialization failed: {error}", - "tpl_palette_fallback_label": "Color {index}" + "tpl_palette_fallback_label": "Color {index}", + "tpl_ssh_connection_failed": "SSH connection failed: {error}", + "tpl_working_directory_not_found": "Working directory not found: {path}", + "tpl_working_directory_not_directory": "Working directory is not a directory: {path}", + "tpl_identity_file_not_found": "Identity file not found: {path}", + "tpl_identity_file_not_file": "Identity file is not a file: {path}", + "tpl_program_not_found_in_path": "Program not found in PATH: {program}", + "tpl_program_not_found": "Program not found: {program}", + "tpl_program_is_directory": "Program is a directory: {program}", + "tpl_program_not_executable": "Program is not executable: {program}" } } diff --git a/app/src/i18n/locales/es.json b/app/src/i18n/locales/es.json index 0657326..d02d3c0 100644 --- a/app/src/i18n/locales/es.json +++ b/app/src/i18n/locales/es.json @@ -117,11 +117,24 @@ "err_invalid_port": "El puerto debe ser un número.", "err_missing_custom_draft": "No se encuentra el borrador del comando personalizado.", "err_missing_ssh_draft": "No se encuentra el borrador del comando SSH.", + "err_ssh_port_positive": "El puerto SSH debe ser mayor que 0.", + "err_missing_target_folder": "No se encontró la carpeta de destino.", + "err_missing_parent_folder": "No se encontró la carpeta superior.", + "err_command_no_longer_exists": "El comando ya no existe.", "tpl_edit_tab_title": "Editar: {title}", "tpl_launch_failed_title": "No se pudo iniciar «{title}»", "tpl_launch_failed_body": "Comando: {command}\nError: {error}", "tpl_terminal_init_failed": "No se pudo inicializar la pestaña de terminal: {error}", - "tpl_palette_fallback_label": "Color {index}" + "tpl_palette_fallback_label": "Color {index}", + "tpl_ssh_connection_failed": "Error en la conexión SSH: {error}", + "tpl_working_directory_not_found": "No se encontró el directorio de trabajo: {path}", + "tpl_working_directory_not_directory": "El directorio de trabajo no es un directorio: {path}", + "tpl_identity_file_not_found": "No se encontró el archivo de identidad: {path}", + "tpl_identity_file_not_file": "El archivo de identidad no es un archivo: {path}", + "tpl_program_not_found_in_path": "No se encontró el programa en PATH: {program}", + "tpl_program_not_found": "No se encontró el programa: {program}", + "tpl_program_is_directory": "El programa es un directorio: {program}", + "tpl_program_not_executable": "El programa no es ejecutable: {program}" } } diff --git a/app/src/i18n/locales/fr.json b/app/src/i18n/locales/fr.json index 854e429..efdb5e1 100644 --- a/app/src/i18n/locales/fr.json +++ b/app/src/i18n/locales/fr.json @@ -117,11 +117,24 @@ "err_invalid_port": "Le port doit être un nombre.", "err_missing_custom_draft": "Brouillon de commande personnalisée introuvable.", "err_missing_ssh_draft": "Brouillon de commande SSH introuvable.", + "err_ssh_port_positive": "Le port SSH doit être supérieur à 0.", + "err_missing_target_folder": "Le dossier cible est introuvable.", + "err_missing_parent_folder": "Le dossier parent est introuvable.", + "err_command_no_longer_exists": "La commande n’existe plus.", "tpl_edit_tab_title": "Modifier : {title}", "tpl_launch_failed_title": "Échec du lancement de « {title} »", "tpl_launch_failed_body": "Commande : {command}\nErreur : {error}", "tpl_terminal_init_failed": "Échec de l'initialisation de l'onglet de terminal : {error}", - "tpl_palette_fallback_label": "Couleur {index}" + "tpl_palette_fallback_label": "Couleur {index}", + "tpl_ssh_connection_failed": "Échec de la connexion SSH : {error}", + "tpl_working_directory_not_found": "Répertoire de travail introuvable : {path}", + "tpl_working_directory_not_directory": "Le répertoire de travail n’est pas un répertoire : {path}", + "tpl_identity_file_not_found": "Fichier d’identité introuvable : {path}", + "tpl_identity_file_not_file": "Le fichier d’identité n’est pas un fichier : {path}", + "tpl_program_not_found_in_path": "Programme introuvable dans PATH : {program}", + "tpl_program_not_found": "Programme introuvable : {program}", + "tpl_program_is_directory": "Le programme est un répertoire : {program}", + "tpl_program_not_executable": "Le programme n’est pas exécutable : {program}" } } diff --git a/app/src/i18n/locales/ja.json b/app/src/i18n/locales/ja.json index 946cd52..320dc76 100644 --- a/app/src/i18n/locales/ja.json +++ b/app/src/i18n/locales/ja.json @@ -117,11 +117,24 @@ "err_invalid_port": "ポートには数値を指定してください。", "err_missing_custom_draft": "カスタムコマンドの下書きが見つかりません。", "err_missing_ssh_draft": "SSH コマンドの下書きが見つかりません。", + "err_ssh_port_positive": "SSH ポートは 0 より大きい値である必要があります。", + "err_missing_target_folder": "対象フォルダーが見つかりません。", + "err_missing_parent_folder": "親フォルダーが見つかりません。", + "err_command_no_longer_exists": "コマンドはもう存在しません。", "tpl_edit_tab_title": "編集: {title}", "tpl_launch_failed_title": "「{title}」の起動に失敗しました", "tpl_launch_failed_body": "コマンド: {command}\nエラー: {error}", "tpl_terminal_init_failed": "ターミナルタブの初期化に失敗しました: {error}", - "tpl_palette_fallback_label": "色 {index}" + "tpl_palette_fallback_label": "色 {index}", + "tpl_ssh_connection_failed": "SSH 接続に失敗しました:{error}", + "tpl_working_directory_not_found": "作業ディレクトリが見つかりません:{path}", + "tpl_working_directory_not_directory": "作業ディレクトリはディレクトリではありません:{path}", + "tpl_identity_file_not_found": "ID ファイルが見つかりません:{path}", + "tpl_identity_file_not_file": "ID ファイルはファイルではありません:{path}", + "tpl_program_not_found_in_path": "PATH 内にプログラムが見つかりません:{program}", + "tpl_program_not_found": "プログラムが見つかりません:{program}", + "tpl_program_is_directory": "プログラムはディレクトリです:{program}", + "tpl_program_not_executable": "プログラムを実行できません:{program}" } } diff --git a/app/src/i18n/locales/ko.json b/app/src/i18n/locales/ko.json index 845a2ce..d5fda88 100644 --- a/app/src/i18n/locales/ko.json +++ b/app/src/i18n/locales/ko.json @@ -117,11 +117,24 @@ "err_invalid_port": "포트는 숫자여야 합니다.", "err_missing_custom_draft": "사용자 지정 명령 초안을 찾을 수 없습니다.", "err_missing_ssh_draft": "SSH 명령 초안을 찾을 수 없습니다.", + "err_ssh_port_positive": "SSH 포트는 0보다 커야 합니다.", + "err_missing_target_folder": "대상 폴더를 찾을 수 없습니다.", + "err_missing_parent_folder": "상위 폴더를 찾을 수 없습니다.", + "err_command_no_longer_exists": "명령이 더 이상 존재하지 않습니다.", "tpl_edit_tab_title": "편집: {title}", "tpl_launch_failed_title": "'{title}' 실행에 실패했습니다", "tpl_launch_failed_body": "명령: {command}\n오류: {error}", "tpl_terminal_init_failed": "터미널 탭 초기화에 실패했습니다: {error}", - "tpl_palette_fallback_label": "색 {index}" + "tpl_palette_fallback_label": "색 {index}", + "tpl_ssh_connection_failed": "SSH 연결 실패: {error}", + "tpl_working_directory_not_found": "작업 디렉터리를 찾을 수 없습니다: {path}", + "tpl_working_directory_not_directory": "작업 디렉터리가 디렉터리가 아닙니다: {path}", + "tpl_identity_file_not_found": "ID 파일을 찾을 수 없습니다: {path}", + "tpl_identity_file_not_file": "ID 파일이 파일이 아닙니다: {path}", + "tpl_program_not_found_in_path": "PATH에서 프로그램을 찾을 수 없습니다: {program}", + "tpl_program_not_found": "프로그램을 찾을 수 없습니다: {program}", + "tpl_program_is_directory": "프로그램이 디렉터리입니다: {program}", + "tpl_program_not_executable": "프로그램을 실행할 수 없습니다: {program}" } } diff --git a/app/src/i18n/locales/pt-BR.json b/app/src/i18n/locales/pt-BR.json index 814e68c..8869dd6 100644 --- a/app/src/i18n/locales/pt-BR.json +++ b/app/src/i18n/locales/pt-BR.json @@ -117,11 +117,24 @@ "err_invalid_port": "A porta deve ser um número.", "err_missing_custom_draft": "Rascunho do comando personalizado não encontrado.", "err_missing_ssh_draft": "Rascunho do comando SSH não encontrado.", + "err_ssh_port_positive": "A porta SSH deve ser maior que 0.", + "err_missing_target_folder": "A pasta de destino não foi encontrada.", + "err_missing_parent_folder": "A pasta pai não foi encontrada.", + "err_command_no_longer_exists": "O comando não existe mais.", "tpl_edit_tab_title": "Editar: {title}", "tpl_launch_failed_title": "Falha ao iniciar “{title}”", "tpl_launch_failed_body": "Comando: {command}\nErro: {error}", "tpl_terminal_init_failed": "Falha ao inicializar a aba de terminal: {error}", - "tpl_palette_fallback_label": "Cor {index}" + "tpl_palette_fallback_label": "Cor {index}", + "tpl_ssh_connection_failed": "Falha na conexão SSH: {error}", + "tpl_working_directory_not_found": "Diretório de trabalho não encontrado: {path}", + "tpl_working_directory_not_directory": "O diretório de trabalho não é um diretório: {path}", + "tpl_identity_file_not_found": "Arquivo de identidade não encontrado: {path}", + "tpl_identity_file_not_file": "O arquivo de identidade não é um arquivo: {path}", + "tpl_program_not_found_in_path": "Programa não encontrado no PATH: {program}", + "tpl_program_not_found": "Programa não encontrado: {program}", + "tpl_program_is_directory": "O programa é um diretório: {program}", + "tpl_program_not_executable": "O programa não é executável: {program}" } } diff --git a/app/src/i18n/locales/ru.json b/app/src/i18n/locales/ru.json index 514d09a..f3d9d98 100644 --- a/app/src/i18n/locales/ru.json +++ b/app/src/i18n/locales/ru.json @@ -117,11 +117,24 @@ "err_invalid_port": "Порт должен быть числом.", "err_missing_custom_draft": "Черновик пользовательской команды не найден.", "err_missing_ssh_draft": "Черновик команды SSH не найден.", + "err_ssh_port_positive": "Порт SSH должен быть больше 0.", + "err_missing_target_folder": "Целевая папка не найдена.", + "err_missing_parent_folder": "Родительская папка не найдена.", + "err_command_no_longer_exists": "Команда больше не существует.", "tpl_edit_tab_title": "Изменение: {title}", "tpl_launch_failed_title": "Не удалось запустить «{title}»", "tpl_launch_failed_body": "Команда: {command}\nОшибка: {error}", "tpl_terminal_init_failed": "Не удалось инициализировать вкладку терминала: {error}", - "tpl_palette_fallback_label": "Цвет {index}" + "tpl_palette_fallback_label": "Цвет {index}", + "tpl_ssh_connection_failed": "Не удалось установить SSH-соединение: {error}", + "tpl_working_directory_not_found": "Рабочий каталог не найден: {path}", + "tpl_working_directory_not_directory": "Рабочий путь не является каталогом: {path}", + "tpl_identity_file_not_found": "Файл идентификации не найден: {path}", + "tpl_identity_file_not_file": "Путь к файлу идентификации не является файлом: {path}", + "tpl_program_not_found_in_path": "Программа не найдена в PATH: {program}", + "tpl_program_not_found": "Программа не найдена: {program}", + "tpl_program_is_directory": "Программа является каталогом: {program}", + "tpl_program_not_executable": "Программа не является исполняемой: {program}" } } diff --git a/app/src/i18n/locales/zh-CN.json b/app/src/i18n/locales/zh-CN.json index 6649d99..2f31235 100644 --- a/app/src/i18n/locales/zh-CN.json +++ b/app/src/i18n/locales/zh-CN.json @@ -117,11 +117,24 @@ "err_invalid_port": "端口必须是数字。", "err_missing_custom_draft": "自定义命令草稿缺失。", "err_missing_ssh_draft": "SSH 命令草稿缺失。", + "err_ssh_port_positive": "SSH 端口必须大于 0。", + "err_missing_target_folder": "找不到目标文件夹。", + "err_missing_parent_folder": "找不到父文件夹。", + "err_command_no_longer_exists": "命令已不存在。", "tpl_edit_tab_title": "编辑:{title}", "tpl_launch_failed_title": "启动“{title}”失败", "tpl_launch_failed_body": "命令:{command}\n错误:{error}", "tpl_terminal_init_failed": "终端标签页初始化失败:{error}", - "tpl_palette_fallback_label": "颜色 {index}" + "tpl_palette_fallback_label": "颜色 {index}", + "tpl_ssh_connection_failed": "SSH 连接失败:{error}", + "tpl_working_directory_not_found": "找不到工作目录:{path}", + "tpl_working_directory_not_directory": "工作目录路径不是目录:{path}", + "tpl_identity_file_not_found": "找不到身份文件:{path}", + "tpl_identity_file_not_file": "身份文件路径不是文件:{path}", + "tpl_program_not_found_in_path": "在 PATH 中找不到程序:{program}", + "tpl_program_not_found": "找不到程序:{program}", + "tpl_program_is_directory": "程序路径是目录:{program}", + "tpl_program_not_executable": "程序不可执行:{program}" } } diff --git a/app/src/i18n/locales/zh-TW.json b/app/src/i18n/locales/zh-TW.json index eb73054..23a4f86 100644 --- a/app/src/i18n/locales/zh-TW.json +++ b/app/src/i18n/locales/zh-TW.json @@ -117,11 +117,24 @@ "err_invalid_port": "連接埠必須是數字。", "err_missing_custom_draft": "找不到自訂指令草稿。", "err_missing_ssh_draft": "找不到 SSH 指令草稿。", + "err_ssh_port_positive": "SSH 連接埠必須大於 0。", + "err_missing_target_folder": "找不到目標資料夾。", + "err_missing_parent_folder": "找不到上層資料夾。", + "err_command_no_longer_exists": "指令已不存在。", "tpl_edit_tab_title": "編輯:{title}", "tpl_launch_failed_title": "啟動「{title}」失敗", "tpl_launch_failed_body": "指令:{command}\n錯誤:{error}", "tpl_terminal_init_failed": "終端機分頁初始化失敗:{error}", - "tpl_palette_fallback_label": "色彩 {index}" + "tpl_palette_fallback_label": "色彩 {index}", + "tpl_ssh_connection_failed": "SSH 連線失敗:{error}", + "tpl_working_directory_not_found": "找不到工作目錄:{path}", + "tpl_working_directory_not_directory": "工作目錄路徑不是目錄:{path}", + "tpl_identity_file_not_found": "找不到身分檔案:{path}", + "tpl_identity_file_not_file": "身分檔案路徑不是檔案:{path}", + "tpl_program_not_found_in_path": "在 PATH 中找不到程式:{program}", + "tpl_program_not_found": "找不到程式:{program}", + "tpl_program_is_directory": "程式路徑是目錄:{program}", + "tpl_program_not_executable": "程式無法執行:{program}" } } diff --git a/app/src/i18n/mod.rs b/app/src/i18n/mod.rs index c229db5..add8a0f 100644 --- a/app/src/i18n/mod.rs +++ b/app/src/i18n/mod.rs @@ -131,13 +131,19 @@ impl Locale { .to_ascii_lowercase() .replace('_', "-"); - // Chinese splits by script rather than by primary subtag, so the - // traditional-script regions and an explicit `Hant` map to zh-TW. - if normalized.starts_with("zh") { - let traditional = normalized.contains("hant") - || normalized.contains("-tw") - || normalized.contains("-hk") - || normalized.contains("-mo"); + // Chinese splits by script rather than by primary subtag. An explicit + // script wins; otherwise traditional-script regions map to zh-TW. + if normalized.split('-').next() == Some("zh") { + if normalized.split('-').any(|subtag| subtag == "hans") { + return Self::ZhCn; + } + if normalized.split('-').any(|subtag| subtag == "hant") { + return Self::ZhTw; + } + + let traditional = normalized + .split('-') + .any(|subtag| matches!(subtag, "tw" | "hk" | "mo")); return if traditional { Self::ZhTw } else { Self::ZhCn }; } @@ -208,21 +214,10 @@ pub(crate) fn set_locale(locale: Locale) { CURRENT_LOCALE.store(locale.index() as u8, Ordering::Relaxed); } -/// Detect the preferred locale from the environment. -/// -/// Checks the POSIX locale variables in the order the C library uses; an unset -/// or unrecognized environment yields [`Locale::En`]. +/// Detect the preferred locale through the current platform's native source. pub(crate) fn detect_system_locale() -> Locale { - for name in ["LC_ALL", "LC_MESSAGES", "LANG"] { - let Ok(value) = std::env::var(name) else { - continue; - }; - if !value.is_empty() { - return Locale::from_tag(&value); - } - } - - Locale::En + let preference = sys_locale::get_locale(); + locale_from_system_preference(preference.as_deref()) } /// Return the translation of `key` in the active locale. @@ -271,6 +266,87 @@ pub(crate) fn terminal_init_failed(error: &str) -> String { ) } +/// Build the message shown when an SSH connection cannot be established. +pub(crate) fn ssh_connection_failed(error: &str) -> String { + fill_in( + current_locale(), + Key::TplSshConnectionFailed, + &[("error", error)], + ) +} + +/// Build the message shown when a working directory cannot be found. +pub(crate) fn working_directory_not_found(path: &str) -> String { + fill_in( + current_locale(), + Key::TplWorkingDirectoryNotFound, + &[("path", path)], + ) +} + +/// Build the message shown when a working path is not a directory. +pub(crate) fn working_directory_not_directory(path: &str) -> String { + fill_in( + current_locale(), + Key::TplWorkingDirectoryNotDirectory, + &[("path", path)], + ) +} + +/// Build the message shown when an SSH identity file cannot be found. +pub(crate) fn identity_file_not_found(path: &str) -> String { + fill_in( + current_locale(), + Key::TplIdentityFileNotFound, + &[("path", path)], + ) +} + +/// Build the message shown when an SSH identity path is not a file. +pub(crate) fn identity_file_not_file(path: &str) -> String { + fill_in( + current_locale(), + Key::TplIdentityFileNotFile, + &[("path", path)], + ) +} + +/// Build the message shown when a program cannot be found in `PATH`. +pub(crate) fn program_not_found_in_path(program: &str) -> String { + fill_in( + current_locale(), + Key::TplProgramNotFoundInPath, + &[("program", program)], + ) +} + +/// Build the message shown when an explicit program path cannot be found. +pub(crate) fn program_not_found(program: &str) -> String { + fill_in( + current_locale(), + Key::TplProgramNotFound, + &[("program", program)], + ) +} + +/// Build the message shown when a program path is a directory. +pub(crate) fn program_is_directory(program: &str) -> String { + fill_in( + current_locale(), + Key::TplProgramIsDirectory, + &[("program", program)], + ) +} + +/// Build the message shown when a program path is not executable. +pub(crate) fn program_not_executable(program: &str) -> String { + fill_in( + current_locale(), + Key::TplProgramNotExecutable, + &[("program", program)], + ) +} + /// Return the translation of `key` in `locale`. /// /// Falls back to English when a key is absent, so a partial catalog degrades @@ -286,6 +362,10 @@ fn text_in(locale: Locale, key: Key) -> &'static str { .map_or("", String::as_str) } +fn locale_from_system_preference(preference: Option<&str>) -> Locale { + preference.map_or(Locale::En, Locale::from_tag) +} + /// Return the label for the palette color at `index` in `locale`. fn palette_label_in(locale: Locale, index: usize) -> String { if let Some(label) = catalog(locale).palette_labels.get(index) { @@ -302,11 +382,34 @@ fn palette_label_in(locale: Locale, index: usize) -> String { /// Substitute `{name}` placeholders in the `locale` template behind `key`. fn fill_in(locale: Locale, key: Key, arguments: &[(&str, &str)]) -> String { - let mut text = text_in(locale, key).to_string(); - for (name, value) in arguments { - text = text.replace(&format!("{{{name}}}"), value); + let template = text_in(locale, key); + let mut remaining = template; + let mut text = String::with_capacity(template.len()); + + while let Some((before, after_open)) = remaining.split_once('{') { + text.push_str(before); + + let Some((name, after_close)) = after_open.split_once('}') else { + text.push('{'); + text.push_str(after_open); + return text; + }; + + if let Some((_, value)) = + arguments.iter().find(|(argument, _)| *argument == name) + { + text.push_str(value); + } else { + text.push('{'); + text.push_str(name); + text.push('}'); + } + + remaining = after_close; } + text.push_str(remaining); + text } @@ -328,7 +431,7 @@ fn catalog(locale: Locale) -> &'static Catalog { mod tests { use super::{ Catalog, Key, Locale, PALETTE_LABEL_COUNT, catalog, fill_in, - palette_label_in, text_in, + locale_from_system_preference, palette_label_in, text_in, }; #[test] @@ -373,10 +476,12 @@ mod tests { fn given_chinese_tags_when_parsed_then_script_selects_the_variant() { assert_eq!(Locale::from_tag("zh_CN.UTF-8"), Locale::ZhCn); assert_eq!(Locale::from_tag("zh-Hans"), Locale::ZhCn); + assert_eq!(Locale::from_tag("zh-Hans_TW"), Locale::ZhCn); assert_eq!(Locale::from_tag("zh"), Locale::ZhCn); assert_eq!(Locale::from_tag("ZH-TW"), Locale::ZhTw); assert_eq!(Locale::from_tag("zh_HK"), Locale::ZhTw); assert_eq!(Locale::from_tag("zh-Hant"), Locale::ZhTw); + assert_eq!(Locale::from_tag("zh-Hant_CN"), Locale::ZhTw); } #[test] @@ -398,6 +503,16 @@ mod tests { assert_eq!(Locale::from_tag(""), Locale::En); } + #[test] + fn given_platform_preference_when_system_locale_resolved_then_platform_value_is_used() + { + assert_eq!( + locale_from_system_preference(Some("zh-Hans_TW")), + Locale::ZhCn + ); + assert_eq!(locale_from_system_preference(None), Locale::En); + } + #[test] fn given_locales_when_indexed_then_positions_match_all_order() { for (position, locale) in Locale::ALL.into_iter().enumerate() { @@ -501,6 +616,48 @@ mod tests { ); } + #[test] + fn given_placeholder_text_in_argument_when_filled_then_argument_remains_verbatim() + { + assert_eq!( + fill_in( + Locale::En, + Key::TplLaunchFailedBody, + &[("command", "deploy {error}"), ("error", "boom")] + ), + "Command: deploy {error}\nError: boom" + ); + } + + #[test] + fn given_quick_launch_diagnostics_when_translated_then_visible_text_is_localized() + { + assert_eq!( + text_in(Locale::ZhCn, Key::ErrSshPortPositive), + "SSH 端口必须大于 0。" + ); + assert_eq!( + text_in(Locale::ZhCn, Key::ErrMissingTargetFolder), + "找不到目标文件夹。" + ); + assert_eq!( + fill_in( + Locale::ZhCn, + Key::TplWorkingDirectoryNotFound, + &[("path", "/tmp/project")] + ), + "找不到工作目录:/tmp/project" + ); + assert_eq!( + fill_in( + Locale::ZhCn, + Key::TplProgramNotExecutable, + &[("program", "deploy")] + ), + "程序不可执行:deploy" + ); + } + #[test] fn given_palette_index_when_labelled_then_named_and_fallback_labels_apply() { @@ -519,12 +676,21 @@ mod tests { #[test] fn given_every_locale_when_templates_read_then_placeholders_survive() { - let required: [(Key, &[&str]); 5] = [ + let required: [(Key, &[&str]); 14] = [ (Key::TplEditTabTitle, &["{title}"]), (Key::TplLaunchFailedTitle, &["{title}"]), (Key::TplLaunchFailedBody, &["{command}", "{error}"]), (Key::TplTerminalInitFailed, &["{error}"]), (Key::TplPaletteFallbackLabel, &["{index}"]), + (Key::TplSshConnectionFailed, &["{error}"]), + (Key::TplWorkingDirectoryNotFound, &["{path}"]), + (Key::TplWorkingDirectoryNotDirectory, &["{path}"]), + (Key::TplIdentityFileNotFound, &["{path}"]), + (Key::TplIdentityFileNotFile, &["{path}"]), + (Key::TplProgramNotFoundInPath, &["{program}"]), + (Key::TplProgramNotFound, &["{program}"]), + (Key::TplProgramIsDirectory, &["{program}"]), + (Key::TplProgramNotExecutable, &["{program}"]), ]; for locale in Locale::ALL { diff --git a/app/src/widgets/quick_launch/errors.rs b/app/src/widgets/quick_launch/errors.rs index a18e8e0..a7bac95 100644 --- a/app/src/widgets/quick_launch/errors.rs +++ b/app/src/widgets/quick_launch/errors.rs @@ -13,8 +13,42 @@ pub(crate) enum QuickLaunchError { TitleEmpty, #[error("{}", i18n::t(Key::ErrTitleDuplicate))] TitleDuplicate, - #[error("{message}")] - Validation { message: String }, + /// A custom command has no program. + #[error("{}", i18n::t(Key::ErrProgramRequired))] + ProgramRequired, + /// An SSH command has no host. + #[error("{}", i18n::t(Key::ErrHostRequired))] + HostRequired, + /// An SSH command uses port zero. + #[error("{}", i18n::t(Key::ErrSshPortPositive))] + SshPortNotPositive, + /// An SSH connection attempt failed. + #[error("{}", i18n::ssh_connection_failed(.0))] + SshConnectionFailed(String), + /// A configured working directory does not exist. + #[error("{}", i18n::working_directory_not_found(.0))] + WorkingDirectoryNotFound(String), + /// A configured working path is not a directory. + #[error("{}", i18n::working_directory_not_directory(.0))] + WorkingDirectoryNotDirectory(String), + /// A configured SSH identity file does not exist. + #[error("{}", i18n::identity_file_not_found(.0))] + IdentityFileNotFound(String), + /// A configured SSH identity path is not a file. + #[error("{}", i18n::identity_file_not_file(.0))] + IdentityFileNotFile(String), + /// A program name could not be resolved through `PATH`. + #[error("{}", i18n::program_not_found_in_path(.0))] + ProgramNotFoundInPath(String), + /// An explicit program path does not exist. + #[error("{}", i18n::program_not_found(.0))] + ProgramNotFound(String), + /// An explicit program path points to a directory. + #[error("{}", i18n::program_is_directory(.0))] + ProgramIsDirectory(String), + /// An explicit program path is not executable. + #[error("{}", i18n::program_not_executable(.0))] + ProgramNotExecutable(String), } /// Errors emitted by quick launch wizard validation. diff --git a/app/src/widgets/quick_launch/reducer.rs b/app/src/widgets/quick_launch/reducer.rs index 25da4c7..2826fda 100644 --- a/app/src/widgets/quick_launch/reducer.rs +++ b/app/src/widgets/quick_launch/reducer.rs @@ -19,7 +19,7 @@ use super::types::{ QuickLaunchFolder, QuickLaunchNode, QuickLaunchSetupOutcome, QuickLaunchWizardSaveRequest, QuickLaunchWizardSaveTarget, WizardMode, }; -use crate::i18n; +use crate::i18n::{self, Key}; /// Runtime context for the quick launch reducer. pub(crate) struct QuickLaunchCtx<'a> { @@ -676,7 +676,11 @@ fn apply_editor_save_request( match request.target { QuickLaunchWizardSaveTarget::Create { parent_path } => { let Some(parent) = state.data_mut().folder_mut(&parent_path) else { - set_wizard_error(state, tab_id, "Missing target folder."); + set_wizard_error( + state, + tab_id, + i18n::t(Key::ErrMissingTargetFolder), + ); return Task::none(); }; let title = @@ -695,7 +699,11 @@ fn apply_editor_save_request( { let Some(parent) = state.data_mut().parent_folder_mut(&path) else { - set_wizard_error(state, tab_id, "Missing parent folder."); + set_wizard_error( + state, + tab_id, + i18n::t(Key::ErrMissingParentFolder), + ); return Task::none(); }; let current = path.last().map(String::as_str); @@ -707,7 +715,11 @@ fn apply_editor_save_request( } } let Some(node) = state.data_mut().node_mut(&path) else { - set_wizard_error(state, tab_id, "Command no longer exists."); + set_wizard_error( + state, + tab_id, + i18n::t(Key::ErrCommandNoLongerExists), + ); return Task::none(); }; *node = QuickLaunchNode::Command(request.command); diff --git a/app/src/widgets/quick_launch/services.rs b/app/src/widgets/quick_launch/services.rs index c4635e1..158e394 100644 --- a/app/src/widgets/quick_launch/services.rs +++ b/app/src/widgets/quick_launch/services.rs @@ -78,23 +78,17 @@ fn validate_command(command: &QuickLaunch) -> Result<(), QuickLaunchError> { match command.spec() { CommandSpec::Custom { custom } => { if custom.program().trim().is_empty() { - return Err(QuickLaunchError::Validation { - message: String::from("Program path is empty."), - }); + return Err(QuickLaunchError::ProgramRequired); } validate_custom_runtime(custom)?; }, CommandSpec::Ssh { ssh } => { if ssh.host().trim().is_empty() { - return Err(QuickLaunchError::Validation { - message: String::from("SSH host is empty."), - }); + return Err(QuickLaunchError::HostRequired); } if ssh.port() == 0 { - return Err(QuickLaunchError::Validation { - message: String::from("SSH port must be greater than 0."), - }); + return Err(QuickLaunchError::SshPortNotPositive); } validate_ssh_runtime(ssh)?; @@ -131,12 +125,9 @@ fn probe_ssh_session( builder = builder.with_cancel_token(cancel_token.clone()); } - let mut session = - builder - .spawn() - .map_err(|err| QuickLaunchError::Validation { - message: format!("SSH connection failed: {err}"), - })?; + let mut session = builder.spawn().map_err(|err| { + QuickLaunchError::SshConnectionFailed(format!("{err}")) + })?; let _ = session.close(); Ok(()) } @@ -212,17 +203,13 @@ fn validate_custom_runtime( let path = Path::new(&expanded); if !path.exists() { - return Err(QuickLaunchError::Validation { - message: format!("Working directory not found: {expanded}"), - }); + return Err(QuickLaunchError::WorkingDirectoryNotFound(expanded)); } if !path.is_dir() { - return Err(QuickLaunchError::Validation { - message: format!( - "Working directory is not a directory: {expanded}" - ), - }); + return Err(QuickLaunchError::WorkingDirectoryNotDirectory( + expanded, + )); } } @@ -239,15 +226,11 @@ fn validate_ssh_runtime( let path = Path::new(&expanded); if !path.exists() { - return Err(QuickLaunchError::Validation { - message: format!("Identity file not found: {expanded}"), - }); + return Err(QuickLaunchError::IdentityFileNotFound(expanded)); } if !path.is_file() { - return Err(QuickLaunchError::Validation { - message: format!("Identity file is not a file: {expanded}"), - }); + return Err(QuickLaunchError::IdentityFileNotFile(expanded)); } } } @@ -277,9 +260,7 @@ fn find_program_path(program: &str) -> Result { } } - Err(QuickLaunchError::Validation { - message: format!("Program not found in PATH: {program}"), - }) + Err(QuickLaunchError::ProgramNotFoundInPath(program.to_string())) } fn validate_program_path( @@ -287,21 +268,15 @@ fn validate_program_path( label: &str, ) -> Result { if !path.exists() { - return Err(QuickLaunchError::Validation { - message: format!("Program not found: {label}"), - }); + return Err(QuickLaunchError::ProgramNotFound(label.to_string())); } if path.is_dir() { - return Err(QuickLaunchError::Validation { - message: format!("Program is a directory: {label}"), - }); + return Err(QuickLaunchError::ProgramIsDirectory(label.to_string())); } if !is_executable_path(path) { - return Err(QuickLaunchError::Validation { - message: format!("Program is not executable: {label}"), - }); + return Err(QuickLaunchError::ProgramNotExecutable(label.to_string())); } Ok(path.to_path_buf()) @@ -454,7 +429,10 @@ mod tests { }, }, }; - assert!(validate_command(&cmd).is_err()); + assert!(matches!( + validate_command(&cmd), + Err(QuickLaunchError::ProgramRequired) + )); } #[test] @@ -487,7 +465,31 @@ mod tests { }, }, }; - assert!(validate_command(&cmd).is_err()); + assert!(matches!( + validate_command(&cmd), + Err(QuickLaunchError::HostRequired) + )); + } + + #[test] + fn given_zero_ssh_port_when_validating_then_positive_port_error_returned() { + let cmd = QuickLaunch { + title: String::from("SSH"), + spec: CommandSpec::Ssh { + ssh: SshCommand { + host: String::from("example.com"), + port: 0, + user: None, + identity_file: None, + extra_args: Vec::new(), + }, + }, + }; + + assert!(matches!( + validate_command(&cmd), + Err(QuickLaunchError::SshPortNotPositive) + )); } #[test] @@ -504,7 +506,10 @@ mod tests { }, }; - assert!(validate_command(&cmd).is_err()); + assert!(matches!( + validate_command(&cmd), + Err(QuickLaunchError::ProgramNotFoundInPath(_)) + )); } #[test] diff --git a/app/src/widgets/tabs/event.rs b/app/src/widgets/tabs/event.rs index d2392c5..e5cfc60 100644 --- a/app/src/widgets/tabs/event.rs +++ b/app/src/widgets/tabs/event.rs @@ -15,6 +15,10 @@ pub(crate) enum TabsIntent { tab_id: u64, title: String, }, + /// Refresh the title of an already-open settings tab. + RefreshSettingsTitle { + title: String, + }, OpenTerminalTab { title: String, }, diff --git a/app/src/widgets/tabs/reducer.rs b/app/src/widgets/tabs/reducer.rs index 83576ee..b868a2f 100644 --- a/app/src/widgets/tabs/reducer.rs +++ b/app/src/widgets/tabs/reducer.rs @@ -19,6 +19,10 @@ pub(crate) fn reduce( state.set_title(tab_id, title); Task::none() }, + TabsIntent::RefreshSettingsTitle { title } => { + state.set_settings_title(title); + Task::none() + }, TabsIntent::OpenTerminalTab { title } => { open_terminal_tab(state, title) }, @@ -268,6 +272,46 @@ mod tests { assert_eq!(state.active_tab_id(), Some(settings_id)); } + #[test] + fn refresh_settings_title_updates_only_the_settings_tab() { + let mut state = TabsState::default(); + let _ = reduce(&mut state, TabsIntent::OpenSettingsTab); + let settings_id = + state.active_tab_id().expect("settings tab should open"); + let _ = reduce( + &mut state, + TabsIntent::OpenTerminalTab { + title: String::from("shell"), + }, + ); + let terminal_id = + state.active_tab_id().expect("terminal tab should open"); + + let _ = reduce( + &mut state, + TabsIntent::RefreshSettingsTitle { + title: String::from("Einstellungen"), + }, + ); + + assert_eq!( + state + .tab_items() + .get(&settings_id) + .expect("settings tab should remain open") + .title(), + "Einstellungen" + ); + assert_eq!( + state + .tab_items() + .get(&terminal_id) + .expect("terminal tab should remain open") + .title(), + "shell" + ); + } + #[test] fn close_active_tab_activates_previous() { let mut state = TabsState::default(); diff --git a/app/src/widgets/tabs/state.rs b/app/src/widgets/tabs/state.rs index 6ba19a0..da07e60 100644 --- a/app/src/widgets/tabs/state.rs +++ b/app/src/widgets/tabs/state.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use super::types::TabItem; +use super::types::{TabContent, TabItem}; /// Runtime state for workspace tabs. #[derive(Default)] @@ -71,6 +71,17 @@ impl TabsState { } } + /// Update the title of the open settings tab, if present. + pub(super) fn set_settings_title(&mut self, title: String) { + if let Some(tab) = self + .tab_items + .values_mut() + .find(|tab| tab.content() == TabContent::Settings) + { + tab.set_title(title); + } + } + /// Return previous tab identifier before `tab_id`. pub(crate) fn previous_tab_id(&self, tab_id: u64) -> Option { self.tab_items From 9f9d0b00b74d5ed2225bcd30f8e5ff94ab8f9fab Mon Sep 17 00:00:00 2001 From: vam Date: Sun, 27 Sep 2026 20:27:33 +0800 Subject: [PATCH 5/5] fix(settings): keep General first in the settings navigation The settings tree is rendered through TreeView, which sorts sibling rows by title. That put Appearance before General in English and made the order depend on the active language. Add TreeView::keep_source_order() so callers with a fixed page list can render rows in the order they provide, use it for the settings navigation, and order the sections as General, Appearance, Terminal. --- app/src/widgets/settings/state.rs | 20 +++++ app/src/widgets/settings/types.rs | 4 +- .../widgets/settings/view/settings_form.rs | 3 +- crates/ui/tree/README.md | 4 + crates/ui/tree/src/model.rs | 37 +++++++- crates/ui/tree/src/view.rs | 86 ++++++++++++++++++- 6 files changed, 145 insertions(+), 9 deletions(-) diff --git a/app/src/widgets/settings/state.rs b/app/src/widgets/settings/state.rs index be10660..d4dbd64 100644 --- a/app/src/widgets/settings/state.rs +++ b/app/src/widgets/settings/state.rs @@ -384,6 +384,26 @@ mod tests { assert!(state.dirty); } + #[test] + fn given_default_state_when_tree_built_then_general_section_comes_first() { + let state = SettingsState::default(); + + let sections: Vec> = state + .tree() + .iter() + .map(|node| node.section_kind()) + .collect(); + + assert_eq!( + sections, + vec![ + Some(SettingsSection::General), + Some(SettingsSection::Appearance), + Some(SettingsSection::Terminal), + ] + ); + } + /// Read a section's tree path from the state so the assertion does not /// depend on which locale rendered the titles. fn section_path( diff --git a/app/src/widgets/settings/types.rs b/app/src/widgets/settings/types.rs index 56f5928..1ec5b49 100644 --- a/app/src/widgets/settings/types.rs +++ b/app/src/widgets/settings/types.rs @@ -283,14 +283,14 @@ impl SettingsData { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsSection { General, - Terminal, Appearance, + Terminal, } impl SettingsSection { /// All sections, in the order they appear in the navigation tree. pub(crate) const ALL: [Self; 3] = - [Self::General, Self::Terminal, Self::Appearance]; + [Self::General, Self::Appearance, Self::Terminal]; /// Human-readable section title in the active locale. /// diff --git a/app/src/widgets/settings/view/settings_form.rs b/app/src/widgets/settings/view/settings_form.rs index de67ef2..a967a8b 100644 --- a/app/src/widgets/settings/view/settings_form.rs +++ b/app/src/widgets/settings/view/settings_form.rs @@ -140,7 +140,8 @@ fn settings_nav_tree<'a>( .on_hover(|path| SettingsIntent::NodeHovered { path }) .row_style(move |context| nav_row_style(&row_palette, context)) .indent_size(NAV_INDENT) - .spacing(0.0); + .spacing(0.0) + .keep_source_order(); let scroll_palette = palette.clone(); let scrollable = scrollable::Scrollable::new(tree_view.view()) diff --git a/crates/ui/tree/README.md b/crates/ui/tree/README.md index d1ed0aa..713e73d 100644 --- a/crates/ui/tree/README.md +++ b/crates/ui/tree/README.md @@ -125,6 +125,10 @@ Full runnable example: `examples/tree_view.rs`. - folders first, then files - natural title ordering (numeric-aware and case-insensitive first) +`TreeView` uses this order by default. Call `keep_source_order()` to render rows +in the order the nodes are given instead, for example a fixed list of pages +whose localized titles would otherwise reorder them. + ## Path Model `TreePath` is built from `title()` values from root to row. diff --git a/crates/ui/tree/src/model.rs b/crates/ui/tree/src/model.rs index 1c16762..436bf4b 100644 --- a/crates/ui/tree/src/model.rs +++ b/crates/ui/tree/src/model.rs @@ -41,11 +41,25 @@ pub struct FlattenedNode<'a, T: TreeNode> { /// Children are included only when `node.is_folder() && node.expanded()`. pub fn flatten_tree<'a, T: TreeNode>( nodes: &'a [T], +) -> Vec> { + flatten_nodes(nodes, true) +} + +/// Flatten a tree like [`flatten_tree`], but keep siblings in input order. +pub(crate) fn flatten_tree_in_source_order<'a, T: TreeNode>( + nodes: &'a [T], +) -> Vec> { + flatten_nodes(nodes, false) +} + +fn flatten_nodes<'a, T: TreeNode>( + nodes: &'a [T], + sort_by_title: bool, ) -> Vec> { let mut entries = Vec::new(); let mut path = Vec::new(); - for index in sorted_indices(nodes) { - push_node(&nodes[index], 0, &mut path, &mut entries); + for index in sibling_order(nodes, sort_by_title) { + push_node(&nodes[index], 0, sort_by_title, &mut path, &mut entries); } entries } @@ -53,6 +67,7 @@ pub fn flatten_tree<'a, T: TreeNode>( fn push_node<'a, T: TreeNode>( node: &'a T, depth: usize, + sort_by_title: bool, path: &mut Vec, entries: &mut Vec>, ) { @@ -67,14 +82,28 @@ fn push_node<'a, T: TreeNode>( && node.expanded() && let Some(children) = node.children() { - for index in sorted_indices(children) { - push_node(&children[index], depth + 1, path, entries); + for index in sibling_order(children, sort_by_title) { + push_node( + &children[index], + depth + 1, + sort_by_title, + path, + entries, + ); } } path.pop(); } +fn sibling_order(nodes: &[T], sort_by_title: bool) -> Vec { + if sort_by_title { + sorted_indices(nodes) + } else { + (0..nodes.len()).collect() + } +} + fn sorted_indices(nodes: &[T]) -> Vec { let mut ordered: Vec = (0..nodes.len()).collect(); ordered.sort_by(|a, b| { diff --git a/crates/ui/tree/src/view.rs b/crates/ui/tree/src/view.rs index a5c26d7..535392f 100644 --- a/crates/ui/tree/src/view.rs +++ b/crates/ui/tree/src/view.rs @@ -1,7 +1,10 @@ use iced::widget::{Column, Row, Space, container, mouse_area}; use iced::{Element, Length, alignment, mouse}; -use crate::model::{FlattenedNode, TreeNode, TreePath, flatten_tree}; +use crate::model::{ + FlattenedNode, TreeNode, TreePath, flatten_tree, + flatten_tree_in_source_order, +}; /// Flattened tree row used by [`TreeView`] render callbacks. pub type TreeRow<'a, T> = FlattenedNode<'a, T>; @@ -49,6 +52,7 @@ pub struct TreeView<'a, T: TreeNode, Message: Clone + 'a> { after_row: Option>>, spacing: f32, indent_size: f32, + keep_source_order: bool, } impl<'a, T, Message> TreeView<'a, T, Message> @@ -78,6 +82,7 @@ where after_row: None, spacing: 0.0, indent_size: 0.0, + keep_source_order: false, } } @@ -226,6 +231,16 @@ where self } + /// Render sibling rows in the order the caller provides them. + /// + /// By default rows are sorted by title (folders first), which suits file + /// trees. Use this when the order is meaningful, such as a fixed list of + /// pages whose localized titles would otherwise reorder them. + pub fn keep_source_order(mut self) -> Self { + self.keep_source_order = true; + self + } + /// Build the tree widget as an `iced::Element`. /// /// If [`TreeView::on_hover`] is configured, the resulting tree also emits @@ -234,7 +249,13 @@ where let mut column = Column::new().spacing(self.spacing).width(Length::Fill); - for entry in flatten_tree(self.nodes) { + let entries = if self.keep_source_order { + flatten_tree_in_source_order(self.nodes) + } else { + flatten_tree(self.nodes) + }; + + for entry in entries { let is_selected = self .selected .map(|path| path == &entry.path) @@ -582,6 +603,67 @@ mod tests { assert_eq!(*pressed.borrow(), vec![path(&["leaf"])]); } + #[test] + fn rows_are_sorted_by_title_by_default() { + let nodes = + vec![TestNode::file("general"), TestNode::file("appearance")]; + + let rendered = rendered_paths(TreeView::::new( + &nodes, + |_| Space::new().into(), + )); + + assert_eq!(rendered, vec![path(&["appearance"]), path(&["general"])]); + } + + #[test] + fn keep_source_order_renders_rows_in_input_order() { + let nodes = vec![ + TestNode::file("general"), + TestNode::folder( + "terminal", + true, + vec![TestNode::file("shell"), TestNode::file("editor")], + ), + TestNode::file("appearance"), + ]; + + let rendered = rendered_paths( + TreeView::::new(&nodes, |_| { + Space::new().into() + }) + .keep_source_order(), + ); + + assert_eq!( + rendered, + vec![ + path(&["general"]), + path(&["terminal"]), + path(&["terminal", "shell"]), + path(&["terminal", "editor"]), + path(&["appearance"]), + ] + ); + } + + /// Render `view` and return the row paths in on-screen order. + fn rendered_paths( + view: TreeView<'_, TestNode, TestMessage>, + ) -> Vec { + let seen = Rc::new(RefCell::new(Vec::new())); + let seen_for_row = Rc::clone(&seen); + + let _ = view + .before_row(move |context| { + seen_for_row.borrow_mut().push(context.entry.path.clone()); + None + }) + .view(); + + seen.take() + } + #[test] fn row_visibility_and_decorators_are_applied_per_entry() { let nodes = mixed_tree();