diff --git a/README.md b/README.md index 41588327..a082c23c 100644 --- a/README.md +++ b/README.md @@ -1236,6 +1236,33 @@ Pressure controls the width of pressure-sensitive freehand Pen strokes. Marker/T Tablet input works out of the box in default builds. Set `[tablet].enabled = false` in `config.toml` to opt out. To build without tablet support, drop only that feature (bare `--no-default-features` would also strip portal capture, tray, and the GTK toolbars): `cargo build --release --no-default-features --features portal,tray,toolbar-gtk`. +### Interface language + +Wayscriber's interface is available in English and Simplified Chinese. By +default it follows your desktop locale and falls back to English. Pick a +language in the configurator (**UI → Language**, which previews it right away +and keeps it when you save) or in `config.toml`: + +```toml +[ui] +language = "zh-CN" # "auto" (default), "en", or "zh-CN" +``` + +The overlay and the About window use the saved language the next time they +open, and background mode after it restarts. To use a language for one run +without changing `config.toml`, or to see which one is in effect: + +```bash +wayscriber --list-languages # the choices, and the language in effect and why +wayscriber --active --language zh-CN # this overlay only +wayscriber --daemon --language en # background mode and every overlay it opens +``` + +`--language` also applies to `--freeze` and `--about`. It beats both +`[ui] language` and `WAYSCRIBER_LANGUAGE`. Typed text, board names and other +content you create are never translated. See [docs/I18N.md](docs/I18N.md) for +translations, local catalog overrides and adding a language. + --- ## Troubleshooting @@ -1329,6 +1356,7 @@ Common toggles: - `WAYSCRIBER_TOOLBAR_BACKEND=auto|gtk|builtin` overrides the toolbar frontend (default: auto) - `WAYSCRIBER_NO_TRAY=1` disables the tray icon (default: tray enabled) - `WAYSCRIBER_TRAY_FORCE_PIXMAP=1` forces colored tray pixmaps and overrides `[tray].icon_style` +- `WAYSCRIBER_LANGUAGE=zh-CN` uses an interface language for this run (overrides `[ui] language`; `--language` overrides it) See `docs/CONFIG.md` for the full list. diff --git a/config.example.toml b/config.example.toml index 5be94038..038ccc8a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -421,7 +421,8 @@ reduced_motion = "auto" # (WAYSCRIBER_LANGUAGE, then LANGUAGE/LC_ALL/LC_MESSAGES/LANG) and falls back # to English when that locale has no translation. Each process reads this when # it starts: the overlay and About window on their next open, background mode -# after a restart. +# after a restart. `wayscriber --list-languages` shows the choices and the one +# in effect; `--language CODE` overrides this for one run. language = "auto" # Show the status bar and its configured contents diff --git a/configurator/src/models/fields/theme.rs b/configurator/src/models/fields/theme.rs index 6472e5ba..0ed7ce5a 100644 --- a/configurator/src/models/fields/theme.rs +++ b/configurator/src/models/fields/theme.rs @@ -117,10 +117,9 @@ impl LanguageOption { /// must still recognize their own language to get back. Only the /// automatic choice, which is not a language, follows the UI. pub fn label(&self) -> &'static str { - match self { - LanguageOption::Auto => tr("Automatic (system locale)"), - LanguageOption::English => "English", - LanguageOption::SimplifiedChinese => "简体中文", + match self.to_language().native_name() { + Some(name) => name, + None => tr("Automatic (system locale)"), } } diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ac86038c..449c729d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1137,7 +1137,7 @@ to save it. **Theme, Motion & Language:** - **Theme**: `theme` selects the overlay chrome theme — `"auto"` (default), `"dark"`, or `"light"`. `"auto"` currently resolves to dark chrome. The command palette supports `"light"` throughout its search, results, shortcut editing, and tooltips. Other popups retain their complete dark palette until migrated. - **Reduced motion**: `reduced_motion = "on"` disables overlay chrome animations (toast and flash fades render instantly; coverage extends to more surfaces as they adopt the shared animation envelopes). `"off"` keeps full motion. `"auto"` (default) is reserved for a future desktop-portal query of the system reduce-motion preference and currently behaves like `"off"` (full motion). -- **Language**: `language` selects the interface language — overlay chrome, the About window, background mode's tray and notices, and the configurator. `"auto"` (default) follows the desktop locale, `"en"` forces English, and `"zh-CN"` forces Simplified Chinese. A locale this build has no catalog for falls back to English, so `"auto"` is always safe; `WAYSCRIBER_LANGUAGE` overrides the setting for one run. Picking a language in the configurator previews it there at once and is written on Save; the overlay and the About window use it the next time they open, and background mode after it restarts. `"auto"` follows each process's own locale, and a systemd user service can see a different one than your terminal. Canvas content you type or name stays exactly as you wrote it. See `docs/I18N.md` for the catalog format, local catalog overrides, and the surfaces that are not translated yet. +- **Language**: `language` selects the interface language — overlay chrome, the About window, background mode's tray and notices, and the configurator. `"auto"` (default) follows the desktop locale, `"en"` forces English, and `"zh-CN"` forces Simplified Chinese. A locale this build has no catalog for falls back to English, so `"auto"` is always safe. For one run, `wayscriber --active|--freeze|--daemon|--about --language CODE` overrides the setting without changing the file, and so does `WAYSCRIBER_LANGUAGE`; the flag wins over the variable. `wayscriber --list-languages` lists the codes and says which language is in effect and why. Picking a language in the configurator previews it there at once and is written on Save; the overlay and the About window use it the next time they open, and background mode after it restarts. `"auto"` follows each process's own locale, and a systemd user service can see a different one than your terminal. Canvas content you type or name stays exactly as you wrote it. See `docs/I18N.md` for the catalog format, local catalog overrides, and the surfaces that are not translated yet. **UI Styling:** - **Font sizes**: Customize text size for status bar and help overlay @@ -2693,6 +2693,8 @@ These override behavior at runtime. Bool-ish values treat anything except `0`, ` - `WAYSCRIBER_NO_TRAY=1` disables the tray icon (default: tray enabled) - `WAYSCRIBER_RESUME_SESSION=1/0` forces session persistence on/off for the current run (default: unset; follows config) +- `WAYSCRIBER_LANGUAGE=en|zh-CN|auto` sets the interface language for the current run (overrides `[ui] language`; `--language` overrides it) +- `WAYSCRIBER_LOCALE_DIR=/path` names a directory of translation catalogs to merge over the built-in ones (default: `$XDG_DATA_HOME/wayscriber/locales`; see `docs/I18N.md`) - `WAYSCRIBER_CONFIGURATOR=/path/to/wayscriber-configurator` overrides the configurator executable path - `WAYSCRIBER_DISABLE_UPDATE_CHECK=1` disables the background update check for this run (overrides `[updates] check`; `--check-update` still works) - `WAYSCRIBER_FORCE_INLINE_TOOLBARS=1` forces inline toolbars on Wayland (default: off) diff --git a/docs/I18N.md b/docs/I18N.md index 8bf12684..26301a28 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -35,11 +35,26 @@ a terminal's: `systemctl --user show-environment` shows what it sees, and a locale set with `systemctl --user set-environment LANG=…` or in `~/.config/environment.d/` applies after the service restarts. +To use a language for one run without editing `config.toml`, pass +`--language`; `wayscriber --list-languages` shows the codes, the configured +language, and which language is in effect and why: + +```sh +wayscriber --list-languages +wayscriber --active --language zh-CN +wayscriber --daemon --language en # background mode and every overlay it opens +``` + +`--language` applies to `--active`, `--freeze`, `--daemon` and `--about`. +A daemon passes it to the overlays it opens and to the About window, but not +to the configurator, whose Language row is the way to preview a language +there. It cannot change a daemon that is already running. + Two environment variables help while testing a translation: -- `WAYSCRIBER_LANGUAGE=zh-CN` overrides `[ui] language` entirely, in every - process that sees it, so a translation can be checked without editing - `config.toml`. +- `WAYSCRIBER_LANGUAGE=zh-CN` overrides `[ui] language` in every process + that sees it, the configurator included, so a translation can be checked + without editing `config.toml`. `--language` beats it. - `WAYSCRIBER_LOCALE_DIR=…` names a directory of catalog files to merge over the embedded ones, so a translation can be corrected without a rebuild. @@ -129,7 +144,9 @@ to decide what to do. ### What is not translated -- `--help` and `--version`, which print before the configuration is read; +- command-line output: `--help` and `--version`, which print before the + configuration is read, and `--list-languages`, which names each language + in itself; - log messages and developer diagnostics; - keyboard key names. `Ctrl+Shift+1`, `Enter`, and `Not bound` are printed as they appear on the keyboard; only the prose around them is translated. @@ -156,14 +173,17 @@ A row's translated caption is not searchable unless it is one of those. ## Adding a language 1. `src/domain/language.rs`: add a variant, its `code()`, its - `catalog_code()`, and its serde name and aliases. + `catalog_code()`, its serde name and aliases, its entry in `ALL`, and its + `native_name()` and `english_name()`. `--list-languages` reads all of + these; the configurator's Language row takes its labels from + `native_name()`. 2. `src/i18n.rs`: the spellings `language_from_code` and `language_from_locale` accept, the variant's `encode`/`decode` atom, and the new file in `EMBEDDED_CATALOGS`. 3. `src/config/types/toolbar/items/definitions.rs`: a table for the language in `toolbar_item_definitions_in`, which keeps one per language. -4. `configurator/src/models/fields/theme.rs`: a `LanguageOption`, labeled with - the language's own name. +4. `configurator/src/models/fields/theme.rs`: a `LanguageOption` for the + variant. 5. `locales/.toml`: translate `locales/zh-CN.toml`, keeping the keys. 6. `cargo test --test repository_guards translations` must pass, which proves the new catalog covers every marked string. diff --git a/src/about_window/mod.rs b/src/about_window/mod.rs index 5a766c58..22705d27 100644 --- a/src/about_window/mod.rs +++ b/src/about_window/mod.rs @@ -115,6 +115,22 @@ fn run_about_event_loop( Ok(()) } +/// The arguments that start the About window: `--about`, and the `--language` +/// this process runs with, if any, so the window matches the one that opened +/// it. +pub(crate) fn launch_arguments() -> Vec { + launch_arguments_for(crate::i18n::command_line_language()) +} + +fn launch_arguments_for(language: Option) -> Vec { + let mut arguments = vec!["--about".into()]; + if let Some(language) = language { + arguments.push("--language".into()); + arguments.push(language.code().into()); + } + arguments +} + /// What the dialog reads from the config before it draws: the theme, from /// the same `[ui] theme` key as the overlay so the dialog matches the toolbars /// the user already sees, and the UI language. A config that cannot be read @@ -198,6 +214,28 @@ mod tests { fs::write(path, contents).expect("write config"); } + #[test] + fn the_window_is_opened_with_the_openers_language() { + assert_eq!(launch_arguments_for(None), ["--about"]); + assert_eq!( + launch_arguments_for(Some(Language::SimplifiedChinese)), + ["--about", "--language", "zh-CN"] + ); + // The arguments it is given parse back to the same language. + let parsed = crate::cli::Cli::try_parse_from( + std::iter::once("wayscriber".into()) + .chain(launch_arguments_for(Some(Language::SimplifiedChinese))), + ); + assert!( + matches!( + parsed, + Ok(crate::cli::CliOutcome::Run(ref cli)) + if cli.about && cli.language == Some(Language::SimplifiedChinese) + ), + "{parsed:?}" + ); + } + #[test] fn the_dialog_reads_its_language_with_its_theme() { with_temp_config_home(|_| { diff --git a/src/app/languages.rs b/src/app/languages.rs new file mode 100644 index 00000000..c35395db --- /dev/null +++ b/src/app/languages.rs @@ -0,0 +1,174 @@ +//! `--list-languages`: the interface languages, and the one this run uses. +//! +//! Command-line output stays English, like the rest of the CLI. + +use crate::cli::Cli; +use crate::config::Config; +use crate::domain::Language; +use crate::env_vars::OVERLAY_LANGUAGE_ENV; +use crate::i18n::{self, LanguageSource}; + +/// The language this run was given: its own `--language`, or, for an overlay +/// a daemon started, that daemon's. +pub(super) fn given_language(cli: &Cli) -> Option { + given_language_with(cli, std::env::var(OVERLAY_LANGUAGE_ENV).ok().as_deref()) +} + +fn given_language_with(cli: &Cli, from_daemon: Option<&str>) -> Option { + cli.language.or_else(|| { + from_daemon + .filter(|_| cli.active || cli.freeze) + .and_then(i18n::language_from_code) + }) +} + +pub(super) fn print_languages() { + let configured = Config::load() + .map(|loaded| loaded.config.ui.language) + .map_err(|err| format!("{err:#}")); + let requested = i18n::requested_language(configured.clone().unwrap_or_default()); + let in_effect = i18n::resolve(requested.0); + + print!("{}", render(configured, requested, in_effect)); +} + +/// The listing, from what was read: the configured language (or why the +/// config could not be read), the setting that decides, and what it resolves +/// to. +fn render( + configured: Result, + (requested, source): (Language, LanguageSource), + in_effect: Language, +) -> String { + let mut out = String::from("Interface languages:\n"); + for language in Language::ALL { + out.push_str(&format!( + " {:<7} {}\n", + language.code(), + describe(language) + )); + } + + out.push('\n'); + match &configured { + Ok(language) => out.push_str(&format!( + "Configured: {} ([ui] language)\n", + language.code() + )), + // The first line names the file and the position; the rest of a + // parse error is a source excerpt that would break up the listing. + Err(err) => out.push_str(&format!( + "Configured: auto, the default, because the config could not be read: {}\n", + err.lines().next().unwrap_or_default() + )), + } + let decided_by = match source { + LanguageSource::CommandLine => "--language", + LanguageSource::Environment => "WAYSCRIBER_LANGUAGE", + LanguageSource::Configured => "[ui] language", + }; + let through_locale = if requested.is_auto() { + " through the desktop locale" + } else { + "" + }; + out.push_str(&format!( + "In effect: {} {}, from {decided_by}{through_locale}\n", + in_effect.code(), + describe(in_effect) + )); + + out.push_str( + "\nUse --language CODE for one run, or set [ui] language in config.toml or the configurator.\n", + ); + out +} + +fn describe(language: Language) -> String { + match language.native_name() { + None => "follow the desktop locale; English when it has no translation".to_string(), + Some(native) if native == language.english_name() => native.to_string(), + Some(native) => format!("{native} ({})", language.english_name()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(arguments: &[&str]) -> Cli { + match Cli::try_parse_from(std::iter::once("wayscriber").chain(arguments.iter().copied())) { + Ok(crate::cli::CliOutcome::Run(cli)) => cli, + other => panic!("{arguments:?} did not parse to a run: {other:?}"), + } + } + + #[test] + fn an_overlay_takes_its_daemons_language_unless_given_its_own() { + let overlay = parse(&["--active"]); + assert_eq!( + given_language_with(&overlay, Some("zh-CN")), + Some(Language::SimplifiedChinese) + ); + assert_eq!(given_language_with(&overlay, None), None); + assert_eq!(given_language_with(&overlay, Some("fr")), None); + + let own = parse(&["--freeze", "--language", "en"]); + assert_eq!( + given_language_with(&own, Some("zh-CN")), + Some(Language::English) + ); + + // Only an overlay reads the daemon's input; a daemon or the About + // window that inherited it does not. + for arguments in [&["--daemon"][..], &["--about"]] { + assert_eq!(given_language_with(&parse(arguments), Some("zh-CN")), None); + } + } + + #[test] + fn the_listing_names_every_language_and_why_one_is_in_effect() { + let listing = render( + Ok(Language::Auto), + (Language::Auto, LanguageSource::Configured), + Language::SimplifiedChinese, + ); + + assert_eq!( + listing, + "Interface languages:\n\ + \x20 auto follow the desktop locale; English when it has no translation\n\ + \x20 en English\n\ + \x20 zh-CN 简体中文 (Simplified Chinese)\n\ + \n\ + Configured: auto ([ui] language)\n\ + In effect: zh-CN 简体中文 (Simplified Chinese), from [ui] language through the desktop locale\n\ + \n\ + Use --language CODE for one run, or set [ui] language in config.toml or the configurator.\n" + ); + } + + #[test] + fn the_listing_says_which_override_won() { + let from_flag = render( + Ok(Language::English), + (Language::SimplifiedChinese, LanguageSource::CommandLine), + Language::SimplifiedChinese, + ); + let from_environment = render( + Err("TOML parse error at line 1, column 4\n |\n1 | [ui\n".to_string()), + (Language::English, LanguageSource::Environment), + Language::English, + ); + + assert!( + from_flag + .contains("In effect: zh-CN 简体中文 (Simplified Chinese), from --language\n") + ); + assert!(from_environment.contains( + "Configured: auto, the default, because the config could not be read: \ + TOML parse error at line 1, column 4\nIn effect:" + )); + assert!(from_environment.contains("In effect: en English, from WAYSCRIBER_LANGUAGE\n")); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 8c851e47..225f66cc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,5 @@ mod env; +mod languages; mod session; mod usage; @@ -203,6 +204,16 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { return Ok(()); } + // Before anything selects a language, so every selection in this run, + // and the overlays and About windows it starts, use it. + if let Some(language) = languages::given_language(&cli) { + crate::i18n::set_command_line_language(language); + } + if cli.list_languages { + languages::print_languages(); + return Ok(()); + } + // Runtime and update-checking modes create their process broker before // acquiring locks or starting threads. The guard spans the complete run. let _process_broker = needs_process_broker(&cli) diff --git a/src/app/usage.rs b/src/app/usage.rs index c576c9b5..3e6020c2 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -217,6 +217,10 @@ pub(crate) fn print_usage() { println!(" wayscriber --no-tray Skip system tray (headless daemon)"); println!(" wayscriber --about Show the About window"); println!(" wayscriber --check-update Check wayscriber.com for a newer release"); + println!(" wayscriber --list-languages List interface languages and the one in effect"); + println!( + " wayscriber --active --language CODE Use an interface language for this run (also --freeze, --daemon, --about)" + ); println!( " wayscriber --resume-session Force session resume on (all boards/history/tool state)" ); diff --git a/src/backend/wayland/state/helper_launch.rs b/src/backend/wayland/state/helper_launch.rs index 098c251b..4f7e57d7 100644 --- a/src/backend/wayland/state/helper_launch.rs +++ b/src/backend/wayland/state/helper_launch.rs @@ -67,7 +67,7 @@ impl WaylandState { match spawn_detached( crate::process_broker::HelperKind::About, executable.as_os_str(), - &["--about".into()], + &crate::about_window::launch_arguments(), ) { Ok(child) => { log::info!("Launched About window (pid {})", child.id()); diff --git a/src/cli.rs b/src/cli.rs index e80fd52b..b25d7114 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,7 @@ use std::ffi::OsString; use std::path::PathBuf; +use crate::domain::Language; use crate::tray_action::TrayAction; #[derive(Debug, Default)] @@ -76,6 +77,13 @@ pub struct Cli { /// Print compiled runtime capabilities for companion tools pub runtime_capabilities: bool, + + /// Interface language for this run, beating `[ui] language` and + /// `WAYSCRIBER_LANGUAGE` (the config file is not changed) + pub language: Option, + + /// List the interface languages and the one in effect, then exit + pub list_languages: bool, } #[derive(Debug)] @@ -151,6 +159,11 @@ impl Cli { "--no-resume-session" => cli.no_resume_session = true, "--about" => cli.about = true, "--check-update" => cli.check_update = true, + "--language" => { + index += 1; + cli.language = Some(parse_language(&value_after(&args, index, "--language")?)?); + } + "--list-languages" => cli.list_languages = true, crate::runtime_capabilities::RUNTIME_CAPABILITIES_FLAG => { cli.runtime_capabilities = true; } @@ -167,6 +180,9 @@ impl Cli { _ if arg.starts_with("--rename-session=") => { cli.rename_session = Some(value_from_equals(arg, "--rename-session")?); } + _ if arg.starts_with("--language=") => { + cli.language = Some(parse_language(&value_from_equals(arg, "--language")?)?); + } _ if is_short_option_cluster(arg) => { match parse_short_option_cluster(&args, index, &mut cli)? { ShortOptionOutcome::Continue(next_index) => index = next_index, @@ -261,12 +277,17 @@ impl Cli { self.validate_session_flag_pairs()?; self.validate_overlay_commands()?; self.validate_session_commands()?; - self.validate_catalog_commands() + self.validate_catalog_commands()?; + self.validate_language_options() } fn validate_runtime_capabilities_command(&self) -> Result<(), String> { if self.runtime_capabilities - && (self.selects_a_launch_command() || self.about || self.check_update) + && (self.selects_a_launch_command() + || self.about + || self.check_update + || self.list_languages + || self.language.is_some()) { return Err("--runtime-capabilities conflicts with launch flags".to_string()); } @@ -462,8 +483,44 @@ impl Cli { return Err("--check-update conflicts with the selected command".to_string()); } + if self.list_languages + && (self.selects_a_launch_command() || self.about || self.check_update) + { + return Err("--list-languages conflicts with the selected command".to_string()); + } + Ok(()) } + + /// `--language` sets the language of the process it starts, and of the + /// overlays and About windows that process starts in turn. A command that + /// hands its work to a running daemon, or only prints English text, has no + /// window for it to change. + fn validate_language_options(&self) -> Result<(), String> { + if self.language.is_none() { + return Ok(()); + } + if self.daemon_toggle || self.overlay_action_count() > 0 { + return Err( + "--language cannot change a running daemon's overlays; start the daemon with --language, or set [ui] language" + .to_string(), + ); + } + if !(self.active || self.freeze || self.daemon || self.about || self.list_languages) { + return Err( + "--language applies to --active, --freeze, --daemon, --about and --list-languages" + .to_string(), + ); + } + Ok(()) + } +} + +/// Parses a `--language` value: any spelling `[ui] language` accepts. +fn parse_language(value: &str) -> Result { + crate::i18n::language_from_code(value).ok_or_else(|| { + format!("unknown language '{value}'; run 'wayscriber --list-languages' to see the choices") + }) } fn is_short_option_cluster(arg: &str) -> bool { @@ -563,6 +620,7 @@ pub(crate) fn print_help() { println!(" wayscriber --clear-tool-state [--session-file PATH]"); println!(" wayscriber --about"); println!(" wayscriber --check-update"); + println!(" wayscriber --list-languages"); println!(); println!("Options:"); println!(" -d, --daemon Run as background daemon"); @@ -588,6 +646,8 @@ pub(crate) fn print_help() { println!(" --session-file PATH Use a named session file"); println!(" --about Show the About window"); println!(" --check-update Check wayscriber.com for a newer release"); + println!(" --language CODE Interface language for this run (auto, en, zh-CN)"); + println!(" --list-languages List interface languages and the one in effect"); println!(" -h, --help Show help"); println!(" -V, --version Show version"); } diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 353335d1..3d28a639 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -506,3 +506,98 @@ fn check_update_rejects_valued_launch_flags() { "--check-update conflicts with the selected command" ); } + +#[test] +fn language_selects_this_runs_interface_language() { + use crate::domain::Language; + + for (args, expected) in [ + ( + ["wayscriber", "--active", "--language", "zh-CN"], + Language::SimplifiedChinese, + ), + ( + ["wayscriber", "--daemon", "--language", "en"], + Language::English, + ), + ( + ["wayscriber", "--about", "--language", "auto"], + Language::Auto, + ), + // Every spelling [ui] language accepts. + ( + ["wayscriber", "--freeze", "--language", "zh"], + Language::SimplifiedChinese, + ), + ] { + assert_eq!(parse_cli(args).language, Some(expected), "{args:?}"); + } + + let cli = parse_cli(["wayscriber", "--active", "--language=zh-CN"]); + assert_eq!(cli.language, Some(Language::SimplifiedChinese)); +} + +#[test] +fn language_rejects_unknown_codes_and_commands_it_cannot_change() { + let error = |args: &[&str]| match Cli::try_parse_from(args.iter().copied()) { + Err(error) => error, + Ok(outcome) => panic!("{args:?} parsed as {outcome:?}"), + }; + + assert_eq!( + error(&["wayscriber", "--active", "--language", "fr"]), + "unknown language 'fr'; run 'wayscriber --list-languages' to see the choices" + ); + assert_eq!( + error(&["wayscriber", "--active", "--language"]), + "--language requires a value" + ); + for args in [ + &["wayscriber", "--daemon-toggle", "--language", "zh-CN"][..], + &["wayscriber", "--light-toggle", "--language", "zh-CN"], + ] { + assert!( + error(args).starts_with("--language cannot change a running daemon's overlays"), + "{args:?}" + ); + } + for args in [ + &["wayscriber", "--language", "zh-CN"][..], + &["wayscriber", "--session-info", "--language", "zh-CN"], + &["wayscriber", "--check-update", "--language", "zh-CN"], + ] { + assert_eq!( + error(args), + "--language applies to --active, --freeze, --daemon, --about and --list-languages", + "{args:?}" + ); + } + assert_eq!( + error(&[ + "wayscriber", + "--runtime-capabilities", + "--language", + "zh-CN" + ]), + "--runtime-capabilities conflicts with launch flags" + ); +} + +#[test] +fn list_languages_is_a_print_and_exit_command() { + assert!(parse_cli(["wayscriber", "--list-languages"]).list_languages); + // It shows what --language would select. + let cli = parse_cli(["wayscriber", "--list-languages", "--language", "zh-CN"]); + assert!(cli.list_languages && cli.language.is_some()); + + for args in [ + ["wayscriber", "--list-languages", "--active"], + ["wayscriber", "--list-languages", "--about"], + ] { + assert_eq!( + Cli::try_parse_from(args).unwrap_err(), + "--list-languages conflicts with the selected command", + "{args:?}" + ); + } +} diff --git a/src/daemon/overlay/launch.rs b/src/daemon/overlay/launch.rs index 46bb7dae..9bd78f1a 100644 --- a/src/daemon/overlay/launch.rs +++ b/src/daemon/overlay/launch.rs @@ -2,9 +2,10 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use crate::daemon::control::DaemonToggleRequest; +use crate::domain::Language; use crate::env_vars::{ - DESKTOP_STARTUP_ID_ENV, NO_DETACH_ENV, OVERLAY_HOME_SESSION_ENV, OVERLAY_PREFERRED_SESSION_ENV, - OVERLAY_SESSION_REPORTS_ENV, XDG_ACTIVATION_TOKEN_ENV, + DESKTOP_STARTUP_ID_ENV, NO_DETACH_ENV, OVERLAY_HOME_SESSION_ENV, OVERLAY_LANGUAGE_ENV, + OVERLAY_PREFERRED_SESSION_ENV, OVERLAY_SESSION_REPORTS_ENV, XDG_ACTIVATION_TOKEN_ENV, }; pub(in crate::daemon) struct OverlayLaunchRequest { @@ -81,11 +82,17 @@ pub(super) struct OverlayLaunch { /// The launch for `request`. A child `generation` reports its session to the /// daemon, which passes the session it remembers from the last report as the /// one to continue, unless the request asked for a session file. +/// +/// `language` is the daemon's own `--language`, which its overlays use too. +/// It travels as an optional input, so an overlay without the flag still +/// starts, and not as `WAYSCRIBER_LANGUAGE`, which a configurator the overlay +/// opens would inherit and obey over its own Language row. pub(super) fn build_overlay_launch( request: &OverlayLaunchRequest, resume_default: Option, generation: Option<&str>, remembered_session_file: Option<&Path>, + language: Option, ) -> OverlayLaunch { let mut arguments = vec![OsString::from("--active")]; if request.freeze { @@ -134,6 +141,10 @@ pub(super) fn build_overlay_launch( .filter(|_| request.explicit_session_file.is_none()) .map(Into::into), ), + ( + OVERLAY_LANGUAGE_ENV.into(), + language.map(|language| language.code().into()), + ), ]); if let Some(mode) = request.mode() { @@ -180,7 +191,7 @@ mod tests { false, ); - let launch = build_overlay_launch(&request, default, generation, None); + let launch = build_overlay_launch(&request, default, generation, None, None); let mut expected = vec![(NO_DETACH_ENV.into(), Some("1".into()))]; if let Some(generation) = generation { @@ -202,6 +213,7 @@ mod tests { ), (OVERLAY_HOME_SESSION_ENV.into(), None), (OVERLAY_PREFERRED_SESSION_ENV.into(), None), + (OVERLAY_LANGUAGE_ENV.into(), None), ]); assert_eq!(launch.environment, expected); } @@ -226,7 +238,8 @@ mod tests { false, ); - let launch = build_overlay_launch(&request, None, Some("generation"), Some(remembered)); + let launch = + build_overlay_launch(&request, None, Some("generation"), Some(remembered), None); let value = |name: &str| { launch @@ -248,4 +261,34 @@ mod tests { ); } } + + #[test] + fn the_daemons_language_reaches_its_overlays_without_a_new_argument() { + let request = OverlayLaunchRequest::new(None, None, None, None, false); + let language_entries = |language| { + build_overlay_launch(&request, None, None, None, language) + .environment + .into_iter() + .filter(|(key, _)| { + key == OVERLAY_LANGUAGE_ENV || key == crate::env_vars::LANGUAGE_OVERRIDE_ENV + }) + .collect::>() + }; + + assert_eq!( + language_entries(Some(Language::SimplifiedChinese)), + [(OsString::from(OVERLAY_LANGUAGE_ENV), Some("zh-CN".into()))] + ); + // Without --language a stale value is not inherited, so the overlay + // reads its config; a WAYSCRIBER_LANGUAGE the user set for the daemon + // is inherited untouched either way. + assert_eq!( + language_entries(None), + [(OsString::from(OVERLAY_LANGUAGE_ENV), None)] + ); + assert_eq!( + build_overlay_launch(&request, None, None, None, Some(Language::Auto)).arguments, + [OsString::from("--active")] + ); + } } diff --git a/src/daemon/overlay/lifecycle.rs b/src/daemon/overlay/lifecycle.rs index c3940942..77b7be7b 100644 --- a/src/daemon/overlay/lifecycle.rs +++ b/src/daemon/overlay/lifecycle.rs @@ -94,6 +94,7 @@ impl OverlayLifecycle { crate::decode_session_override(resume_override.load(Ordering::Acquire)), self.child.generation(), remembered_session_file, + crate::i18n::command_line_language(), ); let attempt = (|| -> Result { diff --git a/src/daemon/overlay/spawn.rs b/src/daemon/overlay/spawn.rs index 0ca6e34c..38daa798 100644 --- a/src/daemon/overlay/spawn.rs +++ b/src/daemon/overlay/spawn.rs @@ -176,7 +176,7 @@ mod tests { fn build_test_launch(daemon: &mut Daemon) -> OverlayLaunch { let request = daemon.take_pending_launch(); - build_overlay_launch(&request, daemon.session_resume_override(), None, None) + build_overlay_launch(&request, daemon.session_resume_override(), None, None, None) } fn launch_args(launch: &OverlayLaunch) -> Vec { diff --git a/src/daemon/overlay/tests.rs b/src/daemon/overlay/tests.rs index 358dd9c9..4eda2607 100644 --- a/src/daemon/overlay/tests.rs +++ b/src/daemon/overlay/tests.rs @@ -166,6 +166,34 @@ fn wait_until_retired(daemon: &mut Daemon) { const HOME: &str = "/tmp/home.wayscriber-session"; const REMEMBERED: &str = "/tmp/remembered.wayscriber-session"; +#[test] +fn a_daemon_started_with_a_language_gives_it_to_its_overlays() { + // The command-line language is set once per process, so this runs alone. + crate::test_env::in_language_isolated_child( + concat!( + module_path!(), + "::a_daemon_started_with_a_language_gives_it_to_its_overlays" + ), + || { + crate::i18n::set_command_line_language(crate::domain::Language::SimplifiedChinese); + with_fixture(false, |daemon, _, root| { + let receipt = show(daemon, root); + assert_eq!(receipt["language"], "zh-CN"); + // Not as an argument, so an older overlay still starts. + assert!( + !receipt["args"] + .as_array() + .unwrap() + .iter() + .any(|argument| argument == "--language"), + "{receipt}" + ); + daemon.hide_overlay().unwrap(); + }); + }, + ); +} + #[test] fn the_next_show_continues_the_session_the_overlay_reported() { with_fixture(false, |daemon, _, root| { @@ -173,6 +201,7 @@ fn the_next_show_continues_the_session_the_overlay_reported() { let first = show(daemon, root); assert_eq!(first["reports"], "1"); assert_eq!(first["home"], HOME); + assert!(first["language"].is_null()); assert!(first["preferred"].is_null()); let generation = first["generation"].as_str().unwrap(); assert!(session_report(generation).exists()); @@ -468,7 +497,7 @@ fn preparation_error_keeps_only_the_token_and_does_not_replay_options() { assert!(daemon.start_launch(request, &[candidate]).is_err()); let retained = daemon.take_pending_launch(); - let actual = launch::build_overlay_launch(&retained, Some(true), None, None); + let actual = launch::build_overlay_launch(&retained, Some(true), None, None, None); assert_eq!( actual.arguments, diff --git a/src/daemon/overlay/tests/fake_overlay.rs b/src/daemon/overlay/tests/fake_overlay.rs index 5c555c32..1967d74b 100644 --- a/src/daemon/overlay/tests/fake_overlay.rs +++ b/src/daemon/overlay/tests/fake_overlay.rs @@ -142,6 +142,7 @@ fn write_receipt() -> Result<()> { "reports": launched_with(crate::env_vars::OVERLAY_SESSION_REPORTS_ENV), "home": launched_with(crate::env_vars::OVERLAY_HOME_SESSION_ENV), "preferred": launched_with(crate::env_vars::OVERLAY_PREFERRED_SESSION_ENV), + "language": launched_with(crate::env_vars::OVERLAY_LANGUAGE_ENV), "pid": std::process::id(), "generation": std::env::var(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV)?, }); diff --git a/src/daemon/tray/helpers.rs b/src/daemon/tray/helpers.rs index 95e81df9..2c67c9d4 100644 --- a/src/daemon/tray/helpers.rs +++ b/src/daemon/tray/helpers.rs @@ -117,7 +117,7 @@ impl WayscriberTray { match spawn_detached( crate::process_broker::HelperKind::About, exe.as_os_str(), - &["--about".into()], + &crate::about_window::launch_arguments(), ) { Ok(child) => { info!("Launched About window (pid {})", child.id()); diff --git a/src/domain/language.rs b/src/domain/language.rs index 71c753dc..511be04f 100644 --- a/src/domain/language.rs +++ b/src/domain/language.rs @@ -24,6 +24,10 @@ pub enum Language { } impl Language { + /// Every choice `[ui] language` and `--language` accept, `auto` first: + /// the order `--list-languages` and the configurator list them in. + pub const ALL: [Self; 3] = [Self::Auto, Self::English, Self::SimplifiedChinese]; + /// The spelling used in configuration and logs. pub fn code(self) -> &'static str { match self { @@ -46,6 +50,26 @@ impl Language { pub fn is_auto(self) -> bool { matches!(self, Self::Auto) } + + /// The language's name in itself, never translated, so a user can find + /// their own language in a list drawn in one they cannot read. `None` for + /// `Auto`, which is not a language. + pub fn native_name(self) -> Option<&'static str> { + match self { + Self::Auto => None, + Self::English => Some("English"), + Self::SimplifiedChinese => Some("简体中文"), + } + } + + /// The language's English name, for command-line output and logs. + pub fn english_name(self) -> &'static str { + match self { + Self::Auto => "Automatic", + Self::English => "English", + Self::SimplifiedChinese => "Simplified Chinese", + } + } } #[cfg(test)] @@ -54,11 +78,7 @@ mod tests { #[test] fn language_codes_round_trip_through_serde() { - for language in [ - Language::Auto, - Language::English, - Language::SimplifiedChinese, - ] { + for language in Language::ALL { let encoded = serde_json::to_string(&language).expect("serialize language"); assert_eq!(encoded, format!("\"{}\"", language.code())); let decoded: Language = serde_json::from_str(&encoded).expect("deserialize language"); @@ -81,4 +101,12 @@ mod tests { fn default_language_follows_the_locale() { assert_eq!(Language::default(), Language::Auto); } + + #[test] + fn every_language_but_auto_is_named_in_itself() { + for language in Language::ALL { + assert_eq!(language.native_name().is_none(), language.is_auto()); + } + assert_eq!(Language::SimplifiedChinese.native_name(), Some("简体中文")); + } } diff --git a/src/env_vars.rs b/src/env_vars.rs index a0636ad3..9bc03e44 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -18,6 +18,9 @@ pub(crate) const OVERLAY_HOME_SESSION_ENV: &str = "WAYSCRIBER_OVERLAY_HOME_SESSI /// The session file a daemon overlay continues in place of home, while it is /// still a usable session file. pub(crate) const OVERLAY_PREFERRED_SESSION_ENV: &str = "WAYSCRIBER_OVERLAY_PREFERRED_SESSION"; +/// The daemon's `--language`, which its overlays take as their own. Absent, +/// an overlay selects its language like any other run. +pub(crate) const OVERLAY_LANGUAGE_ENV: &str = "WAYSCRIBER_OVERLAY_LANGUAGE"; pub const CATALOG_HOOKS_TEST_ENV: &str = "WAYSCRIBER_ENABLE_CATALOG_HOOKS_IN_TESTS"; /// Disables the periodic update check regardless of `[updates] check`. pub const DISABLE_UPDATE_CHECK_ENV: &str = "WAYSCRIBER_DISABLE_UPDATE_CHECK"; diff --git a/src/i18n.rs b/src/i18n.rs index 07609723..506431a2 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -35,20 +35,57 @@ type Catalog = BTreeMap; /// Atoms: English is 0 so a never-configured process needs no catalog. static ACTIVE: AtomicU8 = AtomicU8::new(0); +/// The `--language` this process runs with: its own, or, for an overlay, its +/// daemon's. +static COMMAND_LINE: OnceLock = OnceLock::new(); + /// Selects the language the UI will draw in. /// -/// `Auto` resolves through the locale chain, and `WAYSCRIBER_LANGUAGE` -/// overrides the argument entirely — a testing aid for checking a translation -/// without editing `config.toml`. Each process calls this before it builds -/// translated chrome; the configurator calls it again whenever the language -/// of its draft changes, to preview that language. +/// `Auto` resolves through the locale chain. `--language` and then +/// `WAYSCRIBER_LANGUAGE` override the argument entirely, for one run, without +/// editing `config.toml`. Each process calls this before it builds translated +/// chrome; the configurator calls it again whenever the language of its draft +/// changes, to preview that language. pub fn set_language(language: Language) { - let active = select(language, env_var(LANGUAGE_OVERRIDE_ENV).as_deref()); + let active = resolve(requested_language(language).0); ACTIVE.store(encode(active), Ordering::Relaxed); log::debug!("UI language: {}", active.code()); } +/// Records `--language` for the rest of this process. Set once, at startup, +/// before any language is selected. +pub fn set_command_line_language(language: Language) { + let _ = COMMAND_LINE.set(language); +} + +/// The `--language` this process was started with, so the overlays and About +/// windows it starts can be given the same one. +pub fn command_line_language() -> Option { + COMMAND_LINE.get().copied() +} + +/// Which setting decided the language in effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LanguageSource { + /// `--language`. + CommandLine, + /// The `WAYSCRIBER_LANGUAGE` environment variable. + Environment, + /// `[ui] language` in the config. + Configured, +} + +/// The language the deciding setting asks for, possibly [`Language::Auto`], +/// and which setting that is, given the configured language. +pub fn requested_language(configured: Language) -> (Language, LanguageSource) { + select( + configured, + command_line_language(), + env_var(LANGUAGE_OVERRIDE_ENV).as_deref(), + ) +} + /// Start-up for a process that draws translated chrome: selects the /// configured language and, when it draws from a catalog, loads that catalog /// before the first surface is built. The overlay, the daemon and the About @@ -61,21 +98,29 @@ pub fn initialize(language: Language) { } } -/// The selection rule: `WAYSCRIBER_LANGUAGE` beats the configured language, -/// which beats nothing — an explicit language is already final. +/// The selection rule: `--language` beats `WAYSCRIBER_LANGUAGE`, which beats +/// the configured language. The winner may still be `Auto`; [`resolve`] +/// decides what that draws. /// /// Pure so the precedence is testable without touching the environment. -fn select(configured: Language, override_code: Option<&str>) -> Language { +fn select( + configured: Language, + command_line: Option, + override_code: Option<&str>, +) -> (Language, LanguageSource) { + if let Some(language) = command_line { + return (language, LanguageSource::CommandLine); + } if let Some(code) = override_code { match language_from_code(code) { - Some(language) => return resolve(language), + Some(language) => return (language, LanguageSource::Environment), None => { log::warn!("{LANGUAGE_OVERRIDE_ENV}={code} is not a known language; ignoring it"); } } } - resolve(configured) + (configured, LanguageSource::Configured) } /// The language currently in effect; never [`Language::Auto`]. diff --git a/src/i18n/tests.rs b/src/i18n/tests.rs index 00e3ce39..00a02578 100644 --- a/src/i18n/tests.rs +++ b/src/i18n/tests.rs @@ -61,24 +61,38 @@ fn explicit_languages_never_resolve_to_auto() { } #[test] -fn environment_override_beats_the_configured_language() { +fn the_command_line_beats_the_environment_which_beats_the_config() { + use LanguageSource::{CommandLine, Configured, Environment}; + assert_eq!( - select(Language::English, Some("zh-CN")), - Language::SimplifiedChinese + select(Language::English, None, Some("zh-CN")), + (Language::SimplifiedChinese, Environment) ); assert_eq!( - select(Language::SimplifiedChinese, Some("en")), - Language::English + select(Language::SimplifiedChinese, None, Some("en")), + (Language::English, Environment) ); - assert_eq!(select(Language::English, None), Language::English); assert_eq!( - select(Language::SimplifiedChinese, None), - Language::SimplifiedChinese + select(Language::SimplifiedChinese, None, None), + (Language::SimplifiedChinese, Configured) + ); + assert_eq!( + select( + Language::English, + Some(Language::SimplifiedChinese), + Some("en") + ), + (Language::SimplifiedChinese, CommandLine) + ); + // `--language auto` follows the desktop locale whatever the config says. + assert_eq!( + select(Language::SimplifiedChinese, Some(Language::Auto), None), + (Language::Auto, CommandLine) ); // An unknown override is ignored rather than silencing the UI. assert_eq!( - select(Language::English, Some("klingon")), - Language::English + select(Language::English, None, Some("klingon")), + (Language::English, Configured) ); } @@ -335,3 +349,27 @@ fn the_first_lookup_merges_the_override_directory() { }, ); } + +#[test] +fn the_command_line_language_holds_for_the_whole_process() { + crate::test_env::in_language_isolated_child( + concat!( + module_path!(), + "::the_command_line_language_holds_for_the_whole_process" + ), + || { + // SAFETY: this child process runs only this test. + unsafe { std::env::set_var(LANGUAGE_OVERRIDE_ENV, "en") }; + set_command_line_language(Language::SimplifiedChinese); + + set_language(Language::English); + + assert_eq!(command_line_language(), Some(Language::SimplifiedChinese)); + assert_eq!(language(), Language::SimplifiedChinese); + assert_eq!( + requested_language(Language::English), + (Language::SimplifiedChinese, LanguageSource::CommandLine) + ); + }, + ); +} diff --git a/src/process_broker/manifest.rs b/src/process_broker/manifest.rs index 4467a48b..0bd1a66b 100644 --- a/src/process_broker/manifest.rs +++ b/src/process_broker/manifest.rs @@ -101,6 +101,7 @@ pub(super) fn validate( | "WAYSCRIBER_OVERLAY_SESSION_REPORTS" | "WAYSCRIBER_OVERLAY_HOME_SESSION" | "WAYSCRIBER_OVERLAY_PREFERRED_SESSION" + | "WAYSCRIBER_OVERLAY_LANGUAGE" ) { bail!("environment key {name:?} is not broker-allowed"); } @@ -272,14 +273,15 @@ fn looks_like_uri_bytes(value: &[u8]) -> bool { && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) } -/// Markers through which one wayscriber process tells another what it is. +/// Markers through which one wayscriber process tells another what it is, +/// and how a daemon's overlay should run. /// /// The broker runs with the environment of the process that started it, so /// without this a daemon-launched overlay's markers reached every helper it /// spawned, and from there xdg-open's browser, the configurator, tesseract /// and curl. A wayscriber started anywhere in those trees then took itself -/// for a daemon child. -const INTERNAL_PROCESS_MARKERS: [&str; 7] = [ +/// for a daemon child, or took the daemon's `--language`. +const INTERNAL_PROCESS_MARKERS: [&str; 8] = [ crate::env_vars::OVERLAY_CHILD_GENERATION_ENV, crate::env_vars::DETACHED_ENV, crate::RESUME_SESSION_ENV, @@ -287,6 +289,7 @@ const INTERNAL_PROCESS_MARKERS: [&str; 7] = [ crate::env_vars::OVERLAY_SESSION_REPORTS_ENV, crate::env_vars::OVERLAY_HOME_SESSION_ENV, crate::env_vars::OVERLAY_PREFERRED_SESSION_ENV, + crate::env_vars::OVERLAY_LANGUAGE_ENV, ]; /// Whether `kind` relaunches wayscriber itself, the only helpers that keep diff --git a/src/process_broker/tests.rs b/src/process_broker/tests.rs index aae321d3..0674e20b 100644 --- a/src/process_broker/tests.rs +++ b/src/process_broker/tests.rs @@ -798,10 +798,11 @@ fn owned_child_inherits_daemon_pidfd_without_leaking_broker_copy() { #[test] fn helpers_do_not_inherit_the_internal_process_markers() { - const MARKERS: [&str; 3] = [ + const MARKERS: [&str; 4] = [ crate::env_vars::OVERLAY_CHILD_GENERATION_ENV, crate::env_vars::DETACHED_ENV, crate::RESUME_SESSION_ENV, + crate::env_vars::OVERLAY_LANGUAGE_ENV, ]; let _environment = crate::test_env::lock(); let previous = MARKERS.map(std::env::var_os); @@ -838,7 +839,7 @@ fn helpers_do_not_inherit_the_internal_process_markers() { let output = output.unwrap(); assert_eq!(output.status, 0); - assert_eq!(output.stdout, b"unset,unset,unset"); + assert_eq!(output.stdout, b"unset,unset,unset,unset"); } #[test]