feat(cli): choose and list the interface language from the command line

`--language CODE` sets the interface language for one run of --active,
--freeze, --daemon or --about without changing config.toml. It accepts
the spellings `[ui] language` does and takes precedence over both
WAYSCRIBER_LANGUAGE and the config. A daemon started with it gives it to
the About windows it opens, and to its overlays as an optional
environment input that an older overlay ignores. The process broker
strips that input from every other helper, so a configurator opened from
an overlay keeps previewing its own Language row. With --daemon-toggle or
an overlay action the flag is an error, since a running daemon's
overlays cannot be changed from outside.

`--list-languages` prints each language's code and name, the configured
language, and the language in effect with the setting that decided it.

Languages now carry their native and English names, and the
configurator's Language row takes its labels from them. The help text,
README, CONFIG.md, I18N.md and config.example.toml describe both options.
This commit is contained in:
devmobasa committed 2026-10-16 18:42:33 +02:00
1 parent 98b6f54c15
commit fcbc437816
24 files changed
+678 -54

No files matched your search

+28
View File
@@ -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.
+2 -1
View File
@@ -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
+3 -4
View File
@@ -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)"),
}
}
+3 -1
View File
@@ -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)
+27 -7
View File
@@ -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/<code>.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.
+38
View File
@@ -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<std::ffi::OsString> {
launch_arguments_for(crate::i18n::command_line_language())
}
fn launch_arguments_for(language: Option<crate::domain::Language>) -> Vec<std::ffi::OsString> {
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(|_| {
+174
View File
@@ -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<Language> {
given_language_with(cli, std::env::var(OVERLAY_LANGUAGE_ENV).ok().as_deref())
}
fn given_language_with(cli: &Cli, from_daemon: Option<&str>) -> Option<Language> {
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<Language, String>,
(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"));
}
}
+11
View File
@@ -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)
+4
View File
@@ -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)"
);
+1 -1
View File
@@ -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());
+62 -2
View File
@@ -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<Language>,
/// 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<Language, String> {
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");
}
+95
View File
@@ -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:?}"
);
}
}
+47 -4
View File
@@ -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<bool>,
generation: Option<&str>,
remembered_session_file: Option<&Path>,
language: Option<Language>,
) -> 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::<Vec<_>>()
};
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")]
);
}
}
+1
View File
@@ -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<u32> {
+1 -1
View File
@@ -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<String> {
+30 -1
View File
@@ -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,
+1
View File
@@ -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)?,
});
+1 -1
View File
@@ -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());
+33 -5
View File
@@ -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("简体中文"));
}
}
+3
View File
@@ -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";
+56 -11
View File
@@ -35,20 +35,57 @@ type Catalog = BTreeMap<String, String>;
/// 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<Language> = 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<Language> {
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<Language>,
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`].
+48 -10
View File
@@ -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)
);
},
);
}
+6 -3
View File
@@ -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
+3 -2
View File
@@ -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]