diff --git a/assets/locales/en.toml b/assets/locales/en.toml new file mode 100644 index 0000000..6b66b21 --- /dev/null +++ b/assets/locales/en.toml @@ -0,0 +1,52 @@ +# English translations for Ropy + +# Application +app_name = "Ropy" +app_description = "A clipboard manager built with Rust and GPUI" + +# Tray menu +tray_show = "Show" +tray_quit = "Quit" + +# Main window +search_placeholder = "Use / to search ... " +no_records = "No clipboard records" +clear_all = "Clear All" +pin = "Pin" +unpin = "Unpin" + +# Settings +settings_title = "Ropy Settings" +settings_back = "←" +settings_cancel = "Cancel" +settings_save = "Save" + +# Settings sections +settings_language = "Language" +settings_language_description = "Select your preferred language" + +settings_theme = "Theme" +settings_theme_light = "Light" +settings_theme_dark = "Dark" +settings_theme_system = "System" + +settings_hotkey = "Hotkey Configuration" +settings_activation_key = "Activation Key" + +settings_storage = "Storage Configuration" +settings_max_history = "Max History Records" + +settings_system = "System" +settings_autostart = "Launch at system startup" +settings_autostart_on = "ON" +settings_autostart_off = "OFF" + +# Content types +content_type_text = "Text" +content_type_image = "Image" +content_type_file = "File" + +# Messages +message_copied = "Copied to clipboard" +message_deleted = "Record deleted" +message_cleared = "History cleared" diff --git a/assets/locales/zh-CN.toml b/assets/locales/zh-CN.toml new file mode 100644 index 0000000..4951035 --- /dev/null +++ b/assets/locales/zh-CN.toml @@ -0,0 +1,52 @@ +# 简体中文翻译 + +# 应用程序 +app_name = "Ropy" +app_description = "基于 Rust 和 GPUI 构建的剪贴板管理器" + +# 托盘菜单 +tray_show = "显示" +tray_quit = "退出" + +# 主窗口 +search_placeholder = "使用 / 搜索 ... " +no_records = "无剪贴板记录" +clear_all = "清空全部" +pin = "固定" +unpin = "取消固定" + +# 设置 +settings_title = "Ropy 设置" +settings_back = "←" +settings_cancel = "取消" +settings_save = "保存" + +# 设置选项 +settings_language = "语言" +settings_language_description = "选择您偏好的语言" + +settings_theme = "主题" +settings_theme_light = "浅色" +settings_theme_dark = "深色" +settings_theme_system = "跟随系统" + +settings_hotkey = "快捷键配置" +settings_activation_key = "激活快捷键" + +settings_storage = "存储配置" +settings_max_history = "最大历史记录数" + +settings_system = "系统" +settings_autostart = "开机自动启动" +settings_autostart_on = "开启" +settings_autostart_off = "关闭" + +# 内容类型 +content_type_text = "文本" +content_type_image = "图片" +content_type_file = "文件" + +# 消息 +message_copied = "已复制到剪贴板" +message_deleted = "记录已删除" +message_cleared = "历史已清空" diff --git a/doc/Multi-Language.md b/doc/Multi-Language.md new file mode 100644 index 0000000..92f1fad --- /dev/null +++ b/doc/Multi-Language.md @@ -0,0 +1,50 @@ +# Multi-Language Support + +Ropy now supports multiple languages! Users can easily switch between different languages through the settings panel. + +## Supported Languages + +- **English** (en) +- **简体中文** (Simplified Chinese, zh-CN) + +## How to Use + +1. Open Ropy application +2. Click the **Settings** button (gear icon) in the top right corner +3. In the Settings panel, find the **Language** section +4. Click on your preferred language button +5. Click **Save** to apply the changes +6. The UI will immediately switch to the selected language + +## Adding New Languages + +To add a new language: + +1. Create a new TOML file in `assets/locales/` directory (e.g., `fr.toml` for French) +2. Copy the structure from `en.toml` and translate all strings +3. Update `src/i18n/mod.rs`: + - Add a new variant to the `Language` enum + - Update the `code()`, `display_name()`, and `all()` methods + - Add the new language to the `load_language()` match statement +4. Recompile the application + +## Translation File Format + +Translation files use TOML format with simple key-value pairs: + +```toml +# Application +app_name = "Ropy" +app_description = "A clipboard manager built with Rust and GPUI" + +# Tray menu +tray_show = "Show" +tray_quit = "Quit" +``` + +## Implementation Details + +- Translations are embedded into the binary at compile time using `include_str!()` for optimal performance +- The i18n system is lightweight and has minimal runtime overhead +- Language selection is persisted in the user's configuration file +- All UI strings are centralized in locale files for easy maintenance diff --git a/doc/TODO.md b/doc/TODO.md index b95ab54..8006aa5 100644 --- a/doc/TODO.md +++ b/doc/TODO.md @@ -1,7 +1,6 @@ # TODO - Import and export clipboard history -- Support for multiple languages - Support for html content type - Log files - Provide installation packages and distribution channels diff --git a/src/config/settings.rs b/src/config/settings.rs index 6450f73..eae2095 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,3 +1,4 @@ +use crate::i18n::Language; use config::{Config, ConfigError, File}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -13,6 +14,8 @@ pub struct Settings { pub theme: AppTheme, /// Auto-start configuration pub autostart: AutoStartSettings, + /// Language configuration + pub language: Language, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -67,6 +70,7 @@ impl Default for Settings { }, theme: AppTheme::System, autostart: AutoStartSettings { enabled: false }, + language: Language::default(), } } } diff --git a/src/gui/app.rs b/src/gui/app.rs index 834f1d1..e9242ac 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -225,7 +225,7 @@ pub fn launch_app() { copy_tx, ); setup_hotkey_listener(window_handle, async_app.clone()); - start_tray_handler(window_handle, async_app.clone()); + start_tray_handler(window_handle, async_app.clone(), settings.clone()); cx.activate(true); }); diff --git a/src/gui/board/mod.rs b/src/gui/board/mod.rs index d03c6c8..ad88a41 100644 --- a/src/gui/board/mod.rs +++ b/src/gui/board/mod.rs @@ -5,6 +5,7 @@ mod settings; use crate::clipboard::LastCopyState; use crate::config::Settings; use crate::gui::hide_window; +use crate::i18n::{I18n, Language}; use crate::repository::models::ContentType; use crate::repository::{ClipboardRecord, ClipboardRepository}; use gpui::{ @@ -41,6 +42,9 @@ pub struct RopyBoard { selected_theme: usize, // 0: Light, 1: Dark, 2: System autostart_enabled: bool, pinned: bool, + // I18n + i18n: I18n, + selected_language: usize, // Index into Language::all() } impl RopyBoard { @@ -69,7 +73,7 @@ impl RopyBoard { cx.new(|cx| InputState::new(window, cx).placeholder("Use / to search ... ")); let list_state = ListState::new(0, ListAlignment::Top, gpui::px(100.)); - let (max_history_records, activation_key, theme_index) = { + let (max_history_records, activation_key, theme_index, language) = { let settings_guard = settings.read().unwrap(); let theme_idx = match settings_guard.theme { crate::config::AppTheme::Light => 0, @@ -80,6 +84,7 @@ impl RopyBoard { settings_guard.storage.max_history_records, settings_guard.hotkey.activation_key.clone(), theme_idx, + settings_guard.language, ) }; let autostart_enabled = settings.read().unwrap().autostart.enabled; @@ -87,6 +92,13 @@ impl RopyBoard { cx.new(|cx| InputState::new(window, cx).placeholder(activation_key.to_string())); let settings_max_history_input = cx.new(|cx| InputState::new(window, cx).placeholder(max_history_records.to_string())); + + // Initialize I18n with the language from settings + let i18n = I18n::new(language).unwrap_or_default(); + let selected_language = Language::all() + .iter() + .position(|&lang| lang == language) + .unwrap_or(0); Self { records, @@ -106,6 +118,8 @@ impl RopyBoard { selected_theme: theme_index, autostart_enabled, pinned: false, + i18n, + selected_language, } } @@ -212,17 +226,30 @@ impl RopyBoard { _ => crate::config::AppTheme::System, }; + let language = Language::all().get(self.selected_language).copied().unwrap_or_default(); + { let mut settings = self.settings.write().unwrap(); settings.hotkey.activation_key = activation_key.clone(); settings.storage.max_history_records = max_history; settings.theme = theme.clone(); settings.autostart.enabled = self.autostart_enabled; + settings.language = language; if let Err(e) = settings.save() { eprintln!("[ropy] Failed to save settings: {e}"); } } + // Apply the new language + if let Err(e) = self.i18n.set_language(language) { + eprintln!("[ropy] Failed to set language: {e}"); + } + + // Update search placeholder with new language + self.search_input.update(cx, |input, cx| { + input.set_placeholder(self.i18n.t("search_placeholder"), window, cx); + }); + // Sync auto-start state with system if let Err(e) = self.sync_autostart_state() { eprintln!("[ropy] Failed to sync auto-start state: {e}"); @@ -291,7 +318,7 @@ impl Render for RopyBoard { .on_action(cx.listener(Self::on_select_next)) .on_action(cx.listener(Self::on_confirm_selection)) .on_key_down(cx.listener(Self::on_key_down)) - .child(render_header(self.pinned, cx)) + .child(render_header(self, cx)) .child(render_search_input(&self.search_input, cx)) .child(self.render_records_list(cx)) } diff --git a/src/gui/board/render.rs b/src/gui/board/render.rs index e9c8e20..161461e 100644 --- a/src/gui/board/render.rs +++ b/src/gui/board/render.rs @@ -40,11 +40,11 @@ fn get_hex_color(content: &str) -> Option { } /// Create the "Clear" button element -pub(super) fn create_clear_button(cx: &mut Context<'_, RopyBoard>) -> impl IntoElement { +pub(super) fn create_clear_button(board: &RopyBoard, cx: &mut Context<'_, RopyBoard>) -> impl IntoElement { Button::new("clear-button") .ghost() .icon(Icon::empty().path("clear-all.svg")) - .tooltip("Clear All") + .tooltip(board.i18n.t("clear_all")) .on_click(cx.listener(|this, _, _, _| { this.clear_history(); this.clear_last_copy_state(); @@ -65,8 +65,13 @@ pub(super) fn format_clipboard_content(record: &ClipboardRecord) -> String { } /// Render the header section with title and settings/clear buttons -pub fn render_header(pinned: bool, cx: &mut Context<'_, RopyBoard>) -> impl IntoElement { - let is_pinned = pinned; +pub fn render_header(board: &RopyBoard, cx: &mut Context<'_, RopyBoard>) -> impl IntoElement { + let is_pinned = board.pinned; + let pin_tooltip = if is_pinned { + board.i18n.t("unpin") + } else { + board.i18n.t("pin") + }; let header = h_flex().justify_between().items_center().mb_4().pt_4(); #[cfg(target_os = "windows")] @@ -80,7 +85,7 @@ pub fn render_header(pinned: bool, cx: &mut Context<'_, RopyBoard>) -> impl Into .text_lg() .text_color(cx.theme().foreground) .font_weight(gpui::FontWeight::BOLD) - .child("Ropy"), + .child(board.i18n.t("app_name")), ) .child( h_flex() @@ -93,7 +98,7 @@ pub fn render_header(pinned: bool, cx: &mut Context<'_, RopyBoard>) -> impl Into Button::new("pin-button").ghost() } .icon(Icon::empty().path("pin-to-top.svg")) - .tooltip("Pin to top") + .tooltip(pin_tooltip) .on_click(cx.listener(|this, _, window, cx| { this.pinned = !this.pinned; set_always_on_top(window, cx, this.pinned); @@ -116,7 +121,7 @@ pub fn render_header(pinned: bool, cx: &mut Context<'_, RopyBoard>) -> impl Into })) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()), ) - .child(create_clear_button(cx)), + .child(create_clear_button(board, cx)), ) } diff --git a/src/gui/board/settings.rs b/src/gui/board/settings.rs index 1f12476..21bb61e 100644 --- a/src/gui/board/settings.rs +++ b/src/gui/board/settings.rs @@ -1,3 +1,4 @@ +use crate::i18n::{I18n, Language}; use gpui::{ Context, div, prelude::{InteractiveElement, IntoElement, ParentElement, Styled}, @@ -11,20 +12,59 @@ use super::RopyBoard; #[cfg(target_os = "windows")] use crate::gui::utils::start_window_drag; +/// Render language selection buttons +/// Note: Uses index-based selection from Language::all() which maintains a stable order. +/// The order is: English, ChineseSimplified +fn render_language_selector(board: &mut RopyBoard, cx: &mut Context) -> impl IntoElement { + let languages = Language::all(); + + h_flex() + .gap_2() + .items_center() + .children(languages.iter().enumerate().map(|(index, lang)| { + let is_selected = board.selected_language == index; + let lang_copy = *lang; // Copy the language for the closure + + let mut button = Button::new(("language-button", index)) + .small() + .label(lang.display_name()); + + button = if is_selected { + button.primary() + } else { + button.ghost() + }; + + button.on_click(cx.listener(move |board, _, window, cx| { + board.selected_language = index; + // Update search placeholder immediately for instant feedback + if let Ok(temp_i18n) = I18n::new(lang_copy) { + board.search_input.update(cx, |input, cx| { + input.set_placeholder(temp_i18n.t("search_placeholder"), window, cx); + }); + } + cx.notify(); + })) + })) +} + /// Render theme selection buttons fn render_theme_selector(board: &mut RopyBoard, cx: &mut Context) -> impl IntoElement { - let themes = [("Light", 0), ("Dark", 1), ("System", 2)]; + let theme_names = vec![ + board.i18n.t("settings_theme_light"), + board.i18n.t("settings_theme_dark"), + board.i18n.t("settings_theme_system"), + ]; h_flex() .gap_2() .items_center() - .children(themes.iter().map(|(name, index)| { - let is_selected = board.selected_theme == *index; - let index_val = *index; + .children(theme_names.into_iter().enumerate().map(|(index, name)| { + let is_selected = board.selected_theme == index; - let mut button = Button::new(("theme-button", index_val)) + let mut button = Button::new(("theme-button", index)) .small() - .label(*name); + .label(name); button = if is_selected { button.primary() @@ -33,7 +73,7 @@ fn render_theme_selector(board: &mut RopyBoard, cx: &mut Context) -> }; button.on_click(cx.listener(move |board, _, _window, cx| { - board.selected_theme = index_val; + board.selected_theme = index; cx.notify(); })) })) @@ -52,7 +92,7 @@ pub(super) fn render_settings_content( Button::new("cancel-button") .small() .ghost() - .label("Cancel") + .label(board.i18n.t("settings_cancel")) .on_click(cx.listener(|board, _, window, cx| { // Clear input fields board.settings_max_history_input.update(cx, |input, cx| { @@ -70,7 +110,7 @@ pub(super) fn render_settings_content( .child( Button::new("save-button") .small() - .label("Save") + .label(board.i18n.t("settings_save")) .on_click(cx.listener(|board, _, window, cx| { board.save_settings(cx, window); })), @@ -82,7 +122,7 @@ pub(super) fn render_settings_content( .child( div() .text_color(cx.theme().foreground) - .child("Max History Records"), + .child(board.i18n.t("settings_max_history")), ) .child( Input::new(&board.settings_max_history_input) @@ -100,7 +140,7 @@ pub(super) fn render_settings_content( div() .text_xs() .text_color(cx.theme().foreground) - .child("Activation Key"), + .child(board.i18n.t("settings_activation_key")), ) .child( Input::new(&board.settings_activation_key_input) @@ -119,9 +159,21 @@ pub(super) fn render_settings_content( .text_sm() .text_color(cx.theme().muted_foreground) .font_weight(gpui::FontWeight::BOLD) - .child("Hotkey Configuration"), + .child(board.i18n.t("settings_hotkey")), ) .child(activation_key_label); + + let language_section = v_flex() + .gap_2() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .font_weight(gpui::FontWeight::BOLD) + .child(board.i18n.t("settings_language")), + ) + .child(render_language_selector(board, cx)); + let theme_section = v_flex() .gap_2() .child( @@ -129,7 +181,7 @@ pub(super) fn render_settings_content( .text_sm() .text_color(cx.theme().muted_foreground) .font_weight(gpui::FontWeight::BOLD) - .child("Theme"), + .child(board.i18n.t("settings_theme")), ) .child(render_theme_selector(board, cx)); let storage_section = v_flex() @@ -139,7 +191,7 @@ pub(super) fn render_settings_content( .text_sm() .text_color(cx.theme().muted_foreground) .font_weight(gpui::FontWeight::BOLD) - .child("Storage Configuration"), + .child(board.i18n.t("settings_storage")), ) .child(max_history_input_field); let autostart_section = v_flex() @@ -149,7 +201,7 @@ pub(super) fn render_settings_content( .text_sm() .text_color(cx.theme().muted_foreground) .font_weight(gpui::FontWeight::BOLD) - .child("System"), + .child(board.i18n.t("settings_system")), ) .child( h_flex() @@ -158,15 +210,15 @@ pub(super) fn render_settings_content( .child( div() .text_color(cx.theme().foreground) - .child("Launch at system startup"), + .child(board.i18n.t("settings_autostart")), ) .child({ let mut button = Button::new("autostart-toggle").small(); button = if board.autostart_enabled { - button.primary().label("ON") + button.primary().label(board.i18n.t("settings_autostart_on")) } else { - button.ghost().label("OFF") + button.ghost().label(board.i18n.t("settings_autostart_off")) }; button.on_click(cx.listener(|board, _, _, cx| { @@ -183,7 +235,7 @@ pub(super) fn render_settings_content( Button::new("back-button") .small() .ghost() - .label("←") + .label(board.i18n.t("settings_back")) .on_click(cx.listener(|board, _, window, cx| { board.show_settings = false; window.focus(&board.focus_handle); @@ -196,7 +248,7 @@ pub(super) fn render_settings_content( .text_lg() .text_color(cx.theme().foreground) .font_weight(gpui::FontWeight::BOLD) - .child("Ropy Settings"), + .child(board.i18n.t("settings_title")), ) .child(div().w(px(55.))); @@ -212,6 +264,7 @@ pub(super) fn render_settings_content( v_flex() .gap_4() .flex_1() + .child(language_section) .child(theme_section) // .child(hotkey_section) .child(storage_section) diff --git a/src/gui/tray.rs b/src/gui/tray.rs index ca84b0d..98c80d1 100644 --- a/src/gui/tray.rs +++ b/src/gui/tray.rs @@ -1,3 +1,7 @@ +use crate::config::Settings; +use crate::i18n::I18n; +use std::sync::RwLock; +use std::sync::Arc; use std::time::Duration; use gpui::{AsyncApp, WindowHandle}; @@ -8,10 +12,13 @@ use tray_icon::{ }; /// Initialize and return the tray icon -pub fn init_tray() -> Result<(TrayIcon, MenuId, MenuId), Box> { +pub fn init_tray(settings: Arc>) -> Result<(TrayIcon, MenuId, MenuId), Box> { + let language = settings.read().unwrap().language; + let i18n = I18n::new(language).unwrap_or_default(); + // Create menu items - let show_item = MenuItem::new("Show", true, None); - let quit_item = MenuItem::new("Quit", true, None); + let show_item = MenuItem::new(i18n.t("tray_show"), true, None); + let quit_item = MenuItem::new(i18n.t("tray_quit"), true, None); // Create menu let tray_menu = Menu::new(); @@ -23,7 +30,7 @@ pub fn init_tray() -> Result<(TrayIcon, MenuId, MenuId), Box Result> { } /// Start the system tray handler -pub fn start_tray_handler(window_handle: WindowHandle, async_app: AsyncApp) { +pub fn start_tray_handler(window_handle: WindowHandle, async_app: AsyncApp, settings: Arc>) { let fg_executor = async_app.foreground_executor().clone(); let bg_executor = async_app.background_executor().clone(); - match init_tray() { + match init_tray(settings) { Ok((tray, show_id, quit_id)) => { println!("[ropy] Tray icon initialized successfully"); // Keep tray icon alive for the lifetime of the application diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..a69a90a --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,200 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use thiserror::Error; + +/// Supported languages +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Language { + #[serde(rename = "en")] + English, + #[serde(rename = "zh-CN")] + ChineseSimplified, +} + +impl Language { + pub fn code(&self) -> &'static str { + match self { + Language::English => "en", + Language::ChineseSimplified => "zh-CN", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Language::English => "English", + Language::ChineseSimplified => "简体中文", + } + } + + pub fn all() -> Vec { + vec![Language::English, Language::ChineseSimplified] + } +} + +impl Default for Language { + fn default() -> Self { + Language::English + } +} + +/// Translation keys used throughout the application +#[derive(Debug, Clone)] +pub struct Translations { + strings: HashMap, +} + +impl Translations { + /// Load translations from a TOML string + pub fn from_toml(content: &str) -> Result { + let strings: HashMap = + toml::from_str(content).map_err(|e| I18nError::ParseError(e.to_string()))?; + Ok(Self { strings }) + } + + /// Get a translated string by key + pub fn get(&self, key: &str) -> String { + self.strings + .get(key) + .cloned() + .unwrap_or_else(|| format!("[Missing: {}]", key)) + } +} + +/// I18n manager for handling translations +#[derive(Debug, Clone)] +pub struct I18n { + current_language: Language, + translations: Translations, +} + +impl I18n { + /// Create a new I18n instance with the specified language + pub fn new(language: Language) -> Result { + let translations = Self::load_language(language)?; + Ok(Self { + current_language: language, + translations, + }) + } + + /// Load translations for a specific language + fn load_language(language: Language) -> Result { + let content = match language { + Language::English => include_str!("../../assets/locales/en.toml"), + Language::ChineseSimplified => include_str!("../../assets/locales/zh-CN.toml"), + }; + Translations::from_toml(content) + } + + /// Get the current language + pub fn current_language(&self) -> Language { + self.current_language + } + + /// Change the current language + pub fn set_language(&mut self, language: Language) -> Result<(), I18nError> { + let translations = Self::load_language(language)?; + self.current_language = language; + self.translations = translations; + Ok(()) + } + + /// Get a translated string by key + pub fn t(&self, key: &str) -> String { + self.translations.get(key) + } +} + +impl Default for I18n { + fn default() -> Self { + // Try to load English as default, if that fails, create empty translations + match Self::new(Language::default()) { + Ok(i18n) => i18n, + Err(e) => { + eprintln!("[ropy] Warning: Failed to load default language translations: {e}"); + eprintln!("[ropy] Falling back to empty translations - all strings will show as '[Missing: key]'"); + Self { + current_language: Language::default(), + translations: Translations { + strings: HashMap::new(), + }, + } + } + } + } +} + +/// I18n-related errors +#[derive(Debug, Error)] +pub enum I18nError { + #[error("Failed to parse translation file: {0}")] + ParseError(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_language_code() { + assert_eq!(Language::English.code(), "en"); + assert_eq!(Language::ChineseSimplified.code(), "zh-CN"); + } + + #[test] + fn test_language_display_name() { + assert_eq!(Language::English.display_name(), "English"); + assert_eq!(Language::ChineseSimplified.display_name(), "简体中文"); + } + + #[test] + fn test_translations_from_toml() { + let content = r#" + app_name = "Ropy" + show = "Show" + quit = "Quit" + "#; + let translations = Translations::from_toml(content).unwrap(); + assert_eq!(translations.get("app_name"), "Ropy"); + assert_eq!(translations.get("show"), "Show"); + assert_eq!(translations.get("quit"), "Quit"); + } + + #[test] + fn test_missing_translation() { + let content = r#" + app_name = "Ropy" + "#; + let translations = Translations::from_toml(content).unwrap(); + assert_eq!(translations.get("missing_key"), "[Missing: missing_key]"); + } + + #[test] + fn test_i18n_initialization() { + let i18n = I18n::new(Language::English); + assert!(i18n.is_ok()); + let i18n = i18n.unwrap(); + assert_eq!(i18n.current_language(), Language::English); + assert_eq!(i18n.t("app_name"), "Ropy"); + } + + #[test] + fn test_i18n_language_switch() { + let mut i18n = I18n::new(Language::English).unwrap(); + assert_eq!(i18n.t("tray_show"), "Show"); + + // Switch to Chinese + let result = i18n.set_language(Language::ChineseSimplified); + assert!(result.is_ok()); + assert_eq!(i18n.current_language(), Language::ChineseSimplified); + assert_eq!(i18n.t("tray_show"), "显示"); + } + + #[test] + fn test_language_all() { + let languages = Language::all(); + assert_eq!(languages.len(), 2); + assert!(languages.contains(&Language::English)); + assert!(languages.contains(&Language::ChineseSimplified)); + } +} diff --git a/src/main.rs b/src/main.rs index 8779201..df3184b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod clipboard; mod config; mod gui; +mod i18n; mod repository; #[cfg(target_os = "windows")]