diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index b04f74baf..e20db499e 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -108,3 +108,13 @@ platform ([Agent updates](../../../apps/maple-agent/docs/updates.md)). Releasing is a production action, so run it only when the owner asks. Installed clients parse the feed strictly; keep the wire contract in that doc unchanged. See [desktop builds](../../../apps/maple-agent/docs/desktop-builds.md) for packaging. + +## Localization + +New Agent UI labels use `ui::i18n` and the embedded Fluent catalogs; follow +[the localization contract](../../../apps/maple-agent/docs/localization.md). +Plain labels are cached; parameterized messages are formatted on state changes. +Language changes apply live; invalidate cached translated text when the locale changes. Preserve machine identifiers, model-facing +text, user content and persisted timeline text. Do not translate host/ACP errors +in place. Expand the converted-module guard with each extraction, and require +platform/font/packaging evidence before enabling another locale. diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index 8d271ea11..798eea571 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -164,3 +164,12 @@ This app must feel instant. Treat frame time and UI-thread stalls as bugs. cached view. Avoid `Window::refresh`; it discards every cached view. - Release builds use fat LTO and one codegen unit. Use release builds for any performance check; the dev profile is `opt-level = 1`. + +## Localization + +Use the embedded Fluent catalog and `ui::i18n` for new UI labels, including +accessibility text; see [localization](docs/localization.md) for extraction and +locale enablement. Cache parameterized messages when their state changes. Keep +element IDs, protocol values, model prompts, user content and persisted timeline +text unchanged. Language changes apply live; invalidate cached translated text when the locale changes. Extend the converted-module +guard as each module is extracted; host errors and native menus need owned work. diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index be62c786f..9da0e15d6 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -3383,6 +3383,71 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.3", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.20", +] + +[[package]] +name = "fluent-template-macros" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f17b7ed5b7108d57ca049e1fa7583d4fadbb71ef0e47a72dc847c1e704f37fb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unic-langid", + "walkdir", +] + +[[package]] +name = "fluent-templates" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fc3bfc211d9a9210bd7ccba798da57148e5b02c62b98b6cef09d7228f2f232" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "fluent-template-macros", + "intl-memoizer", + "log", + "thiserror 2.0.20", + "unic-langid", + "walkdir", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -4904,6 +4969,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.24" @@ -5829,6 +5913,9 @@ dependencies = [ "dirs 6.0.0", "embed-resource", "env_logger", + "fluent-bundle", + "fluent-syntax", + "fluent-templates", "futures-lite", "futures-util", "gpui", @@ -5863,6 +5950,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "spellbook", + "syn 2.0.119", + "sys-locale", "tempfile", "tokio", "tokio-util", @@ -8205,6 +8294,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -10529,7 +10624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -10689,6 +10784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -11087,6 +11183,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "typeid" version = "1.0.3" @@ -11116,6 +11221,49 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.119", + "unic-langid-impl", +] + [[package]] name = "unicase" version = "2.9.0" @@ -13390,6 +13538,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index d16e848cc..506d2b6c6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -605,6 +605,11 @@ ignored. See [build profiles](docs/release-profiles.md). | `MAPLE_DISABLE_UPDATE_CHECK` | `1` turns Agent update checks off. | unset | | `RUST_LOG` | Log filter. | `info` | +`MAPLE_ENABLE_LOCALIZATION=1` enables the localization preview in **all** build +profiles, including Dev and Prod. It defaults off: Language settings are hidden +and UI text uses English. Restart to change the gate; saved locale preferences +are preserved. See [localization](docs/localization.md). + ### File locations The application database lives in `/store-v1/`. An absolute diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index dc5842871..6e425ec64 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -15,6 +15,9 @@ default = ["desktop", "acp", "proxy"] # The gpui window. Without it the binary is headless and needs no display # libraries; only `acp` and `proxy` modes remain. desktop = [ + "dep:fluent-bundle", + "dep:fluent-templates", + "dep:sys-locale", "dep:gpui", "dep:gpui-platform", "dep:image", @@ -38,6 +41,9 @@ acp = ["maple-agent/acp"] proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http", "dep:hyper", "dep:hyper-util", "dep:futures-util"] [dependencies] +fluent-bundle = { version = "0.16", optional = true } +fluent-templates = { version = "0.15.1", default-features = false, features = ["macros", "walkdir"], optional = true } +sys-locale = { version = "0.3", optional = true } dirs = "6" maple-agent = { path = "../crates/maple-agent", default-features = false } maple-billing = { path = "../crates/maple-billing" } @@ -113,6 +119,8 @@ raw-window-handle = { version = "0.6", optional = true } windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory", "Win32_System_Threading", "Win32_System_ErrorReporting", "Win32_System_SystemInformation", "Win32_Storage_FileSystem"] } [dev-dependencies] +fluent-syntax = "0.12" +syn = { version = "2", features = ["full", "visit"] } tempfile = "3" parking_lot = "0.12" # The gpui test harness. Keep this out of [dependencies]: with test-support on, diff --git a/apps/maple-agent/app/build.rs b/apps/maple-agent/app/build.rs index e39af89bf..4d0de3d61 100644 --- a/apps/maple-agent/app/build.rs +++ b/apps/maple-agent/app/build.rs @@ -7,6 +7,8 @@ mod git_metadata; mod release_profile; fn main() { + // Track added and removed catalogs as well as edits to embedded resources. + println!("cargo:rerun-if-changed=locales"); release_profile::emit("../release-profiles.json", "../release_profile.rs", true); let git_metadata::GitMetadata { revision, diff --git a/apps/maple-agent/app/locales/en/app.ftl b/apps/maple-agent/app/locales/en/app.ftl new file mode 100644 index 000000000..5d051d499 --- /dev/null +++ b/apps/maple-agent/app/locales/en/app.ftl @@ -0,0 +1,98 @@ +# Source catalog. Keep message IDs stable; never translate protocol or element IDs. +settings-language = Language +settings-language-description = Changes apply immediately. Text not yet translated remains in English. +# $language is the native name of the resolved system language. +settings-language-system = System ({ $language }) +language-name = English +update-confirmation-title = Restart Maple to update? +# $version is the target application version, not translated. +update-confirmation-title-version = Restart Maple to install v{ $version }? +update-confirmation-detail = This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update. +update-confirmation-later = Later +update-confirmation-restart = Restart to Update + +# General settings and shared settings navigation. +settings-defaults = Defaults +settings-chat-appearance = Chat appearance +settings-editing = Editing +settings-voice = Voice +settings-web = New tasks can use the web +settings-web-description = Offers web_search and open_url to the model. Each task can switch web access on or off from its composer. +settings-appearance = Appearance +settings-appearance-description = Follow the system theme, or force dark or light. +settings-chat-font = Chat font +settings-text-size = Text size +settings-text-size-description = Size of conversation and composer text. Sidebar and buttons stay Manrope. +settings-tool-details = Show tool call details +settings-tool-details-description = Tool cards include their input and output payloads. +settings-notifications = Desktop notifications +settings-notifications-description = Notify when a task finishes or needs your input while the window is not focused. +settings-password-details = Show task details in password mode +settings-password-details-description = Include task titles and questions in desktop notifications. Your operating system may retain this text after Maple quits. +settings-reduce-motion = Reduce motion +settings-reduce-motion-description = Hold spinners and pulsing dots still and skip reveal animations. +settings-tool-summaries = Summarize tool calls +settings-tool-summaries-description = Completed tool calls with long output get a one-line summary on their cards. +settings-composer-vim = Vim mode in composer +settings-composer-vim-description = Use Normal, Insert, and Visual editing modes in the main chat composer. Other text fields stay unchanged. +settings-application-vim = Vim navigation across the app +settings-application-vim-description = Navigate stable sidebar, transcript, dialog, and Settings targets. Ordinary text fields still edit normally. +settings-speech-voice = Speech voice +settings-speech-voice-description = The voice that reads messages aloud. +settings-speech-speed = Speech speed +settings-speech-speed-description = How fast messages are read aloud. +settings-preview = Live preview +settings-preview-assistant = Assistant +settings-preview-you = You +settings-preview-text = Clear, comfortable text makes longer conversations easier to follow. +settings-preview-bold = Bold should look crisp, not blurry. +settings-preview-reply = This size feels just right. +settings-notification-test-title = Desktop notifications on +settings-notification-test-body = You will see alerts like this at { $time }. +settings-section-general = General +settings-section-account = Account +settings-section-billing = Billing +settings-section-api-keys = API keys +settings-section-local-api = Local API +settings-section-shortcuts = Keyboard Shortcuts +settings-section-prompt = Additional instructions +settings-section-integrations = Integrations +settings-section-remote = Remote computers +settings-section-usage = Usage +settings-section-about = About +settings-section-advanced = Privacy & Security +settings-back = ← Back +settings-title = Settings +settings-sign-out = Sign out +settings-sections = Settings sections +settings-theme-label-system = System +settings-theme-label-dark = Dark +settings-theme-label-light = Light +settings-font-label-system = System +settings-font-label-manrope = Manrope +settings-font-label-geist = Geist +settings-font-label-serif = Serif +settings-font-note-system = The familiar interface font for your device. +settings-font-note-manrope = Maple's brand face, with a compact modern feel. +settings-font-note-geist = A lighter grotesque that pairs with Geist Mono. +settings-font-note-serif = A traditional reading style with distinct letterforms. +settings-voice-neutral-female = Neutral — Female +settings-voice-neutral-male = Neutral — Male +settings-voice-casual-female = Casual — Female +settings-voice-casual-male = Casual — Male +settings-voice-cheerful-female = Cheerful — Female +settings-voice-ar-male = Arabic-accented — Male +settings-voice-de-female = German-accented — Female +settings-voice-de-male = German-accented — Male +settings-voice-es-female = Spanish-accented — Female +settings-voice-es-male = Spanish-accented — Male +settings-voice-fr-female = French-accented — Female +settings-voice-fr-male = French-accented — Male +settings-voice-hi-female = Hindi-accented — Female +settings-voice-hi-male = Hindi-accented — Male +settings-voice-it-female = Italian-accented — Female +settings-voice-it-male = Italian-accented — Male +settings-voice-nl-female = Dutch-accented — Female +settings-voice-nl-male = Dutch-accented — Male +settings-voice-pt-female = Portuguese-accented — Female +settings-voice-pt-male = Portuguese-accented — Male diff --git a/apps/maple-agent/app/locales/es/app.ftl b/apps/maple-agent/app/locales/es/app.ftl new file mode 100644 index 000000000..c5de32faa --- /dev/null +++ b/apps/maple-agent/app/locales/es/app.ftl @@ -0,0 +1,98 @@ +# Traducción de la interfaz. Los identificadores de mensajes y elementos no se traducen. +settings-language = Idioma +settings-language-description = Los cambios se aplican de inmediato. El texto que aún no se haya traducido seguirá en inglés. +# $language es el nombre del idioma del sistema en su propio idioma. +settings-language-system = Sistema ({ $language }) +language-name = Español +update-confirmation-title = ¿Reiniciar Maple para actualizar? +# $version es la versión de destino de la aplicación y no se traduce. +update-confirmation-title-version = ¿Reiniciar Maple para instalar la versión { $version }? +update-confirmation-detail = Esto detendrá las tareas en ejecución y el acceso remoto en este equipo. Los editores que usan Maple deberán volver a conectarse después de la actualización. +update-confirmation-later = Más tarde +update-confirmation-restart = Reiniciar para actualizar + +# General settings and shared settings navigation. +settings-defaults = Valores predeterminados +settings-chat-appearance = Apariencia del chat +settings-editing = Edición +settings-voice = Voz +settings-web = Las nuevas tareas pueden usar la web +settings-web-description = Ofrece web_search y open_url al modelo. Cada tarea puede activar o desactivar el acceso a la web desde su cuadro de mensaje. +settings-appearance = Apariencia +settings-appearance-description = Usa el tema del sistema o elige el modo oscuro o claro. +settings-chat-font = Fuente del chat +settings-text-size = Tamaño del texto +settings-text-size-description = Tamaño del texto de la conversación y del cuadro de mensaje. La barra lateral y los botones siguen usando Manrope. +settings-tool-details = Mostrar detalles de las llamadas a herramientas +settings-tool-details-description = Las tarjetas de herramientas incluyen sus datos de entrada y salida. +settings-notifications = Notificaciones de escritorio +settings-notifications-description = Recibe una notificación cuando una tarea termine o necesite tu respuesta mientras la ventana no esté activa. +settings-password-details = Mostrar detalles de las tareas en el modo con contraseña +settings-password-details-description = Incluye los títulos de las tareas y las preguntas en las notificaciones de escritorio. El sistema operativo puede conservar este texto después de cerrar Maple. +settings-reduce-motion = Reducir el movimiento +settings-reduce-motion-description = Mantiene estáticos los indicadores de carga y los puntos intermitentes, y omite las animaciones de aparición. +settings-tool-summaries = Resumir las llamadas a herramientas +settings-tool-summaries-description = Las llamadas a herramientas completadas con resultados largos muestran un resumen de una línea en sus tarjetas. +settings-composer-vim = Modo Vim en el cuadro de mensaje +settings-composer-vim-description = Usa los modos Normal, Insertar y Visual en el cuadro de mensaje principal del chat. Los demás campos de texto no cambian. +settings-application-vim = Navegación Vim en toda la aplicación +settings-application-vim-description = Navega por los elementos de la barra lateral, la conversación, los diálogos y los ajustes. Los campos de texto se siguen editando con normalidad. +settings-speech-voice = Voz de lectura +settings-speech-voice-description = La voz que lee los mensajes en voz alta. +settings-speech-speed = Velocidad de lectura +settings-speech-speed-description = La velocidad a la que se leen los mensajes en voz alta. +settings-preview = Vista previa en tiempo real +settings-preview-assistant = Asistente +settings-preview-you = Tú +settings-preview-text = Un texto claro y cómodo facilita seguir conversaciones largas. +settings-preview-bold = La negrita debe verse nítida, sin borrosidad. +settings-preview-reply = Este tamaño se lee muy bien. +settings-notification-test-title = Notificaciones de escritorio activadas +settings-notification-test-body = Verás avisos como este a las { $time }. +settings-section-general = General +settings-section-account = Cuenta +settings-section-billing = Facturación +settings-section-api-keys = Claves de API +settings-section-local-api = API local +settings-section-shortcuts = Atajos de teclado +settings-section-prompt = Instrucciones adicionales +settings-section-integrations = Integraciones +settings-section-remote = Equipos remotos +settings-section-usage = Uso +settings-section-about = Acerca de +settings-section-advanced = Privacidad y seguridad +settings-back = ← Volver +settings-title = Ajustes +settings-sign-out = Cerrar sesión +settings-sections = Secciones de ajustes +settings-theme-label-system = Sistema +settings-theme-label-dark = Oscuro +settings-theme-label-light = Claro +settings-font-label-system = Sistema +settings-font-label-manrope = Manrope +settings-font-label-geist = Geist +settings-font-label-serif = Serif +settings-font-note-system = La fuente habitual de la interfaz de tu dispositivo. +settings-font-note-manrope = La fuente de Maple, de estilo compacto y moderno. +settings-font-note-geist = Una fuente de palo seco ligera que combina con Geist Mono. +settings-font-note-serif = Un estilo de lectura tradicional con letras bien definidas. +settings-voice-neutral-female = Neutra — Femenina +settings-voice-neutral-male = Neutra — Masculina +settings-voice-casual-female = Informal — Femenina +settings-voice-casual-male = Informal — Masculina +settings-voice-cheerful-female = Alegre — Femenina +settings-voice-ar-male = Con acento árabe — Masculina +settings-voice-de-female = Con acento alemán — Femenina +settings-voice-de-male = Con acento alemán — Masculina +settings-voice-es-female = Con acento español — Femenina +settings-voice-es-male = Con acento español — Masculina +settings-voice-fr-female = Con acento francés — Femenina +settings-voice-fr-male = Con acento francés — Masculina +settings-voice-hi-female = Con acento hindi — Femenina +settings-voice-hi-male = Con acento hindi — Masculina +settings-voice-it-female = Con acento italiano — Femenina +settings-voice-it-male = Con acento italiano — Masculina +settings-voice-nl-female = Con acento neerlandés — Femenina +settings-voice-nl-male = Con acento neerlandés — Masculina +settings-voice-pt-female = Con acento portugués — Femenina +settings-voice-pt-male = Con acento portugués — Masculina diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 655262b6a..6488c1c8c 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -895,6 +895,7 @@ impl MapleApp { } } this.set_store_resident(status.keeps_resident(), cx); + ui::i18n::initialize_saved_preference(this.settings.locale.as_deref()); ui::theme::apply_preference( ui::theme::Preference::parse(&this.settings.theme), cx, @@ -1120,10 +1121,15 @@ impl MapleApp { return; } self.restart_prompt_pending = true; + #[cfg(not(target_os = "linux"))] let title = crate::update::restart_title(self.updater.snapshot().version.as_deref()); #[cfg(target_os = "linux")] let answer = { - let (prompt, answer) = ui::update_confirmation::open(title, window, cx); + let (prompt, answer) = ui::update_confirmation::open( + self.updater.snapshot().version.as_deref(), + window, + cx, + ); self.restart_confirmation = Some(prompt); cx.notify(); answer diff --git a/apps/maple-agent/app/src/env.rs b/apps/maple-agent/app/src/env.rs index aab16da24..da6cfe46d 100644 --- a/apps/maple-agent/app/src/env.rs +++ b/apps/maple-agent/app/src/env.rs @@ -2,8 +2,7 @@ //! trimmed, and an empty value counts as unset so a stray `NAME=` in a //! launcher does not override a default with nothing. -// A headless build (no `desktop` feature) has no update check, the only -// caller of `env_flag`. +// Only desktop update checks and the localization preview gate use `env_flag`. #![cfg_attr(not(feature = "desktop"), allow(dead_code))] /// The trimmed value of `name`, or `None` when unset or blank. diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 634dcb4e4..8098ae57d 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -135,6 +135,9 @@ pub struct AppSettings { /// Window size and state from the last run. #[serde(default)] pub window: Option, + /// UI language override; None follows the operating system. + #[serde(default, deserialize_with = "view_preference_or_default")] + pub locale: Option, /// Color theme: "system", "dark", or "light". #[serde(default = "default_theme")] pub theme: String, @@ -183,15 +186,6 @@ pub const TTS_SPEEDS: [f32; 6] = [0.8, 1.0, 1.2, 1.5, 1.8, 2.0]; const DEFAULT_TTS_VOICE: &str = "casual_female"; const DEFAULT_TTS_SPEED: f32 = 1.0; -/// Label for a voice id; the id itself when it is unknown. -pub fn tts_voice_label(voice: &str) -> &str { - TTS_VOICES - .iter() - .find(|(id, _)| *id == voice) - .map(|(_, label)| *label) - .unwrap_or(voice) -} - fn default_tts_voice() -> String { DEFAULT_TTS_VOICE.to_string() } @@ -318,6 +312,7 @@ impl Default for AppSettings { reduce_motion: false, harness_instructions: String::new(), window: None, + locale: None, theme: default_theme(), chat_font_family: default_chat_font_family(), chat_font_size: default_chat_font_size(), @@ -987,6 +982,30 @@ pub(crate) fn install_test_store(store: std::sync::Arc Self { + Self + } +} +#[cfg(test)] +impl Drop for TestLocalizationGuard { + fn drop(&mut self) { + ACTIVE.with_borrow_mut(|state| { + state.locale = ENGLISH; + state.loaded = false; + state.messages.clear(); + state.system_label = None; + }); + } +} + +/// Enabled catalogs. None is the persisted system preference. Add a language +/// here only when its catalog has passed the completeness checks. +pub(super) const LANGUAGES: &[Option<&str>] = &[None, Some("en"), Some("es")]; +static NATIVE_NAMES: LazyLock> = LazyLock::new(|| { + LANGUAGES + .iter() + .flatten() + .map(|locale| { + ( + *locale, + lookup( + &locale.parse().expect("enabled locale"), + "language-name", + &HashMap::new(), + ) + .into(), + ) + }) + .collect() +}); +static SYSTEM: LazyLock = LazyLock::new(|| { + // English assertions must not depend on the machine running the suite. + #[cfg(test)] + { + ENGLISH + } + #[cfg(not(test))] + { + resolve(sys_locale::get_locale().as_deref()) + } +}); + +struct LocaleState { + locale: LanguageIdentifier, + loaded: bool, + messages: HashMap<&'static str, SharedString>, + system_label: Option, +} + +thread_local! { + // GPUI renders on its owning thread. Tests have independent UI threads. + static ACTIVE: RefCell = RefCell::new(LocaleState { + locale: SYSTEM.clone(), loaded: false, messages: HashMap::new(), system_label: None, + }); +} + +fn resolve(requested: Option<&str>) -> LanguageIdentifier { + resolve_enabled(requested, LANGUAGES) +} + +fn resolve_enabled(requested: Option<&str>, enabled: &[Option<&str>]) -> LanguageIdentifier { + let Some(requested) = requested else { + return ENGLISH; + }; + // Accept POSIX locale spellings as well as BCP-47 from platform APIs. + let normalized = requested + .split(['.', '@']) + .next() + .unwrap_or_default() + .replace('_', "-"); + let Ok(mut locale) = normalized.parse::() else { + return ENGLISH; + }; + loop { + let tag = locale.to_string(); + if enabled.iter().flatten().any(|enabled| *enabled == tag) { + return locale; + } + if locale.variants().next().is_some() { + locale.clear_variants(); + } else if locale.region.is_some() { + locale.region = None; + } else if locale.script.is_some() { + locale.script = None; + } else { + return ENGLISH; + } + } +} + +/// Called once after the host hydrates saved settings. Before unlock the UI +/// uses the OS default. User edits subsequently apply through `apply_preference`. +pub(crate) fn initialize_saved_preference(preference: Option<&str>) { + ACTIVE.with_borrow_mut(|state| { + if state.loaded { + return; + } + state.locale = preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))); + state.messages.clear(); + state.system_label = None; + state.loaded = true; + }); +} + +/// Apply a user selection and invalidate translated labels on the owning UI thread. +pub(crate) fn apply_preference(preference: Option<&str>) { + ACTIVE.with_borrow_mut(|state| { + state.locale = preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))); + state.loaded = true; + state.messages.clear(); + state.system_label = None; + }); +} + +pub(crate) fn current_locale() -> LanguageIdentifier { + ACTIVE.with_borrow(|state| state.locale.clone()) +} + +pub(super) fn preference_label(preference: Option<&str>) -> SharedString { + if let Some(locale) = preference { + // Only enabled values can be picked. Unknown saved preferences resolve + // to English while retaining their value for a future upgraded app. + return NATIVE_NAMES + .get(locale) + .unwrap_or(&NATIVE_NAMES["en"]) + .clone(); + } + const KEY: &str = "settings-language-system"; + if let Some(label) = ACTIVE.with_borrow(|state| state.system_label.clone()) { + return label; + } + let name = lookup(&SYSTEM, "language-name", &HashMap::new()); + let label = tr_args(KEY, [("language", name.into())]); + ACTIVE.with_borrow_mut(|state| { + state.system_label = Some(label.clone()); + }); + label +} + +pub(crate) fn tr(key: &'static str) -> SharedString { + ACTIVE.with_borrow_mut(|state| { + state + .messages + .entry(key) + .or_insert_with(|| lookup(&state.locale, key, &HashMap::new()).into()) + .clone() + }) +} + +/// Values retain their Fluent type: pass numbers as numbers for CLDR plurals. +/// Fluent's bidi isolation remains enabled for interpolated text. +pub(crate) fn tr_args<'a>( + key: &'static str, + args: impl IntoIterator)>, +) -> SharedString { + let args = args.into_iter().collect(); + ACTIVE.with_borrow(|state| lookup(&state.locale, key, &args).into()) +} + +fn lookup(locale: &LanguageIdentifier, key: &str, args: &HashMap<&str, FluentValue<'_>>) -> String { + lookup_from(&CATALOGS, locale, key, args) +} + +fn lookup_from( + catalogs: &fluent_templates::StaticLoader, + locale: &LanguageIdentifier, + key: &str, + args: &HashMap<&str, FluentValue<'_>>, +) -> String { + if let Ok(value) = catalogs.lookup_single_language(locale, key, Some(args)) + && !value.trim().is_empty() + { + return value; + } + // Do not log arguments or resolver errors: they can contain user content. + log::warn!("Localization fallback for key {key} in locale {locale}"); + if *locale != ENGLISH + && let Ok(value) = catalogs.lookup_single_language(&ENGLISH, key, Some(args)) + && !value.trim().is_empty() + { + return value; + } + key.to_owned() +} + +#[cfg(test)] +#[path = "i18n/tests.rs"] +mod tests; diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs new file mode 100644 index 000000000..2b6d90e87 --- /dev/null +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use fluent_bundle::{FluentArgs, FluentBundle, FluentResource}; +use fluent_syntax::{ast::Entry, parser}; +use fluent_templates::Loader; +use std::{collections::BTreeSet, path::Path}; +use syn::visit::Visit; + +#[test] +fn locale_resolution_accepts_os_spellings_and_falls_back_safely() { + for locale in [ + None, + Some("en"), + Some("en-US"), + Some("en_GB.UTF-8"), + Some("en_US@variant"), + Some("C"), + Some("fr-FR"), + Some("../../en"), + Some(""), + ] { + assert_eq!(resolve(locale), ENGLISH); + } + for locale in ["es", "es-ES", "es-MX", "es_ES.UTF-8"] { + assert_eq!(resolve(Some(locale)), langid!("es")); + } +} + +#[test] +fn spanish_preference_formats_existing_surfaces_and_initializes_once() { + let _localization = TestLocalizationGuard::enable(); + ACTIVE.with_borrow_mut(|state| { + state.locale = ENGLISH; + state.loaded = false; + state.messages.clear(); + }); + initialize_saved_preference(Some("es")); + assert_eq!(tr("settings-language").as_ref(), "Idioma"); + assert_eq!(preference_label(Some("es")).as_ref(), "Español"); + assert_eq!( + tr("update-confirmation-title").as_ref(), + "¿Reiniciar Maple para actualizar?" + ); + assert_eq!( + tr_args( + "update-confirmation-title-version", + [("version", "0.0.16".into())] + ) + .as_ref(), + "¿Reiniciar Maple para instalar la versión \u{2068}0.0.16\u{2069}?" + ); + assert_eq!( + tr_args("settings-language-system", [("language", "Español".into())]).as_ref(), + "Sistema (\u{2068}Español\u{2069})" + ); + initialize_saved_preference(Some("en")); + assert_eq!(tr("settings-language").as_ref(), "Idioma"); + ACTIVE.with_borrow_mut(|state| { + state.locale = ENGLISH; + state.loaded = false; + state.messages.clear(); + state.system_label = None; + }); +} + +#[test] +fn resolution_prefers_exact_catalogs_then_language_without_losing_script_first() { + let enabled = [ + None, + Some("en"), + Some("fr"), + Some("fr-CA"), + Some("zh"), + Some("zh-Hant"), + ]; + for (requested, expected) in [ + ("fr-CA", "fr-CA"), + ("fr_FR.UTF-8", "fr"), + ("zh-Hant-TW", "zh-Hant"), + ("zh-Hans-CN", "zh"), + ("de-DE", "en"), + ] { + assert_eq!( + resolve_enabled(Some(requested), &enabled).to_string(), + expected + ); + } +} + +#[test] +fn missing_keys_and_locales_fall_back_without_blank_text() { + let args = HashMap::new(); + assert_eq!( + lookup(&langid!("fr"), "update-confirmation-later", &args), + "Later" + ); + assert_eq!(lookup(&ENGLISH, "missing-key", &args), "missing-key"); + // A missing variable must not leak Fluent's error placeholder into UI. + assert_eq!( + lookup(&ENGLISH, "settings-language-system", &args), + "settings-language-system" + ); + assert_eq!( + tr_args("settings-language-system", [("language", "English".into())]).as_ref(), + "System (\u{2068}English\u{2069})" + ); +} + +#[test] +fn saved_preference_is_loaded_once_and_plain_labels_are_cached() { + let _localization = TestLocalizationGuard::enable(); + ACTIVE.with_borrow_mut(|state| { + state.locale = langid!("fr"); + state.loaded = false; + state.messages.clear(); + }); + initialize_saved_preference(Some("en-US")); + assert_eq!(tr("update-confirmation-later").as_ref(), "Later"); + ACTIVE.with_borrow_mut(|state| { + assert_eq!(state.locale, ENGLISH); + assert_eq!(state.messages.len(), 1); + // A cached label remains usable even if a catalog cannot be queried. + state + .messages + .insert("update-confirmation-later", "cached".into()); + }); + ACTIVE.with_borrow_mut(|state| state.locale = langid!("fr")); + initialize_saved_preference(Some("en")); + ACTIVE.with_borrow(|state| assert_eq!(state.locale, langid!("fr"))); + assert_eq!(tr("update-confirmation-later").as_ref(), "cached"); + ACTIVE.with_borrow_mut(|state| { + state.messages.clear(); + state.locale = ENGLISH; + }); +} + +fn catalog_keys(source: &str) -> BTreeSet { + let resource = parser::parse(source).expect("valid Fluent syntax"); + let mut keys = BTreeSet::new(); + for entry in resource.body { + match entry { + Entry::Message(message) => { + assert!( + message.value.is_some(), + "message {} must have a value", + message.id.name + ); + assert!(keys.insert(message.id.name.to_owned()), "duplicate message"); + for attribute in message.attributes { + assert!(keys.insert(format!("{}.{}", message.id.name, attribute.id.name))); + } + } + Entry::Term(term) => { + assert!(keys.insert(format!("-{}", term.id.name)), "duplicate term"); + } + _ => {} + } + } + keys +} + +#[test] +fn shipped_catalogs_parse_and_have_identical_keys() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let english = catalog_keys(&std::fs::read_to_string(root.join("en/app.ftl")).unwrap()); + let mut locales = BTreeSet::new(); + for entry in std::fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + assert!( + entry.file_type().unwrap().is_dir(), + "catalogs live in locale/app.ftl" + ); + let locale = entry.file_name().into_string().unwrap(); + locale.parse::().unwrap(); + assert_eq!( + std::fs::read_dir(entry.path()).unwrap().count(), + 1, + "one app.ftl per locale" + ); + let source = std::fs::read_to_string(entry.path().join("app.ftl")).unwrap(); + assert_eq!(catalog_keys(&source), english, "catalog {locale}"); + locales.insert(locale); + } + assert_eq!( + locales, + LANGUAGES + .iter() + .flatten() + .map(|locale| locale.to_string()) + .collect() + ); + assert_eq!( + locales, + CATALOGS.locales().map(ToString::to_string).collect() + ); +} + +#[test] +fn numeric_arguments_use_locale_plural_rules() { + let mut bundle = FluentBundle::new(vec![langid!("ru")]); + bundle + .add_resource( + FluentResource::try_new( + "count = { $count ->\n [one] one\n [few] few\n [many] many\n *[other] other\n}" + .to_owned(), + ) + .unwrap(), + ) + .unwrap(); + let pattern = bundle.get_message("count").unwrap().value().unwrap(); + for (count, expected) in [(1, "one"), (2, "few"), (5, "many"), (21, "one")] { + let mut args = FluentArgs::new(); + args.set("count", count); + let mut errors = Vec::new(); + assert_eq!( + bundle.format_pattern(pattern, Some(&args), &mut errors), + expected + ); + assert!(errors.is_empty()); + } +} + +#[derive(Default)] +struct UiStrings { + keys: BTreeSet, + literals: Vec, +} + +impl<'ast> Visit<'ast> for UiStrings { + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if node.ident != "tests" { + syn::visit::visit_item_mod(self, node); + } + } + + fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { + if let syn::Expr::Path(path) = &*node.func + && path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "tr" || segment.ident == "tr_args") + && let Some(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(key), + .. + })) = node.args.first() + { + self.keys.insert(key.value()); + } + syn::visit::visit_expr_call(self, node); + } + + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if ["child", "aria_label", "tooltip", "placeholder"] + .iter() + .any(|name| node.method == name) + && let Some(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + })) = node.args.first() + { + self.literals.push(text.value()); + } + syn::visit::visit_expr_method_call(self, node); + } +} + +fn visit_ui(path: &Path, visitor: &mut UiStrings) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit_ui(&path, visitor); + } else if path.extension().is_some_and(|extension| extension == "rs") + && !path.ends_with("i18n/tests.rs") + { + visitor.visit_file(&syn::parse_file(&std::fs::read_to_string(path).unwrap()).unwrap()); + } + } +} + +#[test] +fn ui_lookup_keys_exist_in_source_catalog() { + let mut visitor = UiStrings::default(); + visit_ui( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src/ui"), + &mut visitor, + ); + let keys = catalog_keys(include_str!("../../../locales/en/app.ftl")); + assert!(!visitor.keys.is_empty()); + for key in visitor.keys { + assert!(keys.contains(&key), "missing source message: {key}"); + } +} + +#[test] +fn converted_modules_do_not_reintroduce_inline_labels() { + // Grow this list with each extraction. Unconverted modules are explicitly + // outside the gate rather than freezing hundreds of existing literals. + let mut visitor = UiStrings::default(); + visitor.visit_file(&syn::parse_file(include_str!("../update_confirmation.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/general.rs")).unwrap()); + assert!( + visitor.literals.is_empty(), + "inline UI text: {:?}", + visitor.literals + ); +} + +fluent_templates::static_loader! { + static PARTIAL = { + locales: "./tests/fixtures/i18n", + fallback_language: "en", + }; +} + +#[test] +fn partial_catalog_falls_back_per_message_including_format_errors() { + for (key, expected) in [ + ("present", "Français"), + ("missing", "English fallback"), + ("invalid", "Safe fallback"), + ("empty", "Nonempty fallback"), + ("unknown", "unknown"), + ] { + assert_eq!( + lookup_from(&PARTIAL, &langid!("fr"), key, &HashMap::new()), + expected + ); + } +} + +#[test] +fn live_switch_invalidates_plain_and_system_labels() { + let _locale = TestLocalizationGuard::enable(); + for (locale, label, system) in [ + ("en", "Language", "System"), + ("es", "Idioma", "Sistema"), + ("en", "Language", "System"), + ] { + apply_preference(Some(locale)); + assert_eq!(tr("settings-language").as_ref(), label); + assert!(preference_label(None).starts_with(system)); + } +} diff --git a/apps/maple-agent/app/src/ui/mod.rs b/apps/maple-agent/app/src/ui/mod.rs index 251a55b9f..38f10486c 100644 --- a/apps/maple-agent/app/src/ui/mod.rs +++ b/apps/maple-agent/app/src/ui/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod application_vim; pub mod chat; pub mod context_ring; pub mod decorations; +pub(crate) mod i18n; pub mod icons; pub mod login; pub mod markdown; diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 0717607f9..96372bb79 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -35,6 +35,8 @@ mod advanced; mod api_credits; mod api_keys; mod billing; +mod general; +use general::{font_label, theme_label, voice_label}; #[cfg(feature = "proxy")] mod local_api; mod mcp; @@ -75,7 +77,7 @@ pub enum Section { } impl Section { - fn label(self) -> &'static str { + fn id(self) -> &'static str { match self { Self::General => "General", Self::Account => "Account", @@ -93,6 +95,24 @@ impl Section { } } + fn label(self) -> gpui::SharedString { + super::i18n::tr(match self { + Self::General => "settings-section-general", + Self::Account => "settings-section-account", + Self::Billing => "settings-section-billing", + Self::ApiKeys => "settings-section-api-keys", + #[cfg(feature = "proxy")] + Self::LocalApi => "settings-section-local-api", + Self::Shortcuts => "settings-section-shortcuts", + Self::Prompt => "settings-section-prompt", + Self::Integrations => "settings-section-integrations", + Self::Remote => "settings-section-remote", + Self::Usage => "settings-section-usage", + Self::About => "settings-section-about", + Self::Advanced => "settings-section-advanced", + }) + } + const ALL: &[Self] = &[ Self::General, Self::Account, @@ -115,6 +135,7 @@ impl Section { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SettingMenu { Appearance, + Language, ChatFont, ChatSize, Voice, @@ -126,6 +147,7 @@ impl SettingMenu { fn id(self) -> &'static str { match self { Self::Appearance => "appearance", + Self::Language => "language", Self::ChatFont => "chat-font", Self::ChatSize => "chat-size", Self::Voice => "voice", @@ -951,6 +973,13 @@ impl SettingsScreen { /// share this order, so an index means the same option in both. fn menu_options(&self, menu: SettingMenu) -> Vec { match menu { + SettingMenu::Language => super::i18n::LANGUAGES + .iter() + .map(|locale| SettingOption { + label: super::i18n::preference_label(*locale).to_string(), + current: self.settings.locale.as_deref() == *locale, + }) + .collect(), SettingMenu::Appearance => [ theme::Preference::System, theme::Preference::Dark, @@ -958,14 +987,14 @@ impl SettingsScreen { ] .iter() .map(|&preference| SettingOption { - label: preference.label().to_string(), + label: theme_label(preference).to_string(), current: self.theme == preference, }) .collect(), SettingMenu::ChatFont => crate::ui::typography::ChatFontFamily::ALL .iter() .map(|&family| SettingOption { - label: family.label().to_string(), + label: font_label(family).to_string(), current: crate::ui::typography::ChatFontFamily::parse( &self.settings.chat_font_family, ) == family, @@ -982,8 +1011,8 @@ impl SettingsScreen { .collect(), SettingMenu::Voice => settings::TTS_VOICES .iter() - .map(|(id, label)| SettingOption { - label: label.to_string(), + .map(|(id, _)| SettingOption { + label: voice_label(id), current: *id == self.settings.tts_voice, }) .collect(), @@ -1000,17 +1029,19 @@ impl SettingsScreen { /// The saved value shown on the dropdown's trigger button. fn menu_value(&self, menu: SettingMenu) -> String { match menu { - SettingMenu::Appearance => self.theme.label().to_string(), - SettingMenu::ChatFont => { - crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family) - .label() - .to_string() + SettingMenu::Language => { + super::i18n::preference_label(self.settings.locale.as_deref()).to_string() } + SettingMenu::Appearance => theme_label(self.theme).to_string(), + SettingMenu::ChatFont => font_label(crate::ui::typography::ChatFontFamily::parse( + &self.settings.chat_font_family, + )) + .to_string(), SettingMenu::ChatSize => format!( "{} px", crate::ui::typography::clamp_chat_font_size(self.settings.chat_font_size) ), - SettingMenu::Voice => settings::tts_voice_label(&self.settings.tts_voice).to_string(), + SettingMenu::Voice => voice_label(&self.settings.tts_voice), SettingMenu::SpeechSpeed => format!("{:.1}\u{d7}", self.settings.tts_speed), } } @@ -1031,85 +1062,6 @@ impl SettingsScreen { } } - fn chat_appearance_preview(&self) -> Div { - let family = crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family); - let size = crate::ui::typography::clamp_chat_font_size(self.settings.chat_font_size); - let speaker = |name: &'static str| { - div() - .text_xs() - .font_family(crate::assets::FONT_BODY) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::text_muted())) - .child(name) - }; - // The settings pane and user bubbles both sit on elevated fills in - // dark mode. Recess a real chat well (`bg_app`) so assistant text - // and the user bubble separate from the card, matching the transcript. - widgets::card_row() - .flex() - .flex_col() - .gap_2() - .child( - div() - .text_xs() - .font_family(crate::assets::FONT_BODY) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::text_muted())) - .child(format!("Live preview · {} · {size} px", family.label())), - ) - .child( - div() - .w_full() - .rounded(theme::RADIUS_MD) - .border_1() - .border_color(gpui::rgb(theme::border())) - .bg(gpui::rgb(if theme::is_light() { - theme::bg_sidebar() - } else { - theme::bg_app() - })) - .px_3() - .py_3() - .flex() - .flex_col() - .gap_2() - .child( - crate::ui::typography::chat_reading(div()) - .max_w(gpui::relative(0.82)) - .flex() - .flex_col() - .gap_1() - .text_color(gpui::rgb(theme::text_primary())) - .child(speaker("Assistant")) - .child( - "Clear, comfortable text makes longer conversations easier to follow.", - ) - .child( - div() - .font_weight(crate::ui::typography::emphasis_weight()) - .child("Bold should look crisp, not blurry."), - ), - ) - .child( - div().w_full().flex().justify_end().child( - crate::ui::typography::chat_reading(div()) - .px_3() - .py_2() - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::bg_user_bubble())) - .border_1() - .border_color(gpui::rgb(theme::user_bubble_border())) - .flex() - .flex_col() - .gap_1() - .text_color(gpui::rgb(theme::text_primary())) - .child(speaker("You")) - .child("This size feels just right."), - ), - ), - ) - } - /// Apply the option at `index`: an absolute set, so the local copy /// and the file end in the same state whatever the file held. pub(super) fn pick_setting_option( @@ -1119,6 +1071,15 @@ impl SettingsScreen { cx: &mut Context, ) { match menu { + SettingMenu::Language => { + let Some(locale) = super::i18n::LANGUAGES.get(index) else { + return; + }; + let locale = locale.map(str::to_owned); + super::i18n::apply_preference(locale.as_deref()); + cx.refresh_windows(); + self.edit_setting(move |settings| settings.locale = locale, cx); + } SettingMenu::Appearance => { let Some(preference) = [ theme::Preference::System, @@ -1212,8 +1173,11 @@ impl SettingsScreen { crate::notify::notify( cx, "settings:test", - "Desktop notifications on", - &format!("You will see alerts like this at {enabled_at}."), + &super::i18n::tr("settings-notification-test-title"), + &super::i18n::tr_args( + "settings-notification-test-body", + [("time", enabled_at.into())], + ), &[], ); } @@ -1242,7 +1206,11 @@ impl SettingsScreen { let options = self.menu_options(menu); let dropdown = Menu::new( gpui::SharedString::from(format!("setting-menu-{}", menu.id())), - px(260.), + px(if menu == SettingMenu::Voice { + 360. + } else { + 260. + }), ) .label(title.to_string()) .max_height(px(320.)) @@ -1266,7 +1234,15 @@ impl SettingsScreen { })); self.popup.render(dropdown, Placement::BelowEnd, window, cx) }); - let slug = title.to_lowercase().replace(' ', "-"); + let slug = match menu { + SettingMenu::Appearance => "appearance", + SettingMenu::Language => "language", + SettingMenu::ChatFont => "chat-font", + SettingMenu::ChatSize => "text-size", + SettingMenu::Voice => "speech-voice", + SettingMenu::SpeechSpeed => "speech-speed", + } + .to_owned(); let button = widgets::secondary_button(gpui::SharedString::from(format!("setting-value-{slug}"))) .debug_selector(move || format!("setting-value-{slug}")) @@ -1277,7 +1253,14 @@ impl SettingsScreen { .child(self.menu_value_label(menu)) .child(icon("chevron-down", px(12.), theme::text_muted())); widgets::card_row() - .id(setting_row_id(title)) + .id(gpui::SharedString::from(format!( + "setting-{}", + match menu { + SettingMenu::ChatSize => "text-size", + SettingMenu::Voice => "speech-voice", + _ => menu.id(), + } + ))) .flex() .items_center() .justify_between() @@ -1595,13 +1578,13 @@ impl Render for SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.close(cx); })) - .child("← Back"), + .child(super::i18n::tr("settings-back")), ) .child( div() .font_weight(gpui::FontWeight::BOLD) .text_color(gpui::rgb(theme::text_primary())) - .child("Settings"), + .child(super::i18n::tr("settings-title")), ) .child(div().flex_1()) .child( @@ -1623,7 +1606,7 @@ impl Render for SettingsScreen { .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(SignOutRequested); })) - .child("Sign out"), + .child(super::i18n::tr("settings-sign-out")), ), )) .child( @@ -1654,7 +1637,7 @@ impl SettingsScreen { div() .id("settings-nav") .role(gpui::Role::TabList) - .aria_label("Settings sections") + .aria_label(super::i18n::tr("settings-sections")) .aria_orientation(gpui::Orientation::Vertical) .w(gpui::px(220.)) .h_full() @@ -1671,7 +1654,7 @@ impl SettingsScreen { div() .id(gpui::SharedString::from(format!( "settings-nav-{}", - section.label() + section.id() ))) .role(gpui::Role::Tab) .aria_label(section.label()) @@ -1724,158 +1707,7 @@ impl SettingsScreen { pane = pane.child(self.render_advanced(cx)); } Section::General => { - pane = pane - .child(section_title("Defaults")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Web), - toggle_row( - "New tasks can use the web", - "Offers web_search and open_url to the model. Each task can \ - switch web access on or off from its composer.", - self.settings.default_web_enabled, - cx.listener(|this, _event, _window, cx| { - this.toggle_web_default(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Appearance), - self.setting_menu_row( - "Appearance", - "Follow the system theme, or force dark or light.", - SettingMenu::Appearance, - window, - cx, - ), - )) - .child(section_title("Chat appearance")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ChatFont), - self.setting_menu_row( - "Chat font", - crate::ui::typography::ChatFontFamily::parse( - &self.settings.chat_font_family, - ) - .note(), - SettingMenu::ChatFont, - window, - cx, - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ChatSize), - self.setting_menu_row( - "Text size", - "Size of conversation and composer text. Sidebar and buttons stay Manrope.", - SettingMenu::ChatSize, - window, - cx, - ), - )) - .child(self.chat_appearance_preview()) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ToolDetails), - toggle_row( - "Show tool call details", - "Tool cards include their input and output payloads.", - self.settings.tool_details, - cx.listener(|this, _event, _window, cx| { - this.toggle_tool_details(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Notifications), - toggle_row( - "Desktop notifications", - "Notify when a task finishes or needs your input while \ - the window is not focused.", - self.settings.desktop_notifications, - cx.listener(|this, _event, _window, cx| { - this.toggle_desktop_notifications(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::PasswordNotificationDetails), - toggle_row( - "Show task details in password mode", - "Include task titles and questions in desktop notifications. \ - Your operating system may retain this text after Maple quits.", - self.settings.password_notification_details, - cx.listener(|this, _event, _window, cx| { - this.toggle_password_notification_details(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ReduceMotion), - toggle_row( - "Reduce motion", - "Hold spinners and pulsing dots still and skip reveal \ - animations.", - self.settings.reduce_motion, - cx.listener(|this, _event, _window, cx| { - this.toggle_reduce_motion(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ToolSummaries), - toggle_row( - "Summarize tool calls", - "Completed tool calls with long output get a one-line \ - summary on their cards.", - self.settings.tool_summaries, - cx.listener(|this, _event, _window, cx| { - this.toggle_tool_summaries(cx); - }), - ), - )) - .child(section_title("Editing")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ComposerVim), - toggle_row( - "Vim mode in composer", - "Use Normal, Insert, and Visual editing modes in the main chat composer. Other text fields stay unchanged.", - self.settings.composer_vim_enabled, - cx.listener(|this, _event, _window, cx| { - this.toggle_composer_vim(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ApplicationVim), - toggle_row( - "Vim navigation across the app", - "Navigate stable sidebar, transcript, dialog, and Settings targets. Ordinary text fields still edit normally.", - self.settings.application_vim_enabled, - cx.listener(|this, _event, _window, cx| { - this.toggle_application_vim(cx); - }), - ), - )) - .child(section_title("Voice")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Voice), - self.setting_menu_row( - "Speech voice", - "The voice that reads messages aloud.", - SettingMenu::Voice, - window, - cx, - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::SpeechSpeed), - self.setting_menu_row( - "Speech speed", - "How fast messages are read aloud.", - SettingMenu::SpeechSpeed, - window, - cx, - ), - )); + pane = self.render_general(pane, window, cx); } Section::Account => { pane = pane.child(self.render_account_pane(cx)); diff --git a/apps/maple-agent/app/src/ui/settings/general.rs b/apps/maple-agent/app/src/ui/settings/general.rs new file mode 100644 index 000000000..d0435a3ca --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/general.rs @@ -0,0 +1,325 @@ +//! General settings presentation; persisted values and element IDs stay stable. +use super::*; +use crate::ui::i18n::tr; + +impl SettingsScreen { + pub(super) fn render_general( + &self, + pane: gpui::Stateful
, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Stateful
{ + pane.child(section_title(&tr("settings-defaults"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Web), + localized_toggle_row( + "new-tasks-can-use-the-web", + &tr("settings-web"), + &tr("settings-web-description"), + self.settings.default_web_enabled, + cx.listener(|this, _event, _window, cx| { + this.toggle_web_default(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Appearance), + self.setting_menu_row( + &tr("settings-appearance"), + &tr("settings-appearance-description"), + SettingMenu::Appearance, + window, + cx, + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Language), + self.setting_menu_row( + &crate::ui::i18n::tr("settings-language"), + &crate::ui::i18n::tr("settings-language-description"), + SettingMenu::Language, + window, + cx, + ), + )) + .child(section_title(&tr("settings-chat-appearance"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ChatFont), + self.setting_menu_row( + &tr("settings-chat-font"), + &font_note(crate::ui::typography::ChatFontFamily::parse( + &self.settings.chat_font_family, + )), + SettingMenu::ChatFont, + window, + cx, + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ChatSize), + self.setting_menu_row( + &tr("settings-text-size"), + &tr("settings-text-size-description"), + SettingMenu::ChatSize, + window, + cx, + ), + )) + .child(self.chat_appearance_preview()) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ToolDetails), + localized_toggle_row( + "show-tool-call-details", + &tr("settings-tool-details"), + &tr("settings-tool-details-description"), + self.settings.tool_details, + cx.listener(|this, _event, _window, cx| { + this.toggle_tool_details(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Notifications), + localized_toggle_row( + "desktop-notifications", + &tr("settings-notifications"), + &tr("settings-notifications-description"), + self.settings.desktop_notifications, + cx.listener(|this, _event, _window, cx| { + this.toggle_desktop_notifications(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::PasswordNotificationDetails), + localized_toggle_row( + "show-task-details-in-password-mode", + &tr("settings-password-details"), + &tr("settings-password-details-description"), + self.settings.password_notification_details, + cx.listener(|this, _event, _window, cx| { + this.toggle_password_notification_details(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ReduceMotion), + localized_toggle_row( + "reduce-motion", + &tr("settings-reduce-motion"), + &tr("settings-reduce-motion-description"), + self.settings.reduce_motion, + cx.listener(|this, _event, _window, cx| { + this.toggle_reduce_motion(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ToolSummaries), + localized_toggle_row( + "summarize-tool-calls", + &tr("settings-tool-summaries"), + &tr("settings-tool-summaries-description"), + self.settings.tool_summaries, + cx.listener(|this, _event, _window, cx| { + this.toggle_tool_summaries(cx); + }), + ), + )) + .child(section_title(&tr("settings-editing"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ComposerVim), + localized_toggle_row( + "vim-mode-in-composer", + &tr("settings-composer-vim"), + &tr("settings-composer-vim-description"), + self.settings.composer_vim_enabled, + cx.listener(|this, _event, _window, cx| { + this.toggle_composer_vim(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ApplicationVim), + localized_toggle_row( + "vim-navigation-across-the-app", + &tr("settings-application-vim"), + &tr("settings-application-vim-description"), + self.settings.application_vim_enabled, + cx.listener(|this, _event, _window, cx| { + this.toggle_application_vim(cx); + }), + ), + )) + .child(section_title(&tr("settings-voice"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Voice), + self.setting_menu_row( + &tr("settings-speech-voice"), + &tr("settings-speech-voice-description"), + SettingMenu::Voice, + window, + cx, + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::SpeechSpeed), + self.setting_menu_row( + &tr("settings-speech-speed"), + &tr("settings-speech-speed-description"), + SettingMenu::SpeechSpeed, + window, + cx, + ), + )) + } + fn chat_appearance_preview(&self) -> Div { + let family = crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family); + let size = crate::ui::typography::clamp_chat_font_size(self.settings.chat_font_size); + let speaker = |name: gpui::SharedString| { + div() + .text_xs() + .font_family(crate::assets::FONT_BODY) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_muted())) + .child(name) + }; + // The settings pane and user bubbles both sit on elevated fills in + // dark mode. Recess a real chat well (`bg_app`) so assistant text + // and the user bubble separate from the card, matching the transcript. + widgets::card_row() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_xs() + .font_family(crate::assets::FONT_BODY) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_muted())) + .flex() + .flex_wrap() + .child(tr("settings-preview")) + .child(format!(" · {} · {size} px", font_label(family))), + ) + .child( + div() + .w_full() + .rounded(theme::RADIUS_MD) + .border_1() + .border_color(gpui::rgb(theme::border())) + .bg(gpui::rgb(if theme::is_light() { + theme::bg_sidebar() + } else { + theme::bg_app() + })) + .px_3() + .py_3() + .flex() + .flex_col() + .gap_2() + .child( + crate::ui::typography::chat_reading(div()) + .max_w(gpui::relative(0.82)) + .flex() + .flex_col() + .gap_1() + .text_color(gpui::rgb(theme::text_primary())) + .child(speaker(tr("settings-preview-assistant"))) + .child(tr("settings-preview-text")) + .child( + div() + .font_weight(crate::ui::typography::emphasis_weight()) + .child(tr("settings-preview-bold")), + ), + ) + .child( + div().w_full().flex().justify_end().child( + crate::ui::typography::chat_reading(div()) + .px_3() + .py_2() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_user_bubble())) + .border_1() + .border_color(gpui::rgb(theme::user_bubble_border())) + .flex() + .flex_col() + .gap_1() + .text_color(gpui::rgb(theme::text_primary())) + .child(speaker(tr("settings-preview-you"))) + .child(tr("settings-preview-reply")), + ), + ), + ) + } +} + +fn localized_toggle_row( + slug: &'static str, + title: &str, + description: &str, + on: bool, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, +) -> gpui::Stateful
{ + widgets::card_row() + .id(gpui::SharedString::from(format!("setting-{slug}"))) + .flex() + .items_center() + .justify_between() + .gap_4() + .hover(|style| style.cursor_pointer()) + .on_click(on_click) + .child(setting_copy(title, description)) + .child(widgets::switch( + gpui::SharedString::from(format!("setting-switch-{slug}")), + on, + )) +} +pub(super) fn theme_label(value: theme::Preference) -> gpui::SharedString { + match value { + theme::Preference::System => tr("settings-theme-label-system"), + theme::Preference::Dark => tr("settings-theme-label-dark"), + theme::Preference::Light => tr("settings-theme-label-light"), + } +} +pub(super) fn font_label(value: crate::ui::typography::ChatFontFamily) -> gpui::SharedString { + match value { + crate::ui::typography::ChatFontFamily::System => tr("settings-font-label-system"), + crate::ui::typography::ChatFontFamily::Manrope => tr("settings-font-label-manrope"), + crate::ui::typography::ChatFontFamily::Geist => tr("settings-font-label-geist"), + crate::ui::typography::ChatFontFamily::Serif => tr("settings-font-label-serif"), + } +} +pub(super) fn font_note(value: crate::ui::typography::ChatFontFamily) -> gpui::SharedString { + match value { + crate::ui::typography::ChatFontFamily::System => tr("settings-font-note-system"), + crate::ui::typography::ChatFontFamily::Manrope => tr("settings-font-note-manrope"), + crate::ui::typography::ChatFontFamily::Geist => tr("settings-font-note-geist"), + crate::ui::typography::ChatFontFamily::Serif => tr("settings-font-note-serif"), + } +} +pub(super) fn voice_label(id: &str) -> String { + match id { + "neutral_female" => tr("settings-voice-neutral-female").to_string(), + "neutral_male" => tr("settings-voice-neutral-male").to_string(), + "casual_female" => tr("settings-voice-casual-female").to_string(), + "casual_male" => tr("settings-voice-casual-male").to_string(), + "cheerful_female" => tr("settings-voice-cheerful-female").to_string(), + "ar_male" => tr("settings-voice-ar-male").to_string(), + "de_female" => tr("settings-voice-de-female").to_string(), + "de_male" => tr("settings-voice-de-male").to_string(), + "es_female" => tr("settings-voice-es-female").to_string(), + "es_male" => tr("settings-voice-es-male").to_string(), + "fr_female" => tr("settings-voice-fr-female").to_string(), + "fr_male" => tr("settings-voice-fr-male").to_string(), + "hi_female" => tr("settings-voice-hi-female").to_string(), + "hi_male" => tr("settings-voice-hi-male").to_string(), + "it_female" => tr("settings-voice-it-female").to_string(), + "it_male" => tr("settings-voice-it-male").to_string(), + "nl_female" => tr("settings-voice-nl-female").to_string(), + "nl_male" => tr("settings-voice-nl-male").to_string(), + "pt_female" => tr("settings-voice-pt-female").to_string(), + "pt_male" => tr("settings-voice-pt-male").to_string(), + _ => id.to_owned(), + } +} diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index ba3508c2a..ae24690b1 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -28,6 +28,7 @@ pub(super) enum SettingsRegion { pub(super) enum GeneralTarget { Web, Appearance, + Language, ChatFont, ChatSize, ToolDetails, @@ -134,6 +135,7 @@ impl SettingsScreen { Section::General => [ GeneralTarget::Web, GeneralTarget::Appearance, + GeneralTarget::Language, GeneralTarget::ChatFont, GeneralTarget::ChatSize, GeneralTarget::ToolDetails, @@ -388,6 +390,7 @@ impl SettingsScreen { fn activate_general_target(&mut self, target: GeneralTarget, cx: &mut Context) { match target { GeneralTarget::Web => self.toggle_web_default(cx), + GeneralTarget::Language => self.toggle_setting_menu(SettingMenu::Language, cx), GeneralTarget::Appearance => self.toggle_setting_menu(SettingMenu::Appearance, cx), GeneralTarget::ChatFont => self.toggle_setting_menu(SettingMenu::ChatFont, cx), GeneralTarget::ChatSize => self.toggle_setting_menu(SettingMenu::ChatSize, cx), @@ -1064,6 +1067,31 @@ mod tests { }); } + #[gpui::test] + fn spanish_general_preserves_controls_and_saved_values(cx: &mut TestAppContext) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); + crate::ui::i18n::initialize_saved_preference(Some("es")); + let (settings, cx) = dropdown_window(cx, true); + cx.run_until_parked(); + assert!(cx.debug_bounds("setting-value-appearance").is_some()); + assert!(cx.debug_bounds("setting-value-text-size").is_some()); + settings.update(cx, |this, cx| { + assert_eq!(this.menu_value(SettingMenu::Appearance), "Sistema"); + assert_eq!(this.menu_value(SettingMenu::ChatFont), "Sistema"); + let voices = this.menu_options(SettingMenu::Voice); + assert_eq!(voices.len(), crate::settings::TTS_VOICES.len()); + assert_eq!(voices[8].label, "Con acento español — Femenina"); + this.pick_setting_option(SettingMenu::Voice, 8, cx); + assert_eq!(this.settings.tts_voice, "es_female"); + assert_eq!(this.menu_value(SettingMenu::Voice), voices[8].label); + this.pick_setting_option(SettingMenu::Appearance, 1, cx); + assert_eq!(this.settings.theme, "dark"); + assert_eq!(this.menu_value(SettingMenu::Appearance), "Oscuro"); + assert_eq!(Section::General.id(), "General"); + assert_eq!(Section::Advanced.label().as_ref(), "Privacidad y seguridad"); + }); + } + #[gpui::test] fn dropdown_rows_select_highlight_and_close(cx: &mut TestAppContext) { let (settings, cx) = dropdown_window(cx, false); @@ -1181,8 +1209,37 @@ mod tests { ); } + #[gpui::test] + fn language_dropdown_supports_pointer_and_keyboard_selection(cx: &mut TestAppContext) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); + let (settings, cx) = dropdown_window(cx, false); + for expected in [Some("en"), Some("es"), None] { + let button = cx + .debug_bounds("setting-value-language") + .expect("language selector"); + cx.simulate_click(button.center(), gpui::Modifiers::default()); + cx.simulate_keystrokes("down enter"); + settings.update(cx, |this, _| { + assert_eq!(this.settings.locale.as_deref(), expected); + assert_eq!( + crate::ui::i18n::tr("settings-language").as_ref(), + if expected == Some("es") { + "Idioma" + } else { + "Language" + } + ); + assert_eq!(this.popup.open_key(), None); + }); + } + crate::settings::discard_decrypted_cache(); + } + #[gpui::test] fn picking_from_the_dropdown_persists_in_reopened_store(cx: &mut TestAppContext) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); let root = tempfile::tempdir().unwrap(); let paths = maple_agent::store::StorePaths::new(root.path()); @@ -1197,6 +1254,8 @@ mod tests { settings.update(cx, |this, cx| { this.toggle_setting_menu(SettingMenu::Appearance, cx); this.pick_setting_option(SettingMenu::Appearance, 2, cx); + this.toggle_setting_menu(SettingMenu::Language, cx); + this.pick_setting_option(SettingMenu::Language, 2, cx); }); crate::settings::update_settings_and_wait(|_| {}); // Detach the writer's Arc and discard its cached value before closing @@ -1216,6 +1275,10 @@ mod tests { Some("light"), "a dropdown pick must reach the authoritative store" ); + assert_eq!( + saved.get("locale").and_then(serde_json::Value::as_str), + Some("es") + ); reopened.close().unwrap(); cx.run_until_parked(); } diff --git a/apps/maple-agent/app/src/ui/theme.rs b/apps/maple-agent/app/src/ui/theme.rs index 1754bb1d6..bd284a0b0 100644 --- a/apps/maple-agent/app/src/ui/theme.rs +++ b/apps/maple-agent/app/src/ui/theme.rs @@ -39,14 +39,6 @@ impl Preference { Self::Light => "light", } } - - pub fn label(self) -> &'static str { - match self { - Self::System => "System", - Self::Dark => "Dark", - Self::Light => "Light", - } - } } static PREFERENCE: AtomicU8 = AtomicU8::new(0); diff --git a/apps/maple-agent/app/src/ui/typography.rs b/apps/maple-agent/app/src/ui/typography.rs index 0124a769f..259181dcc 100644 --- a/apps/maple-agent/app/src/ui/typography.rs +++ b/apps/maple-agent/app/src/ui/typography.rs @@ -63,24 +63,6 @@ impl ChatFontFamily { } } - pub fn label(self) -> &'static str { - match self { - Self::System => "System", - Self::Manrope => "Manrope", - Self::Geist => "Geist", - Self::Serif => "Serif", - } - } - - pub fn note(self) -> &'static str { - match self { - Self::System => "The familiar interface font for your device.", - Self::Manrope => "Maple's brand face, with a compact modern feel.", - Self::Geist => "A lighter grotesque that pairs with Geist Mono.", - Self::Serif => "A traditional reading style with distinct letterforms.", - } - } - fn family_name(self) -> &'static str { match self { Self::System => assets::FONT_SYSTEM, diff --git a/apps/maple-agent/app/src/ui/update_confirmation.rs b/apps/maple-agent/app/src/ui/update_confirmation.rs index 2a74d1e9e..c0caae7ed 100644 --- a/apps/maple-agent/app/src/ui/update_confirmation.rs +++ b/apps/maple-agent/app/src/ui/update_confirmation.rs @@ -4,10 +4,15 @@ use gpui::{App, Context, Entity, FocusHandle, Render, SharedString, Window, div, prelude::*, px}; use tokio::sync::oneshot; -use super::{theme, widgets}; +use super::{ + i18n::{tr, tr_args}, + theme, widgets, +}; pub(crate) struct UpdateConfirmation { title: SharedString, + version: Option, + locale: fluent_templates::LanguageIdentifier, answer: Option>, previous_focus: Option, later_focus: FocusHandle, @@ -15,14 +20,24 @@ pub(crate) struct UpdateConfirmation { } pub(crate) fn open( - title: String, + version: Option<&str>, window: &mut Window, cx: &mut App, ) -> (Entity, oneshot::Receiver) { let (sender, receiver) = oneshot::channel(); let previous_focus = window.focused(cx); let view = cx.new(|cx| UpdateConfirmation { - title: title.into(), + title: version.map_or_else( + || tr("update-confirmation-title"), + |version| { + tr_args( + "update-confirmation-title-version", + [("version", version.into())], + ) + }, + ), + version: version.map(str::to_owned), + locale: super::i18n::current_locale(), answer: Some(sender), previous_focus, later_focus: cx.focus_handle(), @@ -50,6 +65,19 @@ impl Render for UpdateConfirmation { if self.answer.is_none() { return div().into_any_element(); } + let locale = super::i18n::current_locale(); + if self.locale != locale { + self.title = self.version.as_deref().map_or_else( + || tr("update-confirmation-title"), + |version| { + tr_args( + "update-confirmation-title-version", + [("version", version.into())], + ) + }, + ); + self.locale = locale; + } let width = (window.viewport_size().width - px(48.)).clamp(px(0.), px(480.)); let height = (window.viewport_size().height - px(48.)).max(px(0.)); div() @@ -103,13 +131,6 @@ impl Render for UpdateConfirmation { .flex_col() .gap_4() .text_color(gpui::rgb(theme::text_primary())) - .child( - div() - .w_full() - .flex_shrink_0() - .text_lg() - .child(self.title.clone()), - ) .child( div() .id("update-confirmation-body") @@ -118,8 +139,15 @@ impl Render for UpdateConfirmation { .min_w_0() .min_h_0() .overflow_y_scroll() - .text_sm() - .child(crate::update::RESTART_DETAIL), + .child( + div() + .w_full() + .flex() + .flex_col() + .gap_4() + .child(div().text_lg().child(self.title.clone())) + .child(div().text_sm().child(tr("update-confirmation-detail"))), + ), ) .child( div() @@ -133,7 +161,7 @@ impl Render for UpdateConfirmation { .debug_selector(|| "update-confirmation-later".to_string()) .track_focus(&self.later_focus) .focus_visible(widgets::focus_ring) - .child("Later") + .child(tr("update-confirmation-later")) .on_click(cx.listener(|this, _, window, cx| { this.respond(0, window, cx) })), @@ -143,7 +171,7 @@ impl Render for UpdateConfirmation { .debug_selector(|| "update-confirmation-restart".to_string()) .track_focus(&self.restart_focus) .focus_visible(widgets::focus_ring) - .child("Restart to Update") + .child(tr("update-confirmation-restart")) .on_click(cx.listener(|this, _, window, cx| { this.respond(1, window, cx) })), @@ -169,17 +197,52 @@ mod tests { } } + #[gpui::test] + fn open_dialog_refreshes_its_title_on_language_change(cx: &mut gpui::TestAppContext) { + let _locale = crate::ui::i18n::TestLocalizationGuard::enable(); + let (host, _answer, cx) = show(cx); + for (locale, prefix) in [("es", "¿Reiniciar Maple"), ("en", "Restart Maple")] { + cx.update(|_, app| { + crate::ui::i18n::apply_preference(Some(locale)); + app.refresh_windows(); + }); + cx.run_until_parked(); + host.update(cx, |host, cx| { + assert!( + host.prompt + .as_ref() + .unwrap() + .read(cx) + .title + .starts_with(prefix) + ); + }); + } + } + fn show( cx: &mut gpui::TestAppContext, ) -> ( Entity, oneshot::Receiver, &mut gpui::VisualTestContext, + ) { + show_with_title(cx, "Restart Maple to install v\u{2068}0.0.14\u{2069}?") + } + + fn show_with_title<'a>( + cx: &'a mut gpui::TestAppContext, + expected_title: &str, + ) -> ( + Entity, + oneshot::Receiver, + &'a mut gpui::VisualTestContext, ) { let answer = Rc::new(RefCell::new(None)); let result = answer.clone(); let (host, cx) = cx.add_window_view(move |window, cx| { - let (prompt, receiver) = open(crate::update::restart_title(Some("0.0.14")), window, cx); + let (prompt, receiver) = open(Some("0.0.14"), window, cx); + assert_eq!(prompt.read(cx).title.as_ref(), expected_title); *result.borrow_mut() = Some(receiver); PromptHost { prompt: Some(prompt), @@ -189,11 +252,41 @@ mod tests { (host, receiver, cx) } + #[gpui::test] + fn updater_confirmation_without_version_uses_generic_title(cx: &mut gpui::TestAppContext) { + cx.add_window_view(|window, cx| { + let (prompt, _answer) = open(None, window, cx); + assert_eq!(prompt.read(cx).title.as_ref(), "Restart Maple to update?"); + PromptHost { + prompt: Some(prompt), + } + }); + } + #[gpui::test] fn updater_confirmation_wraps_and_keeps_actions_inside_the_viewport( cx: &mut gpui::TestAppContext, ) { let (_host, _answer, cx) = show(cx); + assert_layout(cx); + } + + #[gpui::test] + fn spanish_confirmation_wraps_and_keeps_actions_inside_the_viewport( + cx: &mut gpui::TestAppContext, + ) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); + crate::ui::i18n::initialize_saved_preference(Some("es")); + let (_host, mut answer, cx) = show_with_title( + cx, + "¿Reiniciar Maple para instalar la versión \u{2068}0.0.14\u{2069}?", + ); + assert_layout(cx); + cx.simulate_keystrokes("escape"); + assert_eq!(answer.try_recv().unwrap(), 0); + } + + fn assert_layout(cx: &mut gpui::VisualTestContext) { for (width, height) in [(1213., 800.), (640., 600.), (320., 480.), (640., 220.)] { cx.simulate_resize(gpui::size(px(width), px(height))); let card = cx.debug_bounds("update-confirmation").unwrap(); diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index f35bb06ed..e5cc6e7ec 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -45,6 +45,7 @@ pub enum UpdateAction { /// Title of the restart confirmation. It names the version, since the /// sidebar's update button shows only an icon. +#[cfg(not(target_os = "linux"))] pub fn restart_title(version: Option<&str>) -> String { version.map_or_else( || "Restart Maple to update?".to_string(), @@ -52,6 +53,7 @@ pub fn restart_title(version: Option<&str>) -> String { ) } +#[cfg(not(target_os = "linux"))] pub const RESTART_DETAIL: &str = "This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update."; #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/apps/maple-agent/app/tests/fixtures/i18n/en/app.ftl b/apps/maple-agent/app/tests/fixtures/i18n/en/app.ftl new file mode 100644 index 000000000..f0b944335 --- /dev/null +++ b/apps/maple-agent/app/tests/fixtures/i18n/en/app.ftl @@ -0,0 +1,4 @@ +present = English +missing = English fallback +invalid = Safe fallback +empty = Nonempty fallback diff --git a/apps/maple-agent/app/tests/fixtures/i18n/fr/app.ftl b/apps/maple-agent/app/tests/fixtures/i18n/fr/app.ftl new file mode 100644 index 000000000..1bb9f2293 --- /dev/null +++ b/apps/maple-agent/app/tests/fixtures/i18n/fr/app.ftl @@ -0,0 +1,3 @@ +present = Français +invalid = { $missing } +empty = { "" } diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md new file mode 100644 index 000000000..85af77c71 --- /dev/null +++ b/apps/maple-agent/docs/localization.md @@ -0,0 +1,96 @@ +# Agent localization + +The localization foundation uses embedded Fluent catalogs. +It currently covers the update confirmation dialog (including accessibility +text), the full General settings pane (including dropdown choices, the chat +preview and test notification), and shared Settings navigation. English and +Spanish are enabled in the preview. +Most of the app is still English; this is infrastructure for incremental +extraction, not a claim of complete localization. + +## Locale and live switching + +Localization is enabled by default. Settings → General → Language offers System, +English and Español. Changes are saved through the host and apply immediately; +no environment variable or restart is needed. + +The OS locale supplies the initial default. The first host settings snapshot +loads the saved preference. A user selection clears the plain-label and System +label caches and refreshes GPUI windows. General settings resolves labels during +render; an open update confirmation reformats its versioned title only when its +locale changes. Saved control values and element IDs remain stable. + +Locale state currently belongs to the GPUI thread. Call presentation helpers on +that thread; background workers should return semantic data for the UI to format. +Tests use isolated thread-local state and default to English. Other pages and +native/tray menus remain English until extracted. Future translated cached views +must invalidate formatted text on locale changes; refreshing windows alone does +not rebuild their stored strings. + +## Adding UI text + +Catalogs live in `app/locales/{locale}/app.ftl`. `fluent-templates` embeds them +at compile time; there is no runtime catalog directory or disk access. The +existing Nix fileset includes `app/`; `build.rs` tracks the catalog directory, +including additions/removals. `fluent-bundle` supplies typed argument values. + +- Use stable module-prefixed message IDs, such as `update-confirmation-title`. +- Use `ui::i18n::tr("message-id")` for plain labels. Each resolved label is cached + as a `SharedString` for the process locale; rendering only clones that handle. +- Use `tr_args("message-id", [("count", count.into())])` for interpolation. + Format these when the input state changes and retain the result in the view; + do not format dynamic messages on every render. Pass numbers as numbers so + Fluent can select CLDR plural branches. The System language label is cached. +- Translate accessibility labels too. Keep element IDs, keymap slots, commands, + protocol values and other machine-readable identifiers stable. +- Keep complete sentences together. Add translator comments explaining args. + Fluent bidi isolation (U+2068/U+2069) remains enabled; assertions must account + for it. GPUI shaping/fallback-font checks are required before enabling scripts + that need them. + +Lookup tries the selected catalog, then English, then the key itself. Empty or +unformattable values also fall back. Logs contain only the key and catalog, not +arguments or resolver errors that might contain user text. Cached plain labels +log a fallback once per first resolution rather than on every frame. + +## Validation and incremental extraction + +The existing `agent-test` cargo suite checks catalog syntax, duplicate IDs, +identical shipped key sets, enabled/catalog parity, literal UI lookup keys, +fallback behavior, argument isolation and numeric plurals. The converted-module +check rejects direct literals in common label methods; expand its module list +as extraction proceeds. It is deliberately a narrow heuristic, not proof that +all user-facing text has been found. Existing unconverted modules remain debt; +new user-facing text should use the catalog rather than increasing that debt. +No new workflow or CI lane is needed. + +A partial catalog can fall back safely at runtime, but it is not enabled or +shipped until completeness checks pass. To add a language, add its complete +catalog and native `language-name`, then register it in `i18n::LANGUAGES`. +Validate layout, font fallback, bidi marks, and platform-native language +metadata before production enablement. Spanish currently has Linux preview +evidence only; macOS/Windows runtime and Spanish native language metadata remain +follow-ups. The current macOS bundle declares English only, so native OS dialogs +are outside this preview's Spanish coverage. + +## Explicit follow-ups + +Extraction is substantially larger than just `child(...)` calls: shortcut names +and categories, desktop code, backend/host/remote errors, and other non-UI files +also contain display text. Work module by module, starting with Settings/login. + +- Host/ACP/backend errors remain English strings. Localizing them needs typed + semantic errors plus arguments across the process boundary, formatted by the + consuming UI; it is not a reason to translate shared host strings in place. +- Persisted timeline labels remain unchanged. Their future migration requires + semantic kinds, a `TIMELINE_FORMAT` bump and plain/keyed projection tests. +- Model-facing tool descriptions, system prompts, notices sent to models, user + content and generated content are never translated by UI extraction. +- Native/tray menus, OS dialogs and the standalone OAuth completion HTML are + outside this first milestone. Each has its own lifecycle/packaging contract. +- Dates, localized numeric separators and money need a separate formatting + decision; Fluent plural selection does not provide complete locale formatting. +- Spell checking remains `en_US`; UI language and content language are separate. +- RTL layout/mirroring is not supported by this implementation. Do not enable + RTL locales until the GPUI/platform limitation is addressed. CJK and other + scripts require font-fallback testing on every supported desktop platform.