diff --git a/AGENTS.md b/AGENTS.md index 9ebefa7b..fcb01ee9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,12 @@ Exceptions are allowed for migration and package-helper scripts where the helper - Do not add `aliases` to new menu entries. Aliases are reserved for established alternate names users already type, kept for compatibility. +# Language + +- `omarchy-language en|zh` (Setup > Language) owns `LANG` in `/etc/locale.conf`; the installer sets it from its first screen. Nothing in `omarchy update` or migrations may change `/etc/locale.conf`. +- Chinese menu labels live in `default/omarchy/omarchy-menu.zh-cn.jsonc`, merged when `LANG` starts with `zh_CN`; give a new menu row with translatable words a label there. +- Shell text goes through `I18n.tr("English %1", arg)` from `qs.Commons`, with its Chinese in `shell/Commons/I18nZh.js` keyed by the English. Notifications and OSD messages are worded by their sender with `omarchy-text "English %1" "$arg"` (Python: `text()`, QML: `I18n.tr`), which reads the same table; the shell shows them as given, so user text is never translated. `test/shell.d/shell-i18n-test.sh` fails on a missing entry. + # Config Structure - `config/` - default configs copied to `~/.config/` diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md index 05faab5e..fd00f7f2 100644 --- a/agents/skills/shell-dev.md +++ b/agents/skills/shell-dev.md @@ -24,6 +24,10 @@ Run `omarchy-restart-shell` after making changes to QML files. - Panel / overlay / menu plugins must expose `open(payloadJson)` and `close()` lifecycle methods for `shell summon` and `shell hide`. +## Text + +Wrap every user-visible string in `I18n.tr()` (`import qs.Commons`) and add its Chinese to `shell/Commons/I18nZh.js`; plain JS models that Node tests load stay English, and their text is wrapped where QML shows it. + ## IPC - `bin/omarchy-shell` is the canonical IPC entry point. It forwards to diff --git a/bin/omarchy b/bin/omarchy index b0146759..be242d7a 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -59,6 +59,7 @@ GROUP_DESCRIPTIONS[hw]="Hardware detection and controls" GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls" GROUP_DESCRIPTIONS[install]="Optional software installers" GROUP_DESCRIPTIONS[installed]="Installed optional service checks" +GROUP_DESCRIPTIONS[language]="System language" GROUP_DESCRIPTIONS[launch]="Application launchers" GROUP_DESCRIPTIONS[menu]="Omarchy menu commands" GROUP_DESCRIPTIONS[migrate]="Migration runner" diff --git a/bin/omarchy-agent-account-add b/bin/omarchy-agent-account-add index 4885eaed..20b52229 100755 --- a/bin/omarchy-agent-account-add +++ b/bin/omarchy-agent-account-add @@ -85,7 +85,7 @@ finish() { fail() { if [[ $events == "true" ]]; then printf '@@omarchy error %s\n' "$1" - omarchy-notification-send -g 󰚩 -u critical "Couldn't add the $name account" "$1" + omarchy-notification-send -g 󰚩 -u critical "$(omarchy-text "Couldn't add the %1 account" "$name")" "$1" else echo "$1" >&2 fi @@ -113,9 +113,9 @@ interruptible() { } if omarchy-cmd-missing "$provider"; then - say "Installing the $name CLI…" + say "$(omarchy-text "Installing the %1 CLI…" "$name")" if [[ $events == "true" ]]; then - mise use -g "$package" >/dev/null 2>&1 || fail "Installing the $name CLI didn't finish." + mise use -g "$package" >/dev/null 2>&1 || fail "$(omarchy-text "Installing the %1 CLI didn't finish." "$name")" else mise use -g "$package" || exit 1 fi @@ -150,26 +150,26 @@ if [[ -n $reauth ]]; then else account_home=$(omarchy-agent-account-state homes "$provider" | awk -F'\t' -v id="$reauth" '$1 == id { print $2 }') fi - [[ -n $account_home ]] || fail "No $name account named $reauth." + [[ -n $account_home ]] || fail "$(omarchy-text "No %1 account named %2." "$name" "$reauth")" if [[ $account_home == "$HOME/.$provider" ]]; then - say "Sign in to $name again in the browser window that opens." - login || fail "The $name sign-in didn't finish." + say "$(omarchy-text "Sign in to %1 again in the browser window that opens." "$name")" + login || fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")" else - say "Sign in again as this account in the private window that opens." + say "$(omarchy-text "Sign in again as this account in the private window that opens.")" private_browser - interruptible env BROWSER="$browser" "$home_env=$account_home" "${login_command[@]}" || fail "The $name sign-in didn't finish." + interruptible env BROWSER="$browser" "$home_env=$account_home" "${login_command[@]}" || fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")" fi omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 & - finish "Signed in to $name again." + finish "$(omarchy-text "Signed in to %1 again." "$name")" exit 0 fi # The first subscription signs in where the CLI always looks, through your # normal browser: nothing is signed in there yet that it could be mistaken for. if ! primary_signed_in "$provider"; then - say "Sign in to $name in the browser window that opens." + say "$(omarchy-text "Sign in to %1 in the browser window that opens." "$name")" if login && primary_signed_in "$provider"; then # The first agent signed in on a machine with none chosen becomes the # default, so `omarchy agent` and the panel's starter prompts have one to @@ -179,9 +179,9 @@ if ! primary_signed_in "$provider"; then printf '%s\n' "$provider" >"$HOME/.config/omarchy/defaults/agent" fi omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 & - finish "Signed in to $name." + finish "$(omarchy-text "Signed in to %1." "$name")" else - fail "The $name sign-in didn't finish." + fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")" fi else if [[ -z $label && $events == "false" && -t 0 ]]; then @@ -189,7 +189,7 @@ else fi if [[ $events == "true" ]]; then - say "Sign in as the account you're adding in the private window that opens." + say "$(omarchy-text "Sign in as the account you're adding in the private window that opens.")" else cat </dev/null 2>&1 & if [[ $events == "true" ]]; then - finish "Added $added." + finish "$(omarchy-text "Added %1." "$added")" else - finish "Added $added." + finish "$(omarchy-text "Added %1." "$added")" echo "Switch to it with: omarchy agent account use $provider $(jq -r .id <<<"$account")" fi else diff --git a/bin/omarchy-agent-account-state b/bin/omarchy-agent-account-state index 0578b6cc..a5490789 100755 --- a/bin/omarchy-agent-account-state +++ b/bin/omarchy-agent-account-state @@ -73,6 +73,11 @@ class AccountError(Exception): pass +def text(english: str, *args: object) -> str: + """English with %1.. filled, in the session's language, from the shell's table.""" + return subprocess.run(["omarchy-text", english, *map(str, args)], capture_output=True, text=True, check=True).stdout.rstrip("\n") + + # ------------------------------------------------------------------- paths @@ -335,12 +340,12 @@ def begin(provider: str) -> Path: def register(provider: str, label: str, pending: Path) -> dict[str, Any]: pending = pending.resolve() if pending.parent != (accounts_root() / provider / ".pending").resolve(): - raise AccountError(f"{pending} is not a pending {PROVIDERS[provider]['name']} login") + raise AccountError(text("%1 is not a pending %2 login", pending, PROVIDERS[provider]['name'])) found = identity(provider, pending) if not found["accountId"]: shutil.rmtree(pending, ignore_errors=True) - raise AccountError("The login didn't finish, so no account was added.") + raise AccountError(text("The login didn't finish, so no account was added.")) with Locked(provider) as registry: # Each home is asked who it is now, not trusted from when it was added: a @@ -350,10 +355,10 @@ def register(provider: str, label: str, pending: Path) -> dict[str, Any]: for account in registry["accounts"]: if account.get("accountId") == found["accountId"]: shutil.rmtree(pending, ignore_errors=True) - raise AccountError( - f"That's {account['label']} ({found['email'] or 'already added'}). " - "Try again, and sign in as the other account in the private window." - ) + raise AccountError(text( + "That's %1 (%2). Try again, and sign in as the other account in the private window.", + account["label"], found["email"] or text("already added"), + )) label = label.strip() or found["email"].split("@")[0] or "Account" account_id = unique_id(provider, registry, label) @@ -395,11 +400,11 @@ def unique_id(provider: str, registry: dict[str, Any], label: str, keep: dict[st def rename(provider: str, target: str, label: str) -> dict[str, Any]: label = label.strip() if not label: - raise AccountError("Give the account a name") + raise AccountError(text("Give the account a name")) with Locked(provider) as registry: account = find(registry, target) if not account: - raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}") + raise AccountError(text("No %1 account named %2", PROVIDERS[provider]['name'], target)) new_id = unique_id(provider, registry, label, keep=account) if not account.get("primary") and not account.get("home"): account["home"] = str(account_home(provider, account)) @@ -428,7 +433,7 @@ def use(provider: str, target: str) -> tuple[dict[str, Any], dict[str, Any]]: target = ids[(ids.index(previous["id"]) + 1) % len(ids)] account = find(registry, target) if not account: - raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}") + raise AccountError(text("No %1 account named %2", PROVIDERS[provider]['name'], target)) registry["active"] = account["id"] registry["alert"] = "" if not account.get("primary"): @@ -456,12 +461,12 @@ def remove(provider: str, target: str) -> dict[str, Any]: with Locked(provider) as registry: account = find(registry, target) if not account: - raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}") + raise AccountError(text("No %1 account named %2", PROVIDERS[provider]['name'], target)) if account.get("primary"): - raise AccountError(f"{account['label']} is the login in {primary_home(provider)}; it can't be removed here.") + raise AccountError(text("%1 is the login in %2; it can't be removed here.", account["label"], primary_home(provider))) home = account_home(provider, account) if home_in_use(provider, home): - raise AccountError(f"A running {PROVIDERS[provider]['name']} session uses {account['label']}. Quit it first, then remove the account.") + raise AccountError(text("A running %1 session uses %2. Quit it first, then remove the account.", PROVIDERS[provider]['name'], account["label"])) # Only the account's own directory goes. Its links point into the primary # home, and rmtree removes a link without following it. if home.resolve().parent == (accounts_root() / provider).resolve(): @@ -474,14 +479,14 @@ def remove(provider: str, target: str) -> dict[str, Any]: def set_mode(provider: str, mode: str, threshold: str | None) -> dict[str, Any]: if mode not in SWITCH_MODES: - raise AccountError("Switch mode is manual or auto") + raise AccountError(text("Switch mode is manual or auto")) with Locked(provider) as registry: registry["switch"] = mode if threshold is not None: try: registry["threshold"] = max(50, min(100, int(threshold))) except ValueError: - raise AccountError("The threshold is a percentage between 50 and 100") + raise AccountError(text("The threshold is a percentage between 50 and 100")) registry["alert"] = "" return dict(registry) @@ -556,10 +561,10 @@ def duration(delta: dt.timedelta) -> str: hours, minutes = divmod(minutes, 60) days, hours = divmod(hours, 24) if days: - return f"{days}d {hours}h" + return text("%1d %2h", days, hours) if hours: - return f"{hours}h {minutes}m" - return f"{minutes}m" + return text("%1h %2m", hours, minutes) + return text("%1m", minutes) # The whole switching policy, with no I/O: given the registry and the per @@ -659,21 +664,23 @@ def autoswitch(provider: str, now: dt.datetime | None = None) -> bool: threshold = registry["threshold"] if decision["action"] == "exhausted": soonest = decision["soonest"] - when = f" {soonest[1]['label']} resets in {duration(soonest[0] - now)}." if soonest else "" - notify(f"All {name} accounts are over {threshold}%", f"Staying on {source['label']}.{when}") + staying = text("Staying on %1.", source["label"]) + if soonest: + staying = text("Staying on %1. %2 resets in %3.", source["label"], soonest[1]["label"], duration(soonest[0] - now)) + notify(text("All %1 accounts are over %2%", name, threshold), staying) return False target = decision["to"] - headline = f"{source['label']} is at {round(decision['fromPeak'])}% of a {name} limit" + headline = text("%1 is at %2% of a %3 limit", source["label"], round(decision["fromPeak"]), name) if decision["action"] == "switch": registry["active"] = target["id"] if not target.get("primary"): link_shared(provider, account_home(provider, target)) - notify(headline, f"New {name} sessions now use {target['label']} ({round(decision['toPeak'])}%). Running sessions stay on {source['label']}.") + notify(headline, text("New %1 sessions now use %2 (%3%). Running sessions stay on %4.", name, target["label"], round(decision["toPeak"]), source["label"])) return True notify( headline, - f"{target['label']} is at {round(decision['toPeak'])}%. Click to switch new sessions to it.", + text("%1 is at %2%. Click to switch new sessions to it.", target["label"], round(decision["toPeak"])), ["omarchy-agent-account-use", provider, target["id"]], ) return False diff --git a/bin/omarchy-agent-account-use b/bin/omarchy-agent-account-use index 3103e953..d50041c4 100755 --- a/bin/omarchy-agent-account-use +++ b/bin/omarchy-agent-account-use @@ -31,9 +31,9 @@ active=$(jq -r '.active.label + (if (.active.plan // "") != "" then " (" + .acti if [[ $(jq -r .previous.id <<<"$result") == "$(jq -r .active.id <<<"$result")" ]]; then echo "New $name sessions already use $active." else - message="Running sessions stay on $previous." + message=$(omarchy-text "Running sessions stay on %1." "$previous") echo "New $name sessions now use $active. $message" - omarchy-notification-send -g 󰚩 "New $name sessions now use $active" "$message" + omarchy-notification-send -g 󰚩 "$(omarchy-text "New %1 sessions now use %2" "$name" "$active")" "$message" # The usage record's own limits describe the active account. omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 & fi diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude index e615c583..fcef1b4c 100755 --- a/bin/omarchy-agent-usage-claude +++ b/bin/omarchy-agent-usage-claude @@ -22,6 +22,7 @@ import json import os import re import sqlite3 +import subprocess import sys import tempfile import time @@ -32,6 +33,11 @@ from typing import Any AGENT_ID = "claude" AGENT_NAME = "Claude Code" +def text(english, *args): + """English with %1.. filled, in the session's language, from the shell's table.""" + return subprocess.run(["omarchy-text", english, *map(str, args)], capture_output=True, text=True, check=True).stdout.rstrip("\n") + + AUTH_HELP = "Run `claude auth login` to restore authoritative usage." USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage" PROBE_MIN_INTERVAL_SECONDS = 15 @@ -989,11 +995,12 @@ def probe_limits(access_token: str) -> dict[str, Any]: except urllib.error.HTTPError as error: retry_after = error.headers.get("retry-after", "") if error.headers else "" if error.code == 429: - help_text = "Anthropic's usage endpoint is rate limiting checks right now" + ( - f" (retry after {retry_after}s)" if retry_after else "" - ) + ". Local Claude Code stats are still shown." + if retry_after: + help_text = text("Anthropic's usage endpoint is rate limiting checks right now (retry after %1s). Local Claude Code stats are still shown.", retry_after) + else: + help_text = text("Anthropic's usage endpoint is rate limiting checks right now. Local Claude Code stats are still shown.") else: - help_text = f"Anthropic's usage endpoint returned status {error.code}. Local Claude Code stats are still shown." + help_text = text("Anthropic's usage endpoint returned status %1. Local Claude Code stats are still shown.", error.code) return {"ok": False, "helpText": help_text} except Exception: # A transport failure reached no server at all — no route, no DNS. Any diff --git a/bin/omarchy-audio-input-mute b/bin/omarchy-audio-input-mute index 554eae44..8eb2189f 100755 --- a/bin/omarchy-audio-input-mute +++ b/bin/omarchy-audio-input-mute @@ -6,8 +6,8 @@ wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then omarchy-brightness-keyboard-mute on - omarchy-osd -i microphone-muted -m "Microphone muted" + omarchy-osd -i microphone-muted -m "$(omarchy-text "Microphone muted")" else omarchy-brightness-keyboard-mute off - omarchy-osd -i microphone -m "Microphone on" + omarchy-osd -i microphone -m "$(omarchy-text "Microphone on")" fi diff --git a/bin/omarchy-audio-output-switch b/bin/omarchy-audio-output-switch index 11d2a356..a1faf24d 100755 --- a/bin/omarchy-audio-output-switch +++ b/bin/omarchy-audio-output-switch @@ -13,7 +13,7 @@ sinks=$(timeout 2 pactl -f json list sinks | sinks_count=$(jq 'length' <<<"$sinks") if (( sinks_count == 0 )); then - omarchy-osd -m "No audio devices found" + omarchy-osd -m "$(omarchy-text "No audio devices found")" exit 1 fi diff --git a/bin/omarchy-battery-low b/bin/omarchy-battery-low index d107d4f8..23ab7ff0 100755 --- a/bin/omarchy-battery-low +++ b/bin/omarchy-battery-low @@ -13,5 +13,5 @@ fi level=$1 -omarchy-notification-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000 +omarchy-notification-send -g 󱐋 -u critical "$(omarchy-text "Time to recharge!")" "$(omarchy-text "Battery is down to %1%" "$level")" -i battery-caution -t 30000 omarchy-hook battery-low "$level" diff --git a/bin/omarchy-battery-status b/bin/omarchy-battery-status index 13de4454..d232e64c 100755 --- a/bin/omarchy-battery-status +++ b/bin/omarchy-battery-status @@ -150,9 +150,9 @@ if [[ $charge_holding == "true" ]]; then threshold_label="${threshold_end}%" fi - echo "Battery ${percentage}% · Holding at ${threshold_label} · ${power_rate}W / ${capacity}Wh" + omarchy-text "Battery %1% · Holding at %2 · %3W / %4Wh" "$percentage" "$threshold_label" "$power_rate" "$capacity" elif [[ $state == "charging" ]]; then - echo "Battery ${percentage}% · ${time_remaining} to full ·  ${power_rate}W / ${capacity}Wh" + omarchy-text "Battery %1% · %2 to full ·  %3W / %4Wh" "$percentage" "$time_remaining" "$power_rate" "$capacity" else - echo "Battery ${percentage}% · ${time_remaining} left ·  ${power_rate}W / ${capacity}Wh" + omarchy-text "Battery %1% · %2 left ·  %3W / %4Wh" "$percentage" "$time_remaining" "$power_rate" "$capacity" fi diff --git a/bin/omarchy-capture-qr b/bin/omarchy-capture-qr index 17897387..26affebe 100755 --- a/bin/omarchy-capture-qr +++ b/bin/omarchy-capture-qr @@ -23,7 +23,7 @@ SELECTION=$(slurp 2>/dev/null) RESULT=$(grim -g "$SELECTION" - | zbarimg -q --raw -Sdisable -Sqrcode.enable - 2>/dev/null) if [[ -z $RESULT ]]; then - omarchy-notification-send -g 󰐲 -u critical "No QR code found" "Select a region containing a QR code" + omarchy-notification-send -g 󰐲 -u critical "$(omarchy-text "No QR code found")" "$(omarchy-text "Select a region containing a QR code")" exit 1 fi @@ -33,4 +33,4 @@ fi # the notification history, and an unmarked copy would be retained by clipboard # history. Pasting still works; only the recorded copy is given up. printf '%s' "$RESULT" | wl-copy --sensitive -omarchy-notification-send -g 󰐲 "QR code copied to clipboard" +omarchy-notification-send -g 󰐲 "$(omarchy-text "QR code copied to clipboard")" diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index e5269ad1..96008cc3 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -86,7 +86,7 @@ esac # --status only answers whether our recording runs, for the bar and the menu, so # it needs no recordings directory and never notifies. if [[ $STATUS != "true" && ! -d $OUTPUT_DIR ]]; then - omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR" + omarchy-notification-send -u critical -t 3000 "$(omarchy-text "Screen recording directory does not exist: %1" "$OUTPUT_DIR")" exit 1 fi @@ -97,7 +97,7 @@ start_webcam_overlay() { if [[ -z $WEBCAM_DEVICE ]]; then WEBCAM_DEVICE=$(omarchy-capture-webcam-list | sed -n '1s/[[:space:]].*//p') if [[ -z $WEBCAM_DEVICE ]]; then - omarchy-notification-send -u critical -t 3000 "No webcam devices found" + omarchy-notification-send -u critical -t 3000 "$(omarchy-text "No webcam devices found")" return 1 fi fi @@ -235,7 +235,9 @@ wf_audio_device() { } wf_audio_unavailable() { - omarchy-notification-send -u critical -t 5000 "Recording without $1 audio" "The $1 audio could not be added to this recording." >/dev/null 2>&1 + local audio + audio=$(omarchy-text "$1") + omarchy-notification-send -u critical -t 5000 "$(omarchy-text "Recording without %1 audio" "$audio")" "$(omarchy-text "The %1 audio could not be added to this recording." "$audio")" >/dev/null 2>&1 } wf_audio_cleanup() { @@ -424,7 +426,7 @@ stop_screenrecording() { if omarchy-capture-screenrecording-process "${RECORDER[@]}"; then omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal KILL - omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted." + omarchy-notification-send -u critical -t 5000 "$(omarchy-text "Screen recording error")" "$(omarchy-text "Recording process had to be force-killed. Video may be corrupted.")" else finalize_recording local filename=$(cat "$RECORDING_FILE" 2>/dev/null) @@ -434,7 +436,7 @@ stop_screenrecording() { # Generate a preview thumbnail from the first frame ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null - omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \ + omarchy-notification-send "$(omarchy-text "Screen recording saved")" "$(omarchy-text "Open with Super + Alt + , (or click this)")" \ -t 10000 --image "${preview:-$filename}" \ --exec mpv -- "$filename" diff --git a/bin/omarchy-capture-screenrecording-with-webcam b/bin/omarchy-capture-screenrecording-with-webcam index e5b91bfd..f99a0514 100755 --- a/bin/omarchy-capture-screenrecording-with-webcam +++ b/bin/omarchy-capture-screenrecording-with-webcam @@ -5,7 +5,7 @@ mapfile -t devices < <(omarchy-capture-webcam-list) if (( ${#devices[@]} == 0 )); then - omarchy-notification-send "No webcam devices found" -u critical -t 3000 + omarchy-notification-send "$(omarchy-text "No webcam devices found")" -u critical -t 3000 exit 1 fi diff --git a/bin/omarchy-capture-text b/bin/omarchy-capture-text index 84260c4e..1c430f8e 100755 --- a/bin/omarchy-capture-text +++ b/bin/omarchy-capture-text @@ -23,4 +23,4 @@ TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${OMA [[ -z $TEXT ]] && exit 1 printf "%s" "$TEXT" | wl-copy -omarchy-notification-send -g 󰴑 "Copied text from selection to clipboard" +omarchy-notification-send -g 󰴑 "$(omarchy-text "Copied text from selection to clipboard")" diff --git a/bin/omarchy-chromium-copy-url-host b/bin/omarchy-chromium-copy-url-host index d097ee26..6350b6d0 100755 --- a/bin/omarchy-chromium-copy-url-host +++ b/bin/omarchy-chromium-copy-url-host @@ -18,7 +18,7 @@ copy_url() { [[ -n $url ]] || return 1 printf '%s' "$url" | wl-copy --type text/plain - omarchy-notification-send -g 󰅍 "URL copied to clipboard" + omarchy-notification-send -g 󰅍 "$(omarchy-text "URL copied to clipboard")" } reply_copied() { diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index 3911451a..28b43815 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -87,7 +87,7 @@ download_url() { # Don't show anything until yt-dlp confirms there's actually a video to grab. if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then - omarchy-notification-send -u critical -g 󰅖 "No video found for download" "$url" + omarchy-notification-send -u critical -g 󰅖 "$(omarchy-text "No video found for download")" "$url" exit 0 fi @@ -148,7 +148,7 @@ download_url() { # toast would exit before the thumbnail cleanup below is ever scheduled. # `--` keeps mpv from parsing a leading-dash filename as an option; the path # is one discrete argument, so it never reaches a shell. - omarchy-notification-send -g 󰄬 "Download complete" "$title" \ + omarchy-notification-send -g 󰄬 "$(omarchy-text "Download complete")" "$title" \ -t 10000 --image "${preview:-$filepath}" \ --exec mpv -- "$filepath" || true @@ -160,7 +160,7 @@ download_url() { rm -f "$preview" ) & else - omarchy-notification-send -u critical -g 󰅖 "Download failed" "$url" + omarchy-notification-send -u critical -g 󰅖 "$(omarchy-text "Download failed")" "$url" fi exit 0 diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index d3e78d16..605cd78e 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -40,8 +40,8 @@ announce() { omarchy-notification-send \ --urgency critical \ --glyph "$CRASH_GLYPH" \ - "Process crashed: $comm" \ - "Click to diagnose with AI" \ + "$(omarchy-text "Process crashed: %1" "$comm")" \ + "$(omarchy-text "Click to diagnose with AI")" \ --exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal" } diff --git a/bin/omarchy-default-browser b/bin/omarchy-default-browser index 3e54bd3b..9feeb4d9 100755 --- a/bin/omarchy-default-browser +++ b/bin/omarchy-default-browser @@ -48,4 +48,4 @@ fi env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1 -omarchy-notification-send -g $glyph "$name is now the default browser" +omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default browser" "$name")" diff --git a/bin/omarchy-default-editor b/bin/omarchy-default-editor index 74964fd6..a24c6b80 100755 --- a/bin/omarchy-default-editor +++ b/bin/omarchy-default-editor @@ -59,4 +59,4 @@ fi mkdir -p "$(dirname "$editor_file")" printf '%s\n' "$editor" >"$editor_file" -omarchy-notification-send -g $glyph "$name is now the default editor" +omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default editor" "$name")" diff --git a/bin/omarchy-default-terminal b/bin/omarchy-default-terminal index ff0e5df2..177c2d41 100755 --- a/bin/omarchy-default-terminal +++ b/bin/omarchy-default-terminal @@ -48,4 +48,4 @@ cat >~/.config/xdg-terminals.list </dev/null)" || true [[ $new_id =~ ^[0-9]+$ ]] && printf '%s\n' "$new_id" >"$id_file" fi diff --git a/bin/omarchy-font-set b/bin/omarchy-font-set index 080f055b..d5e8abb7 100755 --- a/bin/omarchy-font-set +++ b/bin/omarchy-font-set @@ -74,11 +74,11 @@ XML omarchy-restart-shell if pgrep -x ghostty; then - omarchy-notification-send -g "You must restart Ghostty to see font change" + omarchy-notification-send -g "$(omarchy-text "You must restart Ghostty to see font change")" fi if pgrep -x foot; then - omarchy-notification-send -g "You must restart Foot to see font change" + omarchy-notification-send -g "$(omarchy-text "You must restart Foot to see font change")" fi omarchy-hook font-set "$font_name" diff --git a/bin/omarchy-games-retro-install b/bin/omarchy-games-retro-install index 9b30bb74..a430f222 100755 --- a/bin/omarchy-games-retro-install +++ b/bin/omarchy-games-retro-install @@ -10,7 +10,7 @@ if (( $# == 0 )); then mapfile -t cores < <(omarchy-games-retro-cores) if (( ${#cores[@]} == 0 )); then - omarchy-notification-send -g 󰯉 "No RetroArch cores found" "/usr/lib/libretro" + omarchy-notification-send -g 󰯉 "$(omarchy-text "No RetroArch cores found")" "/usr/lib/libretro" exit 1 fi @@ -69,4 +69,4 @@ EOF chmod +x "$desktop_file" update-desktop-database "$desktop_dir" &>/dev/null || true -omarchy-notification-send -g 󰯉 "$game_name installed" "Start it with Super + Space" +omarchy-notification-send -g 󰯉 "$(omarchy-text "%1 installed" "$game_name")" "$(omarchy-text "Start it with Super + Space")" diff --git a/bin/omarchy-hyprland-corners b/bin/omarchy-hyprland-corners index ed35d168..136e28d8 100755 --- a/bin/omarchy-hyprland-corners +++ b/bin/omarchy-hyprland-corners @@ -20,9 +20,9 @@ if (( $# == 0 )); then fi case $1 in - theme) LABEL="Corners follow the theme" ;; - sharp) LABEL="Sharp corners for all themes" ;; - rounded) LABEL="Rounded corners for all themes" ;; + theme) LABEL=$(omarchy-text "Corners follow the theme") ;; + sharp) LABEL=$(omarchy-text "Sharp corners for all themes") ;; + rounded) LABEL=$(omarchy-text "Rounded corners for all themes") ;; *) echo "Usage: omarchy-hyprland-corners [theme|sharp|rounded]" >&2 exit 1 diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index c0be60fa..dd54d6c6 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -16,7 +16,7 @@ wake() { on() { if omarchy-hyprland-toggle-enabled $TOGGLE; then omarchy-hyprland-toggle $TOGGLE off - omarchy-notification-send -g 󰍹 "Laptop display enabled" + omarchy-notification-send -g 󰍹 "$(omarchy-text "Laptop display enabled")" fi wake @@ -24,25 +24,25 @@ on() { off() { if [[ -z $INTERNAL ]]; then - omarchy-notification-send -g 󰍹 "No laptop display found" + omarchy-notification-send -g 󰍹 "$(omarchy-text "No laptop display found")" exit 1 fi # The name is written into generated Lua below, so only a plain connector # name may pass; anything else could execute on the next reload. if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then - omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + omarchy-notification-send -g 󰍹 "$(omarchy-text "Refusing unsafe monitor name")" exit 1 fi if ! omarchy-hyprland-monitor-external-active; then - omarchy-notification-send -g 󰍹 "Can't disable the only active display" + omarchy-notification-send -g 󰍹 "$(omarchy-text "Can't disable the only active display")" exit 1 fi if omarchy-hyprland-toggle-disabled $TOGGLE && omarchy-hyprland-toggle-disabled $MIRROR_TOGGLE; then printf 'hl.monitor({ output = "%s", disabled = true })\n' "$INTERNAL" >"$TOGGLE_FLAG" - omarchy-notification-send -g 󰍹 "Laptop display disabled" + omarchy-notification-send -g 󰍹 "$(omarchy-text "Laptop display disabled")" hyprctl reload fi } diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index ec48c740..c8df5264 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -13,12 +13,12 @@ EXTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DS on() { if [[ -z $EXTERNAL ]]; then - omarchy-notification-send -g 󰍹 "No external monitors found for mirror" + omarchy-notification-send -g 󰍹 "$(omarchy-text "No external monitors found for mirror")" exit 1 fi if [[ -z $INTERNAL ]]; then - omarchy-notification-send -g 󰍹 "No laptop monitor found to mirror" + omarchy-notification-send -g 󰍹 "$(omarchy-text "No laptop monitor found to mirror")" exit 1 fi @@ -26,7 +26,7 @@ on() { # names may pass; a user-created headless output can carry any name. for output in "$INTERNAL" "$EXTERNAL"; do if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then - omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + omarchy-notification-send -g 󰍹 "$(omarchy-text "Refusing unsafe monitor name")" exit 1 fi done @@ -35,7 +35,7 @@ on() { if omarchy-hyprland-toggle-disabled $TOGGLE; then printf 'hl.monitor({ output = "%s", mode = "preferred", position = "auto", scale = 1, mirror = "%s" })\n' "$EXTERNAL" "$INTERNAL" >"$TOGGLE_FLAG" - omarchy-notification-send -g 󰍹 "Mirroring enabled ($EXTERNAL)" + omarchy-notification-send -g 󰍹 "$(omarchy-text "Mirroring enabled (%1)" "$EXTERNAL")" hyprctl reload fi } @@ -43,7 +43,7 @@ on() { off() { if omarchy-hyprland-toggle-enabled $TOGGLE; then omarchy-hyprland-toggle $TOGGLE off - omarchy-notification-send -g 󰍹 "Extended mode restored" + omarchy-notification-send -g 󰍹 "$(omarchy-text "Extended mode restored")" fi } diff --git a/bin/omarchy-hyprland-window-single-square-aspect-toggle b/bin/omarchy-hyprland-window-single-square-aspect-toggle index dd51f28d..782cf552 100755 --- a/bin/omarchy-hyprland-window-single-square-aspect-toggle +++ b/bin/omarchy-hyprland-window-single-square-aspect-toggle @@ -3,6 +3,6 @@ # omarchy:summary=Toggle single-window square aspect ratio. case $(omarchy-hyprland-toggle single-window-aspect-ratio) in - on) omarchy-notification-send -g  "Enable single-window square aspect ratio" ;; - off) omarchy-notification-send -g  "Disable single-window square aspect ratio" ;; + on) omarchy-notification-send -g  "$(omarchy-text "Enable single-window square aspect ratio")" ;; + off) omarchy-notification-send -g  "$(omarchy-text "Disable single-window square aspect ratio")" ;; esac diff --git a/bin/omarchy-hyprland-window-transparency b/bin/omarchy-hyprland-window-transparency index e99c4b5c..e5b33ea6 100755 --- a/bin/omarchy-hyprland-window-transparency +++ b/bin/omarchy-hyprland-window-transparency @@ -37,9 +37,9 @@ if (( $# == 0 )); then fi case $1 in - opaque) LABEL="Opaque windows" ;; - transparent) LABEL="Transparent windows" ;; - glassy) LABEL="Glassy windows" ;; + opaque) LABEL=$(omarchy-text "Opaque windows") ;; + transparent) LABEL=$(omarchy-text "Transparent windows") ;; + glassy) LABEL=$(omarchy-text "Glassy windows") ;; *) echo "Usage: omarchy-hyprland-window-transparency [opaque|transparent|glassy]" >&2 exit 1 diff --git a/bin/omarchy-hyprland-window-width b/bin/omarchy-hyprland-window-width index 78bf5d89..eb8e3700 100755 --- a/bin/omarchy-hyprland-window-width +++ b/bin/omarchy-hyprland-window-width @@ -40,14 +40,14 @@ notify_missing_width() { local key="$1" local workspace="$2" - omarchy-notification-send -g  "No saved width found for $key on workspace $workspace" "Use Super + Alt + Home to save one for this workspace." + omarchy-notification-send -g  "$(omarchy-text "No saved width found for %1 on workspace %2" "$key" "$workspace")" "Use Super + Alt + Home to save one for this workspace." } notify_saved_width() { local key="$1" local workspace="$2" - omarchy-notification-send -g  "Saved width for $key on workspace $workspace" "Restore using Super + Home on this workspace." + omarchy-notification-send -g  "$(omarchy-text "Saved width for %1 on workspace %2" "$key" "$workspace")" "Restore using Super + Home on this workspace." } hypr_dispatch() { diff --git a/bin/omarchy-hyprland-workspace-layout-default b/bin/omarchy-hyprland-workspace-layout-default index 94012b6b..0475fa86 100755 --- a/bin/omarchy-hyprland-workspace-layout-default +++ b/bin/omarchy-hyprland-workspace-layout-default @@ -32,4 +32,4 @@ printf 'o.workspace_mode({ default = "%s" })\n' "$1" >"$DEFAULT_FILE" hyprctl reload >/dev/null omarchy-shell -q omarchy.workspaces refresh -omarchy-notification-send -g 󱂬 "New workspaces use the $LABEL layout" +omarchy-notification-send -g 󱂬 "$(omarchy-text "New workspaces use the %1 layout" "$LABEL")" diff --git a/bin/omarchy-hyprland-workspace-layout-toggle b/bin/omarchy-hyprland-workspace-layout-toggle index 7bb41912..2b9e5bee 100755 --- a/bin/omarchy-hyprland-workspace-layout-toggle +++ b/bin/omarchy-hyprland-workspace-layout-toggle @@ -43,17 +43,17 @@ if hyprctl eval "$APPLY" >/dev/null 2>&1; then fi if (( ! APPLIED )); then - omarchy-notification-send -g 󱂬 "Workspace layout needs a Hyprland reload" + omarchy-notification-send -g 󱂬 "$(omarchy-text "Workspace layout needs a Hyprland reload")" elif [[ $NEW_MODE == "$(omarchy-hyprland-workspace-layout-default)" ]] && rm -f "$MODE_FILE"; then # Cycled onto the default, a workspace goes back to following it, so a later # change of the default reaches it too. - omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE" + omarchy-notification-send -g 󱂬 "$(omarchy-text "Workspace layout set to %1" "$NEW_MODE")" elif mkdir -p "$MODES_DIR" && printf '%s\n' "$APPLY" >"$MODE_FILE"; then - omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE" + omarchy-notification-send -g 󱂬 "$(omarchy-text "Workspace layout set to %1" "$NEW_MODE")" else # The mode is on screen but nothing will bring it back, and the next press # reads the mode from this file, so say so rather than counting it as set. - omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE but could not be saved" + omarchy-notification-send -g 󱂬 "$(omarchy-text "Workspace layout set to %1 but could not be saved" "$NEW_MODE")" fi # The bar shows the focused workspace's layout. diff --git a/bin/omarchy-install-dictation-superwhisper b/bin/omarchy-install-dictation-superwhisper index 841f0c4e..c3359600 100755 --- a/bin/omarchy-install-dictation-superwhisper +++ b/bin/omarchy-install-dictation-superwhisper @@ -23,5 +23,5 @@ superwhisper shortcuts set toggle "Alt+Space" >/dev/null superwhisper shortcuts set hold none >/dev/null omarchy-dictation-use superwhisper hyprctl reload >/dev/null -omarchy-notification-send -g  "Superwhisper Dictation Ready" "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 +omarchy-notification-send -g  "$(omarchy-text "Superwhisper Dictation Ready")" "$(omarchy-text "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).")" -t 10000 superwhisper settings diff --git a/bin/omarchy-install-dictation-voxtype b/bin/omarchy-install-dictation-voxtype index 7202ca4d..02f70a2b 100755 --- a/bin/omarchy-install-dictation-voxtype +++ b/bin/omarchy-install-dictation-voxtype @@ -33,4 +33,4 @@ systemctl --user enable --now voxtype.service omarchy-dictation-use voxtype hyprctl reload >/dev/null -omarchy-notification-send -g  "Voxtype Dictation Ready" "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 +omarchy-notification-send -g  "$(omarchy-text "Voxtype Dictation Ready")" "$(omarchy-text "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).")" -t 10000 diff --git a/bin/omarchy-language b/bin/omarchy-language new file mode 100755 index 00000000..1e2d3306 --- /dev/null +++ b/bin/omarchy-language @@ -0,0 +1,46 @@ +#!/bin/bash + +# omarchy:summary=Show or switch the system language between English and Simplified Chinese +# omarchy:args=[en|zh] +# omarchy:examples=omarchy language | omarchy language zh +# omarchy:requires-sudo=true + +set -euo pipefail + +locale_conf=/etc/locale.conf + +if (($# == 0)); then + lang=$(sed -n 's/^LANG=//p' "$locale_conf") + # locale.conf may quote the value. + lang=${lang#[\"\']} + printf '%s\n' "${lang%[\"\']}" + exit 0 +fi + +case ${1:-} in +en) lang=en_US.UTF-8 ;; +zh) lang=zh_CN.UTF-8 ;; +*) + echo "Usage: omarchy-language [en|zh]" >&2 + exit 1 + ;; +esac + +sudo sed -i "s/^#\(${lang/./\\.} UTF-8\)/\1/" /etc/locale.gen +sudo locale-gen +if grep -q '^LANG=' "$locale_conf"; then + sudo sed -i "s/^LANG=.*/LANG=$lang/" "$locale_conf" +else + sudo sed -i '$a\' "$locale_conf" + echo "LANG=$lang" | sudo tee -a "$locale_conf" >/dev/null +fi + +# The language reaches only a new session, so offer one in the new language. +prompt=$(LANG=$lang omarchy-text "Log out now to switch the language?") +log_out=$(LANG=$lang omarchy-text "Log out now") +later=$(LANG=$lang omarchy-text "Later") +if [[ -t 0 && -t 1 ]] && gum confirm --affirmative "$log_out" --negative "$later" "$prompt" 2>&1; then + omarchy-system-logout +else + LANG=$lang omarchy-text "Log back in to switch the language." +fi diff --git a/bin/omarchy-launch-config-editor b/bin/omarchy-launch-config-editor index ffe100e9..f4c475b8 100755 --- a/bin/omarchy-launch-config-editor +++ b/bin/omarchy-launch-config-editor @@ -11,5 +11,5 @@ if [[ -z $path ]]; then exit 1 fi -omarchy-notification-send -u low "Editing config file" "$path" +omarchy-notification-send -u low "$(omarchy-text "Editing config file")" "$path" exec omarchy-launch-editor "$path" diff --git a/bin/omarchy-launch-screensaver b/bin/omarchy-launch-screensaver index 78cf7571..d67fae8a 100755 --- a/bin/omarchy-launch-screensaver +++ b/bin/omarchy-launch-screensaver @@ -16,7 +16,7 @@ terminal=$(xdg-terminal-exec --print-id) case $terminal in *Alacritty* | *ghostty* | *foot* | *kitty*) ;; *) - omarchy-notification-send -g ✋ "Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" + omarchy-notification-send -g ✋ "$(omarchy-text "Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty")" exit 1 ;; esac diff --git a/bin/omarchy-menu-plugin b/bin/omarchy-menu-plugin index 35ce5a36..8566bed5 100755 --- a/bin/omarchy-menu-plugin +++ b/bin/omarchy-menu-plugin @@ -28,7 +28,7 @@ rows=$(jq -r --arg icon "$PLUGIN_ICON" \ ". as \$plugins | .[] | select($filter) | \$icon + \"\\t\" + .name + \"\\t\" + .id" <<<"$plugins") -[[ -n $rows ]] || { omarchy-notification-send "No plugin to ${1}"; exit 0; } +[[ -n $rows ]] || { omarchy-notification-send "$(omarchy-text "No plugin to $1")"; exit 0; } selection=$(omarchy-menu-select "${1^} plugin" <<<"$rows") || exit 0 [[ -n $selection ]] || exit 0 diff --git a/bin/omarchy-menu-share b/bin/omarchy-menu-share index 57d94bea..315352fd 100755 --- a/bin/omarchy-menu-share +++ b/bin/omarchy-menu-share @@ -33,7 +33,7 @@ else picked=$(omarchy-file-select "${select_args[@]}") || status=$? if ((${status:-0} > 1)); then - omarchy-notification-send -g "" -u critical "Could not share" "The file chooser did not open" + omarchy-notification-send -g "" -u critical "$(omarchy-text "Could not share")" "$(omarchy-text "The file chooser did not open")" exit 1 fi diff --git a/bin/omarchy-menu-timezone b/bin/omarchy-menu-timezone index 1c95a1ba..0e88493d 100755 --- a/bin/omarchy-menu-timezone +++ b/bin/omarchy-menu-timezone @@ -9,4 +9,4 @@ set -e timezone=$(timedatectl list-timezones | omarchy-menu-select "Set timezone" -- --width 520 --maxheight 520) || exit 1 sudo timedatectl set-timezone "$timezone" omarchy-shell -q omarchy.clock refresh -omarchy-notification-send "Timezone is now set to $timezone" +omarchy-notification-send "$(omarchy-text "Timezone is now set to %1" "$timezone")" diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index 00cce3d6..be2bdf21 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -21,9 +21,9 @@ pending_migrations=$(omarchy-migrate --pending 2>/dev/null) || exit 0 pending_count=$(printf '%s\n' "$pending_migrations" | sed '/^[[:space:]]*$/d' | wc -l) if (( pending_count == 1 )); then - message="Click to run 1 pending migration." + message=$(omarchy-text "Click to run 1 pending migration.") else - message="Click to run $pending_count pending migrations." + message=$(omarchy-text "Click to run %1 pending migrations." "$pending_count") fi # This runs from omarchy-migrate-notify.service after graphical-session.target, @@ -40,7 +40,7 @@ fi # The shell keeps the click command with the toast, so this oneshot can hand the # invitation over and exit instead of staying activated until it is answered. -omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \ +omarchy-notification-send -u critical -g  "$(omarchy-text "Pending Omarchy Migrations")" "$message" \ --exec omarchy-launch-floating-terminal-with-presentation omarchy-migrate && exit 0 # Reached when the notification could not be handed off, so fall back to telling diff --git a/bin/omarchy-notification-time b/bin/omarchy-notification-time index 6963f373..99adb274 100755 --- a/bin/omarchy-notification-time +++ b/bin/omarchy-notification-time @@ -2,4 +2,4 @@ # omarchy:summary=Show the current time and date notification -omarchy-notification-send -g  -u low "$(date +"%A %H:%M · %d %B %Y · Week %V")" +omarchy-notification-send -g  -u low "$(date +"%A %H:%M · %d %B %Y") · $(omarchy-text "Week %1" "$(date +%V)")" diff --git a/bin/omarchy-plugin-clone b/bin/omarchy-plugin-clone index 59ca1617..c83dba46 100755 --- a/bin/omarchy-plugin-clone +++ b/bin/omarchy-plugin-clone @@ -159,8 +159,8 @@ done omarchy-plugin-enable "$new_id" >/dev/null clone_complete=1 omarchy-notification-send -g 󰐱 \ - "Editing Cloned Plugin" \ - "Original plugin has been replace by clone." + "$(omarchy-text "Editing Cloned Plugin")" \ + "$(omarchy-text "Original plugin has been replace by clone.")" echo "Cloned $source_id to $target_dir and switched to $new_id" if (( edit_clone )); then # Word-splitting is deliberate: EDITOR may carry flags. diff --git a/bin/omarchy-reminder b/bin/omarchy-reminder index d1287101..58d354f0 100755 --- a/bin/omarchy-reminder +++ b/bin/omarchy-reminder @@ -57,16 +57,16 @@ show_reminders() { [[ -f $reminder_dir/${timer%.timer}.message ]] && reminder_message=$(<"$reminder_dir/${timer%.timer}.message") if [[ -n $reminder_message ]]; then - body+="$reminder_message in $(format_remaining $remaining) ($(date -d "@$next" +%-H:%M))"$'\n' + body+="$(omarchy-text "%1 in %2 (%3)" "$reminder_message" "$(format_remaining $remaining)" "$(date -d "@$next" +%-H:%M)")"$'\n' else - body+="${reminder_minutes}-min reminder in $(format_remaining $remaining) ($(date -d "@$next" +%-H:%M))"$'\n' + body+="$(omarchy-text "%1-min reminder in %2 (%3)" "$reminder_minutes" "$(format_remaining $remaining)" "$(date -d "@$next" +%-H:%M)")"$'\n' fi done < <(active_reminder_timers "$now") if [[ -z $body ]]; then - omarchy-notification-send -g 󰢌 "Upcoming reminders" "No outstanding reminders" + omarchy-notification-send -g 󰢌 "$(omarchy-text "Upcoming reminders")" "$(omarchy-text "No outstanding reminders")" else - omarchy-notification-send -g 󰢌 "Upcoming reminders" "${body%$'\n'}" + omarchy-notification-send -g 󰢌 "$(omarchy-text "Upcoming reminders")" "${body%$'\n'}" fi } @@ -75,7 +75,8 @@ show_json() { local reminder_dir="${XDG_RUNTIME_DIR:-/tmp}/omarchy-reminders" local now=$(date +%s) local count=0 - local tooltip="Set Reminder" + local tooltip + tooltip=$(omarchy-text "Set Reminder") while IFS=$'\t' read -r timer next; do count=$((count + 1)) @@ -91,7 +92,7 @@ show_json() { if [[ -n $reminder_message ]]; then label=$reminder_message else - label="${reminder_minutes}-min reminder" + label=$(omarchy-text "%1-min reminder" "$reminder_minutes") fi item_json=$(jq -cn \ @@ -109,9 +110,9 @@ show_json() { done < <(active_reminder_timers "$now") if ((count == 1)); then - tooltip="1 reminder" + tooltip=$(omarchy-text "1 reminder") elif ((count > 1)); then - tooltip="$count reminders" + tooltip=$(omarchy-text "%1 reminders" "$count") fi jq -cn --argjson count "$count" --arg tooltip "$tooltip" --argjson reminders "$reminders_json" '{count:$count,active:($count > 0),tooltip:$tooltip,reminders:$reminders}' @@ -129,7 +130,7 @@ clear_reminders() { rm -f "$reminder_dir"/omarchy-reminder-*.message 2>/dev/null || true refresh_indicator - omarchy-notification-send -g 󰢌 "All reminders have been cleared" + omarchy-notification-send -g 󰢌 "$(omarchy-text "All reminders have been cleared")" } usage() { @@ -176,7 +177,7 @@ if [[ -z $minutes ]] || [[ ! $minutes =~ ^[0-9]+$ ]] || ((minutes == 0)); then fi if [[ -z $message ]]; then - message="Your ${minutes} minutes are up" + message=$(omarchy-text "Your %1 minutes are up" "$minutes") fi set_at=$(date +%s) @@ -184,18 +185,18 @@ remind_at=$(date -d "+${minutes} minutes" +%H:%M) unit="omarchy-reminder-${minutes}m-$set_at" reminder_dir="${XDG_RUNTIME_DIR:-/tmp}/omarchy-reminders" message_file="$reminder_dir/$unit.message" -confirmation="You'll be reminded at $remind_at" -confirmation_title="Reminder set for ${minutes} minutes" +confirmation=$(omarchy-text "You'll be reminded at %1" "$remind_at") +confirmation_title=$(omarchy-text "Reminder set for %1 minutes" "$minutes") mkdir -p "$reminder_dir" if [[ -n $custom_message ]]; then printf "%s" "$custom_message" >"$message_file" - confirmation_title="$custom_message in ${minutes} minutes" + confirmation_title=$(omarchy-text "%1 in %2 minutes" "$custom_message" "$minutes") fi systemd-run --user --quiet --collect --on-active="${minutes}m" --unit="$unit" \ - bash -c 'omarchy-notification-send -g 󰢌 "Reminder" "$1"; rm -f "$2"; omarchy-shell -q omarchy.indicators refresh >/dev/null 2>&1 || true' bash "$message" "$message_file" + bash -c 'omarchy-notification-send -g 󰢌 "$(omarchy-text "Reminder")" "$1"; rm -f "$2"; omarchy-shell -q omarchy.indicators refresh >/dev/null 2>&1 || true' bash "$message" "$message_file" refresh_indicator omarchy-notification-send -g 󰢌 "$confirmation_title" "$confirmation" diff --git a/bin/omarchy-system-logout b/bin/omarchy-system-logout index cb0fd660..d4e2bdcf 100755 --- a/bin/omarchy-system-logout +++ b/bin/omarchy-system-logout @@ -6,7 +6,7 @@ nohup bash -c "sleep 2 && uwsm stop" >/dev/null 2>&1 & -omarchy-osd -i logout -m "Logging out" -d 5000 +omarchy-osd -i logout -m "$(omarchy-text "Logging out")" -d 5000 # Now close all windows omarchy-hyprland-window-close-all diff --git a/bin/omarchy-system-reboot b/bin/omarchy-system-reboot index 4d2b6c86..f0d14955 100755 --- a/bin/omarchy-system-reboot +++ b/bin/omarchy-system-reboot @@ -8,7 +8,7 @@ # scope cannot terminate it before it runs. systemd-run --user --collect --quiet --on-active="2s" --timer-property=AccuracySec=100ms systemctl reboot --no-wall || exit 1 -omarchy-osd -i reboot -m "Rebooting" -d 5000 +omarchy-osd -i reboot -m "$(omarchy-text "Rebooting")" -d 5000 omarchy-state clear re*-required diff --git a/bin/omarchy-system-shutdown b/bin/omarchy-system-shutdown index 74b091f5..75731dd0 100755 --- a/bin/omarchy-system-shutdown +++ b/bin/omarchy-system-shutdown @@ -7,7 +7,7 @@ if [[ ${1:-} == "--inhibited" ]]; then # Close windows in the background so they cannot delay power-off. ( - omarchy-osd -i shutdown -m "Shutting down" -d 5000 + omarchy-osd -i shutdown -m "$(omarchy-text "Shutting down")" -d 5000 omarchy-state clear re*-required omarchy-hyprland-window-close-all ) & @@ -21,7 +21,7 @@ elif [[ ${1:-} == "--inhibit" ]]; then --who=Omarchy --why="Shutdown in progress" --mode=block \ "$0" --inhibited || { status=$? - omarchy-notification-send -u critical "Shutdown failed" "Could not complete shutdown. Please try again." + omarchy-notification-send -u critical "$(omarchy-text "Shutdown failed")" "$(omarchy-text "Could not complete shutdown. Please try again.")" exit "$status" } else diff --git a/bin/omarchy-system-sleep-lock b/bin/omarchy-system-sleep-lock index d471cea0..aa1cf6fa 100755 --- a/bin/omarchy-system-sleep-lock +++ b/bin/omarchy-system-sleep-lock @@ -98,8 +98,8 @@ report_unsecured() { "$1" >&2 omarchy-notification-send -u critical -g 󰌾 \ - "Screen did not lock before suspend" \ - "The session was left unlocked ($1)." >/dev/null 2>&1 || true + "$(omarchy-text "Screen did not lock before suspend")" \ + "$(omarchy-text "The session was left unlocked (%1)." "$1")" >/dev/null 2>&1 || true exit 1 } diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive index 3f27641c..fd3e0eca 100755 --- a/bin/omarchy-tailscale-receive +++ b/bin/omarchy-tailscale-receive @@ -59,7 +59,7 @@ claim_path() { announce() { local path="$1" local name="${path##*/}" - local args=("Received $name" "Saved to ${dir/#$HOME/~}" -u critical) + local args=("$(omarchy-text "Received %1" "$name")" "$(omarchy-text "Saved to %1" "${dir/#$HOME/~}")" -u critical) case "${name,,}" in *.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff) diff --git a/bin/omarchy-tailscale-send b/bin/omarchy-tailscale-send index 84093cc8..fb411bbc 100755 --- a/bin/omarchy-tailscale-send +++ b/bin/omarchy-tailscale-send @@ -27,8 +27,8 @@ if ((${#files[@]} == 0)); then picked=$(omarchy-file-select --title "Send to $name" --multiple) || status=$? if ((${status:-0} > 1)); then - omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" \ - "The file chooser did not open" + omarchy-notification-send -g "󰒊" -u critical "$(omarchy-text "Could not send to %1" "$name")" \ + "$(omarchy-text "The file chooser did not open")" exit 1 fi @@ -43,8 +43,8 @@ else fi if error=$(tailscale file cp --update-interval=0 -- "${files[@]}" "$machine:" 2>&1); then - omarchy-notification-send -g "󰒊" "Sent to $name" "$what" + omarchy-notification-send -g "󰒊" "$(omarchy-text "Sent to %1" "$name")" "$what" else - omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" "${error:-Taildrop transfer failed}" + omarchy-notification-send -g "󰒊" -u critical "$(omarchy-text "Could not send to %1" "$name")" "${error:-$(omarchy-text "Taildrop transfer failed")}" exit 1 fi diff --git a/bin/omarchy-text b/bin/omarchy-text new file mode 100755 index 00000000..17768a9c --- /dev/null +++ b/bin/omarchy-text @@ -0,0 +1,38 @@ +#!/bin/bash + +# omarchy:summary=Print an Omarchy message in the session's language, with %1.. filled from the arguments +# omarchy:args= [args...] +# omarchy:examples=omarchy-text "Sent to %1" phone +# omarchy:hidden=true + +set -euo pipefail + +if (($# < 1)); then + echo "Usage: omarchy-text [args...]" >&2 + exit 1 +fi + +text=$1 +shift + +# The shell's table is the one source: one JSON-encoded "english": "chinese" pair per line. +if [[ ${LANG:-} == zh_CN* ]]; then + translated=$(grep -E '^ ".*": ".*",?$' "$OMARCHY_PATH/shell/Commons/I18nZh.js" | sed 's/,$//' | paste -sd, | + sed 's/^/{/; s/$/}/' | jq -r --arg key "$text" '.[$key] // empty') + [[ -z $translated ]] || text=$translated +fi + +# One pass over the template, so an argument that holds %2 is printed as given. +out="" +rest=$text +while [[ $rest =~ ^([^%]*)%(.)(.*)$ ]]; do + out+=${BASH_REMATCH[1]} + mark=${BASH_REMATCH[2]} + rest=${BASH_REMATCH[3]} + if [[ $mark == [1-9] ]] && ((mark <= $#)); then + out+=${!mark} + else + out+="%$mark" + fi +done +printf '%s\n' "$out$rest" diff --git a/bin/omarchy-theme-bg-intro b/bin/omarchy-theme-bg-intro index 2bfe4ab2..6d0dba56 100755 --- a/bin/omarchy-theme-bg-intro +++ b/bin/omarchy-theme-bg-intro @@ -10,12 +10,12 @@ toggle="$HOME/.local/state/omarchy/toggles/background-intros-off" case ${1:-} in enable) rm -f "$toggle" - omarchy-notification-send "Background Intros" "Enabled" + omarchy-notification-send "$(omarchy-text "Background Intros")" "$(omarchy-text "Enabled")" ;; disable) mkdir -p "$(dirname "$toggle")" touch "$toggle" - omarchy-notification-send "Background Intros" "Disabled" + omarchy-notification-send "$(omarchy-text "Background Intros")" "$(omarchy-text "Disabled")" ;; toggle) if [[ -f $toggle ]]; then diff --git a/bin/omarchy-theme-bg-next b/bin/omarchy-theme-bg-next index f9907d7a..1d0f7152 100755 --- a/bin/omarchy-theme-bg-next +++ b/bin/omarchy-theme-bg-next @@ -17,7 +17,7 @@ mapfile -d '' -t BACKGROUNDS < <( TOTAL=${#BACKGROUNDS[@]} if (( TOTAL == 0 )); then - omarchy-notification-send "No background was found for theme" -t 2000 + omarchy-notification-send "$(omarchy-text "No background was found for theme")" -t 2000 else # Get current background from symlink if [[ -L $CURRENT_BACKGROUND_LINK ]]; then diff --git a/bin/omarchy-theme-remove b/bin/omarchy-theme-remove index 7e58289f..4b77e7d4 100755 --- a/bin/omarchy-theme-remove +++ b/bin/omarchy-theme-remove @@ -35,5 +35,5 @@ fi # Now remove the theme directory for THEME_NAME rm -rf "$THEME_PATH" -omarchy-notification-send "Theme removed" "$THEME_NAME" || true +omarchy-notification-send "$(omarchy-text "Theme removed")" "$THEME_NAME" || true echo "Removed $THEME_NAME" diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index d6922c5d..76fb117e 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -190,7 +190,7 @@ set_theme_background() { if [[ -z $CHOSEN_THEME_BACKGROUND || ! -f $CHOSEN_THEME_BACKGROUND ]]; then if ! choose_theme_background; then - omarchy-notification-send "No background was found for theme" -t 2000 + omarchy-notification-send "$(omarchy-text "No background was found for theme")" -t 2000 shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true return fi diff --git a/bin/omarchy-toggle-animations b/bin/omarchy-toggle-animations index 70db0166..cabb15df 100755 --- a/bin/omarchy-toggle-animations +++ b/bin/omarchy-toggle-animations @@ -16,7 +16,7 @@ off) omarchy-hyprland-toggle no-animations on ;; esac if omarchy-hyprland-toggle-enabled no-animations; then - omarchy-notification-send -g 󰗘 "Animations disabled" + omarchy-notification-send -g 󰗘 "$(omarchy-text "Animations disabled")" else - omarchy-notification-send -g 󰗘 "Animations enabled" + omarchy-notification-send -g 󰗘 "$(omarchy-text "Animations enabled")" fi diff --git a/bin/omarchy-toggle-crash-capture b/bin/omarchy-toggle-crash-capture index 1b4fb8b6..9135aa5f 100755 --- a/bin/omarchy-toggle-crash-capture +++ b/bin/omarchy-toggle-crash-capture @@ -11,8 +11,8 @@ omarchy-toggle crash-capture-off # starting only settles this session; the next login reads the flag itself. if omarchy-toggle-enabled crash-capture-off; then systemctl --user stop omarchy-crash-watch.service - omarchy-notification-send -g "$CRASH_GLYPH" "Crash capture disabled" + omarchy-notification-send -g "$CRASH_GLYPH" "$(omarchy-text "Crash capture disabled")" else systemctl --user start omarchy-crash-watch.service - omarchy-notification-send -g "$CRASH_GLYPH" "Crash capture enabled" + omarchy-notification-send -g "$CRASH_GLYPH" "$(omarchy-text "Crash capture enabled")" fi diff --git a/bin/omarchy-toggle-screensaver b/bin/omarchy-toggle-screensaver index 8f631318..a6bba188 100755 --- a/bin/omarchy-toggle-screensaver +++ b/bin/omarchy-toggle-screensaver @@ -5,7 +5,7 @@ omarchy-toggle screensaver-off if omarchy-toggle-enabled screensaver-off; then - omarchy-notification-send -g 󱄄 "Screensaver disabled" + omarchy-notification-send -g 󱄄 "$(omarchy-text "Screensaver disabled")" else - omarchy-notification-send -g 󱄄 "Screensaver enabled" + omarchy-notification-send -g 󱄄 "$(omarchy-text "Screensaver enabled")" fi diff --git a/bin/omarchy-toggle-suspend b/bin/omarchy-toggle-suspend index 736e23d4..e22fabb1 100755 --- a/bin/omarchy-toggle-suspend +++ b/bin/omarchy-toggle-suspend @@ -5,7 +5,7 @@ omarchy-toggle suspend-off if omarchy-toggle-enabled suspend-off; then - omarchy-notification-send -g 󰒲 "Suspend removed from system menu" + omarchy-notification-send -g 󰒲 "$(omarchy-text "Suspend removed from system menu")" else - omarchy-notification-send -g 󰒲 "Suspend now available in system menu" + omarchy-notification-send -g 󰒲 "$(omarchy-text "Suspend now available in system menu")" fi diff --git a/bin/omarchy-toggle-theme-sync b/bin/omarchy-toggle-theme-sync index c65a7d52..e6a7362c 100755 --- a/bin/omarchy-toggle-theme-sync +++ b/bin/omarchy-toggle-theme-sync @@ -5,7 +5,7 @@ omarchy-toggle herdr-theme-sync if omarchy-toggle-enabled herdr-theme-sync; then - omarchy-notification-send -g 󰸌 "Herdr theme sync enabled" + omarchy-notification-send -g 󰸌 "$(omarchy-text "Herdr theme sync enabled")" else - omarchy-notification-send -g 󰸌 "Herdr theme sync disabled" + omarchy-notification-send -g 󰸌 "$(omarchy-text "Herdr theme sync disabled")" fi diff --git a/bin/omarchy-transcode b/bin/omarchy-transcode index 27337d06..1101a20c 100755 --- a/bin/omarchy-transcode +++ b/bin/omarchy-transcode @@ -193,14 +193,14 @@ main() { output=$(output_path "$input" "$format" "$resolution") if [[ $type == "video" ]]; then - omarchy-notification-send -g  "Transcoding video…" "$(basename -- "$input") to $format ($resolution)" + omarchy-notification-send -g  "$(omarchy-text "Transcoding video…")" "$(omarchy-text "%1 to %2 (%3)" "$(basename -- "$input")" "$format" "$resolution")" transcode_video "$input" "$format" "$resolution" "$output" copy_to_clipboard "$output" - omarchy-notification-send -g  "Transcoded to $resolution $format" "Saved and copied to clipboard." + omarchy-notification-send -g  "$(omarchy-text "Transcoded to %1 %2" "$resolution" "$format")" "Saved and copied to clipboard." else transcode_picture "$input" "$format" "$resolution" "$output" copy_to_clipboard "$output" - omarchy-notification-send -g  "Transcoded to $resolution $format" "Saved and copied to clipboard." + omarchy-notification-send -g  "$(omarchy-text "Transcoded to %1 %2" "$resolution" "$format")" "Saved and copied to clipboard." fi } diff --git a/bin/omarchy-tui-remove b/bin/omarchy-tui-remove index 87dab9b8..6e990a07 100755 --- a/bin/omarchy-tui-remove +++ b/bin/omarchy-tui-remove @@ -37,5 +37,5 @@ icon_name=$(printf '%s\n' "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^[: rm -f "$DESKTOP_DIR/$APP_NAME.desktop" rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$APP_NAME.png" "$OLD_ICON_DIR/$APP_NAME.png" if [[ ${OMARCHY_REMOVE_NOTIFY:-true} != "false" ]]; then - omarchy-notification-send -g  "TUI removed" "$APP_NAME" + omarchy-notification-send -g  "$(omarchy-text "TUI removed")" "$APP_NAME" fi diff --git a/bin/omarchy-webapp-remove b/bin/omarchy-webapp-remove index b3244637..3324282d 100755 --- a/bin/omarchy-webapp-remove +++ b/bin/omarchy-webapp-remove @@ -56,6 +56,6 @@ rm -f "${desktop_file:-$DESKTOP_DIR/$APP_NAME.desktop}" rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$APP_NAME.png" "$OLD_ICON_DIR/$APP_NAME.png" if [[ ${OMARCHY_REMOVE_NOTIFY:-true} != "false" ]]; then - omarchy-notification-send -g  "Web app removed" "$APP_NAME" + omarchy-notification-send -g  "$(omarchy-text "Web app removed")" "$APP_NAME" fi update-desktop-database "$DESKTOP_DIR" &>/dev/null diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 07f60d7f..9920d139 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -1420,7 +1420,7 @@ launch_windows() { if ! priv up_wait; then echo "❌ Failed to start Windows VM!" echo " Try checking: omarchy-windows-vm status" - omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" + omarchy-notification-send -u critical "$(omarchy-text "Windows VM")" "$(omarchy-text "Failed to start Windows VM")" exit 1 fi diff --git a/default/input-methods/typing_setup.py b/default/input-methods/typing_setup.py index 13993ab2..fa406c7f 100644 --- a/default/input-methods/typing_setup.py +++ b/default/input-methods/typing_setup.py @@ -10,6 +10,11 @@ import sys import configure as setup +def text(english, *args): + """English with %1.. filled, in the session's language, from the shell's table.""" + return subprocess.run(["omarchy-text", english, *map(str, args)], capture_output=True, text=True, check=True).stdout.rstrip("\n") + + def keyboard_catalog(): # Read the same choices as the ISO and first-boot form, then convert console # keymaps through systemd's mapping, just as localectl does during install. @@ -64,7 +69,7 @@ def choose(title, catalog, selected, kind, overrides=None): if result.returncode == 1: return None if result.returncode: - raise RuntimeError(result.stderr.strip() or "The selection menu could not open") + raise RuntimeError(result.stderr.strip() or text("The selection menu could not open")) def input_catalog(items): @@ -78,7 +83,7 @@ def input_catalog(items): def input_items(items, selected, overrides=None): if not any(item[0].startswith("keyboard-") for item in items): - raise RuntimeError("The current input group has no keyboard input") + raise RuntimeError(text("The current input group has no keyboard input")) # Retained entries keep their switching order; new engines follow them. kept = [item for item in items if item[0].startswith("keyboard-") or item[0] in selected] current = dict(items) @@ -93,7 +98,7 @@ def set_inputs(group, layout, before, after): setup.run(["fcitx5-remote", "-c"]) setup.live_set(group, layout, after) if setup.live_group() != (group, layout, after): - raise RuntimeError("Fcitx did not retain the input selection") + raise RuntimeError(text("Fcitx did not retain the input selection")) except BaseException: setup.live_set(group, layout, before) raise @@ -103,7 +108,7 @@ def configure_inputs(): group, layout, items = setup.live_group() catalog, available = input_catalog(items) current = [name for name, override in items if not name.startswith("keyboard-")] - choose("Input Methods", catalog, current, "input", dict(items)) + choose(text("Input Methods"), catalog, current, "input", dict(items)) def load_engines(names, catalog): @@ -112,7 +117,7 @@ def load_engines(names, catalog): if subprocess.run(["omarchy-pkg-present", package], capture_output=True).returncode] if absent or any(name not in setup.PRESETS for name in names): labels = ", ".join(catalog.get(name, name) for name in names) - raise RuntimeError(f"Install {' '.join(absent) or 'its engine'} to use {labels}") + raise RuntimeError(text("Install %1 to use %2", " ".join(absent) or text("its engine"), labels)) # Fcitx discovers engines only at startup, so one installed while it was # running (by an update, say) needs a restart before it can be selected. setup.run(["omarchy-restart-xcompose"]) @@ -149,7 +154,7 @@ def add_input(method): def keyboard_values(selected): if not selected: - raise ValueError("Select at least one keyboard layout") + raise ValueError(text("Select at least one keyboard layout")) parts = [value.split(":", 1) for value in selected] # Preserve the installer's Latin-leading rule for desktop keybindings. if parts[0][0] in setup.NON_LATIN: @@ -176,19 +181,19 @@ def save_keyboard(selected): expected = [layout + (":" + variant if variant else "") for layout, variant in zip(layouts.split(","), variants.split(","))] if keyboard_selection() != expected: - raise RuntimeError("Your personal keyboard layout override takes precedence over this selection") + raise RuntimeError(text("Your personal keyboard layout override takes precedence over this selection")) first_layout = layouts.split(",")[0] first_variant = variants.split(",")[0] group_layout = first_layout + ("-" + first_variant if first_variant else "") primary = next((item[0] for item in before if item[0].startswith("keyboard-")), None) if primary is None: - raise RuntimeError("The current input group has no keyboard input") + raise RuntimeError(text("The current input group has no keyboard input")) keyboard = "keyboard-" + group_layout after = [[keyboard, ""]] + [item for item in before if item[0] not in (primary, keyboard)] group_changed = True setup.live_set(group, group_layout, after) if setup.live_group() != (group, group_layout, after): - raise RuntimeError("Fcitx did not retain the keyboard selection") + raise RuntimeError(text("Fcitx did not retain the keyboard selection")) except BaseException: if original is None: path.resolve().unlink(missing_ok=True) @@ -205,7 +210,7 @@ def save_keyboard(selected): def configure_keyboard(): current = keyboard_selection() catalog = keyboard_catalog() - choose("Keyboard Layouts", catalog, current, "keyboard") + choose(text("Keyboard Layouts"), catalog, current, "keyboard") def apply_selection(kind, context, values): @@ -213,7 +218,7 @@ def apply_selection(kind, context, values): rows = picker_rows(catalog, []) valid = {row[1:]: key for key, row in rows.items()} if not isinstance(values, list) or any(not isinstance(value, str) or value not in valid for value in values): - raise ValueError("The menu returned an invalid selection") + raise ValueError(text("The menu returned an invalid selection")) selected = list(dict.fromkeys(valid[value] for value in values)) if kind == "input": save_inputs(selected, context.get("overrides")) @@ -228,13 +233,13 @@ def main(): try: add_input(sys.argv[2]) except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: - raise SystemExit(f"Input method could not be added: {error}") + raise SystemExit(text("Input method could not be added: %1", error)) return applying = len(sys.argv) == 5 and sys.argv[1] == "apply" kind = sys.argv[2] if applying else sys.argv[1] if len(sys.argv) == 2 else "" if kind not in ("input", "keyboard"): raise ValueError("Choose keyboard or input setup") - title = "Input methods" if kind == "input" else "Keyboard layouts" + title = text("Input methods could not be updated") if kind == "input" else text("Keyboard layouts could not be updated") try: if applying: apply_selection(kind, json.loads(sys.argv[3]), json.loads(sys.argv[4])) @@ -243,7 +248,7 @@ def main(): else: configure_keyboard() except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: - subprocess.run(["omarchy-notification-send", title + " could not be updated", str(error)], check=True) + subprocess.run(["omarchy-notification-send", title, str(error)], check=True) raise SystemExit(1) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 6f111f22..c968aa99 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -212,6 +212,9 @@ "setup.default.editor.helix": {"icon":"","label":"Helix","checked":"[[ \"$(omarchy-default-editor)\" == \"helix\" ]]","action":"omarchy-default-editor helix"}, "setup.default.editor.vim": {"icon":"","label":"Vim","checked":"[[ \"$(omarchy-default-editor)\" == \"vim\" ]]","action":"omarchy-default-editor vim"}, "setup.default.editor.emacs": {"icon":" ","label":"Emacs","checked":"[[ \"$(omarchy-default-editor)\" == \"emacs\" ]]","action":"omarchy-default-editor emacs"}, + "setup.language": {"icon":"󰗊","label":"Language"}, + "setup.language.en": {"icon":"󰗊","label":"English","checked":"[[ \"$(omarchy-language)\" == \"en_US.UTF-8\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-language en'"}, + "setup.language.zh": {"icon":"󰗊","label":"简体中文","checked":"[[ \"$(omarchy-language)\" == \"zh_CN.UTF-8\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-language zh'"}, "setup.plugin": {"icon":"󰐱","label":"Plugins","aliases":["plugin","plugins"]}, "setup.plugin.enable": {"icon":"󰄬","label":"Enable Plugin","action":"omarchy-menu-plugin enable"}, "setup.plugin.disable": {"icon":"󰅖","label":"Disable Plugin","action":"omarchy-menu-plugin disable"}, diff --git a/default/omarchy/omarchy-menu.zh-cn.jsonc b/default/omarchy/omarchy-menu.zh-cn.jsonc new file mode 100644 index 00000000..9bc484b0 --- /dev/null +++ b/default/omarchy/omarchy-menu.zh-cn.jsonc @@ -0,0 +1,192 @@ +// Chinese menu labels, overlaid on omarchy-menu.jsonc when LANG is zh_CN. +{ + "apps": {"label":"应用"}, + "learn": {"label":"学习"}, + "trigger": {"label":"触发"}, + "style": {"label":"风格"}, + "setup": {"label":"设置"}, + "install": {"label":"安装"}, + "remove": {"label":"移除"}, + "update": {"label":"更新"}, + "about": {"label":"关于"}, + "system": {"label":"系统"}, + "system.screensaver": {"label":"屏保"}, + "system.lock": {"label":"锁屏"}, + "system.suspend": {"label":"睡眠"}, + "system.hibernate": {"label":"休眠"}, + "system.logout": {"label":"注销"}, + "system.reboot": {"label":"重启"}, + "system.shutdown": {"label":"关机"}, + "learn.keybindings": {"label":"快捷键"}, + "learn.omarchy": {"label":"Omarchy 手册"}, + "learn.community": {"label":"社区"}, + "trigger.emoji": {"label":"表情"}, + "trigger.reminder": {"label":"提醒"}, + "trigger.capture": {"label":"截取"}, + "trigger.capture.screenshot": {"label":"截图"}, + "trigger.capture.screenrecord.stop": {"label":"停止录屏"}, + "trigger.capture.screenrecord": {"label":"录屏"}, + "trigger.capture.text": {"label":"OCR 取字"}, + "trigger.capture.qr": {"label":"二维码"}, + "trigger.capture.color": {"label":"取色"}, + "trigger.capture.screenrecord.no-audio": {"label":"无音频"}, + "trigger.capture.screenrecord.desktop-audio": {"label":"含桌面音频"}, + "trigger.capture.screenrecord.microphone": {"label":"含桌面与麦克风音频"}, + "trigger.capture.screenrecord.webcam": {"label":"含桌面、麦克风与摄像头"}, + "trigger.transcode": {"label":"转码"}, + "trigger.share": {"label":"分享"}, + "trigger.toggle": {"label":"开关"}, + "trigger.hardware": {"label":"硬件"}, + "trigger.tests": {"label":"测速"}, + "trigger.hardware.laptop-display": {"label":"笔记本屏幕"}, + "trigger.hardware.mirror-display": {"label":"镜像显示"}, + "trigger.hardware.hybrid-gpu": {"label":"混合显卡"}, + "trigger.hardware.touchpad": {"label":"触控板"}, + "trigger.hardware.touchpad-haptics": {"label":"触控板振动"}, + "trigger.hardware.touchpad-haptics.low": {"label":"低"}, + "trigger.hardware.touchpad-haptics.mid": {"label":"中"}, + "trigger.hardware.touchpad-haptics.high": {"label":"高"}, + "trigger.hardware.touchscreen": {"label":"触摸屏"}, + "trigger.reminder.set": {"label":"设一个提醒"}, + "trigger.reminder.show": {"label":"查看全部"}, + "trigger.reminder.clear": {"label":"清除全部"}, + "trigger.share.clipboard": {"label":"剪贴板"}, + "trigger.share.file": {"label":"文件"}, + "trigger.share.folder": {"label":"文件夹"}, + "trigger.share.receive": {"label":"接收"}, + "trigger.toggle.idle-lock": {"label":"保持唤醒"}, + "trigger.toggle.notifications": {"label":"通知"}, + "trigger.toggle.crash-capture": {"label":"崩溃捕获"}, + "trigger.toggle.screensaver": {"label":"屏保"}, + "trigger.toggle.nightlight": {"label":"夜间模式"}, + "trigger.tests.network-speedtest": {"label":"网络测速"}, + "trigger.tests.disk-speedtest": {"label":"磁盘测速"}, + "style.theme": {"label":"主题"}, + "style.background": {"label":"壁纸"}, + "style.unlock": {"label":"解锁画面"}, + "style.font": {"label":"字体"}, + "style.screensaver": {"label":"屏保"}, + "style.about": {"label":"关于"}, + "style.about.text": {"label":"编辑文字"}, + "style.about.image": {"label":"取自图片"}, + "style.about.default": {"label":"恢复默认"}, + "style.screensaver.text": {"label":"编辑文字"}, + "style.screensaver.image": {"label":"取自图片"}, + "style.screensaver.default": {"label":"恢复默认"}, + "setup.monitors": {"label":"显示器"}, + "setup.keybindings": {"label":"快捷键"}, + "setup.input": {"label":"输入设备"}, + "setup.language": {"label":"语言"}, + "setup.network": {"label":"网络"}, + "setup.network.dns.custom": {"label":"自定义"}, + "setup.network.qr": {"label":"二维码"}, + "setup.default": {"label":"默认程序"}, + "setup.default.agent": {"title":"默认 Agent"}, + "setup.default.dictation": {"label":"语音听写","title":"默认语音听写"}, + "setup.default.browser": {"label":"浏览器","title":"默认浏览器"}, + "setup.default.terminal": {"label":"终端","title":"默认终端"}, + "setup.default.editor": {"label":"编辑器","title":"默认编辑器"}, + "setup.plugin": {"label":"插件"}, + "setup.plugin.enable": {"label":"启用插件"}, + "setup.plugin.disable": {"label":"停用插件"}, + "setup.plugin.add": {"label":"添加插件"}, + "setup.plugin.clone": {"label":"克隆插件"}, + "setup.plugin.remove": {"label":"移除插件"}, + "setup.security": {"label":"安全"}, + "setup.config": {"label":"配置"}, + "setup.security.fingerprint": {"label":"指纹"}, + "setup.security.passwordless-sudo": {"label":"免密 sudo"}, + "setup.security.sudoless-docker": {"label":"免 sudo Docker"}, + "setup.direct-boot": {"label":"直接启动"}, + "setup.reset": {"label":"恢复出厂设置"}, + "install.package": {"label":"软件包"}, + "install.service": {"label":"服务"}, + "install.development": {"label":"开发环境"}, + "install.editor": {"label":"编辑器"}, + "install.style": {"label":"风格"}, + "install.style.theme": {"label":"主题"}, + "install.style.background": {"label":"壁纸"}, + "install.style.font": {"label":"字体"}, + "install.gaming": {"label":"游戏"}, + "install.browser": {"label":"浏览器"}, + "install.webapp": {"label":"网页应用"}, + "install.terminal": {"label":"终端"}, + "install.preinstalls": {"label":"预装应用"}, + "install.service.chromium-account": {"label":"Chromium 账号"}, + "install.gaming.xbox-cloud": {"label":"Xbox 云游戏"}, + "install.gaming.xbox-controllers": {"label":"Xbox 手柄"}, + "install.gaming.retro-launcher": {"label":"RetroArch 启动器"}, + "install.development.docker-dbs": {"label":"Docker 数据库"}, + "remove.package": {"label":"软件包"}, + "remove.ai": {"title":"移除"}, + "remove.service": {"label":"服务","title":"移除"}, + "remove.development": {"label":"开发环境","title":"移除"}, + "remove.theme": {"label":"主题"}, + "remove.gaming": {"label":"游戏","title":"移除"}, + "remove.browser": {"label":"浏览器","title":"移除"}, + "remove.webapp": {"label":"网页应用"}, + "remove.preinstalls": {"label":"预装应用"}, + "remove.security": {"label":"安全","title":"移除"}, + "remove.security.fingerprint": {"label":"指纹"}, + "remove.security.sudoless-docker": {"label":"免 sudo Docker"}, + "remove.dictation": {"label":"语音听写"}, + "remove.gaming.xbox-cloud": {"label":"Xbox 云游戏"}, + "remove.gaming.xbox-controllers": {"label":"Xbox 手柄 (󰂯)"}, + "remove.development.javascript": {"title":"移除"}, + "remove.development.php": {"title":"移除"}, + "remove.development.elixir": {"title":"移除"}, + "update.channel": {"label":"通道"}, + "update.config": {"label":"配置","title":"重置为默认"}, + "update.themes": {"label":"附加主题"}, + "update.process": {"label":"进程","title":"重启"}, + "update.hardware": {"label":"硬件","title":"重启"}, + "update.firmware": {"label":"固件"}, + "update.password": {"label":"密码"}, + "update.timezone": {"label":"时区"}, + "update.time": {"label":"时间"}, + "update.hardware.audio": {"label":"音频"}, + "update.hardware.bluetooth": {"label":"蓝牙"}, + "update.hardware.trackpad": {"label":"触控板"}, + "update.password.drive": {"label":"磁盘加密"}, + "update.password.user": {"label":"用户"}, + "update.channel.stable": {"label":"稳定版"}, + "update.channel.rc": {"label":"候选版"}, + "update.channel.edge": {"label":"尝鲜版"}, + "update.channel.dev": {"label":"开发版"}, + "trigger.appearance": {"label":"外观"}, + "trigger.appearance.animations": {"label":"动画"}, + "trigger.appearance.one-window-ratio": {"label":"单窗口比例"}, + "trigger.appearance.theme-sync": {"label":"Herdr 主题同步"}, + "trigger.appearance.top-bar": {"label":"菜单栏"}, + "trigger.appearance.top-bar.show": {"label":"显示菜单栏"}, + "trigger.appearance.top-bar.hide": {"label":"隐藏菜单栏"}, + "trigger.appearance.top-bar.position": {"label":"菜单栏位置"}, + "trigger.appearance.top-bar.position.top": {"label":"顶部"}, + "trigger.appearance.top-bar.position.bottom": {"label":"底部"}, + "trigger.appearance.top-bar.position.left": {"label":"左侧"}, + "trigger.appearance.top-bar.position.right": {"label":"右侧"}, + "trigger.appearance.top-bar.background": {"label":"菜单栏背景"}, + "trigger.appearance.top-bar.background.opaque": {"label":"不透明"}, + "trigger.appearance.top-bar.background.glassy": {"label":"毛玻璃"}, + "trigger.appearance.top-bar.background.clear": {"label":"全透明"}, + "trigger.appearance.top-bar.indicators": {"label":"菜单栏指示"}, + "trigger.appearance.top-bar.indicators.battery-percentage": {"label":"电量百分比"}, + "trigger.appearance.top-bar.indicators.clock": {"label":"时钟"}, + "trigger.appearance.window-corners": {"label":"窗口圆角"}, + "trigger.appearance.window-corners.theme": {"label":"跟随主题"}, + "trigger.appearance.window-corners.rounded": {"label":"圆角"}, + "trigger.appearance.window-corners.sharp": {"label":"直角"}, + "trigger.appearance.window-gaps": {"label":"窗口间距"}, + "trigger.appearance.window-transparency": {"label":"窗口透明度"}, + "trigger.appearance.window-transparency.opaque": {"label":"不透明"}, + "trigger.appearance.window-transparency.glassy": {"label":"毛玻璃"}, + "trigger.appearance.window-transparency.transparent": {"label":"透明"}, + "trigger.appearance.workspace-layout": {"label":"工作区布局","title":"默认工作区布局"}, + "trigger.appearance.workspace-layout.dwindle": {"label":"平铺"}, + "trigger.appearance.workspace-layout.scrolling": {"label":"滚动"}, + "trigger.appearance.workspace-layout.floating": {"label":"浮动"}, + "trigger.clipboard-history": {"label":"剪贴板历史"}, + "setup.typing": {"label":"输入"}, + "setup.typing.keyboard": {"label":"键盘布局"}, + "setup.typing.input": {"label":"输入法"}, +} diff --git a/docs/menu.md b/docs/menu.md index 42b31f16..045dc97a 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -56,6 +56,8 @@ or re-icon a row without re-declaring its action, and an overridden entry keeps its original position in the list. New ids append. A `root` entry is injected if neither file declares one. +When `LANG` starts with `zh_CN`, `default/omarchy/omarchy-menu.zh-cn.jsonc` is merged between the defaults and the user extension. It only retitles shipped ids, so a user extension still wins, and the language follows `/etc/locale.conf` instead of any file `omarchy update` rewrites. + The sample extension at `config/omarchy/extensions/omarchy-menu.jsonc` (refreshed into `~/.config/`) documents the format in its header and ships only comments, so the default state adds nothing. diff --git a/install/provisioning/setup-form.sh b/install/provisioning/setup-form.sh index e5ce3cb0..74be9807 100644 --- a/install/provisioning/setup-form.sh +++ b/install/provisioning/setup-form.sh @@ -87,6 +87,39 @@ OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd- OMARCHY_HOSTNAME_PATTERN='^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' OMARCHY_HOSTNAME_DEFAULT='omarchy' +# The ISO sets OMARCHY_INSTALL_LANG=zh in its CJK console; omarchy_text then prints the OMARCHY_ZH entry. +declare -gA OMARCHY_ZH=( + ["Select keyboard layout"]="选择键盘布局" + ["Alphanumeric without spaces (like dhh)"]="字母和数字,不含空格(如 dhh)" + ["Username> "]="用户名> " + ["Username is reserved for system"]="这个用户名由系统保留" + ["That username already exists on this machine"]="本机已有这个用户名" + ["Username must be alphanumeric with no spaces"]="用户名只能用字母和数字,不含空格" + ["Used for user + root, and disk encryption when enabled"]="用于用户和 root,开启加密时也用于解锁磁盘" + ["Password> "]="密码> " + ["Must match the password you just typed"]="与刚才输入的密码一致" + ["Confirm> "]="确认密码> " + ["Your password can't be blank!"]="密码不能为空" + ["Passwords didn't match!"]="两次输入的密码不一致" + ["Used for git authentication (hit return to skip)"]="用于 git 身份(回车跳过)" + ["Full name> "]="姓名> " + ["Email address> "]="邮箱> " + ["Letters, digits, and dashes (or return for 'omarchy')"]="字母、数字和连字符(回车用 omarchy)" + ["Hostname> "]="主机名> " + ["Hostname must be 1-63 letters, digits, or dashes, and cannot start or end with a dash"]="主机名为 1-63 个字母、数字或连字符,首尾不能是连字符" + ["Timezone"]="时区" +) + +omarchy_text() { + local text=$1 + shift + if [[ ${OMARCHY_INSTALL_LANG:-} == "zh" && -n ${OMARCHY_ZH[$text]:-} ]]; then + text=${OMARCHY_ZH[$text]} + fi + # shellcheck disable=SC2059 + printf -- "$text" "$@" +} + # Installer targets are empty, so any account is fair game; first-boot setup # overrides this because its machine already has users. omarchy_username_taken() { return 1; } @@ -95,10 +128,29 @@ omarchy_username_taken() { return 1; } # by `status=$?`: one caller runs under `set -e`, where a cancelled prompt is a # failing assignment that would kill the script before the status is read. +# English or Simplified Chinese; the Chinese name shows only in a CJK console. +omarchy_prompt_language() { + local chinese="Chinese" header="Select language" selected=English choice status + if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then + chinese="简体中文 Chinese" + header="Select language · 选择语言" + fi + [[ $language == "zh" ]] && selected=$chinese + choice=$(printf '%s\n' English "$chinese" | gum choose --selected "$selected" --header "$header") && status=0 || status=$? + ((status == 0)) || return $status + + if [[ $choice == "English" ]]; then + language=en + else + language=zh + fi +} + +# Callers may name the layout to preselect. omarchy_prompt_keyboard() { - local choice status + local choice status selected=${1:-English (US)} choice=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | cut -d'|' -f1 | - gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") && status=0 || status=$? + gum choose --height 10 --selected "${selected//,/\\,}" --header "$(omarchy_text "Select keyboard layout")") && status=0 || status=$? ((status == 0)) || return $status local selection @@ -122,19 +174,19 @@ omarchy_persist_input_selection() { omarchy_prompt_username() { local status while true; do - username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") && status=0 || status=$? + username=$(gum input --placeholder "$(omarchy_text "Alphanumeric without spaces (like dhh)")" --prompt.foreground="#845DF9" --prompt "$(omarchy_text "Username> ")") && status=0 || status=$? ((status == 0)) || return $status if [[ "$username" =~ $OMARCHY_USERNAME_PATTERN ]]; then if [[ "$username" =~ $OMARCHY_RESERVED_USERNAMES ]]; then - notice "Username is reserved for system" 1 + notice "$(omarchy_text "Username is reserved for system")" 1 elif omarchy_username_taken "$username"; then - notice "That username already exists on this machine" 1 + notice "$(omarchy_text "That username already exists on this machine")" 1 else return 0 fi else - notice "Username must be alphanumeric with no spaces" 1 + notice "$(omarchy_text "Username must be alphanumeric with no spaces")" 1 fi done } @@ -142,17 +194,17 @@ omarchy_prompt_username() { omarchy_prompt_password() { local status while true; do - password=$(gum input --placeholder "Used for user + root, and disk encryption when enabled" --prompt.foreground="#845DF9" --password --prompt "Password> ") && status=0 || status=$? + password=$(gum input --placeholder "$(omarchy_text "Used for user + root, and disk encryption when enabled")" --prompt.foreground="#845DF9" --password --prompt "$(omarchy_text "Password> ")") && status=0 || status=$? ((status == 0)) || return $status - password_confirmation=$(gum input --placeholder "Must match the password you just typed" --prompt.foreground="#845DF9" --password --prompt "Confirm> ") && status=0 || status=$? + password_confirmation=$(gum input --placeholder "$(omarchy_text "Must match the password you just typed")" --prompt.foreground="#845DF9" --password --prompt "$(omarchy_text "Confirm> ")") && status=0 || status=$? ((status == 0)) || return $status if [[ -n "$password" && "$password" == "$password_confirmation" ]]; then return 0 elif [[ -z "$password" ]]; then - notice "Your password can't be blank!" 1 + notice "$(omarchy_text "Your password can't be blank!")" 1 else - notice "Passwords didn't match!" 1 + notice "$(omarchy_text "Passwords didn't match!")" 1 fi done } @@ -161,16 +213,16 @@ omarchy_prompt_password() { # only Esc/Ctrl+C end the prompt early. omarchy_prompt_identity() { local status - full_name=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Full name> ") && status=0 || status=$? + full_name=$(gum input --placeholder "$(omarchy_text "Used for git authentication (hit return to skip)")" --prompt.foreground="#845DF9" --prompt "$(omarchy_text "Full name> ")") && status=0 || status=$? ((status == 0)) || return $status - email_address=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Email address> ") && status=0 || status=$? + email_address=$(gum input --placeholder "$(omarchy_text "Used for git authentication (hit return to skip)")" --prompt.foreground="#845DF9" --prompt "$(omarchy_text "Email address> ")") && status=0 || status=$? return $status } omarchy_prompt_hostname() { local status while true; do - hostname=$(gum input --placeholder "Letters, digits, and dashes (or return for 'omarchy')" --prompt.foreground="#845DF9" --prompt "Hostname> ") && status=0 || status=$? + hostname=$(gum input --placeholder "$(omarchy_text "Letters, digits, and dashes (or return for 'omarchy')")" --prompt.foreground="#845DF9" --prompt "$(omarchy_text "Hostname> ")") && status=0 || status=$? ((status == 0)) || return $status if [[ -z $hostname ]]; then @@ -179,7 +231,7 @@ omarchy_prompt_hostname() { elif [[ "$hostname" =~ $OMARCHY_HOSTNAME_PATTERN ]]; then return 0 else - notice "Hostname must be 1-63 letters, digits, or dashes, and cannot start or end with a dash" 1 + notice "$(omarchy_text "Hostname must be 1-63 letters, digits, or dashes, and cannot start or end with a dash")" 1 fi done } @@ -191,9 +243,9 @@ omarchy_prompt_timezone() { guess=$(tzupdate -p 2>/dev/null) || guess="" if [[ -n $guess ]]; then - timezone=$(timedatectl list-timezones | gum choose --height 10 --selected "$guess" --header "Timezone") && status=0 || status=$? + timezone=$(timedatectl list-timezones | gum choose --height 10 --selected "$guess" --header "$(omarchy_text "Timezone")") && status=0 || status=$? else - timezone=$(timedatectl list-timezones | gum filter --height 10 --header "Timezone") && status=0 || status=$? + timezone=$(timedatectl list-timezones | gum filter --height 10 --header "$(omarchy_text "Timezone")") && status=0 || status=$? fi ((status == 0)) || return $status diff --git a/install/user/first-run/setup-agent.hook b/install/user/first-run/setup-agent.hook index 90596b83..9f969a6b 100644 --- a/install/user/first-run/setup-agent.hook +++ b/install/user/first-run/setup-agent.hook @@ -5,7 +5,7 @@ set -e # Omarchy ships no default agent, so invite once rather than picking one. An # agent already chosen means the invitation has nothing to offer. if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then - omarchy-notification-send -u critical -g 󰚩 "Set your default agent" \ - "Let your favorite agent help with Omarchy." \ + omarchy-notification-send -u critical -g 󰚩 "$(omarchy-text "Set your default agent")" \ + "$(omarchy-text "Let your favorite agent help with Omarchy.")" \ --exec omarchy menu summon setup.default.agent fi diff --git a/install/user/first-run/setup-fingerprint.hook b/install/user/first-run/setup-fingerprint.hook index 2861eef0..b3a79c8d 100644 --- a/install/user/first-run/setup-fingerprint.hook +++ b/install/user/first-run/setup-fingerprint.hook @@ -6,7 +6,7 @@ set -e # PAM file is the last thing the setup writes on success). if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] && omarchy-done ensure fingerprint-setup-invitation; then - omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" \ - "Enable sudo and unlocking with your fingerprint." \ + omarchy-notification-send -u critical -g 󰈷 "$(omarchy-text "Setup Fingerprint Reader")" \ + "$(omarchy-text "Enable sudo and unlocking with your fingerprint.")" \ --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint fi diff --git a/install/user/first-run/welcome.sh b/install/user/first-run/welcome.sh index 22e6629e..e7c64d55 100644 --- a/install/user/first-run/welcome.sh +++ b/install/user/first-run/welcome.sh @@ -1,5 +1,5 @@ # Real newlines, not a literal \n: the card renders the body as it arrives, and # elides past three lines. -omarchy-notification-send -u critical -g  "Learn Keybindings" \ - $'Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.' \ +omarchy-notification-send -u critical -g  "$(omarchy-text "Learn Keybindings")" \ + "$(omarchy-text $'Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.')" \ --exec omarchy-menu-keybindings diff --git a/install/user/first-run/wifi.sh b/install/user/first-run/wifi.sh index 3b2af23a..223b7c74 100644 --- a/install/user/first-run/wifi.sh +++ b/install/user/first-run/wifi.sh @@ -1,10 +1,10 @@ notify_update() { - omarchy-notification-send -u critical -g  "Update System" "Click to update the system." \ + omarchy-notification-send -u critical -g  "$(omarchy-text "Update System")" "$(omarchy-text "Click to update the system.")" \ --exec omarchy-launch-floating-terminal-with-presentation omarchy-update } notify_wifi() { - omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." \ + omarchy-notification-send -u critical -g 󰖩 "$(omarchy-text "Setup Wi-Fi")" "$(omarchy-text "Click to configure the wireless network.")" \ --exec omarchy-shell shell toggle omarchy.network } diff --git a/manual/02-getting-started.md b/manual/02-getting-started.md index 86ca7aa9..65e244eb 100644 --- a/manual/02-getting-started.md +++ b/manual/02-getting-started.md @@ -22,7 +22,7 @@ The full-disk encryption won't allow you to enter the password from a Bluetooth ### Installing for another owner -If you're setting up a machine for someone else — a family member, a new employee, a buyer — you shouldn't be answering the personal questions on their behalf. Hit `Ctrl + C` on the very first screen of the installer (the keyboard selection), and Omarchy will offer to prepare the machine for another owner instead. The system installs right away, but all the personal setup — keyboard layout, username, password — is deferred until the machine boots for the first time. The drive is still encrypted by default, and the password the new owner picks on that first boot becomes the encryption password too. (A machine you've already been using can be handed over without a reinstall too — see [resetting the computer](48-security.md).) +If you're setting up a machine for someone else — a family member, a new employee, a buyer — you shouldn't be answering the personal questions on their behalf. Hit `Ctrl + C` on the very first screen of the installer (the language and keyboard selection), and Omarchy will offer to prepare the machine for another owner instead. The system installs right away, but all the personal setup — keyboard layout, username, password — is deferred until the machine boots for the first time. The drive is still encrypted by default, and the password the new owner picks on that first boot becomes the encryption password too. (A machine you've already been using can be handed over without a reinstall too — see [resetting the computer](48-security.md).) ### Unattended installs diff --git a/shell/Commons/I18n.qml b/shell/Commons/I18n.qml new file mode 100644 index 00000000..857b21d4 --- /dev/null +++ b/shell/Commons/I18n.qml @@ -0,0 +1,16 @@ +pragma Singleton +import QtQuick +import "Tr.js" as Tr + +// tr("English %1", arg) returns the Chinese entry under zh_CN; plain JS imports Tr.js for the same. +QtObject { + readonly property bool chinese: Tr.chinese + + function tr() { + return Tr.tr.apply(null, arguments) + } + + function trMatch(text, templates) { + return Tr.trMatch(text, templates) + } +} diff --git a/shell/Commons/I18nZh.js b/shell/Commons/I18nZh.js new file mode 100644 index 00000000..b05bbbf6 --- /dev/null +++ b/shell/Commons/I18nZh.js @@ -0,0 +1,642 @@ +// Chinese for the shell, keyed by the English text each I18n.tr() call passes; %1, %2 are arguments. +.pragma library + +var table = { + // Agents panel + "Agents": "AI 代理", + "Monthly": "每月", + "Weekly": "每周", + "Session": "会话", + "Limit": "额度", + "now": "现在", + "%1d %2h": "%1天 %2小时", + "%1h %2m": "%1小时 %2分", + "%1m": "%1分钟", + "%1 free reset": "%1 次免费重置", + "%1 free resets": "%1 次免费重置", + "%1 spent of %2 funded": "已用 %1,共充值 %2", + "estimated": "估算", + "%1 tokens this week": "本周 %1 tokens", + "%1 tokens today": "今天 %1 tokens", + "Mostly %1": "主要使用 %1", + "Busiest day: %1": "最忙的一天:%1", + "%1 prompt today": "今天 %1 条提示", + "%1 prompts today": "今天 %1 条提示", + "%1 session today": "今天 %1 个会话", + "%1 sessions today": "今天 %1 个会话", + "Merged from %1 device": "合并自 %1 台设备", + "Merged from %1 devices": "合并自 %1 台设备", + "Add an account": "添加账户", + "Add an %1 account": "添加 %1 账户", + "Add a %1 account": "添加 %1 账户", + "The sign-in didn't finish.": "登录没有完成。", + "Back to the limits": "返回额度", + "Add a subscription": "添加订阅", + "Start the default agent": "启动默认 AI 代理", + "MAKE SOMETHING COOL": "做点有意思的", + "Sign in to an AI coding subscription, and this panel keeps track of how much of it you have left.": "登录一个 AI 编程订阅,这个面板会记录剩余额度。", + "Name this account. It signs in through a private window, so your browser's current account isn't picked up.": "给这个账户起个名字。登录在隐私窗口中进行,不会用到浏览器当前登录的账户。", + "Work": "工作", + "Confirm this code in your browser": "在浏览器中确认这个代码", + "If the page shows a code instead of finishing, paste it here.": "如果页面显示代码而没有完成,把代码粘贴到这里。", + "Code": "代码", + "Open the sign-in page again": "重新打开登录页", + "Sign in to %1 again": "重新登录 %1", + "Balance": "余额", + "Start your default agent on a new %1": "用默认 AI 代理新建一个%1", + "Sign in to this account again": "重新登录这个账户", + "ACTIVE": "使用中", + "Autoswitch": "自动切换", + "Stop switching automatically": "停止自动切换", + "Switch here automatically at %1%": "用到 %1% 时自动切换到这里", + "Use": "使用", + "Usage unavailable": "用量不可用", + "Usage has not been updated yet.": "用量还没有更新。", + "%1: %2% used": "%1:已用 %2%", + "resets in %1": "%1后重置", + "Last updated %1 ago": "%1前更新", + "Last updated less than a minute ago": "不到一分钟前更新", + "Last updated time unavailable": "更新时间不可用", + "Theme": "主题", + "Plugin": "插件", + "App": "应用", + "Sunday": "星期日", + "Monday": "星期一", + "Tuesday": "星期二", + "Wednesday": "星期三", + "Thursday": "星期四", + "Friday": "星期五", + "Saturday": "星期六", + "%1: %2% of its %3 allowance": "%1:占%3额度的 %2%", + "Subscriptions": "订阅", + "next expires in %1": "下一次%1后过期", + "Installing the %1 CLI…": "正在安装 %1 命令行…", + "Sign in to %1 again in the browser window that opens.": "在打开的浏览器窗口中重新登录 %1。", + "Sign in to %1 in the browser window that opens.": "在打开的浏览器窗口中登录 %1。", + "Sign in again as this account in the private window that opens.": "在打开的隐私窗口中以这个账户重新登录。", + "Sign in as the account you're adding in the private window that opens.": "在打开的隐私窗口中登录要添加的账户。", + "Signed in to %1 again.": "已重新登录 %1。", + "Signed in to %1.": "已登录 %1。", + "The %1 sign-in didn't finish.": "%1 登录没有完成。", + "Added %1.": "已添加 %1。", + "Starting…": "正在开始…", + "Checking the code…": "正在核对代码…", + "Run `claude auth login` to restore authoritative usage.": "运行 `claude auth login` 恢复准确的用量。", + "Run `codex login` to authenticate.": "运行 `codex login` 完成认证。", + "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in pi or opencode.": "设置 FIREWORKS_API_KEY,运行 `firectl set-api-key`,或在 pi 或 opencode 中登录 Fireworks。", + "Start Grok, or run `grok login`, to sign in.": "启动 Grok 或运行 `grok login` 登录。", + "Start Grok to see its limits; signing in again is not needed.": "启动 Grok 查看额度,不需要重新登录。", + "Grok's saved sign-in expired. Start Grok to refresh it.": "Grok 保存的登录已过期,启动 Grok 刷新。", + "Fireworks unavailable": "Fireworks 不可用", + "Fireworks usage scan failed": "Fireworks 用量扫描失败", + "Balance unavailable": "余额不可用", + "Claude limits unavailable": "Claude 额度不可用", + "Codex limits unavailable": "Codex 额度不可用", + "Codex unavailable": "Codex 不可用", + "Grok limits unavailable": "Grok 额度不可用", + "Limits paused": "额度已暂停", + "Sign-in expired": "登录已过期", + "Waiting for auth": "等待认证", + "Anthropic's usage endpoint is rate limiting checks right now (retry after %1s). Local Claude Code stats are still shown.": "Anthropic 用量接口正在限制查询频率(%1 秒后重试)。仍显示本地 Claude Code 统计。", + "Anthropic's usage endpoint returned status %1. Local Claude Code stats are still shown.": "Anthropic 用量接口返回状态 %1。仍显示本地 Claude Code 统计。", + "Claude Code's access token has lapsed — showing the last known limits. Start Claude Code to refresh it; signing in again is not needed.": "Claude Code 的访问令牌已过期,显示上次的额度。启动 Claude Code 即可刷新,不需要重新登录。", + "Claude Code's access token has lapsed. Start Claude Code to refresh it; signing in again is not needed.": "Claude Code 的访问令牌已过期。启动 Claude Code 即可刷新,不需要重新登录。", + "Claude Code's saved sign-in expired — showing the last known limits. Start Claude Code, or run `claude auth login`, to refresh it.": "Claude Code 保存的登录已过期,显示上次的额度。启动 Claude Code 或运行 `claude auth login` 刷新。", + "Claude Code's saved sign-in expired. Start Claude Code, or run `claude auth login`, to refresh it.": "Claude Code 保存的登录已过期。启动 Claude Code 或运行 `claude auth login` 刷新。", + "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown.": "无法连接 Anthropic 用量接口,稍后重试。仍显示本地 Claude Code 统计。", + "Anthropic's usage endpoint returned no limits. Local Claude Code stats are still shown.": "Anthropic 用量接口没有返回额度。仍显示本地 Claude Code 统计。", + "Anthropic's usage endpoint is rate limiting checks right now. Local Claude Code stats are still shown.": "Anthropic 用量接口正在限制查询频率。仍显示本地 Claude Code 统计。", + // Network panel + "Wiring bits": "接线中", + "Handling packets": "处理数据包", + "Sorting frames": "整理数据帧", + "Hauling bytes": "搬运字节", + "Routing crumbs": "转发碎片", + "Counting collisions": "清点冲突", + "Bending light": "弯折光线", + "Turn Wi-Fi off": "关闭 Wi-Fi", + "Turn Wi-Fi on": "打开 Wi-Fi", + "WI-FI BAND": "WI-FI 频段", + "WI-FI BAND: %1": "WI-FI 频段:%1", + "Auto": "自动", + "Let Wi-Fi pick the band": "由 Wi-Fi 自动选择频段", + "Stay on %1": "固定在 %1", + "Timeout": "超时", + "KNOWN NETWORKS": "已知网络", + "OTHER NETWORKS": "其他网络", + "Ethernet": "有线网络", + "Passphrase required": "需要密码", + "Wrong password": "密码错误", + "Network lost": "网络丢失", + "Disconnected": "已断开", + "Connection failed": "连接失败", + "Failed to connect": "无法连接", + "Timed out connecting": "连接超时", + "Timed out disconnecting": "断开超时", + "Timed out forgetting": "忘记网络超时", + "Sign in to this network": "登录这个网络", + "Limited internet access": "互联网访问受限", + "Show QR code": "显示二维码", + "Run a speed test": "测速", + "No connection": "无连接", + "SIGN-IN REQUIRED": "需要登录", + "LIMITED INTERNET ACCESS": "互联网访问受限", + "NOT CONNECTED": "未连接", + "Open Captive Portal": "打开认证页面", + "Sign in or accept this network’s terms to access the internet.": "登录或接受这个网络的条款后才能上网。", + "AUTOMATIC": "自动", + "DNS PROVIDER": "DNS 服务商", + "Use DNS from DHCP": "使用 DHCP 提供的 DNS", + "Set DNS to %1": "DNS 设为 %1", + "Set custom DNS servers": "设置自定义 DNS 服务器", + "Custom": "自定义", + "SCANNING WI-FI…": "正在扫描 WI-FI…", + "Connecting…": "正在连接…", + "Disconnecting…": "正在断开…", + "Forgetting…": "正在忘记…", + "Failed": "失败", + "Sign-in required": "需要登录", + "Connected": "已连接", + "Forget network": "忘记网络", + "Hidden": "隐藏网络", + "Identity (user@domain)": "身份(user@domain)", + "Passphrase": "密码", + "Connect": "连接", + "Copy to clipboard": "复制到剪贴板", + "Ping": "延迟", + "Packet Loss": "丢包", + "Receiving": "接收", + "Sending": "发送", + "Downloaded": "已下载", + "Uploaded": "已上传", + "IP Address": "IP 地址", + "Gateway": "网关", + "Copy IP": "复制 IP", + "Copy gateway": "复制网关", + "Connecting...": "正在连接…", + // Display panel + "Display": "显示", + "FIXED BRIGHTNESS": "亮度固定", + "BRIGHTNESS": "亮度", + "TEXT SIZE": "文字大小", + "SCALE": "缩放", + "DISPLAYS": "显示器", + "Sun blast": "烈日当空", + "Solar flare": "耀眼", + "Golden hour": "黄金时刻", + "Even day": "白昼", + "Soft glow": "柔光", + "Lamp light": "灯光", + "Candlelit": "烛光", + "Night owl": "夜猫子", + "focused": "当前", + // Audio panel + "Audio": "音频", + "OUTPUT": "输出", + "INPUT": "输入", + "SOURCES": "音源", + "Mute": "静音", + "Unmute": "取消静音", + "Muted": "已静音", + "Silenced": "无声", + "Concert hall": "音乐厅", + "Party mode": "派对模式", + "Cranked up": "开大了", + "Steady groove": "稳稳的节奏", + "Easy listening": "轻松聆听", + "Murmur": "低语", + "Whisper": "耳语", + // Bluetooth panel + "Untangling wires": "理顺线缆", + "Streaming vikings": "维京人在传输", + "Pairing mysteries": "配对谜题", + "Herding headsets": "召集耳机", + "Taming radios": "驯服无线电", + "Summoning speakers": "召唤音箱", + "Wrangling codecs": "摆弄编解码器", + "Polishing packets": "打磨数据包", + "Bluetooth": "蓝牙", + "CONNECTED": "已连接", + "Forget": "忘记", + "No adapter": "没有适配器", + "Turned Off": "已关闭", + "Turn Bluetooth off": "关闭蓝牙", + "Turn Bluetooth on": "打开蓝牙", + "PAIRED": "已配对", + "AVAILABLE": "可用", + "No Bluetooth adapter": "没有蓝牙适配器", + "Turn Bluetooth on to scan": "打开蓝牙后扫描", + "Scanning for devices…": "正在扫描设备…", + "Disconnect": "断开", + "Pair": "配对", + "Device": "设备", + // Power panel + "Pumping power": "注入电力", + "Injecting electrons": "注入电子", + "Pouring juice": "倒入电量", + "Amassing watts": "积攒瓦特", + "Hoarding joules": "囤积焦耳", + "Sucking volts": "吸收伏特", + "Topping reserves": "补满储备", + "Soaking amps": "吸饱安培", + "Inhaling kilowatts": "吸入千瓦", + "Slurping power": "啜饮电力", + "Spending joules": "花费焦耳", + "Draining watts": "消耗瓦特", + "Burning electrons": "燃烧电子", + "Sipping juice": "小口用电", + "Spending coulombs": "花费库仑", + "Bleeding amps": "流失安培", + "Guzzling volts": "狂饮伏特", + "Munching reserves": "啃食储备", + "Battery": "电池", + "POWER PROFILE": "电源模式", + "Fully charged": "已充满", + "Threshold": "充电上限", + "On battery": "使用电池", + "Charging": "正在充电", + "Battery size": "电池容量", + "Charge cycles": "充电次数", + "Charge limit": "充电上限", + "Time left": "剩余时间", + "Time to full": "充满还需", + "Battery state": "电池状态", + "Discharging": "正在放电", + "Holding": "保持中", + "Performance": "性能", + "Balanced": "均衡", + "Power-saver": "省电", + // Tailscale panel + "Encrypting connections": "加密连接", + "Sending secrets": "传递秘密", + "Guarding wires": "守护线路", + "Braiding packets": "编织数据包", + "Polishing tunnels": "打磨隧道", + "Hiding routes": "隐藏路由", + "Sealing ports": "封好端口", + "Sorting tailnets": "整理 tailnet", + "Shuffling keys": "洗牌密钥", + "Watching machines": "照看机器", + "Tailscale CLI is not installed or not on PATH.": "没有安装 Tailscale 命令行,或它不在 PATH 中。", + "CONNECTIONS": "连接", + "EXIT NODES": "出口节点", + "Search regions": "搜索地区", + "No Mullvad regions found.": "没有找到 Mullvad 地区。", + "MACHINES": "机器", + "No machines found on this tailnet.": "这个 tailnet 上没有机器。", + "Authorize Tailscale operator": "授权 Tailscale 操作员", + "Allow this user to operate this Tailscale profile": "允许这个用户操作这个 Tailscale 配置", + "Send files": "发送文件", + "Turn Tailscale off": "关闭 Tailscale", + "Authorize this device": "授权这台设备", + "Turn Tailscale on": "打开 Tailscale", + "Choose Mullvad region": "选择 Mullvad 地区", + "Tailscale is disconnected": "Tailscale 已断开", + "Account": "账户", + "Starting Tailscale login…": "正在开始 Tailscale 登录…", + "Authorizing Tailscale operator...": "正在授权 Tailscale 操作员…", + "Tailscale login link not available yet": "Tailscale 登录链接还不可用", + "Authorize Tailscale operator to show connections": "授权 Tailscale 操作员后显示连接", + "Could not list Tailscale connections": "无法列出 Tailscale 连接", + "Tailscale command failed": "Tailscale 命令失败", + "Account switch failed": "切换账户失败", + "Exit node selection failed": "选择出口节点失败", + "Tailscale authorization failed": "Tailscale 授权失败", + "Tailscale operator authorized": "Tailscale 操作员已授权", + "Unknown": "未知", + // Dropbox panel + "Filing files": "归档文件", + "Distributing data": "分发数据", + "Shuffling folders": "整理文件夹", + "Boxing bytes": "装箱字节", + "Sorting stuff": "分拣东西", + "Syncing secrets": "同步秘密", + "Packing packets": "打包数据包", + "Moving memories": "搬运回忆", + "Wrangling revisions": "整理修订", + "Cataloging chaos": "编目混乱", + "RECENT FILES": "最近文件", + "No synced files found.": "没有找到同步的文件。", + "Pause syncing": "暂停同步", + "Resume syncing": "继续同步", + "Syncing paused": "同步已暂停", + "Stored": "已存储", + "Login to Dropbox": "登录 Dropbox", + "Dropbox CLI is not installed": "没有安装 Dropbox 命令行", + "Start the authentication flow": "开始认证", + "Install Dropbox from the service menu": "从服务菜单安装 Dropbox", + "Untitled": "未命名", + "%1 of %2": "%1 / %2", + "Just now": "刚刚", + "Unknown time": "时间未知", + "%1mo ago": "%1个月前", + "%1m ago": "%1分钟前", + "%1h ago": "%1小时前", + "%1d ago": "%1天前", + "%1y ago": "%1年前", + "Starting Dropbox login…": "正在开始 Dropbox 登录…", + "Opened Dropbox login": "已打开 Dropbox 登录", + "Failed to read Dropbox status": "无法读取 Dropbox 状态", + "Could not read Dropbox status": "无法读取 Dropbox 状态", + "Dropbox login failed": "Dropbox 登录失败", + "Dropbox command failed": "Dropbox 命令失败", + "Failed to parse Dropbox status": "无法解析 Dropbox 状态", + // Weather panel + "Search city": "搜索城市", + "FEELS": "体感", + "WIND": "风", + "HUMID": "湿度", + "Fetching forecast…": "正在获取天气预报…", + // Wi-Fi QR panel + "Generating QR code…": "正在生成二维码…", + "Scan to join this network": "扫码加入这个网络", + "Could not generate the Wi-Fi QR code": "无法生成 Wi-Fi 二维码", + "Could not read the Wi-Fi password": "无法读取 Wi-Fi 密码", + "Show password": "显示密码", + // Speed tests + "Speed test failed": "测速失败", + "DOWNLOAD": "下载", + "UPLOAD": "上传", + "Measure again via fast.com": "通过 fast.com 重新测速", + "Disk speed test failed": "磁盘测速失败", + "READ": "读取", + "WRITE": "写入", + "Measure again": "重新测速", + "Run Again": "再测一次", + // Lock screen + "Enter Password": "输入密码", + "Checking…": "正在验证…", + "Fingerprint reader unavailable": "指纹识别器不可用", + "Authentication failed (%1)": "认证失败(%1)", + // Authentication prompt + "Authentication is needed...": "需要认证…", + "Wrong": "错误", + "Checking...": "正在验证…", + "Enter password": "输入密码", + // Reminders + "Reminder message": "提醒内容", + "Remind in minutes": "几分钟后提醒", + "%1 in %2 (%3)": "%1,还有 %2(%3)", + "Set Reminder": "设置提醒", + "1 reminder": "1 个提醒", + "%1 reminders": "%1 个提醒", + "Invalid reminder": "无效的提醒", + "Enter the number of minutes": "输入分钟数", + // Notifications + "No recent notifications": "没有最近的通知", + // Bar + "Microphone muted": "麦克风已静音", + "Microphone in use": "麦克风使用中", + "Microphone live": "麦克风已开启", + "Pending Omarchy Updates": "有待安装的 Omarchy 更新", + "Tray icons": "托盘图标", + "Pinned icons stay visible. Hidden icons never show.": "固定的图标一直显示,隐藏的图标不再显示。", + "No tray items reporting.": "没有托盘项目。", + "Unpin": "取消固定", + "Pin": "固定", + "Show": "显示", + "Hide": "隐藏", + "Allow Notifications": "允许通知", + "Silence Notifications": "静默通知", + "Day Light": "日间光线", + "Night Light": "夜间光线", + "Stop recording": "停止录屏", + "Screen Recording": "录屏", + "Allow Idle Lock & Screensaver": "允许空闲锁屏和屏保", + "Stay Awake": "保持唤醒", + // Shared controls + "Cancel": "取消", + "Confirm": "确认", + "Search...": "搜索…", + "No options": "没有选项", + "None selected": "未选择", + "Loading…": "正在加载…", + "No matches": "没有匹配项", + // Notifications and on-screen messages + "Learn Keybindings": "了解快捷键", + "Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.": "Super + K 查看快捷键速查表。\nSuper + Space 打开 Omarchy 菜单。", + "Update System": "更新系统", + "Click to update the system.": "点击更新系统。", + "Setup Wi-Fi": "设置 Wi-Fi", + "Click to configure the wireless network.": "点击配置无线网络。", + "All reminders have been cleared": "所有提醒已清除", + "Animations disabled": "动画已关闭", + "Animations enabled": "动画已开启", + "Can't disable the only active display": "不能关闭唯一的显示器", + "Copied text from selection to clipboard": "已把选中文字复制到剪贴板", + "Could not complete shutdown. Please try again.": "无法完成关机,请重试。", + "Could not share": "无法分享", + "Crash capture disabled": "崩溃捕获已关闭", + "Crash capture enabled": "崩溃捕获已开启", + "Disable single-window square aspect ratio": "关闭单窗口方形比例", + "Enable single-window square aspect ratio": "开启单窗口方形比例", + "Download failed": "下载失败", + "Editing config file": "正在编辑配置文件", + "Extended mode restored": "已恢复扩展模式", + "Failed to start Windows VM": "无法启动 Windows 虚拟机", + "Herdr theme sync disabled": "Herdr 主题同步已关闭", + "Herdr theme sync enabled": "Herdr 主题同步已开启", + "Laptop display disabled": "笔记本屏幕已关闭", + "Laptop display enabled": "笔记本屏幕已开启", + "Logging out": "正在注销", + "Microphone on": "麦克风已开启", + "No QR code found": "没有找到二维码", + "No RetroArch cores found": "没有找到 RetroArch 核心", + "No audio devices found": "没有找到音频设备", + "No background was found for theme": "这个主题没有壁纸", + "No external monitors found for mirror": "没有可镜像的外接显示器", + "No laptop display found": "没有找到笔记本屏幕", + "No laptop monitor found to mirror": "没有可镜像的笔记本屏幕", + "No outstanding reminders": "没有待办提醒", + "No video found for download": "没有可下载的视频", + "No webcam devices found": "没有找到摄像头", + "QR code copied to clipboard": "二维码已复制到剪贴板", + "Rebooting": "正在重启", + "Recording process had to be force-killed. Video may be corrupted.": "录屏进程被强制结束,视频可能已损坏。", + "Refusing unsafe monitor name": "拒绝不安全的显示器名称", + "Reminder": "提醒", + "Restore using Super + Home on this workspace.": "在这个工作区用 Super + Home 恢复。", + "Saved and copied to clipboard.": "已保存并复制到剪贴板。", + "Screen recording error": "录屏出错", + "Screensaver disabled": "屏保已关闭", + "Screensaver enabled": "屏保已开启", + "Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty": "屏保只能在 Alacritty、Foot、Ghostty 或 Kitty 中运行", + "Select a region containing a QR code": "框选包含二维码的区域", + "Shutdown failed": "关机失败", + "Shutting down": "正在关机", + "Start it with Super + Space": "用 Super + Space 启动", + "Suspend now available in system menu": "系统菜单中已加入挂起", + "Suspend removed from system menu": "系统菜单中已移除挂起", + "TUI removed": "TUI 已移除", + "The file chooser did not open": "文件选择器没有打开", + "Theme removed": "主题已移除", + "Time to recharge!": "该充电了!", + "Transcoding video…": "正在转码视频…", + "URL copied to clipboard": "链接已复制到剪贴板", + "Upcoming reminders": "即将到来的提醒", + "Use Super + Alt + Home to save one for this workspace.": "用 Super + Alt + Home 为这个工作区保存一个。", + "Voxtype Dictation Ready": "Voxtype 语音输入就绪", + "Web app removed": "网页应用已移除", + "Windows VM": "Windows 虚拟机", + "Battery is down to %1%": "电量降到 %1%", + "Could not send to %1": "无法发送给 %1", + "Couldn't add the %1 account": "无法添加 %1 账户", + "Mirroring enabled (%1)": "已开启镜像(%1)", + "New %1 sessions now use %2": "新的 %1 会话现在使用 %2", + "No saved width found for %1 on workspace %2": "工作区 %2 没有为 %1 保存的宽度", + "Saved width for %1 on workspace %2": "已为工作区 %2 的 %1 保存宽度", + "Sent to %1": "已发送给 %1", + "Timezone is now set to %1": "时区已设为 %1", + "Transcoded to %1 %2": "已转码为 %1 %2", + "Workspace layout set to %1": "工作区布局已设为 %1", + "Workspace layout set to %1 but could not be saved": "工作区布局已设为 %1,但未能保存", + "Workspace layout needs a Hyprland reload": "工作区布局需要重新加载 Hyprland", + "New workspaces use the %1 layout": "新工作区使用 %1 布局", + "Screen recording directory does not exist: %1": "录屏目录不存在:%1", + "Recording without %1 audio": "录屏不含%1音频", + "The %1 audio could not be added to this recording.": "无法把%1音频加入这段录屏。", + "desktop": "桌面", + "Corners follow the theme": "圆角跟随主题", + "Sharp corners for all themes": "所有主题使用直角", + "Rounded corners for all themes": "所有主题使用圆角", + "Opaque windows": "窗口不透明", + "Transparent windows": "窗口透明", + "Glassy windows": "窗口毛玻璃", + "Input Methods": "输入法", + "Keyboard Layouts": "键盘布局", + "Input methods could not be updated": "无法更新输入法", + "Keyboard layouts could not be updated": "无法更新键盘布局", + "Input method could not be added: %1": "无法添加输入法:%1", + "The selection menu could not open": "选择菜单无法打开", + "The current input group has no keyboard input": "当前输入组没有键盘输入", + "Fcitx did not retain the input selection": "Fcitx 没有保留输入法选择", + "Fcitx did not retain the keyboard selection": "Fcitx 没有保留键盘选择", + "Select at least one keyboard layout": "至少选择一个键盘布局", + "Your personal keyboard layout override takes precedence over this selection": "你自己的键盘布局设置优先于这个选择", + "The menu returned an invalid selection": "菜单返回了无效的选择", + "Install %1 to use %2": "安装 %1 后才能使用 %2", + "its engine": "对应的输入引擎", + "Apply": "应用", + "%1 selected": "已选 %1 项", + "microphone": "麦克风", + "%1 is now the default browser": "%1 现在是默认浏览器", + "%1 is now the default editor": "%1 现在是默认编辑器", + "%1 is now the default terminal": "%1 现在是默认终端", + "%1 installed": "%1 已安装", + "Screen recording saved": "录屏已保存", + "Open with Super + Alt + , (or click this)": "用 Super + Alt + , 打开(或点击这里)", + "Download complete": "下载完成", + "Restart Foot to apply the new terminal font size": "重启 Foot 以应用新的终端字号", + "You must restart Ghostty to see font change": "需要重启 Ghostty 才能看到字体变化", + "You must restart Foot to see font change": "需要重启 Foot 才能看到字体变化", + "Pending Omarchy Migrations": "有待运行的 Omarchy 迁移", + "Click to run 1 pending migration.": "点击运行 1 个待运行的迁移。", + "Editing Cloned Plugin": "正在编辑克隆的插件", + "Original plugin has been replace by clone.": "原插件已被克隆替换。", + "Click to diagnose with AI": "点击用 AI 诊断", + "Screen did not lock before suspend": "挂起前屏幕没有锁定", + "Taildrop transfer failed": "Taildrop 传输失败", + "Setup Fingerprint Reader": "设置指纹识别器", + "Enable sudo and unlocking with your fingerprint.": "用指纹进行 sudo 和解锁。", + "Set your default agent": "设置默认 AI 代理", + "Let your favorite agent help with Omarchy.": "让你喜欢的 AI 代理帮你使用 Omarchy。", + "All %1 accounts are over %2%": "所有 %1 账户都超过 %2%", + "Staying on %1. %2 resets in %3.": "继续使用 %1。%2 将在 %3 后重置。", + "Staying on %1.": "继续使用 %1。", + "%1 is at %2% of a %3 limit": "%1 已用到 %3 额度的 %2%", + "New %1 sessions now use %2 (%3%). Running sessions stay on %4.": "新的 %1 会话现在使用 %2(%3%)。正在运行的会话仍使用 %4。", + "%1 is at %2%. Click to switch new sessions to it.": "%1 已用到 %2%。点击把新会话切换到它。", + "Running sessions stay on %1.": "正在运行的会话仍使用 %1。", + "Click to run %1 pending migrations.": "点击运行 %1 个待运行的迁移。", + "Process crashed: %1": "进程崩溃:%1", + "You'll be reminded at %1": "将在 %1 提醒你", + "Reminder set for %1 minutes": "已设置 %1 分钟后的提醒", + "%1 in %2 minutes": "%2 分钟后:%1", + "Received %1": "已收到 %1", + "Saved to %1": "已保存到 %1", + "The session was left unlocked (%1).": "会话没有锁定(%1)。", + "%1 to %2 (%3)": "%1 转为 %2(%3)", + "Battery %1% · Holding at %2 · %3W / %4Wh": "电池 %1% · 保持在 %2 · %3W / %4Wh", + "Battery %1% · %2 to full ·  %3W / %4Wh": "电池 %1% · %2 后充满 ·  %3W / %4Wh", + "Battery %1% · %2 left ·  %3W / %4Wh": "电池 %1% · 剩余 %2 ·  %3W / %4Wh", + "Week %1": "第 %1 周", + "Installing the %1 CLI didn't finish.": "%1 命令行没有装完。", + "No %1 account named %2.": "没有名为 %2 的 %1 账户。", + "No %1 account named %2": "没有名为 %2 的 %1 账户", + "Couldn't set up a home for the new account.": "无法为新账户准备目录。", + "%1 is not a pending %2 login": "%1 不是待完成的 %2 登录", + "The login didn't finish, so no account was added.": "登录没有完成,没有添加账户。", + "That's %1 (%2). Try again, and sign in as the other account in the private window.": "这是 %1(%2)。请重试,并在隐私窗口中登录另一个账户。", + "already added": "已添加", + "Give the account a name": "给账户起个名字", + "%1 is the login in %2; it can't be removed here.": "%1 是 %2 中的登录,不能在这里移除。", + "A running %1 session uses %2. Quit it first, then remove the account.": "有一个正在运行的 %1 会话使用 %2。先退出它,再移除账户。", + "Switch mode is manual or auto": "切换模式为手动或自动", + "The threshold is a percentage between 50 and 100": "阈值是 50 到 100 之间的百分比", + "%1-min reminder in %2 (%3)": "%1 分钟提醒,还有 %2(%3)", + "%1-min reminder": "%1 分钟提醒", + "Your %1 minutes are up": "%1 分钟到了", + "No plugin to enable": "没有可启用的插件", + "No plugin to disable": "没有可停用的插件", + "No plugin to clone": "没有可克隆的插件", + "No plugin to remove": "没有可移除的插件", + // Clock + "Right-click to toggle format": "右键切换格式", + "Back to today": "回到今天", + "BORN": "出生", + "LIVE TO": "活到", + "LIFE": "人生", + "Start weeks on %1": "每周从%1开始", + "Previous month": "上个月", + "Next month": "下个月", + "year": "年份", + // Menu + "Go": "前往", + "Input": "输入", + "Select": "选择", + "Do you want to uninstall %1?": "要卸载 %1 吗?", + "Uninstall": "卸载", + "No matches for “%1”": "没有匹配“%1”的结果", + "Nothing here yet": "这里还没有内容", + // World clock, emojis, clipboard + "Search cities…": "搜索城市…", + "World": "世界", + "Clock": "时钟", + "Remove": "移除", + "World clock": "世界时钟", + "Delete entire clipboard history?": "删除全部剪贴板历史?", + "Delete": "删除", + "Clipboard is empty": "剪贴板是空的", + "Sunrise": "日出", + "Sunset": "日落", + "It's %1 here": "这里现在是 %1", + "Jump to a city": "跳到一个城市", + "Add a city": "添加城市", + "Loading zones…": "正在加载时区…", + "Tomorrow": "明天", + "Yesterday": "昨天", + " in %1": "(%1)", + "Loading cities…": "正在加载城市…", + "Search clipboard…": "搜索剪贴板…", + "Search emojis…": "搜索表情…", + "+%1 days": "%1 天后", + "%1 days": "%1 天", + // Media + "Pause": "暂停", + "Play": "播放", + "Next": "下一首", + "Previous": "上一首", + "Disable Passwordless Sudo": "关闭免密码 sudo", + "Passwordless Sudo": "免密码 sudo", + "Could not disable passwordless sudo": "无法关闭免密码 sudo", + "Check the sudo configuration and try again.": "请检查 sudo 配置后重试。", + "Log out now": "立即注销", + "Later": "稍后", + "Log out now to switch the language?": "立即注销以切换语言?", + "Log back in to switch the language.": "重新登录后切换语言。", + "Superwhisper Dictation Ready": "Superwhisper 语音输入就绪", + "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).": "按住右 Alt 或 F9 说话(或用 Super + Ctrl + X 切换)。", + "Background Intros": "背景开场动画", + "Enabled": "已开启", + "Disabled": "已关闭" +} diff --git a/shell/Commons/Tr.js b/shell/Commons/Tr.js new file mode 100644 index 00000000..b4be1108 --- /dev/null +++ b/shell/Commons/Tr.js @@ -0,0 +1,24 @@ +.pragma library +.import "I18nZh.js" as Zh + +// Shell text in the session's language; QML reaches this through the I18n singleton, plain JS imports it. +var chinese = Qt.locale().name.indexOf("zh_CN") === 0 + +function tr(text) { + var args = arguments + var template = chinese && Object.prototype.hasOwnProperty.call(Zh.table, text) ? Zh.table[text] : text + // One pass, so an argument that itself contains %2 stays as given. + return String(template).replace(/%(\d)/g, function(match, n) { + return n > 0 && n < args.length ? String(args[n]) : match + }) +} + +// Text a panel's own model builds in known formats: the first matching template is translated with what %1.. captured. +function trMatch(text, templates) { + for (var i = 0; i < templates.length; i++) { + var pattern = "^" + templates[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%\d/g, "(.+)") + "$" + var match = String(text).match(new RegExp(pattern)) + if (match) return tr.apply(null, [templates[i]].concat(match.slice(1))) + } + return tr(text) +} diff --git a/shell/Commons/qmldir b/shell/Commons/qmldir index e5be766b..71c3255d 100644 --- a/shell/Commons/qmldir +++ b/shell/Commons/qmldir @@ -2,6 +2,7 @@ module qs.Commons singleton AudioNodes 1.0 AudioNodes.qml singleton Border 1.0 Border.qml singleton Color 1.0 Color.qml +singleton I18n 1.0 I18n.qml singleton IpcRegistry 1.0 IpcRegistry.qml singleton InputMethodState 1.0 InputMethodState.qml singleton Style 1.0 Style.qml diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index 9bbb1304..08207623 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -7,8 +7,8 @@ Item { property bool opened: false property string message: "" - property string cancelText: "Cancel" - property string confirmText: "Confirm" + property string cancelText: I18n.tr("Cancel") + property string confirmText: I18n.tr("Confirm") property int selectedIndex: 1 property color background: Commons.Color.background property color foreground: Commons.Color.foreground diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml index 656047dd..c73bbf90 100644 --- a/shell/Ui/MultiSelect.qml +++ b/shell/Ui/MultiSelect.qml @@ -28,9 +28,9 @@ Item { property var options: [] property var optionsCommand: [] property string optionsCommandCwd: "" - property string placeholderText: "Search..." - property string emptyText: "No options" - property string noSelectionText: "None selected" + property string placeholderText: I18n.tr("Search...") + property string emptyText: I18n.tr("No options") + property string noSelectionText: I18n.tr("None selected") property string triggerLabel: "" property bool showLabel: true @@ -493,7 +493,7 @@ Item { textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 - text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText) + text: root.loadingOptions ? I18n.tr("Loading…") : (root.optionsError !== "" ? root.optionsError : root.emptyText) color: Qt.darker(root.foreground, 1.6) font.family: root.fontFamily font.pixelSize: Style.font.body diff --git a/shell/Ui/SearchableDropdown.qml b/shell/Ui/SearchableDropdown.qml index 256e4293..339d2206 100644 --- a/shell/Ui/SearchableDropdown.qml +++ b/shell/Ui/SearchableDropdown.qml @@ -22,8 +22,8 @@ Item { property string label: "" property string value: "" property var options: [] - property string placeholderText: "Search..." - property string emptyText: "No matches" + property string placeholderText: I18n.tr("Search...") + property string emptyText: I18n.tr("No matches") property string triggerLabel: "" property color foreground: Commons.Color.popups.text diff --git a/shell/Ui/SpeedTestOverlay.qml b/shell/Ui/SpeedTestOverlay.qml index b0ad24ed..827c73aa 100644 --- a/shell/Ui/SpeedTestOverlay.qml +++ b/shell/Ui/SpeedTestOverlay.qml @@ -23,7 +23,7 @@ PanelWindow { property string unit: "Mbps" property string title: "" property string layerNamespace: "omarchy-speed-test" - property string runAgainTooltip: "Measure again" + property string runAgainTooltip: I18n.tr("Measure again") property real leftValue: 0 property real rightValue: 0 property bool leftLive: false @@ -165,7 +165,7 @@ PanelWindow { // Centered on the dial pair. Fades rather than unmounts while a run // is in flight, so the cluster never shifts. Button { - text: "Run Again" + text: I18n.tr("Run Again") tooltipText: root.runAgainTooltip bordered: true enabled: !root.running diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 4e40a2ea..3d6762a8 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -339,9 +339,9 @@ Panel { // A few ways into making Omarchy your own, handed to the default agent. readonly property var starterPrompts: [ - { glyph: "󰏘", label: "Theme", prompt: "Make me a new Omarchy theme. Ask me what look or inspiration I have in mind, then build it following the Omarchy skill's theming guide and switch to it." }, - { glyph: "󰐱", label: "Plugin", prompt: "Make me a new Omarchy shell plugin. Ask me what I'd like it to do, then build it following the Omarchy skill's plugin guide and enable it." }, - { glyph: "󰣆", label: "App", prompt: "Make me a new app for my Omarchy desktop. Ask me what it should do, then build it following the omarchy-app skill and install it so it shows up in the app launcher." } + { glyph: "󰏘", label: I18n.tr("Theme"), prompt: "Make me a new Omarchy theme. Ask me what look or inspiration I have in mind, then build it following the Omarchy skill's theming guide and switch to it." }, + { glyph: "󰐱", label: I18n.tr("Plugin"), prompt: "Make me a new Omarchy shell plugin. Ask me what I'd like it to do, then build it following the Omarchy skill's plugin guide and enable it." }, + { glyph: "󰣆", label: I18n.tr("App"), prompt: "Make me a new app for my Omarchy desktop. Ask me what it should do, then build it following the omarchy-app skill and install it so it shows up in the app launcher." } ] function startPrompt(prompt) { @@ -407,7 +407,7 @@ Panel { var parts = [] if (String(account.plan || "") !== "") parts.push(account.plan) if (account.resetCredits && Number(account.resetCredits.available) > 0) - parts.push(account.resetCredits.available + " free reset" + (Number(account.resetCredits.available) === 1 ? "" : "s")) + parts.push(I18n.tr(Number(account.resetCredits.available) === 1 ? "%1 free reset" : "%1 free resets", account.resetCredits.available)) return parts.join(" · ") } @@ -450,12 +450,18 @@ Panel { function resetCreditsText(credits) { if (!credits || !(Number(credits.available) > 0)) return "" var count = Number(credits.available) - var text = count + " free reset" + (count === 1 ? "" : "s") + var text = I18n.tr(count === 1 ? "%1 free reset" : "%1 free resets", count) var expires = new Date(String(credits.nextExpiresAt || "")).getTime() - if (isFinite(expires) && expires > nowMs) text += " · next expires in " + formatDuration(expires - nowMs) + if (isFinite(expires) && expires > nowMs) text += " · " + I18n.tr("next expires in %1", formatDuration(expires - nowMs)) return text } + // A model-scoped title ("Opus Monthly") keeps the model and translates the window. + function windowLabel(title) { + var match = String(title || "").match(/^(.*\S)\s+(Monthly|Weekly|Session|Limit)$/) + return match ? match[1] + " " + I18n.tr(match[2]) : I18n.tr(String(title || "")) + } + function planLabel(p) { var tier = String(p && p.tierLabel || "") return tier === "" ? "" : tier.charAt(0).toUpperCase() + tier.slice(1) @@ -576,13 +582,13 @@ Panel { } function formatDuration(ms) { - if (!(ms > 0)) return "now" + if (!(ms > 0)) return I18n.tr("now") var minutes = Math.floor(ms / 60000) var hours = Math.floor(minutes / 60) var days = Math.floor(hours / 24) - if (days > 0) return days + "d " + (hours % 24) + "h" - if (hours > 0) return hours + "h " + (minutes % 60) + "m" - return Math.max(1, minutes) + "m" + if (days > 0) return I18n.tr("%1d %2h", days, hours % 24) + if (hours > 0) return I18n.tr("%1h %2m", hours, minutes % 60) + return I18n.tr("%1m", Math.max(1, minutes)) } // ---------------------------------------------------------------- balance @@ -612,8 +618,8 @@ Panel { function balanceDetailText(b) { if (!b || !(b.funded > 0)) return "" - var text = formatMoney(b.spent, b.currency) + " spent of " + formatMoney(b.funded, b.currency) + " funded" - if (b.estimated) text += " · estimated" + var text = I18n.tr("%1 spent of %2 funded", formatMoney(b.spent, b.currency), formatMoney(b.funded, b.currency)) + if (b.estimated) text += " · " + I18n.tr("estimated") return text } @@ -654,16 +660,16 @@ Panel { } var phrases = [] - if (week > 0) phrases.push(usage.formatTokenCount(week) + " tokens this week") - if (today > 0) phrases.push(usage.formatTokenCount(today) + " tokens today") + if (week > 0) phrases.push(I18n.tr("%1 tokens this week", usage.formatTokenCount(week))) + if (today > 0) phrases.push(I18n.tr("%1 tokens today", usage.formatTokenCount(today))) var topModel = "" for (var model in byModel) if (topModel === "" || byModel[model] > byModel[topModel]) topModel = model - if (topModel !== "" && byModel[topModel] > 0) phrases.push("Mostly " + topModel) + if (topModel !== "" && byModel[topModel] > 0) phrases.push(I18n.tr("Mostly %1", topModel)) var busiest = "" for (var date in byDay) if (busiest === "" || byDay[date] > byDay[busiest]) busiest = date - if (busiest !== "" && byDay[busiest] > 0) phrases.push("Busiest day: " + dayName(busiest)) - if (prompts > 0) phrases.push(prompts + " prompt" + (prompts === 1 ? "" : "s") + " today") - if (sessions > 0) phrases.push(sessions + " session" + (sessions === 1 ? "" : "s") + " today") + if (busiest !== "" && byDay[busiest] > 0) phrases.push(I18n.tr("Busiest day: %1", dayName(busiest))) + if (prompts > 0) phrases.push(I18n.tr(prompts === 1 ? "%1 prompt today" : "%1 prompts today", prompts)) + if (sessions > 0) phrases.push(I18n.tr(sessions === 1 ? "%1 session today" : "%1 sessions today", sessions)) return phrases } property int phraseIndex: 0 @@ -673,7 +679,7 @@ Panel { property string shownPhrase: "" readonly property string heroPhrase: addStage !== "" || blankSlate ? addHeading - : (shownPhrase !== "" ? shownPhrase : "Subscriptions") + : (shownPhrase !== "" ? shownPhrase : I18n.tr("Subscriptions")) function showPhrase() { var n = summaryPhrases.length @@ -685,7 +691,7 @@ Panel { function dayName(date) { var parsed = new Date(String(date || "") + "T00:00:00") if (isNaN(parsed.getTime())) return String(date || "") - return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][parsed.getDay()] + return I18n.tr(["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][parsed.getDay()]) } // Only speaks up when the numbers cover more than this machine. @@ -694,7 +700,7 @@ Panel { for (var i = 0; i < providers.length; i++) { var count = Number(providers[i].syncDeviceCount || 0) if (providers[i].syncEnabled && count > 0) - return "Merged from " + count + " device" + (count === 1 ? "" : "s") + return I18n.tr(count === 1 ? "Merged from %1 device" : "Merged from %1 devices", count) } return "" } @@ -744,8 +750,8 @@ Panel { // Adding takes over the hero's line, and the cursor starts over. readonly property string addHeading: picking || addProvider === "" - ? "Add an account" - : "Add " + (/^[AEIOU]/.test(addProviderName(addProvider)) ? "an " : "a ") + addProviderName(addProvider) + " account" + ? I18n.tr("Add an account") + : I18n.tr(/^[AEIOU]/.test(addProviderName(addProvider)) ? "Add an %1 account" : "Add a %1 account", addProviderName(addProvider)) onAddStageChanged: { phraseSwap.stop() hero.metaOpacity = 1.0 @@ -804,7 +810,7 @@ Panel { stdout: SplitParser { onRead: function(line) { root.handleAddLine(line) } } onExited: { if (root.addStage === "running") { - root.addResult = "The sign-in didn't finish." + root.addResult = I18n.tr("The sign-in didn't finish.") root.addStage = "error" } } @@ -939,7 +945,7 @@ Panel { PanelHero { id: hero width: parent.width - title: "Agents" + title: I18n.tr("Agents") meta: root.heroPhrase foreground: root.foreground fontFamily: root.fontFamily @@ -965,7 +971,7 @@ Panel { onHovered: root.pointAt("add") readonly property bool adding: root.addStage !== "" glyph: adding ? "󰅖" : "󰐕" - tooltip: adding ? "Back to the limits" : "Add a subscription" + tooltip: adding ? I18n.tr("Back to the limits") : I18n.tr("Add a subscription") onClicked: adding ? root.cancelAdd() : root.addAccount() } @@ -974,7 +980,7 @@ Panel { hasCursor: root.hasKey("launch") onHovered: root.pointAt("launch") glyph: "󰞷" - tooltip: "Start the default agent" + tooltip: I18n.tr("Start the default agent") onClicked: root.launchAgent() } } @@ -1011,7 +1017,7 @@ Panel { spacing: Style.space(12) PanelSectionHeader { - text: "MAKE SOMETHING COOL" + text: I18n.tr("MAKE SOMETHING COOL") foreground: root.foreground fontFamily: root.fontFamily } @@ -1065,7 +1071,8 @@ Panel { Text { visible: root.blankSlate && root.picking width: parent.width - text: "Sign in to an AI coding subscription, and this panel keeps track of how much of it you have left." + textFormat: Text.PlainText + text: I18n.tr("Sign in to an AI coding subscription, and this panel keeps track of how much of it you have left.") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1137,7 +1144,8 @@ Panel { Text { width: parent.width - text: "Name this account. It signs in through a private window, so your browser's current account isn't picked up." + textFormat: Text.PlainText + text: I18n.tr("Name this account. It signs in through a private window, so your browser's current account isn't picked up.") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1147,7 +1155,7 @@ Panel { TextField { id: addNameField width: parent.width - placeholderText: "Work" + placeholderText: I18n.tr("Work") foreground: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body @@ -1166,7 +1174,7 @@ Panel { Text { width: parent.width textFormat: Text.PlainText - text: root.addStatus + text: I18n.tr(root.addStatus) color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -1179,7 +1187,8 @@ Panel { spacing: Style.space(4) Text { - text: "Confirm this code in your browser" + textFormat: Text.PlainText + text: I18n.tr("Confirm this code in your browser") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1203,7 +1212,8 @@ Panel { Text { width: parent.width - text: "If the page shows a code instead of finishing, paste it here." + textFormat: Text.PlainText + text: I18n.tr("If the page shows a code instead of finishing, paste it here.") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1213,7 +1223,7 @@ Panel { TextField { id: pasteField width: parent.width - placeholderText: "Code" + placeholderText: I18n.tr("Code") foreground: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body @@ -1224,7 +1234,7 @@ Panel { TextLink { visible: root.addUrl !== "" - text: "Open the sign-in page again" + text: I18n.tr("Open the sign-in page again") onClicked: root.reopenSignIn() } } @@ -1234,7 +1244,7 @@ Panel { visible: root.addStage === "done" || root.addStage === "error" width: parent.width textFormat: Text.PlainText - text: root.addResult + text: I18n.tr(root.addResult) color: root.addStage === "error" ? root.urgent : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -1330,9 +1340,9 @@ Panel { TextLink { visible: !section.multi && root.needsSignIn(section.provider) picked: root.hasKey("providerSignin", section.providerIndex) - text: "Sign-in required" + text: I18n.tr("Sign-in required") idleColor: root.urgent - tooltip: "Sign in to " + (section.provider ? section.provider.providerName : "") + " again" + tooltip: I18n.tr("Sign in to %1 again", section.provider ? section.provider.providerName : "") onClicked: root.signInAgain(section.provider, null) } @@ -1341,7 +1351,7 @@ Panel { width: parent.width textFormat: Text.PlainText // Shown for the status, so a record with no help to offer says the status rather than nothing. - text: section.provider ? String(section.provider.authHelpText || section.provider.usageStatusText || "") : "" + text: section.provider ? I18n.tr(String(section.provider.authHelpText || section.provider.usageStatusText || "")) : "" color: root.urgent font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1398,7 +1408,7 @@ Panel { textFormat: Text.PlainText width: parent.width * 0.3 anchors.verticalCenter: parent.verticalCenter - text: "Balance" + text: I18n.tr("Balance") color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1568,7 +1578,7 @@ Panel { PanelToolTip { visible: tileMouse.containsMouse - text: "Start your default agent on a new " + tile.title.toLowerCase() + text: I18n.tr("Start your default agent on a new %1", tile.title.toLowerCase()) } } @@ -1772,9 +1782,9 @@ Panel { id: signInLink visible: root.needsSignIn(head.account) picked: head.pickedKind === "signin" - text: "Sign-in required" + text: I18n.tr("Sign-in required") idleColor: root.urgent - tooltip: "Sign in to this account again" + tooltip: I18n.tr("Sign in to this account again") onClicked: root.signInAgain(head.owner, head.account) } } @@ -1810,7 +1820,8 @@ Panel { id: headActive visible: head.isActive anchors.right: parent.right - text: "ACTIVE" + textFormat: Text.PlainText + text: I18n.tr("ACTIVE") color: Commons.Color.accent font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1829,19 +1840,19 @@ Panel { TextLink { visible: head.autoOn || useHover.hovered || head.picked - text: head.pickedKind === "autoswitch" ? "Autoswitch ⏎" : "Autoswitch" + text: I18n.tr("Autoswitch") + (head.pickedKind === "autoswitch" ? " ⏎" : "") picked: head.pickedKind === "autoswitch" current: head.autoOn tooltip: head.autoOn - ? "Stop switching automatically" - : "Switch here automatically at " + root.switchThreshold(head.owner) + "%" + ? I18n.tr("Stop switching automatically") + : I18n.tr("Switch here automatically at %1%", root.switchThreshold(head.owner)) onClicked: root.setSwitchMode(head.owner, head.autoOn ? "manual" : "auto") } // With Autoswitch on, Use waits until the line is hovered or picked. TextLink { visible: !head.autoOn || useHover.hovered || head.picked - text: head.pickedKind === "account" ? "Use ⏎" : "Use" + text: I18n.tr("Use") + (head.pickedKind === "account" ? " ⏎" : "") picked: head.pickedKind === "account" onClicked: root.useAccount(head.owner, head.account) } @@ -1858,7 +1869,8 @@ Panel { Text { id: unavailableLabel - text: "Usage unavailable" + textFormat: Text.PlainText + text: I18n.tr("Usage unavailable") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -1868,7 +1880,7 @@ Panel { PanelToolTip { visible: unavailableHover.hovered && unavailable.visible - text: unavailable.record ? String(unavailable.record.authHelpText || "Usage has not been updated yet.") : "" + text: unavailable.record ? I18n.tr(String(unavailable.record.authHelpText || "Usage has not been updated yet.")) : "" } } @@ -1893,16 +1905,16 @@ Panel { text: { var lines = [] if (compact.window) { - lines.push(compact.window.title + ": " + Math.round(compact.window.percent * 100) + "% used" - + (compact.resetMs > 0 ? " · resets in " + root.formatDuration(compact.resetMs) : "")) + lines.push(I18n.tr("%1: %2% used", root.windowLabel(compact.window.title), Math.round(compact.window.percent * 100)) + + (compact.resetMs > 0 ? " · " + I18n.tr("resets in %1", root.formatDuration(compact.resetMs)) : "")) } if (compact.stale) lines.push(compact.fetchedAt > 0 && root.nowMs - compact.fetchedAt > 60000 - ? "Last updated " + root.formatDuration(root.nowMs - compact.fetchedAt) + " ago" - : compact.fetchedAt > 0 ? "Last updated less than a minute ago" : "Last updated time unavailable") + ? I18n.tr("Last updated %1 ago", root.formatDuration(root.nowMs - compact.fetchedAt)) + : compact.fetchedAt > 0 ? I18n.tr("Last updated less than a minute ago") : I18n.tr("Last updated time unavailable")) for (var i = 0; i < compact.scoped.length; i++) - lines.push(compact.scoped[i].title + ": " + Math.round(compact.scoped[i].percent * 100) + "% of its " - + String(compact.window ? compact.window.title : "").toLowerCase() + " allowance") + lines.push(I18n.tr("%1: %2% of its %3 allowance", root.windowLabel(compact.scoped[i].title), Math.round(compact.scoped[i].percent * 100), + root.windowLabel(compact.window ? compact.window.title : "").toLowerCase())) return lines.join("\n") } } @@ -1912,7 +1924,7 @@ Panel { textFormat: Text.PlainText width: parent.width * 0.3 anchors.verticalCenter: parent.verticalCenter - text: compact.window ? compact.window.title : "" + text: compact.window ? root.windowLabel(compact.window.title) : "" color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/bar/indicators/Dnd.qml b/shell/plugins/bar/indicators/Dnd.qml index 052469bf..063608a4 100644 --- a/shell/plugins/bar/indicators/Dnd.qml +++ b/shell/plugins/bar/indicators/Dnd.qml @@ -11,8 +11,8 @@ BarIndicator { active: dnd activeText: "󰂛" inactiveText: "󰂛" - activeTooltipText: "Allow Notifications" - inactiveTooltipText: "Silence Notifications" + activeTooltipText: I18n.tr("Allow Notifications") + inactiveTooltipText: I18n.tr("Silence Notifications") onPressed: function() { if (root.notificationService) { diff --git a/shell/plugins/bar/indicators/NightLight.qml b/shell/plugins/bar/indicators/NightLight.qml index b15744c7..e338cf07 100644 --- a/shell/plugins/bar/indicators/NightLight.qml +++ b/shell/plugins/bar/indicators/NightLight.qml @@ -1,4 +1,5 @@ import QtQuick +import qs.Commons import qs.Ui BarIndicator { @@ -9,8 +10,8 @@ BarIndicator { active: nightlightService ? nightlightService.enabled : false activeText: "󰔎" inactiveText: "󰔎" - activeTooltipText: "Day Light" - inactiveTooltipText: "Night Light" + activeTooltipText: I18n.tr("Day Light") + inactiveTooltipText: I18n.tr("Night Light") function toggle() { if (root.nightlightService) root.nightlightService.setNightlight(!root.active) diff --git a/shell/plugins/bar/indicators/PasswordlessSudo.qml b/shell/plugins/bar/indicators/PasswordlessSudo.qml index f9b1cb9c..ea307af2 100644 --- a/shell/plugins/bar/indicators/PasswordlessSudo.qml +++ b/shell/plugins/bar/indicators/PasswordlessSudo.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import Quickshell.Io import qs.Commons import qs.Commons as Commons @@ -13,8 +14,8 @@ BarIndicator { active: granted activeText: "󰟵" inactiveText: "󰟵" - activeTooltipText: "Disable Passwordless Sudo" - inactiveTooltipText: "Passwordless Sudo" + activeTooltipText: I18n.tr("Disable Passwordless Sudo") + inactiveTooltipText: I18n.tr("Passwordless Sudo") useActiveColor: true activeColor: Commons.Color.urgent @@ -54,7 +55,7 @@ BarIndicator { command: ["omarchy-sudo-passwordless", "--disable"] onExited: function(exitCode, exitStatus) { if ((exitCode !== 0 || exitStatus !== 0) && root.bar) - root.bar.run('omarchy-notification-send "Could not disable passwordless sudo" "Check the sudo configuration and try again."') + Quickshell.execDetached([Quickshell.env("OMARCHY_PATH") + "/bin/omarchy-notification-send", I18n.tr("Could not disable passwordless sudo"), I18n.tr("Check the sudo configuration and try again.")]) if (root.indicatorHost) root.indicatorHost.refresh() else root.refresh() } diff --git a/shell/plugins/bar/indicators/ScreenRecording.qml b/shell/plugins/bar/indicators/ScreenRecording.qml index 59b4cab2..795e92bf 100644 --- a/shell/plugins/bar/indicators/ScreenRecording.qml +++ b/shell/plugins/bar/indicators/ScreenRecording.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell.Io +import qs.Commons import qs.Ui BarIndicator { @@ -10,8 +11,8 @@ BarIndicator { active: recording activeText: "󰻂" inactiveText: "󰻂" - activeTooltipText: "Stop recording" - inactiveTooltipText: "Screen Recording" + activeTooltipText: I18n.tr("Stop recording") + inactiveTooltipText: I18n.tr("Screen Recording") function refresh() { if (!root.bar || statusProc.running) return diff --git a/shell/plugins/bar/indicators/StayAwake.qml b/shell/plugins/bar/indicators/StayAwake.qml index 70a7829e..605022e9 100644 --- a/shell/plugins/bar/indicators/StayAwake.qml +++ b/shell/plugins/bar/indicators/StayAwake.qml @@ -1,4 +1,5 @@ import QtQuick +import qs.Commons import qs.Ui BarIndicator { @@ -9,8 +10,8 @@ BarIndicator { active: idleService ? idleService.stayAwake : false activeText: "󰅶" inactiveText: "󰅶" - activeTooltipText: "Allow Idle Lock & Screensaver" - inactiveTooltipText: "Stay Awake" + activeTooltipText: I18n.tr("Allow Idle Lock & Screensaver") + inactiveTooltipText: I18n.tr("Stay Awake") function toggle() { if (root.idleService) root.idleService.setIdleEnabled(root.active) diff --git a/shell/plugins/bar/widgets/Microphone.qml b/shell/plugins/bar/widgets/Microphone.qml index 10b0e1f9..1187b3e4 100644 --- a/shell/plugins/bar/widgets/Microphone.qml +++ b/shell/plugins/bar/widgets/Microphone.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Services.Pipewire +import qs.Commons import qs.Ui import qs.Commons @@ -57,7 +58,7 @@ BarWidget { bar: root.bar text: root.muted ? "󰍭" : "󰍬" active: root.inUse - tooltipText: root.muted ? "Microphone muted" : (root.inUse ? "Microphone in use" : "Microphone live") + tooltipText: root.muted ? I18n.tr("Microphone muted") : (root.inUse ? I18n.tr("Microphone in use") : I18n.tr("Microphone live")) onPressed: function(b) { if (b === Qt.MiddleButton) root.bar.run("omarchy-shell shell toggle omarchy.audio") else root.toggleMute() diff --git a/shell/plugins/bar/widgets/SystemUpdate.qml b/shell/plugins/bar/widgets/SystemUpdate.qml index b509323a..5feabb1f 100644 --- a/shell/plugins/bar/widgets/SystemUpdate.qml +++ b/shell/plugins/bar/widgets/SystemUpdate.qml @@ -58,7 +58,7 @@ BarWidget { bar: root.bar text: "\uf021" slotSize: Style.bar.statusSlot - tooltipText: "Pending Omarchy Updates" + tooltipText: I18n.tr("Pending Omarchy Updates") onPressed: root.runUpdate() } } diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index 0a49b76d..bc6b94bd 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -411,7 +411,8 @@ BarWidget { spacing: Style.space(8) Text { - text: "Tray icons" + textFormat: Text.PlainText + text: I18n.tr("Tray icons") color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body @@ -419,7 +420,8 @@ BarWidget { } Text { - text: "Pinned icons stay visible. Hidden icons never show." + textFormat: Text.PlainText + text: I18n.tr("Pinned icons stay visible. Hidden icons never show.") color: Qt.darker(root.foreground, 1.4) font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -429,7 +431,8 @@ BarWidget { Text { visible: root.allItems.length === 0 - text: "No tray items reporting." + textFormat: Text.PlainText + text: I18n.tr("No tray items reporting.") color: Qt.darker(root.foreground, 1.5) font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -486,7 +489,7 @@ BarWidget { anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right iconText: "\uf08d" - text: rowRoot.isPinned ? "Unpin" : "Pin" + text: rowRoot.isPinned ? I18n.tr("Unpin") : I18n.tr("Pin") foreground: root.foreground horizontalPadding: 8 verticalPadding: 3 @@ -501,7 +504,7 @@ BarWidget { anchors.right: rowPinBtn.left anchors.rightMargin: Style.space(6) iconText: "\uf06e" - text: rowRoot.isHidden ? "Show" : "Hide" + text: rowRoot.isHidden ? I18n.tr("Show") : I18n.tr("Hide") foreground: root.foreground horizontalPadding: 8 verticalPadding: 3 diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index b6fb9f94..a74032b3 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -399,8 +399,8 @@ Item { anchors.fill: parent opened: root.clearConfirmOpen z: 10 - message: "Delete entire clipboard history?" - confirmText: "Delete" + message: I18n.tr("Delete entire clipboard history?") + confirmText: I18n.tr("Delete") background: root.background foreground: root.foreground scrim: root.scrim @@ -432,7 +432,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - text: root.filterText || "Search clipboard…" + text: root.filterText || I18n.tr("Search clipboard…") color: root.foreground opacity: root.filterText ? 1 : 0.58 font.family: root.fontFamily @@ -593,7 +593,7 @@ Item { Text { textFormat: Text.PlainText - text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”" + text: root.history.length === 0 ? I18n.tr("Clipboard is empty") : I18n.tr("No matches for “%1”", root.filterText) color: root.foreground opacity: 0.7 font.family: root.fontFamily diff --git a/shell/plugins/emojis/Emojis.qml b/shell/plugins/emojis/Emojis.qml index 74c9dbae..fe65d431 100644 --- a/shell/plugins/emojis/Emojis.qml +++ b/shell/plugins/emojis/Emojis.qml @@ -247,7 +247,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - text: root.filterText || "Search emojis…" + text: root.filterText || I18n.tr("Search emojis…") color: root.foreground opacity: root.filterText ? 1 : 0.58 font.family: root.fontFamily @@ -325,7 +325,7 @@ Item { Text { textFormat: Text.PlainText - text: "No matches for “" + root.filterText + "”" + text: I18n.tr("No matches for “%1”", root.filterText) color: root.foreground opacity: 0.7 font.family: root.fontFamily diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index f9280f2e..312fa91b 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -126,7 +126,7 @@ Item { function currentLabel() { var path = currentPath() - if (!path) return filterText ? "No matches" : "" + if (!path) return filterText ? I18n.tr("No matches") : "" return labelForPath(path) } diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index ecec660c..63cbf251 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -25,7 +25,7 @@ Item { property string passwordText: "" property bool syncingPasswordText: false - readonly property string placeholderText: "Enter Password" + readonly property string placeholderText: I18n.tr("Enter Password") readonly property int fieldWidth: 381 readonly property int fieldHeight: 67 readonly property int outlineThickness: 3 @@ -223,7 +223,7 @@ Item { Text { textFormat: Text.PlainText anchors.fill: passwordInput - text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) + text: root.authenticatingPassword ? I18n.tr("Checking…") : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) visible: passwordInput.text.length === 0 color: root.authenticatingPassword ? Commons.Color.lock.text : (root.failureMessage.length > 0 ? Commons.Color.lock.textError : Commons.Color.lock.placeholder) font.family: Style.font.family @@ -266,7 +266,7 @@ Item { anchors.topMargin: 18 anchors.horizontalCenter: inputField.horizontalCenter visible: root.fingerprintConfigured && root.fingerprintUnavailable - text: "Fingerprint reader unavailable" + text: I18n.tr("Fingerprint reader unavailable") color: Commons.Color.lock.textError font.family: Style.font.family font.pixelSize: Style.font.heading diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index ccd496a8..9a75a081 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -361,7 +361,7 @@ Item { enteredPassword = "" pendingPassword = "" failedAttempts += 1 - failureMessage = "Authentication failed (" + failedAttempts + ")" + failureMessage = I18n.tr("Authentication failed (%1)", failedAttempts) runWake() } diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 4a952c25..9da25fe4 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -38,6 +38,7 @@ Item { function refresh() { defaultMenuFile.reload() + localeMenuFile.reload() userMenuFile.reload() return "ok" } @@ -50,7 +51,9 @@ Item { // path doesn't have to shell out to bash + jq on every open. property string defaultMenuPath: omarchyPath + "/default/omarchy/omarchy-menu.jsonc" property string userMenuPath: Quickshell.env("HOME") + "/.config/omarchy/extensions/omarchy-menu.jsonc" + property string localeMenuPath: I18n.chinese ? omarchyPath + "/default/omarchy/omarchy-menu.zh-cn.jsonc" : "" property var defaultMenuItems: [] + property var localeMenuItems: [] property var userMenuItems: [] property bool opened: false property string mode: "menu" @@ -254,7 +257,7 @@ Item { // on a per-key basis (so the user can tweak label/icon/action without // re-declaring the whole row). function rebuildItemsFromSources() { - var mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.userMenuItems) + var mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.localeMenuItems.concat(root.userMenuItems)) root.providerRevision += 1 root.providersLoaded = ({}) root.providerQueue = [] @@ -572,8 +575,8 @@ Item { if (root.dmenuMultiple && root.dmenuOnChange.length === 0) { displayModel.append({ itemId: "dmenu.apply", disabled: false, kind: "dmenu", icon: "", iconFont: "", - appIcon: "", appId: "", label: "Apply", target: "", - detail: root.dmenuSelected.length + " selected", path: "", childCount: 0, + appIcon: "", appId: "", label: I18n.tr("Apply"), target: "", + detail: I18n.tr("%1 selected", root.dmenuSelected.length), path: "", childCount: 0, action: "", provider: "", score: -1, section: "" }) } @@ -624,7 +627,7 @@ Item { for (var i = 0; i < displayModel.count; i++) { var row = displayModel.get(i) if (row.itemId === "dmenu.apply") { - displayModel.setProperty(i, "detail", root.dmenuSelected.length + " selected") + displayModel.setProperty(i, "detail", I18n.tr("%1 selected", root.dmenuSelected.length)) } else if (row.kind === "dmenu") { var value = MenuModel.dmenuValue(root.dmenuOptions[Number(row.itemId.substring(6))]) displayModel.setProperty(i, "icon", root.dmenuSelected.indexOf(value) !== -1 ? "✓" : "○") @@ -906,7 +909,7 @@ Item { function openDmenu(payload) { requestSerial += 1 mode = payload.mode === "input" ? "input" : "select" - dmenuPrompt = String(payload.prompt || (mode === "input" ? "Input" : "Select")) + dmenuPrompt = String(payload.prompt || (mode === "input" ? I18n.tr("Input") : I18n.tr("Select"))) dmenuOptions = Array.isArray(payload.options) ? payload.options : [] dmenuMultiple = mode === "select" && payload.multiple === true dmenuOnChange = dmenuMultiple && Array.isArray(payload.onChange) @@ -1084,6 +1087,16 @@ Item { onFileChanged: reload() } + FileView { + id: localeMenuFile + path: root.localeMenuPath + watchChanges: true + printErrors: false + onLoaded: { root.localeMenuItems = root.parseMenuJsonc(text()); root.rebuildItemsFromSources() } + onLoadFailed: { root.localeMenuItems = []; root.rebuildItemsFromSources() } + onFileChanged: reload() + } + FileView { id: userMenuFile path: root.userMenuPath @@ -1279,8 +1292,8 @@ Item { anchors.fill: parent opened: root.deleteConfirmOpen z: 10 - message: "Do you want to uninstall " + ((root.deleteTarget && root.deleteTarget.label) || "") + "?" - confirmText: "Uninstall" + message: I18n.tr("Do you want to uninstall %1?", (root.deleteTarget && root.deleteTarget.label) || "") + confirmText: I18n.tr("Uninstall") background: root.background foreground: root.foreground scrim: root.scrim @@ -1312,7 +1325,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - text: root.filterText || (root.dmenuActive ? (root.dmenuPrompt + "…") : ((root.item(root.activeMenu) ? (root.item(root.activeMenu).title || root.item(root.activeMenu).label) : "Go") + "…")) + text: root.filterText || (root.dmenuActive ? (root.dmenuPrompt + "…") : ((root.item(root.activeMenu) ? I18n.tr(root.item(root.activeMenu).title || root.item(root.activeMenu).label) : I18n.tr("Go")) + "…")) color: root.foreground opacity: root.filterText ? 1 : 0.58 font.family: root.fontFamily @@ -1578,7 +1591,7 @@ Item { Text { textFormat: Text.PlainText - text: root.filterText ? "No matches for “" + root.filterText + "”" : "Nothing here yet" + text: root.filterText ? I18n.tr("No matches for “%1”", root.filterText) : I18n.tr("Nothing here yet") color: root.foreground opacity: 0.7 font.family: root.fontFamily diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 588e48bb..8e2dc813 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -58,7 +58,9 @@ function parseMenuJsonc(raw) { for (var id in source) { var entry = source[id] if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue - out.push(normalizeItem(id, entry)) + var item = normalizeItem(id, entry) + item.declared = Object.keys(entry) + out.push(item) } return out } @@ -74,11 +76,24 @@ function mergeMenuSources(defaultItems, userItems) { var entry = src[i] if (!entry || !entry.id) continue if (!nextItems[entry.id]) nextOrder.push(entry.id) - var prior = nextItems[entry.id] || {} + var prior = nextItems[entry.id] var merged = {} - for (var k in prior) merged[k] = prior[k] - for (var k2 in entry) merged[k2] = entry[k2] + // An override replaces only the fields its file declared. + if (prior && entry.declared) { + for (var k in prior) merged[k] = prior[k] + for (var d = 0; d < entry.declared.length; d++) { + var key = entry.declared[d] + if (key in entry) merged[key] = entry[key] + } + if (entry.declared.indexOf("action") >= 0 || entry.declared.indexOf("target") >= 0) + merged.kind = merged.action ? "action" : (merged.target ? "link" : "menu") + } else { + prior = prior || {} + for (var k1 in prior) merged[k1] = prior[k1] + for (var k2 in entry) merged[k2] = entry[k2] + } merged.id = entry.id + delete merged.declared nextItems[entry.id] = merged } } @@ -406,7 +421,8 @@ var GUARD_READERS = [ "omarchy-dns", "omarchy-hyprland-corners", "omarchy-hyprland-window-transparency", - "omarchy-hyprland-workspace-layout-default" + "omarchy-hyprland-workspace-layout-default", + "omarchy-language" ] // Package and command presence account for most of what the guards ask, and diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index a239b19f..ea102e9e 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -725,7 +725,7 @@ Item { originalId: -1, app: "omarchy-action", appIcon: "", - summary: "No recent notifications", + summary: I18n.tr("No recent notifications"), body: "", image: "", glyph: "󰂚", diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index 46cb39a8..72dddd5d 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -200,7 +200,7 @@ Panel { readonly property bool hasOutput: !!(volumeSink && volumeSink.audio) readonly property bool hasInput: !!(source && source.audio) || (inputViaWpctl && UntypedInput.known) readonly property bool anyAudible: (hasOutput && !outputMuted) || (hasInput && !inputMuted) - readonly property string toggleHint: anyAudible ? "Mute" : "Unmute" + readonly property string toggleHint: anyAudible ? I18n.tr("Mute") : I18n.tr("Unmute") readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Commons.Color.accent) @@ -458,7 +458,7 @@ Panel { // panel's brightnessName ladder; bands are wide enough that small // tweaks don't rename the room you're in. function outputVolumeName(volume, muted) { - return Model.outputVolumeName(volume, muted) + return I18n.tr(Model.outputVolumeName(volume, muted)) } function setOutputVolume(v) { @@ -813,7 +813,8 @@ Panel { spacing: Style.space(2) Text { - text: "Audio" + textFormat: Text.PlainText + text: I18n.tr("Audio") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title @@ -855,7 +856,7 @@ Panel { PanelSectionHeader { id: outputHeader - text: "OUTPUT" + text: I18n.tr("OUTPUT") foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.left: parent.left @@ -942,7 +943,7 @@ Panel { PanelSectionHeader { id: microphoneHeader - text: "INPUT" + text: I18n.tr("INPUT") foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.left: parent.left @@ -1050,7 +1051,7 @@ Panel { visible: root.displayAudioStreams.length > 0 PanelSectionHeader { - text: "SOURCES" + text: I18n.tr("SOURCES") foreground: root.bar.foreground fontFamily: root.bar.fontFamily } diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 6603209d..bde33cd8 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -65,19 +65,19 @@ Panel { property int phraseIndex: 0 readonly property var activePhrases: [ - "Untangling wires", - "Streaming vikings", - "Pairing mysteries", - "Herding headsets", - "Taming radios", - "Summoning speakers", - "Wrangling codecs", - "Polishing packets" + I18n.tr("Untangling wires"), + I18n.tr("Streaming vikings"), + I18n.tr("Pairing mysteries"), + I18n.tr("Herding headsets"), + I18n.tr("Taming radios"), + I18n.tr("Summoning speakers"), + I18n.tr("Wrangling codecs"), + I18n.tr("Polishing packets") ] readonly property bool rotatingPhrases: adapter && adapter.enabled readonly property string heroStatusText: { - if (!adapter) return "No adapter" - if (!adapter.enabled) return "Turned Off" + if (!adapter) return I18n.tr("No adapter") + if (!adapter.enabled) return I18n.tr("Turned Off") return activePhrases[phraseIndex % activePhrases.length] } @@ -102,7 +102,7 @@ Panel { // sits above the device sections so the adapter can be toggled by keyboard // even when it is off and no device rows exist. readonly property bool headerHasCursor: cursorActive && focusSection === "header" - readonly property string toggleHint: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on" + readonly property string toggleHint: root.adapter && root.adapter.enabled ? I18n.tr("Turn Bluetooth off") : I18n.tr("Turn Bluetooth on") readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Commons.Color.accent) @@ -184,7 +184,7 @@ Panel { var rows = scrollRows if (index < 0 || index >= rows.length) return "" if (index > 0 && rows[index - 1].section === rows[index].section) return "" - return rows[index].section === "known" ? "PAIRED" : "AVAILABLE" + return rows[index].section === "known" ? I18n.tr("PAIRED") : I18n.tr("AVAILABLE") } function audioSinks() { @@ -739,7 +739,8 @@ Panel { spacing: Style.space(2) Text { - text: "Bluetooth" + textFormat: Text.PlainText + text: I18n.tr("Bluetooth") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title @@ -776,7 +777,7 @@ Panel { spacing: Style.space(10) PanelSectionHeader { - text: "CONNECTED" + text: I18n.tr("CONNECTED") foreground: root.bar.foreground fontFamily: root.bar.fontFamily } @@ -868,9 +869,9 @@ Panel { Text { textFormat: Text.PlainText visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0 - text: !root.adapter ? "No Bluetooth adapter" - : !root.adapter.enabled ? "Turn Bluetooth on to scan" - : "Scanning for devices…" + text: !root.adapter ? I18n.tr("No Bluetooth adapter") + : !root.adapter.enabled ? I18n.tr("Turn Bluetooth on to scan") + : I18n.tr("Scanning for devices…") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall @@ -895,9 +896,9 @@ Panel { readonly property string action: root.pendingAction(dev ? dev.address : "") readonly property string actionTooltip: { if (!dev) return "" - if (isConnected) return "Disconnect" - if (isDiscovered) return "Pair" - return "Connect" + if (isConnected) return I18n.tr("Disconnect") + if (isDiscovered) return I18n.tr("Pair") + return I18n.tr("Connect") } readonly property bool rowSelected: root.cursorActive && root.focusSection === sectionName && root.selectedIndex === rowIndex @@ -912,13 +913,13 @@ Panel { readonly property string statusText: { if (!dev) return "" - if (action === "forgetting") return "Forgetting…" - if (action === "disconnecting" || devState === 2) return "Disconnecting…" + if (action === "forgetting") return I18n.tr("Forgetting…") + if (action === "disconnecting" || devState === 2) return I18n.tr("Disconnecting…") if (isConnected) { if (dev.batteryAvailable) return Math.round(dev.battery * 100) + "%" - return sectionName === "connected" ? "" : "Connected" + return sectionName === "connected" ? "" : I18n.tr("Connected") } - if (action === "connecting" || devState === 3 || dev.pairing === true) return "Connecting…" + if (action === "connecting" || devState === 3 || dev.pairing === true) return I18n.tr("Connecting…") if (isDiscovered) return "" return "" } @@ -995,7 +996,7 @@ Panel { Text { textFormat: Text.PlainText - text: root.deviceLabel(row.dev) || "Device" + text: root.deviceLabel(row.dev) || I18n.tr("Device") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body @@ -1020,7 +1021,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter visible: row.showForgetButton iconText: "󰅙" - tooltipText: "Forget" + tooltipText: I18n.tr("Forget") foreground: root.bar.foreground hoverColor: root.bar.foreground fontFamily: root.bar.fontFamily diff --git a/shell/plugins/panels/clock/BarWidget.qml b/shell/plugins/panels/clock/BarWidget.qml index 3235a1cc..81f7b038 100644 --- a/shell/plugins/panels/clock/BarWidget.qml +++ b/shell/plugins/panels/clock/BarWidget.qml @@ -21,7 +21,7 @@ BarWidget { : setting("format", "dddd HH:mm") readonly property string configuredAltFormat: vertical ? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy") - : setting("formatAlt", "d MMMM 'W'ww yyyy") + : setting("formatAlt", I18n.chinese ? "yyyy年M月d日 'W'ww" : "d MMMM 'W'ww yyyy") readonly property var formatRing: Model.clockFormatRing(configuredFormat, configuredAltFormat, Model.clockFormats(vertical)) @@ -56,8 +56,10 @@ BarWidget { root.bar.shell.updateEntryInline(root.moduleName, entry) } + // Qt.formatDateTime is always English; a Chinese session takes day and month names from its locale. function formatted(date) { - return Qt.formatDateTime(date, activeFormat.replace(/ww/g, Model.isoWeekLiteral(date.getFullYear(), date.getMonth(), date.getDate()))) + var format = activeFormat.replace(/ww/g, Model.isoWeekLiteral(date.getFullYear(), date.getMonth(), date.getDate())) + return I18n.chinese ? Qt.locale().toString(date, format) : Qt.formatDateTime(date, format) } // ---- Calendar popup. Shape contract for shell.summon/hide/toggle @@ -153,7 +155,7 @@ BarWidget { fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1 horizontalMargin: 8.75 verticalPadding: 8.75 - tooltipText: "Right-click to toggle format" + tooltipText: I18n.tr("Right-click to toggle format") onPressed: function(b) { if (b === Qt.RightButton) root.cycleFormat() diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index 042c2ef8..e8065530 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -67,10 +67,8 @@ Panel { // convention. Clicking the grid's "W" heading writes the choice back to // shell.json. readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek) - // The interface is English throughout, so day names are not taken from the - // system locale. Where the week starts still is: that is a regional - // convention rather than a translation, and it stays overridable above. - readonly property var labelLocale: Qt.locale("en_US") + // Day names follow the interface language, not the system locale; the week start above is a regional convention. + readonly property var labelLocale: I18n.chinese ? Qt.locale("zh_CN") : Qt.locale("en_US") readonly property string nextWeekStartLabel: labelLocale.dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat) readonly property var weekdays: Model.weekdayOrder(weekStart) readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey) @@ -318,7 +316,7 @@ Panel { id: heroDate textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter - text: Qt.formatDate(root.today, "MMMM d") + text: I18n.chinese ? root.labelLocale.toString(root.today, "M月d日") : Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse ? Style.hoverStateColor(root.contentForeground, Commons.Color.accent) : root.contentForeground @@ -341,7 +339,7 @@ Panel { PanelToolTip { visible: heroMouse.containsMouse - text: "Back to today" + text: I18n.tr("Back to today") fontFamily: root.contentFontFamily } } @@ -374,7 +372,8 @@ Panel { Text { anchors.verticalCenter: parent.verticalCenter - text: "BORN" + textFormat: Text.PlainText + text: I18n.tr("BORN") color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall @@ -385,7 +384,7 @@ Panel { id: bornField width: Style.space(70) anchors.verticalCenter: parent.verticalCenter - placeholderText: "year" + placeholderText: I18n.tr("year") foreground: root.contentForeground font.family: root.contentFontFamily inputMethodHints: Qt.ImhDigitsOnly @@ -397,7 +396,8 @@ Panel { anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 0 leftPadding: Style.space(6) - text: "LIVE TO" + textFormat: Text.PlainText + text: I18n.tr("LIVE TO") color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall @@ -484,7 +484,8 @@ Panel { id: lifeLabel anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - text: "LIFE" + textFormat: Text.PlainText + text: I18n.tr("LIFE") color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall @@ -603,7 +604,7 @@ Panel { PanelToolTip { visible: weekStartMouse.containsMouse - text: "Start weeks on " + root.nextWeekStartLabel + text: I18n.tr("Start weeks on %1", root.nextWeekStartLabel) fontFamily: root.contentFontFamily } } @@ -726,7 +727,7 @@ Panel { // "MAY 2026" and a "SEPTEMBER 2026". width: Style.space(130) horizontalAlignment: Text.AlignHCenter - text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase() + text: I18n.chinese ? root.labelLocale.toString(root.viewDate, "yyyy年M月") : Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase() color: Qt.darker(root.contentForeground, 1.4) font.family: root.contentFontFamily font.pixelSize: Style.font.body @@ -740,7 +741,7 @@ Panel { anchors.leftMargin: -Style.space(8) anchors.verticalCenter: parent.verticalCenter iconText: "󰅁" - tooltipText: "Previous month" + tooltipText: I18n.tr("Previous month") foreground: root.contentForeground fontFamily: root.contentFontFamily onClicked: root.moveMonth(-1) @@ -751,7 +752,7 @@ Panel { anchors.rightMargin: -Style.space(8) anchors.verticalCenter: parent.verticalCenter iconText: "󰅂" - tooltipText: "Next month" + tooltipText: I18n.tr("Next month") foreground: root.contentForeground fontFamily: root.contentFontFamily onClicked: root.moveMonth(1) diff --git a/shell/plugins/panels/disk-speedtest/Panel.qml b/shell/plugins/panels/disk-speedtest/Panel.qml index 3b86f5be..256699eb 100644 --- a/shell/plugins/panels/disk-speedtest/Panel.qml +++ b/shell/plugins/panels/disk-speedtest/Panel.qml @@ -117,7 +117,7 @@ Item { } if (!root.expectedStop && exitCode !== 0) { - root.error = root.stderrText || "Disk speed test failed" + root.error = root.stderrText || I18n.tr("Disk speed test failed") root.phase = "" root.running = false return @@ -133,10 +133,10 @@ Item { fontFamily: Style.font.family layerNamespace: "omarchy-disk-speedtest" title: root.diskName - leftLabel: "READ" - rightLabel: "WRITE" + leftLabel: I18n.tr("READ") + rightLabel: I18n.tr("WRITE") unit: "MB/s" - runAgainTooltip: "Measure again" + runAgainTooltip: I18n.tr("Measure again") running: root.running leftValue: root.toRate(root.readMBps) rightValue: root.toRate(root.writeMBps) diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index 4fd44f84..d561ae5e 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -21,16 +21,16 @@ Panel { property int phraseIndex: 0 readonly property var activePhrases: [ - "Filing files", - "Distributing data", - "Shuffling folders", - "Boxing bytes", - "Sorting stuff", - "Syncing secrets", - "Packing packets", - "Moving memories", - "Wrangling revisions", - "Cataloging chaos" + I18n.tr("Filing files"), + I18n.tr("Distributing data"), + I18n.tr("Shuffling folders"), + I18n.tr("Boxing bytes"), + I18n.tr("Sorting stuff"), + I18n.tr("Syncing secrets"), + I18n.tr("Packing packets"), + I18n.tr("Moving memories"), + I18n.tr("Wrangling revisions"), + I18n.tr("Cataloging chaos") ] readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length] readonly property color foreground: bar ? bar.foreground : Commons.Color.foreground @@ -38,7 +38,14 @@ Panel { readonly property color dim: Qt.darker(foreground, 1.55) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property color iconColor: dropbox.authenticated && dropbox.active ? foreground : dim - readonly property string toggleHint: dropbox.active ? "Pause syncing" : "Resume syncing" + // Model.fileMeta reads "5m ago · Folder"; the age is translated, the folder name kept. + function fileMetaText(file) { + var parts = Model.fileMeta(file).split(" · ") + parts[0] = I18n.trMatch(parts[0], ["Just now", "Unknown time", "%1mo ago", "%1m ago", "%1h ago", "%1d ago", "%1y ago"]) + return parts.join(" · ") + } + + readonly property string toggleHint: dropbox.active ? I18n.tr("Pause syncing") : I18n.tr("Resume syncing") readonly property color barIconColor: dropbox.authenticated && dropbox.active ? barForeground : Qt.darker(barForeground, 1.55) // Only claim the header cursor when the switch is actually on screen — // "header" stays navigable, but an absent CLI leaves nothing to highlight. @@ -247,7 +254,7 @@ Panel { id: hero width: parent.width title: "Dropbox" - meta: dropbox.active ? root.heroPhraseText : "Syncing paused" + meta: dropbox.active ? root.heroPhraseText : I18n.tr("Syncing paused") foreground: root.foreground fontFamily: root.fontFamily iconOpacity: dropbox.active ? 1.0 : 0.5 @@ -287,7 +294,7 @@ Panel { textFormat: Text.PlainText visible: dropbox.actionStatus !== "" || dropbox.lastError !== "" width: parent.width - text: dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError + text: I18n.tr(dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError) color: dropbox.lastError !== "" && dropbox.actionStatus === "" ? root.urgent : root.dim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -307,7 +314,7 @@ Panel { Column { width: parent.width spacing: Style.spacing.labelGap - InfoPair { label: "Stored"; value: Model.usageText(dropbox.usedBytes, dropbox.quotaBytes, dropbox.quotaKnown) } + InfoPair { label: I18n.tr("Stored"); value: I18n.trMatch(Model.usageText(dropbox.usedBytes, dropbox.quotaBytes, dropbox.quotaKnown), ["%1 of %2"]) } } } @@ -322,7 +329,7 @@ Panel { spacing: Style.space(10) PanelSectionHeader { - text: "RECENT FILES" + text: I18n.tr("RECENT FILES") foreground: root.foreground fontFamily: root.fontFamily } @@ -330,7 +337,8 @@ Panel { Text { visible: dropbox.files.length === 0 width: parent.width - text: "No synced files found." + textFormat: Text.PlainText + text: I18n.tr("No synced files found.") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.body @@ -427,7 +435,7 @@ Panel { Text { textFormat: Text.PlainText Layout.fillWidth: true - text: dropbox.installed ? "Login to Dropbox" : "Dropbox CLI is not installed" + text: dropbox.installed ? I18n.tr("Login to Dropbox") : I18n.tr("Dropbox CLI is not installed") color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body @@ -437,7 +445,7 @@ Panel { Text { textFormat: Text.PlainText Layout.fillWidth: true - text: dropbox.installed ? "Start the authentication flow" : "Install Dropbox from the service menu" + text: dropbox.installed ? I18n.tr("Start the authentication flow") : I18n.tr("Install Dropbox from the service menu") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -460,7 +468,7 @@ Panel { id: fileRow property var file: null property int rowIndex: 0 - readonly property string fileName: file ? String(file.name || "Untitled") : "Untitled" + readonly property string fileName: file ? String(file.name || I18n.tr("Untitled")) : I18n.tr("Untitled") hasCursor: root.cursorActive && root.focusSection === "files" && root.fileIndex === rowIndex foreground: root.foreground @@ -510,7 +518,7 @@ Panel { Text { textFormat: Text.PlainText Layout.fillWidth: true - text: Model.fileMeta(fileRow.file) + text: root.fileMetaText(fileRow.file) color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption diff --git a/shell/plugins/panels/elsewhen/CityMatches.qml b/shell/plugins/panels/elsewhen/CityMatches.qml index bb80f789..8e1df8b6 100644 --- a/shell/plugins/panels/elsewhen/CityMatches.qml +++ b/shell/plugins/panels/elsewhen/CityMatches.qml @@ -73,7 +73,7 @@ Column { width: parent.width horizontalAlignment: Text.AlignHCenter topPadding: Style.spacing.md - text: list.citySearch.loading ? list.citySearch.loadingText : "No matches" + text: list.citySearch.loading ? list.citySearch.loadingText : I18n.tr("No matches") color: list.citySearch.fainter } diff --git a/shell/plugins/panels/elsewhen/CityRow.qml b/shell/plugins/panels/elsewhen/CityRow.qml index 03bba250..cecd988c 100644 --- a/shell/plugins/panels/elsewhen/CityRow.qml +++ b/shell/plugins/panels/elsewhen/CityRow.qml @@ -12,6 +12,16 @@ import "Greetings.js" as Greet Rectangle { id: row + // Model.formatDate reads "Tue Oct 6"; a Chinese session reads 10月6日 周二. + function dateText(date) { + var match = String(date).match(/^(\w{3}) (\w{3}) (\d+)$/) + if (!I18n.chinese || !match) return date + var month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(match[2]) + 1 + var weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(match[1]) + if (month < 1 || weekday < 0) return date + return month + "月" + match[3] + "日 " + ["周日", "周一", "周二", "周三", "周四", "周五", "周六"][weekday] + } + required property var modelData required property int index // The owning Panel: the row reads its clock, facts and drag state. @@ -223,7 +233,7 @@ Rectangle { Behavior on opacity { NumberAnimation { duration: Style.duration(110) } } Caption { - text: row.ready ? row.rowData.date : "\u2026" + text: row.ready ? row.dateText(row.rowData.date) : "\u2026" color: row.ready && row.rowData.dayLabel !== "" ? row.dim : row.fainter } @@ -234,7 +244,7 @@ Rectangle { } Caption { - text: row.ready ? row.rowData.dayLabel : "" + text: row.ready ? I18n.trMatch(row.rowData.dayLabel, ["Tomorrow", "Yesterday", "+%1 days", "%1 days"]) : "" visible: text !== "" } } @@ -301,7 +311,7 @@ Rectangle { color: Model.mix(Commons.Color.popups.background, row.foreground, _hot ? 0.24 : 0.14) borderSpec: Border.flat(Model.mix(Commons.Color.popups.background, row.foreground, _hot ? 0.65 : 0.35), Style.space(1)) iconText: "\u00d7" - tooltipText: "Remove" + tooltipText: I18n.tr("Remove") foreground: row.dim hoverColor: row.foreground fontFamily: row.fontFamily diff --git a/shell/plugins/panels/elsewhen/CitySearch.qml b/shell/plugins/panels/elsewhen/CitySearch.qml index f1ffb4b6..b01e8cfe 100644 --- a/shell/plugins/panels/elsewhen/CitySearch.qml +++ b/shell/plugins/panels/elsewhen/CitySearch.qml @@ -17,9 +17,9 @@ Column { // Zone id -> offset text; read inside a binding, so its dependencies track. property var offsetLabel: function(zoneId) { return "" } property bool loading: false - property string buttonText: "+ Add a city" - property string placeholderText: "Search cities\u2026" - property string loadingText: "Loading zones\u2026" + property string buttonText: "+ " + I18n.tr("Add a city") + property string placeholderText: I18n.tr("Search cities…") + property string loadingText: I18n.tr("Loading zones…") property real fontSize: Style.font.bodySmall property color foreground: Commons.Color.foreground property color dim: Qt.darker(foreground, 1.55) diff --git a/shell/plugins/panels/elsewhen/DaylightStrip.qml b/shell/plugins/panels/elsewhen/DaylightStrip.qml index b55a55e9..de9228e4 100644 --- a/shell/plugins/panels/elsewhen/DaylightStrip.qml +++ b/shell/plugins/panels/elsewhen/DaylightStrip.qml @@ -66,8 +66,8 @@ Item { var out = [] var up = Sun.eventMark(sun.riseMinutes) var down = Sun.eventMark(sun.setMinutes) - if (up !== null) out.push({ x: up, minutes: sun.riseMinutes, rising: true, name: "Sunrise" }) - if (down !== null) out.push({ x: down, minutes: sun.setMinutes, rising: false, name: "Sunset" }) + if (up !== null) out.push({ x: up, minutes: sun.riseMinutes, rising: true, name: I18n.tr("Sunrise") }) + if (down !== null) out.push({ x: down, minutes: sun.setMinutes, rising: false, name: I18n.tr("Sunset") }) return out } diff --git a/shell/plugins/panels/elsewhen/Globe.qml b/shell/plugins/panels/elsewhen/Globe.qml index 83cbbc37..4fda37aa 100644 --- a/shell/plugins/panels/elsewhen/Globe.qml +++ b/shell/plugins/panels/elsewhen/Globe.qml @@ -585,8 +585,8 @@ Item { limit: 5 inlineResults: false loading: root.jumpOptions.length === 0 - buttonText: "Jump to a city" - loadingText: "Loading cities\u2026" + buttonText: I18n.tr("Jump to a city") + loadingText: I18n.tr("Loading cities…") offsetLabel: function(zoneId) { return Model.utcOffsetLabel(root.offsets[zoneId]) } foreground: root.foreground dim: root.dim diff --git a/shell/plugins/panels/elsewhen/HeroTitle.qml b/shell/plugins/panels/elsewhen/HeroTitle.qml index fc47f0ad..090b4711 100644 --- a/shell/plugins/panels/elsewhen/HeroTitle.qml +++ b/shell/plugins/panels/elsewhen/HeroTitle.qml @@ -73,7 +73,7 @@ Column { anchors.horizontalCenter: parent.horizontalCenter spacing: Style.space(9) - TitleWord { text: "World" } + TitleWord { text: I18n.tr("World") } Item { id: heroIcon @@ -151,7 +151,7 @@ Column { } } - TitleWord { text: "Clock" } + TitleWord { text: I18n.tr("Clock") } } component TitleWord: Text { diff --git a/shell/plugins/panels/elsewhen/Panel.qml b/shell/plugins/panels/elsewhen/Panel.qml index 087ba0ba..8de7cdef 100644 --- a/shell/plugins/panels/elsewhen/Panel.qml +++ b/shell/plugins/panels/elsewhen/Panel.qml @@ -335,8 +335,8 @@ Panel { } // No full stop: a period hanging off the arc reads as a speck. - readonly property string hereLine: "It's " + localTime + " here" - + (homeCity !== "" ? " in " + homeCity : "") + readonly property string hereLine: I18n.tr("It's %1 here", localTime) + + (homeCity !== "" ? I18n.tr(" in %1", homeCity) : "") + (scrubLabel !== "" ? " " + scrubLabel : "") // A capital's line box has empty space above it; the row's top pad drops by it. @@ -715,7 +715,7 @@ Panel { bar: root.bar slotSize: Style.bar.statusSlot text: "󰇧" - tooltipText: "World clock" + tooltipText: I18n.tr("World clock") onPressed: function(buttonCode) { if (buttonCode === Qt.RightButton || buttonCode === Qt.MiddleButton) root.refresh() else root.toggle() diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index 357b129b..15ee417c 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -556,7 +556,8 @@ Panel { spacing: Style.space(2) Text { - text: "Display" + textFormat: Text.PlainText + text: I18n.tr("Display") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title @@ -570,9 +571,9 @@ Panel { textFormat: Text.PlainText text: { if (root.brightnessAvailable) { - return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() + return I18n.tr(root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent)).toUpperCase() } - return "FIXED BRIGHTNESS" + return I18n.tr("FIXED BRIGHTNESS") } color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -602,7 +603,7 @@ Panel { PanelSectionHeader { id: brightnessHeader - text: "BRIGHTNESS" + text: I18n.tr("BRIGHTNESS") foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.left: parent.left @@ -675,7 +676,7 @@ Panel { PanelSectionHeader { id: textSizeHeader - text: "TEXT SIZE" + text: I18n.tr("TEXT SIZE") foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.left: parent.left @@ -747,7 +748,7 @@ Panel { PanelSectionHeader { id: scaleHeader - text: "SCALE" + text: I18n.tr("SCALE") foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.left: parent.left @@ -809,7 +810,7 @@ Panel { visible: root.displays.length > 1 PanelSectionHeader { - text: "DISPLAYS" + text: I18n.tr("DISPLAYS") foreground: root.bar.foreground fontFamily: root.bar.fontFamily } @@ -900,7 +901,7 @@ Panel { Text { textFormat: Text.PlainText - text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "") + text: monitorRow.display.name + (monitorRow.display.focused ? " · " + I18n.tr("focused") : "") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 213031c9..cb4f33fa 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -57,13 +57,13 @@ Panel { readonly property bool hasTransferStats: info.rx_bytes !== undefined property int connectionPhraseIndex: 0 readonly property var connectionPhrases: [ - "Wiring bits", - "Handling packets", - "Sorting frames", - "Hauling bytes", - "Routing crumbs", - "Counting collisions", - "Bending light", + I18n.tr("Wiring bits"), + I18n.tr("Handling packets"), + I18n.tr("Sorting frames"), + I18n.tr("Hauling bytes"), + I18n.tr("Routing crumbs"), + I18n.tr("Counting collisions"), + I18n.tr("Bending light"), ] readonly property string connectionPhrase: connectionPhrases[connectionPhraseIndex % connectionPhrases.length] readonly property bool networkManagerAvailable: Networking.backend === NetworkBackendType.NetworkManager @@ -138,7 +138,7 @@ Panel { readonly property bool qrHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === qrHeaderIndex readonly property bool speedHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === speedHeaderIndex readonly property bool toggleHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === toggleHeaderIndex - readonly property string toggleHint: Networking.wifiEnabled ? "Turn Wi-Fi off" : "Turn Wi-Fi on" + readonly property string toggleHint: Networking.wifiEnabled ? I18n.tr("Turn Wi-Fi off") : I18n.tr("Turn Wi-Fi on") readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] property int dnsIndex: 0 // ["2.4", "5", ...], or empty when there is nothing to choose between. @@ -160,7 +160,10 @@ Panel { // Under Automatic there is nothing to pick, so the pills collapse away and // the header states the live band instead. readonly property bool bandPillsVisible: canSelectBand && bandPinned - readonly property string bandSectionTitle: Model.bandSectionTitle(bandEffective, bandCurrent) + readonly property string bandSectionTitle: { + var title = Model.bandSectionTitle(bandEffective, bandCurrent) + return title.indexOf("WI-FI BAND: ") === 0 ? I18n.tr("WI-FI BAND: %1", title.slice(12)) : I18n.tr(title) + } readonly property bool bandBusy: pendingBand !== "" // The speed test needs an interface to test, so its hero action only // appears once there is one. @@ -280,11 +283,12 @@ Panel { } function bandLabel(band) { - return Model.bandLabel(band) + return I18n.tr(Model.bandLabel(band)) } function bandTooltip(band) { - return Model.bandTooltip(band) + var tip = Model.bandTooltip(band) + return tip.indexOf("Stay on ") === 0 ? I18n.tr("Stay on %1", bandLabel(band)) : I18n.tr(tip) } // Single cursor model: exactly one highlighted spot across the whole @@ -663,7 +667,7 @@ Panel { } function formatPingLatency(ms) { - return Model.formatPingLatency(ms, hasInternetPing) + return I18n.tr(Model.formatPingLatency(ms, hasInternetPing)) } function formatPacketLoss(percent) { @@ -710,7 +714,7 @@ Panel { } function wifiSectionTitle(index) { - return Model.wifiSectionTitle(wifiNetworks, index) + return I18n.tr(Model.wifiSectionTitle(wifiNetworks, index)) } function wifiIconFor(strength) { @@ -755,7 +759,7 @@ Panel { cancelPasswordPrompt() var connection = "" if (info.type === "wifi") connection = info.ssid || "Wi-Fi" - else if (info.type === "ethernet") connection = "Ethernet" + else if (info.type === "ethernet") connection = I18n.tr("Ethernet") bar.shell.summon("omarchy.speedtest", connection ? JSON.stringify({ connection: connection }) : "{}") } @@ -836,7 +840,7 @@ Panel { if (!network || actionKind === "" || actionSsid !== (network.name || "")) return actionTimeout.stop() failureSsid = actionSsid - failureReason = networkFailureReason(reason, requiresCredentials(network.security)) + failureReason = I18n.tr(networkFailureReason(reason, requiresCredentials(network.security))) actionSsid = "" actionKind = "" refresh() @@ -1063,9 +1067,9 @@ Panel { onTriggered: { if (!root.actionKind) return var reason - if (root.actionKind === "connect") reason = "Timed out connecting" - else if (root.actionKind === "disconnect") reason = "Timed out disconnecting" - else reason = "Timed out forgetting" + if (root.actionKind === "connect") reason = I18n.tr("Timed out connecting") + else if (root.actionKind === "disconnect") reason = I18n.tr("Timed out disconnecting") + else reason = I18n.tr("Timed out forgetting") root.failureSsid = root.actionSsid root.failureReason = reason root.actionSsid = "" @@ -1080,8 +1084,8 @@ Panel { bar: root.bar text: root.icon active: root.restricted - tooltipText: root.hasCaptivePortal ? "Sign in to this network" - : (root.restricted ? "Limited internet access" : "") + tooltipText: root.hasCaptivePortal ? I18n.tr("Sign in to this network") + : (root.restricted ? I18n.tr("Limited internet access") : "") onPressed: function(b) { if (root.opened) root.close() @@ -1250,7 +1254,7 @@ Panel { id: qrAction visible: root.canShareWifi iconText: "󰐲" - tooltipText: "Show QR code" + tooltipText: I18n.tr("Show QR code") foreground: root.bar.foreground fontFamily: root.bar.fontFamily fontSize: Style.font.subtitle * 1.5 @@ -1264,7 +1268,7 @@ Panel { id: speedAction visible: root.canRunSpeedTest iconText: "󰓅" - tooltipText: "Run a speed test" + tooltipText: I18n.tr("Run a speed test") foreground: root.bar.foreground fontFamily: root.bar.fontFamily fontSize: Style.font.subtitle * 1.5 @@ -1313,8 +1317,8 @@ Panel { // SSID even before route/details polling has returned anything. if (root.kind === "wifi" && root.connectedWifiNetwork) return root.connectedWifiNetwork.name || "Wi-Fi" if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" - if (root.info.type === "ethernet") return "Ethernet" - return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection") + if (root.info.type === "ethernet") return I18n.tr("Ethernet") + return root.info.iface || (root.kind === "disconnected" ? I18n.tr("Disconnected") : I18n.tr("No connection")) } readonly property string detail: root.headerDetail() @@ -1331,15 +1335,15 @@ Panel { textFormat: Text.PlainText width: parent.width text: { - if (root.hasCaptivePortal) return "SIGN-IN REQUIRED" - if (root.restricted) return "LIMITED INTERNET ACCESS" + if (root.hasCaptivePortal) return I18n.tr("SIGN-IN REQUIRED") + if (root.restricted) return I18n.tr("LIMITED INTERNET ACCESS") if (root.info.type === "wifi") { if (root.canDisconnect) return root.connectionPhrase.toUpperCase() - if (root.kind === "disconnected") return "NOT CONNECTED" + if (root.kind === "disconnected") return I18n.tr("NOT CONNECTED") return "" } if (root.info.type === "ethernet") return root.connectionPhrase.toUpperCase() - if (root.kind === "disconnected") return "NOT CONNECTED" + if (root.kind === "disconnected") return I18n.tr("NOT CONNECTED") return "" } visible: text !== "" @@ -1362,7 +1366,7 @@ Panel { Button { id: portalAction width: parent.width - text: "Open Captive Portal" + text: I18n.tr("Open Captive Portal") iconText: "󰏌" foreground: root.bar.urgent accent: root.bar.urgent @@ -1381,7 +1385,7 @@ Panel { Text { width: parent.width - text: "Sign in or accept this network’s terms to access the internet." + text: I18n.tr("Sign in or accept this network’s terms to access the internet.") textFormat: Text.PlainText wrapMode: Text.WordWrap color: root.bar.foreground @@ -1407,38 +1411,38 @@ Panel { // opened, once the first probe returned, shoving everything below // them down. They now hold their place and read "--" until there is // a sample. - InfoLabel { text: "Ping" } + InfoLabel { text: I18n.tr("Ping") } DetailValue { text: root.formatPingLatency(root.internetPingLatency) color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground } - InfoLabel { text: "Packet Loss" } + InfoLabel { text: I18n.tr("Packet Loss") } DetailValue { text: root.formatPacketLoss(root.internetPingPacketLoss) color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground } - InfoLabel { text: "Receiving" } + InfoLabel { text: I18n.tr("Receiving") } DetailValue { text: root.hasTransferStats ? root.formatRate(root.downloadRate) : "--" } - InfoLabel { text: "Sending" } + InfoLabel { text: I18n.tr("Sending") } DetailValue { text: root.hasTransferStats ? root.formatRate(root.uploadRate) : "--" } - InfoLabel { text: "Downloaded" } + InfoLabel { text: I18n.tr("Downloaded") } DetailValue { text: root.hasTransferStats ? root.formatBytes(parseFloat(root.info.rx_bytes || "0")) : "--" } - InfoLabel { text: "Uploaded" } + InfoLabel { text: I18n.tr("Uploaded") } DetailValue { text: root.hasTransferStats ? root.formatBytes(parseFloat(root.info.tx_bytes || "0")) : "--" } - InfoLabel { text: "IP Address" } + InfoLabel { text: I18n.tr("IP Address") } DetailValue { text: root.info.ip || "--" copyable: !!root.info.ip - tooltipText: "Copy IP" + tooltipText: I18n.tr("Copy IP") } - InfoLabel { text: "Gateway" } + InfoLabel { text: I18n.tr("Gateway") } DetailValue { text: root.info.gateway || "--" copyable: !!root.info.gateway - tooltipText: "Copy gateway" + tooltipText: I18n.tr("Copy gateway") } } } @@ -1479,7 +1483,7 @@ Panel { PanelSectionHeader { id: bandAutoLabel - text: "AUTOMATIC" + text: I18n.tr("AUTOMATIC") foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.verticalCenter: parent.verticalCenter @@ -1512,8 +1516,8 @@ Panel { PanelToolTip { visible: bandAutoSwitch.containsMouse text: root.bandPinned - ? "Let Wi-Fi pick the band" - : "Stay on " + root.bandLabel(root.bandCurrent) + ? I18n.tr("Let Wi-Fi pick the band") + : I18n.tr("Stay on %1", root.bandLabel(root.bandCurrent)) fontFamily: root.bar.fontFamily } } @@ -1584,7 +1588,7 @@ Panel { spacing: Style.space(10) PanelSectionHeader { - text: "DNS PROVIDER" + text: I18n.tr("DNS PROVIDER") foreground: root.bar.foreground fontFamily: root.bar.fontFamily } @@ -1600,7 +1604,7 @@ Panel { DnsProviderPill { provider: "DHCP" index: 0 - tooltipText: "Use DNS from DHCP" + tooltipText: I18n.tr("Use DNS from DHCP") width: dnsRow.cellWidth onClicked: root.setDns(provider) } @@ -1608,7 +1612,7 @@ Panel { DnsProviderPill { provider: "Cloudflare" index: 1 - tooltipText: "Set DNS to Cloudflare" + tooltipText: I18n.tr("Set DNS to %1", "Cloudflare") width: dnsRow.cellWidth onClicked: root.setDns(provider) } @@ -1616,7 +1620,7 @@ Panel { DnsProviderPill { provider: "Google" index: 2 - tooltipText: "Set DNS to Google" + tooltipText: I18n.tr("Set DNS to %1", "Google") width: dnsRow.cellWidth onClicked: root.setDns(provider) } @@ -1624,7 +1628,7 @@ Panel { DnsProviderPill { provider: "Custom" index: 3 - tooltipText: "Set custom DNS servers" + tooltipText: I18n.tr("Set custom DNS servers") width: dnsRow.cellWidth onClicked: root.setDns(provider) } @@ -1640,7 +1644,7 @@ Panel { PanelSectionHeader { visible: root.wifiStationAvailable && root.scanning - text: "SCANNING WI-FI…" + text: I18n.tr("SCANNING WI-FI…") foreground: root.bar.foreground fontFamily: root.bar.fontFamily } @@ -1743,7 +1747,7 @@ Panel { required property string provider required property int index - text: provider + text: I18n.tr(provider) fontSize: Style.font.bodySmall foreground: root.bar.foreground fontFamily: root.bar.fontFamily @@ -1826,12 +1830,12 @@ Panel { readonly property string statusText: { if (!net) return "" if (isPasswordOpen) return "" - if (isBusy && root.actionKind === "connect") return "Connecting…" - if (isBusy && root.actionKind === "disconnect") return "Disconnecting…" - if (isBusy && root.actionKind === "forget") return "Forgetting…" - if (isFailed) return root.failureReason || "Failed" - if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return "Sign-in required" - if (isConnected) return "Connected" + if (isBusy && root.actionKind === "connect") return I18n.tr("Connecting…") + if (isBusy && root.actionKind === "disconnect") return I18n.tr("Disconnecting…") + if (isBusy && root.actionKind === "forget") return I18n.tr("Forgetting…") + if (isFailed) return root.failureReason || I18n.tr("Failed") + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return I18n.tr("Sign-in required") + if (isConnected) return I18n.tr("Connected") return "" } @@ -1948,7 +1952,7 @@ Panel { PanelToolTip { visible: rightMouse.containsMouse || row.forgetFocused - text: "Forget network" + text: I18n.tr("Forget network") fontFamily: root.bar.fontFamily } } @@ -1964,7 +1968,7 @@ Panel { Text { textFormat: Text.PlainText - text: row.net ? (row.net.ssid || "Hidden") : "" + text: row.net ? (row.net.ssid || I18n.tr("Hidden")) : "" color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body @@ -2024,7 +2028,7 @@ Panel { anchors.right: connectPwBtn.left anchors.top: parent.top anchors.rightMargin: Style.space(6) - placeholderText: "Identity (user@domain)" + placeholderText: I18n.tr("Identity (user@domain)") font.family: Style.font.family font.pixelSize: Style.font.body foreground: root.bar.foreground @@ -2050,7 +2054,7 @@ Panel { anchors.bottomMargin: Style.spacing.rowGap / 2 anchors.rightMargin: Style.space(6) password: true - placeholderText: "Passphrase" + placeholderText: I18n.tr("Passphrase") font.family: Style.font.family font.pixelSize: Style.font.body foreground: root.bar.foreground @@ -2083,7 +2087,7 @@ Panel { anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter - text: row.isFailed ? "Wrong password" : "Connecting..." + text: row.isFailed ? I18n.tr("Wrong password") : I18n.tr("Connecting...") color: row.isFailed ? root.bar.urgent : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall @@ -2100,7 +2104,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter enabled: row.net && pwField.text.length > 0 && (!row.isEnterprise || idField.text.length > 0) iconText: "󰄬" - tooltipText: "Connect" + tooltipText: I18n.tr("Connect") foreground: root.bar.foreground fontFamily: root.bar.fontFamily onClicked: row.submitCredentials() @@ -2111,7 +2115,7 @@ Panel { component DetailValue: InfoValue { property bool copyable: false - property string tooltipText: "Copy to clipboard" + property string tooltipText: I18n.tr("Copy to clipboard") Layout.fillWidth: true horizontalAlignment: Text.AlignRight diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 0e60ed7f..77461f39 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -55,7 +55,7 @@ Panel { function modeLabel() { var device = UPower.displayDevice - return Model.modeLabel(device, root.discharging, upowerStates()) + return I18n.tr(Model.modeLabel(device, root.discharging, upowerStates())) } function profileIcon(name) { @@ -95,26 +95,26 @@ Panel { // Cute agent-flavored phrases shown in the hero status line, rotated on a // timer so the panel feels alive when current is flowing (either direction). readonly property var chargingPhrases: [ - "Pumping power", - "Injecting electrons", - "Pouring juice", - "Amassing watts", - "Hoarding joules", - "Sucking volts", - "Topping reserves", - "Soaking amps", - "Inhaling kilowatts" + I18n.tr("Pumping power"), + I18n.tr("Injecting electrons"), + I18n.tr("Pouring juice"), + I18n.tr("Amassing watts"), + I18n.tr("Hoarding joules"), + I18n.tr("Sucking volts"), + I18n.tr("Topping reserves"), + I18n.tr("Soaking amps"), + I18n.tr("Inhaling kilowatts") ] readonly property var onBatteryPhrases: [ - "Slurping power", - "Spending joules", - "Draining watts", - "Burning electrons", - "Sipping juice", - "Spending coulombs", - "Bleeding amps", - "Guzzling volts", - "Munching reserves" + I18n.tr("Slurping power"), + I18n.tr("Spending joules"), + I18n.tr("Draining watts"), + I18n.tr("Burning electrons"), + I18n.tr("Sipping juice"), + I18n.tr("Spending coulombs"), + I18n.tr("Bleeding amps"), + I18n.tr("Guzzling volts"), + I18n.tr("Munching reserves") ] property int phraseIndex: 0 @@ -128,7 +128,7 @@ Panel { readonly property bool rotatingPhrases: activePhrases.length > 0 readonly property string heroStatusText: { - if (fullyCharged) return "Fully charged" + if (fullyCharged) return I18n.tr("Fully charged") if (rotatingPhrases) return activePhrases[phraseIndex % activePhrases.length] return modeLabel() } @@ -346,7 +346,8 @@ Panel { spacing: Style.space(2) Text { - text: "Battery" + textFormat: Text.PlainText + text: I18n.tr("Battery") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title @@ -433,20 +434,20 @@ Panel { Column { width: (parent.width - parent.spacing) / 2 spacing: Style.spacing.labelGap - InfoPair { label: "Battery size"; value: root.batteryInfo.size || "" } - InfoPair { label: "Charge cycles"; value: root.batteryInfo.cycles || "—" } + InfoPair { label: I18n.tr("Battery size"); value: root.batteryInfo.size || "" } + InfoPair { label: I18n.tr("Charge cycles"); value: root.batteryInfo.cycles || "—" } } Column { width: (parent.width - parent.spacing) / 2 spacing: Style.spacing.labelGap InfoPair { - label: root.chargeThresholdActive ? "Charge limit" : (root.discharging ? "Time left" : "Time to full") + label: root.chargeThresholdActive ? I18n.tr("Charge limit") : (root.discharging ? I18n.tr("Time left") : I18n.tr("Time to full")) value: root.chargeThresholdActive ? (root.batteryInfo.threshold || "-") : (root.batteryFlowIdle ? "-" : (root.batteryInfo.time || "—")) } InfoPair { - label: root.chargeThresholdActive ? "Battery state" : (root.discharging ? "Discharging" : "Charging") - value: root.chargeThresholdActive ? "Holding" : (root.batteryFull ? "-" : (root.batteryInfo.rate || "")) + label: root.chargeThresholdActive ? I18n.tr("Battery state") : (root.discharging ? I18n.tr("Discharging") : I18n.tr("Charging")) + value: root.chargeThresholdActive ? I18n.tr("Holding") : (root.batteryFull ? "-" : (root.batteryInfo.rate || "")) } } } @@ -461,7 +462,7 @@ Panel { spacing: Style.space(10) PanelSectionHeader { - text: "POWER PROFILE" + text: I18n.tr("POWER PROFILE") foreground: root.bar.foreground fontFamily: root.bar.fontFamily } @@ -483,7 +484,7 @@ Panel { width: profileRow.cellWidth iconText: root.profileIcon(String(modelData)) iconSize: Style.font.title - text: String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1) + text: I18n.tr(String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1)) fontSize: Style.font.bodySmall foreground: root.bar.foreground fontFamily: root.bar.fontFamily diff --git a/shell/plugins/panels/speedtest/Panel.qml b/shell/plugins/panels/speedtest/Panel.qml index 6805aa05..d41fbd54 100644 --- a/shell/plugins/panels/speedtest/Panel.qml +++ b/shell/plugins/panels/speedtest/Panel.qml @@ -149,7 +149,7 @@ Item { } if (!root.expectedStop && exitCode !== 0) { - root.error = root.stderrText || "Speed test failed" + root.error = root.stderrText || I18n.tr("Speed test failed") root.phase = "" root.running = false return @@ -177,7 +177,7 @@ Item { onStreamFinished: { var fields = String(text || "").trim().split("\t") if (fields[0] === "wifi") root.connectionName = fields[1] || "Wi-Fi" - else if (fields[0] === "ethernet") root.connectionName = "Ethernet" + else if (fields[0] === "ethernet") root.connectionName = I18n.tr("Ethernet") } } } @@ -186,9 +186,9 @@ Item { fontFamily: Style.font.family layerNamespace: "omarchy-network-speedtest" title: root.connectionName - leftLabel: "DOWNLOAD" - rightLabel: "UPLOAD" - runAgainTooltip: "Measure again via fast.com" + leftLabel: I18n.tr("DOWNLOAD") + rightLabel: I18n.tr("UPLOAD") + runAgainTooltip: I18n.tr("Measure again via fast.com") running: root.running leftValue: root.downloadValue rightValue: root.uploadValue diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 21954587..b9f5fb9c 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -25,16 +25,16 @@ Panel { property string mullvadQuery: "" property int phraseIndex: 0 readonly property var activePhrases: [ - "Encrypting connections", - "Sending secrets", - "Guarding wires", - "Braiding packets", - "Polishing tunnels", - "Hiding routes", - "Sealing ports", - "Sorting tailnets", - "Shuffling keys", - "Watching machines" + I18n.tr("Encrypting connections"), + I18n.tr("Sending secrets"), + I18n.tr("Guarding wires"), + I18n.tr("Braiding packets"), + I18n.tr("Polishing tunnels"), + I18n.tr("Hiding routes"), + I18n.tr("Sealing ports"), + I18n.tr("Sorting tailnets"), + I18n.tr("Shuffling keys"), + I18n.tr("Watching machines") ] readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length] @@ -53,7 +53,7 @@ Panel { // "header" stays navigable, but an absent CLI leaves nothing to highlight. readonly property bool headerHasCursor: cursorActive && focusSection === "header" && tailscale.installed readonly property color iconColor: tailscale.active ? foreground : dim - readonly property string toggleHint: tailscale.active ? "Turn Tailscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Tailscale on") + readonly property string toggleHint: tailscale.active ? I18n.tr("Turn Tailscale off") : (tailscale.needsLogin ? I18n.tr("Authorize this device") : I18n.tr("Turn Tailscale on")) readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55) readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Commons.Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Commons.Color.accent) : "transparent" @@ -77,7 +77,7 @@ Panel { var nodes = [] for (var i = 0; i < tailscale.tailnetExitNodes.length; i++) nodes.push(tailscale.tailnetExitNodes[i]) for (var j = 0; j < recentMullvadExitNodes.length; j++) nodes.push(recentMullvadExitNodes[j]) - if (tailscale.mullvadRegions.length > 0) nodes.push({ id: "mullvad:add", AddMullvad: true, DisplayName: "Choose Mullvad region" }) + if (tailscale.mullvadRegions.length > 0) nodes.push({ id: "mullvad:add", AddMullvad: true, DisplayName: I18n.tr("Choose Mullvad region") }) return nodes } @@ -133,7 +133,7 @@ Panel { } function mullvadRegionTitle(peer) { - if (!peer) return "Unknown" + if (!peer) return I18n.tr("Unknown") var city = String(peer.City || "").trim() var country = String(peer.Country || "").trim() if (city === "" || city === "Any") return country || String(peer.DisplayName || "Unknown") @@ -460,7 +460,7 @@ Panel { id: hero width: parent.width title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" - meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected" + meta: tailscale.active ? root.heroPhraseText : I18n.tr("Tailscale is disconnected") foreground: root.foreground fontFamily: root.fontFamily iconOpacity: tailscale.active ? 1.0 : 0.5 @@ -503,7 +503,7 @@ Panel { textFormat: Text.PlainText visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width - text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError + text: I18n.tr(tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError) color: tailscale.lastError !== "" && tailscale.actionStatus === "" ? root.urgent : root.dim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -522,7 +522,8 @@ Panel { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.margins: Style.space(12) - text: "Tailscale CLI is not installed or not on PATH." + textFormat: Text.PlainText + text: I18n.tr("Tailscale CLI is not installed or not on PATH.") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.body @@ -541,7 +542,7 @@ Panel { spacing: Style.space(10) PanelSectionHeader { - text: "CONNECTIONS" + text: I18n.tr("CONNECTIONS") foreground: root.foreground fontFamily: root.fontFamily } @@ -574,7 +575,7 @@ Panel { spacing: Style.space(10) PanelSectionHeader { - text: "EXIT NODES" + text: I18n.tr("EXIT NODES") foreground: root.foreground fontFamily: root.fontFamily } @@ -604,7 +605,7 @@ Panel { id: mullvadSearch width: parent.width foreground: root.foreground - placeholderText: "Search regions" + placeholderText: I18n.tr("Search regions") text: root.mullvadQuery onTextChanged: { root.mullvadQuery = text @@ -640,7 +641,8 @@ Panel { Text { visible: root.filteredMullvadRegions.length === 0 width: parent.width - text: "No Mullvad regions found." + textFormat: Text.PlainText + text: I18n.tr("No Mullvad regions found.") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -678,7 +680,7 @@ Panel { spacing: Style.space(10) PanelSectionHeader { - text: "MACHINES" + text: I18n.tr("MACHINES") foreground: root.foreground fontFamily: root.fontFamily } @@ -686,7 +688,8 @@ Panel { Text { visible: tailscale.installed && tailscale.active && tailscale.peers.length === 0 width: parent.width - text: "No machines found on this tailnet." + textFormat: Text.PlainText + text: I18n.tr("No machines found on this tailnet.") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.body @@ -779,7 +782,8 @@ Panel { Text { Layout.fillWidth: true - text: "Authorize Tailscale operator" + textFormat: Text.PlainText + text: I18n.tr("Authorize Tailscale operator") color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body @@ -788,7 +792,8 @@ Panel { Text { Layout.fillWidth: true - text: "Allow this user to operate this Tailscale profile" + textFormat: Text.PlainText + text: I18n.tr("Allow this user to operate this Tailscale profile") color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -805,7 +810,7 @@ Panel { property int rowIndex: 0 readonly property bool selectedAccount: account && account.selected === true readonly property bool switchingAccount: account && tailscale.switchingAccountId === String(account.id || "") - readonly property string accountText: account ? tailscale.accountLabel(account) : "Account" + readonly property string accountText: account ? tailscale.accountLabel(account) : I18n.tr("Account") hasCursor: root.cursorActive && root.focusSection === "accounts" && root.accountIndex === rowIndex current: selectedAccount @@ -869,7 +874,7 @@ Panel { id: peerRow property var peer: null property int rowIndex: 0 - readonly property string peerName: peer ? String(peer.DisplayName || peer.HostName || "Unknown") : "Unknown" + readonly property string peerName: peer ? String(peer.DisplayName || peer.HostName || I18n.tr("Unknown")) : I18n.tr("Unknown") readonly property string peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : "" readonly property string peerIpv6: { if (!peer || !peer.TailscaleIPv6 || peer.TailscaleIPv6.length === 0) return "" @@ -980,7 +985,7 @@ Panel { id: sendButton visible: tailscale.canSendFiles(peerRow.peer) iconText: "󰒊" - tooltipText: "Send files" + tooltipText: I18n.tr("Send files") foreground: root.foreground fontFamily: root.fontFamily Layout.alignment: Qt.AlignVCenter @@ -1120,8 +1125,8 @@ Panel { readonly property bool addMullvad: peer && peer.AddMullvad === true readonly property bool activeExitNode: peer && peer.ExitNode === true readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "") - readonly property string peerName: peer ? String(peer.DisplayName || peer.HostName || "Unknown") : "Unknown" - readonly property string actionTooltip: addMullvad ? "" : (activeExitNode ? "Disconnect" : "Connect") + readonly property string peerName: peer ? String(peer.DisplayName || peer.HostName || I18n.tr("Unknown")) : I18n.tr("Unknown") + readonly property string actionTooltip: addMullvad ? "" : (activeExitNode ? I18n.tr("Disconnect") : I18n.tr("Connect")) hasCursor: root.cursorActive && root.focusSection === "exitNodes" && root.exitNodeIndex === rowIndex current: activeExitNode || settingExitNode || (addMullvad && root.mullvadPickerOpen) @@ -1201,7 +1206,7 @@ Panel { readonly property bool activeExitNode: peer && peer.ExitNode === true readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "") readonly property bool selectedRegion: root.mullvadPickerOpen && root.mullvadRegionIndex === rowIndex - readonly property string actionTooltip: activeExitNode ? "Disconnect" : "Connect" + readonly property string actionTooltip: activeExitNode ? I18n.tr("Disconnect") : I18n.tr("Connect") foreground: root.foreground fill: root.hoverFill diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index ba02fc19..6aa56013 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -309,7 +309,7 @@ Panel { } function dayName(dateString) { - return Model.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") }) + return Model.dayName(dateString, function(date) { return I18n.chinese ? Qt.locale().toString(date, "dddd") : Qt.formatDate(date, "dddd") }) } // Bare degree value (no unit letter), used in the forecast row. @@ -617,7 +617,7 @@ Panel { id: locationField width: Style.space(190) enabled: !root.savingLocation - placeholderText: "Search city" + placeholderText: I18n.tr("Search city") foreground: root.bar.foreground font.family: root.bar.fontFamily @@ -684,7 +684,8 @@ Panel { Column { spacing: Style.space(5) Text { - text: "FEELS" + textFormat: Text.PlainText + text: I18n.tr("FEELS") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall @@ -702,7 +703,8 @@ Panel { Column { spacing: Style.space(5) Text { - text: "WIND" + textFormat: Text.PlainText + text: I18n.tr("WIND") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall @@ -720,7 +722,8 @@ Panel { Column { spacing: Style.space(5) Text { - text: "HUMID" + textFormat: Text.PlainText + text: I18n.tr("HUMID") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall @@ -793,7 +796,8 @@ Panel { Text { visible: !root.current - text: "Fetching forecast…" + textFormat: Text.PlainText + text: I18n.tr("Fetching forecast…") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/wifiqr/Panel.qml b/shell/plugins/panels/wifiqr/Panel.qml index 1fdf4dec..d67e6dbb 100644 --- a/shell/plugins/panels/wifiqr/Panel.qml +++ b/shell/plugins/panels/wifiqr/Panel.qml @@ -183,7 +183,7 @@ Item { if (exitCode !== 0 || root.qrSize === 0) { root.qrSize = 0 root.qrRows = [] - if (root.error === "") root.error = "Could not generate the Wi-Fi QR code" + if (root.error === "") root.error = I18n.tr("Could not generate the Wi-Fi QR code") } } } @@ -206,7 +206,7 @@ Item { if (root.pwExpectedStop) return if (!root.opened) return if (exitCode === 0 && root.password !== "") root.passwordVisible = true - else root.passwordError = "Could not read the Wi-Fi password" + else root.passwordError = I18n.tr("Could not read the Wi-Fi password") } } @@ -305,7 +305,8 @@ Item { Text { visible: root.loading - text: "Generating QR code…" + textFormat: Text.PlainText + text: I18n.tr("Generating QR code…") color: root.onScrimDim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -328,7 +329,8 @@ Item { Text { visible: root.showingQr - text: "Scan to join this network" + textFormat: Text.PlainText + text: I18n.tr("Scan to join this network") color: root.onScrimDim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -341,7 +343,7 @@ Item { visible: root.showingQr && root.secured text: root.passwordError !== "" ? root.passwordError : root.passwordVisible ? root.password - : "Show password" + : I18n.tr("Show password") color: root.passwordError !== "" ? root.onScrimUrgent : root.onScrim opacity: root.passwordVisible || root.passwordError !== "" ? 1 : 0.6 font.family: root.fontFamily diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index acd6514c..4dbbe10c 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -79,7 +79,7 @@ Item { var flow = polkitAgent.flow if (!flow) return - currentMessage = String(flow.message || "Authentication is needed...") + currentMessage = String(flow.message || I18n.tr("Authentication is needed...")) currentPrompt = String(flow.inputPrompt || "") currentSupplementary = String(flow.supplementaryMessage || "") responseRequired = !!flow.isResponseRequired @@ -337,7 +337,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - text: root.errorFlash ? "Wrong" : (root.submitted ? "Checking..." : "Enter password") + text: root.errorFlash ? I18n.tr("Wrong") : (root.submitted ? I18n.tr("Checking...") : I18n.tr("Enter password")) color: root.errorFlash ? Commons.Color.polkit.textError : root.foreground opacity: root.errorFlash ? 1 : 0.36 font.family: root.fontFamily diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index 48361c61..00509cc6 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -29,7 +29,7 @@ Item { property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2) property int cardWidth: Math.min(Style.space(300), panel.width - Style.gapsOut * 2) property int cardHeight: Math.min(contentMargin * 2 + headerHeight, panel.height - Style.gapsOut * 2) - readonly property string promptText: root.step === "message" ? "Reminder message" : "Remind in minutes" + readonly property string promptText: root.step === "message" ? I18n.tr("Reminder message") : I18n.tr("Remind in minutes") function open(payloadJson) { var payload = ({}) @@ -75,7 +75,7 @@ Item { } if (!nextMinutes) { - Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-notification-send", "Invalid reminder", "Enter the number of minutes"]) + Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-notification-send", I18n.tr("Invalid reminder"), I18n.tr("Enter the number of minutes")]) return } diff --git a/shell/plugins/services/media/Service.qml b/shell/plugins/services/media/Service.qml index 0653627c..f3fe7ea3 100644 --- a/shell/plugins/services/media/Service.qml +++ b/shell/plugins/services/media/Service.qml @@ -411,7 +411,7 @@ Item { handled = true } } else if (action === "playPause") { - actionLabel = player && player.isPlaying ? "Pause" : "Play" + actionLabel = player && player.isPlaying ? I18n.tr("Pause") : I18n.tr("Play") iconName = player && player.isPlaying ? "media-pause" : "media-play" if (player && player.isPlaying && player.canPause) { player.pause() diff --git a/test/shell.d/agent-usage-claude-limits-test.sh b/test/shell.d/agent-usage-claude-limits-test.sh index df39f9d8..5428efe9 100644 --- a/test/shell.d/agent-usage-claude-limits-test.sh +++ b/test/shell.d/agent-usage-claude-limits-test.sh @@ -5,6 +5,12 @@ source "$(dirname "$0")/base-test.sh" require_command jq require_command python3 +# The collector words its diagnostics through omarchy-text. +text_bin=$(mktemp -d) +trap 'rm -rf "$text_bin"' EXIT +ln -s "$ROOT/bin/omarchy-text" "$text_bin/omarchy-text" +export PATH="$text_bin:$PATH" + # probe_limits reaches Anthropic, so the reader that interprets its answer is # exercised on its own: the collector loads as a module, and a recorded payload # stands in for the response. @@ -112,7 +118,7 @@ pass "Claude collector keeps Max multipliers and plain plan names" # is still good. Drive collect_limits over a planted cache with the network # unreachable, so nothing but the credential state decides the answer. CACHE_HOME=$(mktemp -d) -trap 'rm -rf "$CACHE_HOME"' EXIT +trap 'rm -rf "$CACHE_HOME" "$text_bin"' EXIT collect_limits() { COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" TOKEN="$1" EXPIRES_AT="$2" \ @@ -320,10 +326,12 @@ pass "Claude collector caches a successful probe" # The panel reads a window out of a label, and that guess cannot survive a # model name — "Opus 5 (1M context)" parses as a one-minute window. A collector # that states the title outright is taken at its word. -run_node_test <<'JS' +run_node_test <<'JS' || exit 1 const fs = require('fs') const source = fs.readFileSync(root + '/shell/plugins/agents/Panel.qml', 'utf8') const panelRoot = { nowMs: Date.now() } +// The panel's text goes through I18n; English here, with its %1 arguments filled. +const I18n = { tr: (text, ...args) => text.replace(/%(\d)/g, (m, n) => (n > 0 && n <= args.length ? String(args[n - 1]) : m)) } const start = source.indexOf('function windowIsLong') const end = source.indexOf('// The window that decides') assert(start > 0 && end > start, 'agents panel exposes its limit-window helpers') diff --git a/test/shell.d/agents-panel-test.sh b/test/shell.d/agents-panel-test.sh index e79ca340..5449aaff 100755 --- a/test/shell.d/agents-panel-test.sh +++ b/test/shell.d/agents-panel-test.sh @@ -27,11 +27,11 @@ assert(/target\.kind === "signin"\) signInAgain\(/.test(panelSource) && /target\ assert(/target\.kind === "autoswitch"\) setSwitchMode\(/.test(panelSource), 'Enter on Autoswitch flips the switch mode') assert(/keyColumn = use >= 0 \? use : /.test(panelSource), 'moving up or down onto an account lands on Use') assert(/Qt\.callLater\(function\(\) \{ if \(picking\) pointAt\("choice", 0\) \}\)/.test(panelSource), 'picking an agent to add starts with the first one focused') -assert(!/opacity: stale \?/.test(panelSource) && /"Last updated " \+ root\.formatDuration/.test(panelSource), 'stale limits say how old they are only on hover') +assert(!/opacity: stale \?/.test(panelSource) && /I18n\.tr\("Last updated %1 ago", root\.formatDuration/.test(panelSource), 'stale limits say how old they are only on hover') assert(/onPickingChanged: resetKeys\(\)/.test(panelSource), 'the cursor starts over when the agent list comes or goes') assert(!/t === "a" \|\| t === "A"/.test(panelSource), 'adding an account has no hotkey; the + is the way in') assert(/if \(!accounts\[a\]\.active\) \{/.test(panelSource) && /if \(row\.length > 0\) rows\.push\(row\)/.test(panelSource), 'the active account with nothing to fix is not a keyboard stop') -assert(/text: section\.provider \? String\(section\.provider\.authHelpText \|\| section\.provider\.usageStatusText \|\| ""\) : ""/.test(panelSource), 'the trouble line falls back to the usage status when a record has no auth help') +assert(/text: section\.provider \? I18n\.tr\(String\(section\.provider\.authHelpText \|\| section\.provider\.usageStatusText \|\| ""\)\) : ""/.test(panelSource), 'the trouble line falls back to the usage status when a record has no auth help') const mainSource = fs.readFileSync(root + '/shell/plugins/agents/Main.qml', 'utf8') assert(/var from = Math\.min\(0\.8, \(threshold - 15\) \/ 100\)/.test(mainSource), 'faster checks start 15 points below the switch threshold') assert(/rows\.push\(\[\{ kind: "provider", index: p \}\]\)/.test(panelSource), "every agent's header is a keyboard stop") diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh index 5b11f112..1720f630 100755 --- a/test/shell.d/clock-test.sh +++ b/test/shell.d/clock-test.sh @@ -276,7 +276,7 @@ assert(/readonly property int lifeExpectancy: Model\.parseLifeExpectancy\(settin assert(/id: expectancyField/.test(panelSource) && /id: bornField/.test(panelSource), 'calendar offers both inputs') assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(panelSource), 'calendar centers the inputs over the bar they replace') assert(/visible: root\.birthYear > 0/.test(panelSource), 'calendar hides the life bar until a birth year is known') -assert(/text: "LIFE"/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar') +assert(/text: I18n\.tr\("LIFE"\)/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar') assert(/text: "Memento Mori"/.test(panelSource), 'calendar names the life bar on hover') assert(/onDoubleTapped: root\.clearLife\(\)/.test(panelSource), 'calendar puts the life bar away when it is double-tapped') assert(/persistSettings\(\{ birthYear: 0 \}\)/.test(panelSource), 'calendar clears the birth year to hide the life bar') diff --git a/test/shell.d/hyprland-corners-test.sh b/test/shell.d/hyprland-corners-test.sh index 6652c83b..d87bfa3e 100755 --- a/test/shell.d/hyprland-corners-test.sh +++ b/test/shell.d/hyprland-corners-test.sh @@ -40,6 +40,11 @@ run omarchy-hyprland-corners theme [[ ! -e $toggles_dir/corners-sharp.lua ]] || fail "theme corners drop the override" pass "theme corners drop the override" +LANG=zh_CN.UTF-8 run omarchy-hyprland-corners sharp +grep -qx 'omarchy-notification-send -g 󰘇 所有主题使用直角' "$tmpdir/commands.log" || fail "a Chinese session announces the corners in Chinese" "$(<"$tmpdir/commands.log")" +run omarchy-hyprland-corners theme +pass "a Chinese session announces the corners in Chinese" + if run omarchy-hyprland-corners square 2>/dev/null; then fail "unknown corners are refused" fi diff --git a/test/shell.d/language-test.sh b/test/shell.d/language-test.sh new file mode 100755 index 00000000..7dbed49e --- /dev/null +++ b/test/shell.d/language-test.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +root="$test_tmp/root" sandbox="$test_tmp/omarchy" calls="$test_tmp/calls" +mkdir -p "$root/etc" "$sandbox/bin" "$sandbox/shell/Commons" "$test_tmp/bin" +cp "$ROOT/shell/Commons/I18nZh.js" "$sandbox/shell/Commons/" +sed "s|/etc/|$root/etc/|g" "$ROOT/bin/omarchy-language" >"$sandbox/bin/omarchy-language" +for command in locale-gen omarchy-system-logout; do + printf '#!/bin/bash\necho "%s $*" >>"%s"\n' "$command" "$calls" >"$test_tmp/bin/$command" +done +printf '#!/bin/bash\nexec "$@"\n' >"$test_tmp/bin/sudo" +printf '#!/bin/bash\necho "gum $*" >>"%s"\nexit "${GUM_ANSWER:-1}"\n' "$calls" >"$test_tmp/bin/gum" +chmod +x "$test_tmp/bin"/* "$sandbox/bin/omarchy-language" +ln -s "$ROOT/bin/omarchy-text" "$test_tmp/bin/omarchy-text" +export PATH="$test_tmp/bin:$sandbox/bin:$PATH" OMARCHY_PATH="$sandbox" LANG=en_US.UTF-8 + +printf '#zh_CN.UTF-8 UTF-8\nen_US.UTF-8 UTF-8\n' >"$root/etc/locale.gen" +printf 'LANG=en_US.UTF-8\nLC_TIME=en_DK.UTF-8\n' >"$root/etc/locale.conf" + +[[ $(omarchy-language) == "en_US.UTF-8" ]] || fail "omarchy-language prints the system LANG" +pass "omarchy-language prints the system LANG" + +: >"$calls" +output=$(omarchy-language zh) +[[ $(<"$root/etc/locale.gen") == $'zh_CN.UTF-8 UTF-8\nen_US.UTF-8 UTF-8' ]] || fail "Chinese enables zh_CN in locale.gen" +[[ $(<"$root/etc/locale.conf") == $'LANG=zh_CN.UTF-8\nLC_TIME=en_DK.UTF-8' ]] || fail "Chinese sets LANG and keeps other settings" "$(<"$root/etc/locale.conf")" +[[ $(<"$calls") == "locale-gen " ]] || fail "Chinese generates the locale and asks nothing without a terminal" "$(<"$calls")" +[[ $output == "重新登录后切换语言。" ]] || fail "Chinese reminds to log back in, in Chinese" "$output" +pass "switching to Chinese generates zh_CN, sets LANG, and reminds in Chinese" + +sed -i 's/^en_US/#en_US/' "$root/etc/locale.gen" +output=$(omarchy-language en) +[[ $(<"$root/etc/locale.gen") == $'zh_CN.UTF-8 UTF-8\nen_US.UTF-8 UTF-8' ]] || fail "English enables en_US in locale.gen" +[[ $(<"$root/etc/locale.conf") == $'LANG=en_US.UTF-8\nLC_TIME=en_DK.UTF-8' ]] || fail "English sets LANG back" "$(<"$root/etc/locale.conf")" +[[ $output == "Log back in to switch the language." ]] || fail "English reminds in English" "$output" +pass "switching to English generates en_US and sets LANG" + +printf 'LC_TIME=en_DK.UTF-8' >"$root/etc/locale.conf" +omarchy-language zh >/dev/null +[[ $(<"$root/etc/locale.conf") == $'LC_TIME=en_DK.UTF-8\nLANG=zh_CN.UTF-8' ]] || fail "a locale.conf without LANG or a final newline gains LANG on its own line" "$(<"$root/etc/locale.conf")" +: >"$root/etc/locale.conf" +omarchy-language en >/dev/null +[[ $(<"$root/etc/locale.conf") == "LANG=en_US.UTF-8" ]] || fail "an empty locale.conf gains LANG" "$(<"$root/etc/locale.conf")" +pass "a locale.conf without LANG gains one on its own line" + +printf 'LANG="zh_CN.UTF-8"\n' >"$root/etc/locale.conf" +[[ $(omarchy-language) == "zh_CN.UTF-8" ]] || fail "a double-quoted LANG reads unquoted" "$(omarchy-language)" +printf "LANG='en_US.UTF-8'\n" >"$root/etc/locale.conf" +[[ $(omarchy-language) == "en_US.UTF-8" ]] || fail "a single-quoted LANG reads unquoted" "$(omarchy-language)" +printf 'LC_TIME=en_DK.UTF-8\n' >"$root/etc/locale.conf" +[[ -z $(LANG=de_DE.UTF-8 omarchy-language) ]] || fail "a locale.conf without LANG reads empty, not the caller's LANG" +pass "omarchy-language reads quoted values and ignores the caller's LANG" + +before=$(<"$root/etc/locale.conf") +if output=$(omarchy-language fr 2>&1); then + fail "an unknown language is refused" "$output" +fi +[[ $output == "Usage: omarchy-language [en|zh]" && $(<"$root/etc/locale.conf") == "$before" ]] || fail "a refused language changes nothing" "$output" +pass "an unknown language is refused and changes nothing" + +: >"$calls" +GUM_ANSWER=0 script -qec "omarchy-language en" /dev/null >/dev/null +grep -qxF 'gum confirm --affirmative Log out now --negative Later Log out now to switch the language?' "$calls" && + grep -qx 'omarchy-system-logout ' "$calls" || fail "a terminal switch offers to log out and logs out on yes" "$(<"$calls")" +: >"$calls" +GUM_ANSWER=1 script -qec "omarchy-language zh" /dev/null >/dev/null +grep -qxF 'gum confirm --affirmative 立即注销 --negative 稍后 立即注销以切换语言?' "$calls" && ! grep -q '^omarchy-system-logout' "$calls" || + fail "a terminal switch asks in the new language and keeps the session on no" "$(<"$calls")" +pass "a terminal switch offers to log out in the new language" diff --git a/test/shell.d/menu-locale-test.sh b/test/shell.d/menu-locale-test.sh new file mode 100755 index 00000000..0b63ea31 --- /dev/null +++ b/test/shell.d/menu-locale-test.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command node + +node - "$ROOT" <<'JS' +const fs = require('fs') +const root = process.argv[2] +const model = require(`${root}/shell/plugins/menu/MenuModel.js`) +const read = name => model.parseMenuJsonc(fs.readFileSync(`${root}/default/omarchy/${name}`, 'utf8')) +const fail = message => { console.error(`not ok - ${message}`); process.exit(1) } + +const defaults = read('omarchy-menu.jsonc') +const chinese = read('omarchy-menu.zh-cn.jsonc') +const byId = Object.fromEntries(defaults.map(item => [item.id, item])) + +const unknown = chinese.filter(item => !byId[item.id]).map(item => item.id) +if (unknown.length) fail(`every Chinese label names a shipped menu id: ${unknown}`) +const extra = chinese.filter(item => item.declared.some(key => key !== 'label' && key !== 'title')).map(item => item.id) +if (extra.length) fail(`the Chinese overlay only retitles rows: ${extra}`) +console.log('ok - the Chinese overlay only retitles shipped menu rows') + +const user = model.parseMenuJsonc('{"system.lock": {"label": "Mine"}}') +const merged = model.mergeMenuSources(defaults, chinese.concat(user)).items +for (const item of defaults) { + const result = merged[item.id] + const zh = chinese.find(entry => entry.id === item.id) + for (const key of ['action', 'when', 'checked', 'disabled', 'target', 'provider', 'kind', 'icon', 'parent']) { + if (JSON.stringify(result[key]) !== JSON.stringify(item[key])) fail(`${item.id} keeps its ${key} under the Chinese overlay`) + } + if (item.id !== 'system.lock' && zh && zh.declared.includes('label') && result.label !== zh.label) fail(`${item.id} shows its Chinese label`) +} +if (merged['system.lock'].label !== 'Mine') fail('a user extension still overrides the Chinese label') +if (merged['system.reboot'].label !== '重启' || merged['setup.language'].label !== '语言') fail('Chinese labels reach the merged menu') +console.log('ok - Chinese labels replace only the label, and user extensions still win') +JS + +grep -qF 'root.localeMenuItems.concat(root.userMenuItems)' "$ROOT/shell/plugins/menu/Menu.qml" && + grep -qF 'I18n.chinese ? omarchyPath + "/default/omarchy/omarchy-menu.zh-cn.jsonc"' "$ROOT/shell/plugins/menu/Menu.qml" && + grep -qF 'var chinese = Qt.locale().name.indexOf("zh_CN") === 0' "$ROOT/shell/Commons/Tr.js" || + fail "the menu loads the Chinese overlay under zh_CN, beneath the user extension" +pass "the menu loads the Chinese overlay under zh_CN, beneath the user extension" diff --git a/test/shell.d/menu-plugin-test.sh b/test/shell.d/menu-plugin-test.sh index d29403cb..20166f84 100755 --- a/test/shell.d/menu-plugin-test.sh +++ b/test/shell.d/menu-plugin-test.sh @@ -45,6 +45,7 @@ printf 'terminal: %s\n' "$*" >>"$FAKE_CALLS" STUB chmod +x "$STUB_DIR"/* +ln -s "$ROOT/bin/omarchy-text" "$STUB_DIR/omarchy-text" # Runs the picker against a plugin list, answering its prompt with $2. Leaves # the rows it offered in $ROWS and what it called in $CALLS. diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 9ce3c10a..1d1775eb 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -62,9 +62,10 @@ assertDeepEqual( aliases: ['theme'], when: '', checked: '', - disabled: '' + disabled: '', + declared: ['label', 'aliases', 'description', 'action'] }, - 'menu normalizes parsed items' + 'menu normalizes parsed items and records what the file declared' ) const user = [ @@ -76,6 +77,16 @@ assertEqual(merged.items['style.theme'].label, 'Theme picker', 'menu user entrie assertEqual(merged.items['style.theme'].order, 2, 'menu preserves original order on override') assert(merged.items.root, 'menu injects root when merging sources') +const retitled = menu.mergeMenuSources( + menu.parseMenuJsonc('{"apps": {"icon":"A","label":"Apps","provider":"apps"}, "apps.go": {"label":"Go","action":"run-go"}}'), + menu.parseMenuJsonc('{"apps": {"label":"应用"}, "apps.go": {"label":"出发"}}') +) +assertEqual(retitled.items['apps'].provider, 'apps', 'menu keeps the provider when a user entry only retitles') +assertEqual(retitled.items['apps'].icon, 'A', 'menu keeps the icon when a user entry only retitles') +assertEqual(retitled.items['apps.go'].action, 'run-go', 'menu keeps the action when a user entry only retitles') +assertEqual(retitled.items['apps.go'].kind, 'action', 'menu keeps the kind when a user entry only retitles') +assertEqual(retitled.items['apps.go'].label, '出发', 'menu applies the user retitle') + assertEqual(menu.slugify('Power Saver!'), 'power-saver', 'menu slugifies provider rows') assertEqual(menu.pathFor(merged.items, 'style.theme'), 'Style › Theme picker', 'menu builds item paths') assertEqual(menu.parentPathFor(merged.items, 'style.theme'), 'Style', 'menu builds parent paths') @@ -284,6 +295,15 @@ assert( 'menu always exposes every supported browser, terminal, and editor under Defaults' ) assert(!defaultById['install.ai.crush'], 'menu removes Crush from Install > AI') +const languageEntries = defaultItems.filter(item => item.parent === 'setup.language') +assert( + languageEntries.map(item => item.id).join('\0') === 'setup.language.en\0setup.language.zh' + && defaultById['setup.language.en'].checked.includes('== \"en_US.UTF-8\"') + && defaultById['setup.language.zh'].checked.includes('== \"zh_CN.UTF-8\"') + && defaultById['setup.language.en'].action.includes('omarchy-language en') + && defaultById['setup.language.zh'].action.includes('omarchy-language zh'), + 'menu offers English and Simplified Chinese under Setup > Language' +) // Software you already have keeps its place in Install, dimmed rather than // dropped, so the list reads as a catalog of what Omarchy can install. // Chromium Account is the sole Install row with anything left to hide for. The diff --git a/test/shell.d/screenrecording-wf-audio-test.sh b/test/shell.d/screenrecording-wf-audio-test.sh index 3b04969c..8d6380d0 100644 --- a/test/shell.d/screenrecording-wf-audio-test.sh +++ b/test/shell.d/screenrecording-wf-audio-test.sh @@ -41,6 +41,7 @@ echo "notify $*" >>"$TMP/notes" echo "noise on stdout" SH chmod +x "$tmp/bin"/* +ln -s "$ROOT/bin/omarchy-text" "$tmp/bin/omarchy-text" device() { rm -f "$tmp/calls" "$tmp/notes" "$tmp/next" "$tmp/modules" diff --git a/test/shell.d/setup-form-test.sh b/test/shell.d/setup-form-test.sh index 4f6d7f46..f0771092 100755 --- a/test/shell.d/setup-form-test.sh +++ b/test/shell.d/setup-form-test.sh @@ -53,14 +53,17 @@ source "$ROOT/install/provisioning/setup-form.sh" notice() { printf '%s\n' "$1" >>"$NOTICES"; } +language=${FORM_LANGUAGE:-en} + if [[ -n ${TAKEN_USERS:-} ]]; then omarchy_username_taken() { [[ " $TAKEN_USERS " == *" $1 "* ]]; } fi trap 'if [[ ${FUNCNAME[0]:-} == "$PROMPT_FN" ]]; then printf "returned\n" >>"$MARKER"; fi' RETURN -"$PROMPT_FN" +"$PROMPT_FN" ${PROMPT_ARG:+"$PROMPT_ARG"} +printf 'language=%s\n' "${language:-}" printf 'keyboard=%s\n' "${keyboard:-}" printf 'keyboard_label=%s\n' "${keyboard_label:-}" printf 'keyboard_input_method=%s\n' "${keyboard_input_method:-}" @@ -125,6 +128,37 @@ source "$ROOT/install/provisioning/setup-form.sh" fail "English (US) leads the keyboard layouts so gum choose opens on the default" pass "the form publishes the 0/1/130 status contract and leads with English (US)" +# Language + +run_prompt omarchy_prompt_language "0:Chinese" +assert_status 0 "language prompt succeeds" +[[ $(field language) == "zh" ]] || fail "language prompt stores Chinese as zh" +[[ $(<"$tmp_dir/stdin.1") == $'English\nChinese' ]] || fail "language prompt offers English then Chinese" +grep -qxF -- 'choose --selected English --header Select language' "$GUM_ARGS" || fail "language prompt preselects English on a fresh form" +FORM_LANGUAGE=zh run_prompt omarchy_prompt_language "0:English" +[[ $(field language) == "en" ]] || fail "language prompt stores English as en" +grep -qxF -- 'choose --selected Chinese --header Select language' "$GUM_ARGS" || fail "language prompt preselects the language chosen on an earlier visit" +pass "language prompt maps English and Chinese and preselects the current language" + +run_prompt omarchy_prompt_language "1:" +assert_status "$OMARCHY_FORM_BACK" "language prompt reports Esc as back" +assert_returned "language prompt survives Esc under set -e" + +run_prompt omarchy_prompt_language "130:" +assert_status "$OMARCHY_FORM_SIGNAL" "language prompt reports Ctrl+C as the caller's signal" +assert_returned "language prompt survives Ctrl+C under set -e" +pass "language prompt propagates Esc and Ctrl+C without dying under set -e" + +OMARCHY_CJK_CONSOLE=1 run_prompt omarchy_prompt_language "0:简体中文 Chinese" +assert_status 0 "language prompt succeeds in a CJK console" +[[ $(field language) == "zh" ]] || fail "language prompt stores 简体中文 as zh" +[[ $(<"$tmp_dir/stdin.1") == $'English\n简体中文 Chinese' ]] || fail "a CJK console offers the Chinese name" +grep -qxF -- 'choose --selected English --header Select language · 选择语言' "$GUM_ARGS" || fail "a CJK console asks in both languages" +OMARCHY_CJK_CONSOLE=1 FORM_LANGUAGE=zh run_prompt omarchy_prompt_language "0:English" +[[ $(field language) == "en" ]] || fail "language prompt stores English as en in a CJK console" +grep -qxF -- 'choose --selected 简体中文 Chinese --header Select language · 选择语言' "$GUM_ARGS" || fail "a CJK console preselects Chinese by its shown name" +pass "a CJK console names Chinese in Chinese and maps it back to zh" + # Keyboard run_prompt omarchy_prompt_keyboard "0:German" @@ -135,6 +169,11 @@ assert_status 0 "keyboard prompt succeeds" grep -qF -- '--selected English (US)' "$GUM_ARGS" || fail "keyboard prompt preselects English (US)" pass "keyboard prompt maps the chosen label to its keymap" +PROMPT_ARG="Chinese (Simplified, Pinyin)" run_prompt omarchy_prompt_keyboard "0:Chinese (Simplified, Pinyin)" +grep -qF -- '--selected Chinese (Simplified\, Pinyin)' "$GUM_ARGS" && [[ $(field keyboard_input_method) == "pinyin" ]] || + fail "keyboard prompt preselects the layout its caller names, escaping the comma gum splits --selected on" +pass "keyboard prompt preselects the layout its caller names" + for selection in 'Japanese|jp106|mozc|' 'Japanese (US keyboard)|us|mozc|' 'Korean|us|hangul|kr' 'Lao|us|none|la' 'Chinese (Simplified, Pinyin)|us|pinyin|' 'Chinese (Traditional, Zhuyin)|us|chewing|'; do IFS='|' read -r label keymap method layout <<< "$selection" run_prompt omarchy_prompt_keyboard "0:$label" @@ -224,6 +263,21 @@ assert_status "$OMARCHY_FORM_BACK" "hostname prompt reports Esc as back" assert_returned "hostname prompt survives Esc under set -e" pass "hostname prompt propagates Esc without dying under set -e" +# Chinese text + +OMARCHY_INSTALL_LANG=zh run_prompt omarchy_prompt_username "0:Bad Name" "0:dhh" +assert_status 0 "username prompt succeeds in Chinese" +grep -qF -- '--prompt 用户名> ' "$GUM_ARGS" || fail "OMARCHY_INSTALL_LANG=zh asks for the username in Chinese" +assert_notices "OMARCHY_INSTALL_LANG=zh explains a bad username in Chinese" "用户名只能用字母和数字,不含空格" +run_prompt omarchy_prompt_username "0:dhh" +grep -qF -- '--prompt Username> ' "$GUM_ARGS" || fail "the form stays English without OMARCHY_INSTALL_LANG" +mapfile -t texts < <(grep -o 'omarchy_text "[^"]*"' "$ROOT/install/provisioning/setup-form.sh" | sed 's/^omarchy_text "//; s/"$//') +((${#texts[@]} > 15)) || fail "the form routes its text through omarchy_text" +for text in "${texts[@]}"; do + [[ -n ${OMARCHY_ZH[$text]:-} ]] || fail "every form text has a Chinese entry" "missing: $text" +done +pass "OMARCHY_INSTALL_LANG=zh puts every form text in Chinese" + # Timezone TZ_GUESS=Europe/Copenhagen run_prompt omarchy_prompt_timezone "0:Europe/Copenhagen" diff --git a/test/shell.d/shell-i18n-test.sh b/test/shell.d/shell-i18n-test.sh new file mode 100644 index 00000000..668022d1 --- /dev/null +++ b/test/shell.d/shell-i18n-test.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const vm = require('vm') + +const source = fs.readFileSync(path.join(root, 'shell/Commons/I18nZh.js'), 'utf8').replace('.pragma library', '') +const context = {} +vm.runInNewContext(source + '\nthis.table = table', context) +const table = context.table + +const files = [] +const walk = dir => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (/\.(qml|js)$/.test(entry.name)) files.push(full) + } +} +walk(path.join(root, 'shell')) + +const missing = [] +let calls = 0 +for (const file of files) { + const text = fs.readFileSync(file, 'utf8') + for (const list of text.matchAll(/I18n\.trMatch\([^\[]*\[([^\]]*)\]/g)) { + for (const item of list[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)) { + const key = JSON.parse('"' + item[1] + '"') + if (!(key in table)) missing.push(`${path.relative(root, file)}: ${key}`) + } + } + for (const match of text.matchAll(/I18n\.tr\(\s*"((?:[^"\\]|\\.)*)"/g)) { + calls++ + const key = JSON.parse('"' + match[1] + '"') + if (!(key in table)) missing.push(`${path.relative(root, file)}: ${key}`) + } +} +assert(calls > 300, 'the shell routes its text through I18n.tr', `found ${calls} calls`) + +// Senders translate messages that carry a name or number with omarchy-text, or text() in Python. +const senders = [] +for (const dir of ['bin', 'install/helpers', 'default/input-methods']) +for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) { + if (!entry.isFile()) continue + const name = path.join(dir, entry.name) + const text = fs.readFileSync(path.join(root, name), 'utf8') + for (const match of text.matchAll(/(?:omarchy-text\s+|\btext\(\s*)"((?:[^"\\]|\\.)*)"/g)) { + if (match[1].includes('$')) continue + senders.push([name, JSON.parse('"' + match[1] + '"')]) + } +} +assert(senders.length > 40, 'senders translate their parameterized messages', `found ${senders.length}`) +const unsent = senders.filter(([, key]) => !(key in table)).map(([file, key]) => `${file}: ${key}`) +assert(unsent.length === 0, 'every sender message has a Chinese entry', unsent.join('\n')) +assert(missing.length === 0, 'every I18n.tr text has a Chinese entry', missing.join('\n')) + +const placeholders = s => (s.match(/%\d/g) || []).sort().join(',') +const mismatched = Object.keys(table).filter(key => placeholders(key) !== placeholders(table[key])) +assert(mismatched.length === 0, 'every Chinese entry keeps its English placeholders', mismatched.join('\n')) +JS + +text=$ROOT/bin/omarchy-text +[[ $(OMARCHY_PATH=$ROOT LANG=zh_CN.UTF-8 "$text" "Sent to %1" "phone %2") == "已发送给 phone %2" ]] || + fail "omarchy-text translates and fills %1 in one pass" +[[ $(OMARCHY_PATH=$ROOT LANG=en_US.UTF-8 "$text" "Battery is down to %1%" 9) == "Battery is down to 9%" ]] || + fail "omarchy-text keeps English and a literal % outside zh_CN" +[[ $(OMARCHY_PATH=$ROOT LANG=zh_CN.UTF-8 "$text" "Unknown 50% of %1" x) == "Unknown 50% of x" ]] || + fail "omarchy-text passes an unknown message through" +pass "omarchy-text translates by exact key and fills arguments in one pass" diff --git a/test/shell.d/sleep-lock-test.sh b/test/shell.d/sleep-lock-test.sh index 519f2350..70c02cb6 100755 --- a/test/shell.d/sleep-lock-test.sh +++ b/test/shell.d/sleep-lock-test.sh @@ -18,6 +18,7 @@ setup_scenario() { notify_log="$scenario_dir/notifications" journal_log="$scenario_dir/journal" mkdir -p "$mock_bin" "$state_dir" + ln -s "$ROOT/bin/omarchy-text" "$mock_bin/omarchy-text" : >"$notify_log" : >"$journal_log" diff --git a/test/shell.d/system-power-test.sh b/test/shell.d/system-power-test.sh index baad2b80..bfb5b145 100755 --- a/test/shell.d/system-power-test.sh +++ b/test/shell.d/system-power-test.sh @@ -74,6 +74,7 @@ esac SH done chmod +x "$mock_bin"/* +ln -s "$ROOT/bin/omarchy-text" "$mock_bin/omarchy-text" run_power_command() { local action="$1" diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh index 93ec4da3..493303d6 100644 --- a/test/shell.d/tailscale-receive-test.sh +++ b/test/shell.d/tailscale-receive-test.sh @@ -28,6 +28,7 @@ printf '%s\n' "\$*" >>"$WORKDIR/notifications" SH chmod +x "$WORKDIR/bin/"* +ln -s "$ROOT/bin/omarchy-text" "$WORKDIR/bin/omarchy-text" receive() { local expected="$1" diff --git a/test/shell.d/typing-setup-checks.py b/test/shell.d/typing-setup-checks.py index dea81e00..54648f51 100644 --- a/test/shell.d/typing-setup-checks.py +++ b/test/shell.d/typing-setup-checks.py @@ -74,7 +74,8 @@ class TypingSetupTest(unittest.TestCase): def test_an_uninstalled_engine_names_its_package_without_restarting(self): absent = subprocess.CompletedProcess([], 1) - with patch.object(typing.setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])), patch.object(typing.setup, "available_methods", return_value=[]), patch.object(typing.subprocess, "run", return_value=absent), patch.object(typing.setup, "run") as run, patch.object(typing, "set_inputs") as save: + english = lambda text, *args: text.replace("%1", str(args[0]) if args else "").replace("%2", str(args[1]) if len(args) > 1 else "") + with patch.object(typing.setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])), patch.object(typing.setup, "available_methods", return_value=[]), patch.object(typing.subprocess, "run", return_value=absent), patch.object(typing, "text", side_effect=english), patch.object(typing.setup, "run") as run, patch.object(typing, "set_inputs") as save: with self.assertRaisesRegex(RuntimeError, "Install fcitx5-mozc to use Japanese"): typing.save_inputs(["mozc"]) run.assert_not_called() diff --git a/test/shell.d/typing-setup-test.sh b/test/shell.d/typing-setup-test.sh index 91034375..631eff21 100644 --- a/test/shell.d/typing-setup-test.sh +++ b/test/shell.d/typing-setup-test.sh @@ -3,7 +3,11 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" -OMARCHY_PATH="$ROOT" python "$ROOT/test/shell.d/typing-setup-checks.py" +PATH="$ROOT/bin:$PATH" OMARCHY_PATH="$ROOT" python "$ROOT/test/shell.d/typing-setup-checks.py" + +zh=$(cd "$ROOT/default/input-methods" && PATH="$ROOT/bin:$PATH" OMARCHY_PATH="$ROOT" LANG=zh_CN.UTF-8 python -c 'import typing_setup; print(typing_setup.text("Install %1 to use %2", "fcitx5-mozc", "Mozc"))') +[[ $zh == "安装 fcitx5-mozc 后才能使用 Mozc" ]] || fail "a Chinese session words the input setup errors in Chinese" "$zh" +pass "a Chinese session words the input setup errors in Chinese" run_node_test <<'JS' const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')