feat(windows): localize the Windows tray into Simplified Chinese (#1608)

Localizes the Windows tray menu, tooltip, usage row, dock menu and footer into Simplified Chinese, reusing the macOS menubar glossary. Refs #1336, #1646.
This commit is contained in:
Matt Van Horn 2026-10-07 11:10:56 -07:00 • committed by GitHub
parent f3f6c4c3b0
commit ec2822a147
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 863 additions and 60 deletions

View file

@ -0,0 +1,33 @@
{
"%@ · no usage yet": "%@ · no usage yet",
"%1$lld of %2$lld devices": "%1$lld of %2$lld devices",
"%lld calls": "%lld calls",
"1 call": "1 call",
"6 Months": "6 Months",
"About CodeBurn": "About CodeBurn",
"Bottom": "Bottom",
"Capacity Dock Settings…": "Capacity Dock Settings…",
"Check for Updates": "Check for Updates",
"CodeBurn %1$@ · %2$lld of %3$lld devices reporting": "CodeBurn · %1$@ · %2$lld of %3$lld devices reporting",
"Dock to Edge": "Dock to Edge",
"Hide Capacity Dock": "Hide Capacity Dock",
"Language": "Language",
"Left": "Left",
"Month": "Month",
"Open CodeBurn": "Open CodeBurn",
"Open Full Report": "Open Full Report",
"Quit CodeBurn": "Quit CodeBurn",
"Refresh": "Refresh",
"Right": "Right",
"Settings…": "Settings…",
"Show Capacity Dock": "Show Capacity Dock",
"Switch to Dark Theme": "Switch to Dark Theme",
"Switch to Light Theme": "Switch to Light Theme",
"Switch to System Theme": "Switch to System Theme",
"System": "System",
"Today": "Today",
"Today · %1$@ · %2$@": "Today · %1$@ · %2$@",
"Today · no usage yet": "Today · no usage yet",
"Top": "Top",
"Week": "Week"
}

View file

@ -0,0 +1,33 @@
{
"%@ · no usage yet": "%@ · 暂无用量",
"%1$lld of %2$lld devices": "%1$lld/%2$lld 台设备",
"%lld calls": "%lld 次调用",
"1 call": "1 次调用",
"6 Months": "6 个月",
"About CodeBurn": "关于 CodeBurn",
"Bottom": "底部",
"Capacity Dock Settings…": "容量 Dock 设置…",
"Check for Updates": "检查更新",
"CodeBurn %1$@ · %2$lld of %3$lld devices reporting": "CodeBurn %1$@ · %2$lld/%3$lld 台设备已上报",
"Dock to Edge": "停靠到边缘",
"Hide Capacity Dock": "隐藏容量 Dock",
"Language": "语言",
"Left": "左侧",
"Month": "本月",
"Open CodeBurn": "打开 CodeBurn",
"Open Full Report": "打开完整报告",
"Quit CodeBurn": "退出 CodeBurn",
"Refresh": "刷新",
"Right": "右侧",
"Settings…": "设置…",
"Show Capacity Dock": "显示容量 Dock",
"Switch to Dark Theme": "切换到深色主题",
"Switch to Light Theme": "切换到浅色主题",
"Switch to System Theme": "切换到系统主题",
"System": "跟随系统",
"Today": "今天",
"Today · %1$@ · %2$@": "今天 · %1$@ · %2$@",
"Today · no usage yet": "今天 · 暂无用量",
"Top": "顶部",
"Week": "本周"
}

View file

@ -1601,13 +1601,14 @@ pub fn popup_context_menu(app: &AppHandle) -> tauri::Result<()> {
let Some(window) = app.get_webview_window(DOCK_LABEL) else {
return Ok(());
};
let refresh = MenuItem::with_id(app, "dock_refresh", "Refresh", true, None::<&str>)?;
let left = MenuItem::with_id(app, "dock_left", "Left", true, None::<&str>)?;
let right = MenuItem::with_id(app, "dock_right", "Right", true, None::<&str>)?;
let top = MenuItem::with_id(app, "dock_top", "Top", true, None::<&str>)?;
let bottom = MenuItem::with_id(app, "dock_bottom", "Bottom", true, None::<&str>)?;
let edges = Submenu::with_items(app, "Dock to Edge", true, &[&left, &right, &top, &bottom])?;
let hide = MenuItem::with_id(app, "dock_hide", "Hide Capacity Dock", true, None::<&str>)?;
let label = |key: &str| crate::i18n::lookup(key);
let refresh = MenuItem::with_id(app, "dock_refresh", label("Refresh"), true, None::<&str>)?;
let left = MenuItem::with_id(app, "dock_left", label("Left"), true, None::<&str>)?;
let right = MenuItem::with_id(app, "dock_right", label("Right"), true, None::<&str>)?;
let top = MenuItem::with_id(app, "dock_top", label("Top"), true, None::<&str>)?;
let bottom = MenuItem::with_id(app, "dock_bottom", label("Bottom"), true, None::<&str>)?;
let edges = Submenu::with_items(app, label("Dock to Edge"), true, &[&left, &right, &top, &bottom])?;
let hide = MenuItem::with_id(app, "dock_hide", label("Hide Capacity Dock"), true, None::<&str>)?;
let menu = Menu::with_items(app, &[&refresh, &edges, &hide])?;
menu.popup(window.as_ref().window())
}
@ -2098,6 +2099,14 @@ mod tests {
eprintln!("skipping cross-process test: worker not found at {}", worker.display());
return;
}
// `node --import tsx` resolves the loader from the repo root. Without
// `npm install`, node still spawns and then exits with ERR_MODULE_NOT_FOUND.
// That is a missing harness, same as a missing node binary, not a lost update.
let tsx = repo.join("node_modules").join("tsx");
if !tsx.exists() {
eprintln!("skipping cross-process test: tsx not found at {}", tsx.display());
return;
}
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let root = std::env::temp_dir().join(format!("cb-xproc-{}-{nanos}", std::process::id()));

View file

@ -0,0 +1,475 @@
//! One lookup for the tray menu and the webview that fills it.
//!
//! Keys are the English sentence, the same contract as `Localization.swift`. A missing
//! zh-Hans entry falls back to that sentence. The catalogs are embedded so the MSI does
//! not need the mac bundle beside it.
//!
//! `system` follows the Windows UI language list (`GetUserPreferredUILanguages`), not
//! `GetUserDefaultLocaleName`, which telemetry already uses for a country code. Only
//! Simplified Chinese is shipped: `zh-Hans`, `zh-CN` and `zh-SG` select zh-Hans, and
//! `zh-Hant` / `zh-TW` / `zh-HK` stay on English.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde_json::Value;
const EN_JSON: &str = include_str!("../locales/en.json");
const ZH_HANS_JSON: &str = include_str!("../locales/zh-Hans.json");
struct Catalogs {
en: BTreeMap<String, String>,
zh_hans: BTreeMap<String, String>,
}
fn catalogs() -> &'static Catalogs {
static CATALOGS: OnceLock<Catalogs> = OnceLock::new();
CATALOGS.get_or_init(|| Catalogs {
en: parse_catalog(EN_JSON, "en"),
zh_hans: parse_catalog(ZH_HANS_JSON, "zh-Hans"),
})
}
fn parse_catalog(raw: &str, name: &str) -> BTreeMap<String, String> {
serde_json::from_str(raw)
.unwrap_or_else(|err| panic!("{name} locale catalog is not a string map: {err}"))
}
fn catalog_for(locale: &str) -> &'static BTreeMap<String, String> {
let catalogs = catalogs();
if locale == "zh-Hans" {
&catalogs.zh_hans
} else {
&catalogs.en
}
}
/// The sentence for `key` in the resolved language. An unknown key comes back unchanged,
/// which is also what a missing translation does.
pub fn lookup(key: &str) -> String {
translate(&resolved_language(), key)
}
fn translate(locale: &str, key: &str) -> String {
catalog_for(locale)
.get(key)
.cloned()
.unwrap_or_else(|| key.to_owned())
}
/// The map the webview caches. Substitution stays here; the page only looks sentences up.
pub fn active_catalog() -> BTreeMap<String, String> {
catalog_for(&resolved_language()).clone()
}
/// Localized `key`, with `%@`, `%lld`, `%1$@` and `%%` filled from `args`.
pub fn format_message(key: &str, args: &[Value]) -> String {
fill(&lookup(key), args)
}
/// Shared config preference; only a missing language follows the system.
pub fn normalize_preference(value: Option<&str>) -> &'static str {
match value {
Some("zh-CN") => "zh-Hans",
Some(_) => "en",
None => "system",
}
}
/// `en` and `zh-Hans` win over the machine. `system` (and any unknown preference) follows
/// the first UI language. The list is an argument so tests never call Win32.
pub fn resolve_language<'a>(
preference: &str,
ui_languages: impl IntoIterator<Item = &'a str>,
) -> &'static str {
match preference {
"en" => "en",
"zh-Hans" => "zh-Hans",
_ => match first_ui_language(ui_languages) {
Some(tag) if is_simplified_chinese(tag) => "zh-Hans",
_ => "en",
},
}
}
fn first_ui_language<'a>(ui_languages: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
ui_languages
.into_iter()
.map(str::trim)
.find(|tag| !tag.is_empty())
}
fn is_simplified_chinese(tag: &str) -> bool {
let head = tag.split(['.', '@']).next().unwrap_or(tag);
let normalized = head.replace('_', "-").to_ascii_lowercase();
language_is(&normalized, "zh-hans")
|| language_is(&normalized, "zh-cn")
|| language_is(&normalized, "zh-sg")
}
fn language_is(tag: &str, want: &str) -> bool {
tag == want || tag.starts_with(&format!("{want}-"))
}
fn language_preference() -> &'static str {
let stored = crate::config::read();
normalize_preference(stored.get("language").and_then(Value::as_str))
}
fn resolved_language() -> String {
#[cfg(test)]
if let Some(locale) = forced_locale() {
return locale.to_owned();
}
let languages = host_ui_languages();
resolve_language(language_preference(), languages.iter().map(String::as_str)).to_owned()
}
#[derive(Clone, Copy)]
enum TokenKind {
Percent,
Text,
Integer,
}
struct Token {
len: usize,
kind: TokenKind,
/// Set when the placeholder names its argument (`%1$@`, `%2$lld`).
index: Option<usize>,
}
fn take_token(source: &str) -> Option<Token> {
if source.starts_with("%%") {
return Some(Token {
len: 2,
kind: TokenKind::Percent,
index: None,
});
}
let after = source.get(1..)?;
let (head, index) = positional_prefix(after);
let rest = after.get(head..)?;
if let Some(kind) = token_kind(rest) {
let kind_len = match kind {
TokenKind::Integer => 3,
TokenKind::Text => 1,
TokenKind::Percent => return None,
};
return Some(Token {
len: 1 + head + kind_len,
kind,
index,
});
}
None
}
fn positional_prefix(after: &str) -> (usize, Option<usize>) {
let digits = after
.bytes()
.take_while(|byte| byte.is_ascii_digit())
.count();
if digits > 0 && after[digits..].starts_with('$') {
let index = after[..digits]
.parse::<usize>()
.ok()
.filter(|index| *index > 0);
(digits + 1, index.map(|index| index - 1))
} else {
(0, None)
}
}
fn token_kind(rest: &str) -> Option<TokenKind> {
if rest.starts_with("lld") {
Some(TokenKind::Integer)
} else if rest.starts_with('@') {
Some(TokenKind::Text)
} else {
None
}
}
#[cfg(test)]
fn placeholder_tokens(text: &str) -> Vec<&str> {
let mut tokens = Vec::new();
let mut rest = text;
while let Some(pos) = rest.find('%') {
rest = &rest[pos..];
match take_token(rest) {
Some(token) => {
tokens.push(&rest[..token.len]);
rest = &rest[token.len..];
}
None => rest = &rest[1..],
}
}
tokens
}
fn fill(template: &str, args: &[Value]) -> String {
let mut out = String::new();
let mut sequential = 0usize;
let mut rest = template;
while let Some(pos) = rest.find('%') {
out.push_str(&rest[..pos]);
rest = &rest[pos..];
match take_token(rest) {
Some(token) => {
match token.kind {
TokenKind::Percent => out.push('%'),
TokenKind::Text | TokenKind::Integer => {
let index = token.index.unwrap_or_else(|| {
let current = sequential;
sequential += 1;
current
});
out.push_str(&render_arg(token.kind, args.get(index)));
}
}
rest = &rest[token.len..];
}
None => {
out.push('%');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
fn render_arg(kind: TokenKind, value: Option<&Value>) -> String {
let Some(value) = value else {
return String::new();
};
match kind {
TokenKind::Integer => integer_text(value),
TokenKind::Text => match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
other => other.to_string().trim_matches('"').to_owned(),
},
TokenKind::Percent => "%".to_owned(),
}
}
fn integer_text(value: &Value) -> String {
if let Some(number) = value.as_i64() {
return number.to_string();
}
if let Some(number) = value.as_u64() {
return number.to_string();
}
if let Some(number) = value.as_f64() {
if number.is_finite() && number.fract() == 0.0 {
return format!("{}", number as i64);
}
return number.to_string();
}
value.as_str().unwrap_or("").to_owned()
}
#[cfg(target_os = "windows")]
fn host_ui_languages() -> Vec<String> {
use windows_sys::Win32::Globalization::{GetUserPreferredUILanguages, MUI_LANGUAGE_NAME};
let mut count = 0u32;
let mut needed = 0u32;
// A null buffer asks for the size. `needed` stays 0 when the list cannot be read.
let _probe = unsafe {
GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME,
&mut count,
std::ptr::null_mut(),
&mut needed,
)
};
if needed == 0 {
return Vec::new();
}
let mut buffer = vec![0u16; needed as usize];
let ok = unsafe {
GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME,
&mut count,
buffer.as_mut_ptr(),
&mut needed,
)
};
if ok == 0 {
return Vec::new();
}
let len = (needed as usize).min(buffer.len());
decode_ui_languages(&buffer[..len])
}
#[cfg(not(target_os = "windows"))]
fn host_ui_languages() -> Vec<String> {
// Same order as `telemetry::user_country`: the first non-empty locale wins, so
// `cargo test` resolves `system` without Win32.
["LC_ALL", "LC_MESSAGES", "LANG"]
.into_iter()
.find_map(|key| std::env::var(key).ok())
.filter(|locale| !locale.is_empty())
.into_iter()
.collect()
}
#[cfg(target_os = "windows")]
fn decode_ui_languages(units: &[u16]) -> Vec<String> {
let mut languages = Vec::new();
let mut start = 0;
for (index, unit) in units.iter().enumerate() {
if *unit != 0 {
continue;
}
if index == start {
break;
}
languages.push(String::from_utf16_lossy(&units[start..index]));
start = index + 1;
}
languages
}
#[cfg(test)]
thread_local! {
static FORCED_LOCALE: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
}
#[cfg(test)]
fn forced_locale() -> Option<&'static str> {
FORCED_LOCALE.with(|cell| cell.get())
}
/// Pins `lookup` for one test. Dropping it restores the real preference and UI list.
#[cfg(test)]
pub(crate) struct LocaleLock;
#[cfg(test)]
impl LocaleLock {
pub(crate) fn acquire(locale: &'static str) -> Self {
FORCED_LOCALE.with(|cell| cell.set(Some(locale)));
Self
}
}
#[cfg(test)]
impl Drop for LocaleLock {
fn drop(&mut self) {
FORCED_LOCALE.with(|cell| cell.set(None));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalogs_share_every_key_and_placeholder() {
let en = &catalogs().en;
let zh = &catalogs().zh_hans;
assert_eq!(
en.keys().cloned().collect::<Vec<_>>(),
zh.keys().cloned().collect::<Vec<_>>(),
"en and zh-Hans must list the same keys"
);
for (key, english) in en {
let translated = zh
.get(key)
.unwrap_or_else(|| panic!("zh-Hans is missing {key}"));
assert_eq!(
placeholder_tokens(english),
placeholder_tokens(translated),
"{key}"
);
}
}
#[test]
fn placeholder_order_covers_escaped_percent_and_positional_tokens() {
assert_eq!(
placeholder_tokens("100%% · %1$@ · %lld · %2$lld · %@"),
vec!["%%", "%1$@", "%lld", "%2$lld", "%@"]
);
}
#[test]
fn lookup_under_zh_hans_uses_the_glossary_and_returns_an_unknown_key() {
let _lock = LocaleLock::acquire("zh-Hans");
assert_eq!(lookup("Quit CodeBurn"), "退出 CodeBurn");
assert_eq!(lookup("System"), "跟随系统");
assert_eq!(lookup("not a catalog key"), "not a catalog key");
}
#[test]
fn an_explicit_language_beats_the_ui_list_and_system_follows_it() {
assert_eq!(resolve_language("en", ["zh-CN", "en-US"]), "en");
assert_eq!(resolve_language("zh-Hans", ["en-US"]), "zh-Hans");
assert_eq!(resolve_language("system", ["zh-CN"]), "zh-Hans");
assert_eq!(resolve_language("system", ["zh-Hans"]), "zh-Hans");
assert_eq!(resolve_language("system", ["zh-SG"]), "zh-Hans");
assert_eq!(resolve_language("system", ["zh_CN.UTF-8"]), "zh-Hans");
assert_eq!(resolve_language("system", ["fr-FR"]), "en");
assert_eq!(resolve_language("system", ["zh-TW"]), "en");
assert_eq!(resolve_language("system", ["zh-HK"]), "en");
assert_eq!(resolve_language("system", ["zh-Hant"]), "en");
assert_eq!(resolve_language("system", std::iter::empty()), "en");
}
#[test]
fn shared_config_language_maps_zh_cn_and_defaults_only_when_missing() {
assert_eq!(normalize_preference(Some("zh-Hans")), "en");
assert_eq!(normalize_preference(Some("en")), "en");
assert_eq!(normalize_preference(Some("garbage")), "en");
assert_eq!(normalize_preference(Some("zh-CN")), "zh-Hans");
assert_eq!(normalize_preference(Some("zh-TW")), "en");
assert_eq!(normalize_preference(None), "system");
}
#[test]
fn english_shortfall_tooltip_keeps_the_separator_after_codeburn() {
let _lock = LocaleLock::acquire("en");
assert_eq!(
format_message(
"CodeBurn %1$@ · %2$lld of %3$lld devices reporting",
&[Value::from("$4"), Value::from(1), Value::from(2)]
),
"CodeBurn · $4 · 1 of 2 devices reporting"
);
}
#[test]
fn format_fills_calls_today_and_the_device_shortfall() {
let _lock = LocaleLock::acquire("zh-Hans");
assert_eq!(
format_message("%@ · no usage yet", &[Value::from("本周")]),
"本周 · 暂无用量"
);
assert_eq!(
format_message(
"%1$lld of %2$lld devices",
&[Value::from(1), Value::from(2)]
),
"1/2 台设备"
);
assert_eq!(format_message("1 call", &[]), "1 次调用");
assert_eq!(format_message("%lld calls", &[Value::from(3)]), "3 次调用");
assert_eq!(
format_message(
"Today · %1$@ · %2$@",
&[Value::from("$1.0"), Value::from("1 次调用")]
),
"今天 · $1.0 · 1 次调用"
);
assert_eq!(
format_message(
"CodeBurn %1$@ · %2$lld of %3$lld devices reporting",
&[Value::from("$4"), Value::from(1), Value::from(2)]
),
"CodeBurn $4 · 1/2 台设备已上报"
);
}
}

View file

@ -8,6 +8,7 @@ mod config;
mod dock;
mod fx;
mod glance;
mod i18n;
mod plan;
mod refresh;
mod session;
@ -45,6 +46,22 @@ static DOCK_MENU_ITEM: std::sync::OnceLock<CheckMenuItem<tauri::Wry>> = std::syn
static USAGE_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static THEME_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static OPEN_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static REFRESH_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static SETTINGS_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static DOCK_SETTINGS_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static REPORT_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static UPDATES_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static ABOUT_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
#[cfg(not(target_os = "linux"))]
static QUIT_MENU_ITEM: std::sync::OnceLock<MenuItem<tauri::Wry>> = std::sync::OnceLock::new();
use crate::cli::CodeburnCli;
use crate::config::CurrencyConfig;
@ -266,6 +283,8 @@ pub fn run() {
commands::settings_section,
commands::settings_load,
commands::settings_patch,
commands::i18n_catalog,
commands::i18n_format,
commands::terminals,
commands::claude_config_dirs,
commands::set_claude_config_dirs,
@ -311,36 +330,34 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
// Disabled by design: the mac's menu opens with what today cost, which is the one thing
// worth knowing without opening anything. The frontend fills it in on every refresh.
let usage = MenuItem::with_id(app, "usage", "Today", false, None::<&str>)?;
let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?;
let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?;
let theme = MenuItem::with_id(
app,
"toggle_theme",
theme_menu_text(&theme_choice()),
true,
None::<&str>,
)?;
let settings = MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>)?;
let dock_settings = MenuItem::with_id(
app,
"dock_settings",
"Capacity Dock Settings...",
true,
None::<&str>,
)?;
// The usage row is the one title a language change does not rewrite; the webview sends
// it again. Every other title comes from the same list `sync_localized_menu_items` uses.
let labels = localized_static_labels(&theme_choice());
let text = |id: &str| -> String {
labels
.iter()
.find(|(key, _)| *key == id)
.map(|(_, value)| value.clone())
.unwrap_or_else(|| id.to_owned())
};
let usage = MenuItem::with_id(app, "usage", i18n::lookup("Today"), false, None::<&str>)?;
let open = MenuItem::with_id(app, "open", text("open"), true, None::<&str>)?;
let refresh = MenuItem::with_id(app, "refresh", text("refresh"), true, None::<&str>)?;
let theme = MenuItem::with_id(app, "toggle_theme", text("toggle_theme"), true, None::<&str>)?;
let settings = MenuItem::with_id(app, "settings", text("settings"), true, None::<&str>)?;
let dock_settings = MenuItem::with_id(app, "dock_settings", text("dock_settings"), true, None::<&str>)?;
let capacity_dock = CheckMenuItem::with_id(
app,
"toggle_dock",
"Show Capacity Dock",
text("toggle_dock"),
true,
dock::is_enabled(),
None::<&str>,
)?;
let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?;
let updates = MenuItem::with_id(app, "check_updates", "Check for Updates", true, None::<&str>)?;
let about = MenuItem::with_id(app, "about", "About CodeBurn", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?;
let report = MenuItem::with_id(app, "report", text("report"), true, None::<&str>)?;
let updates = MenuItem::with_id(app, "check_updates", text("check_updates"), true, None::<&str>)?;
let about = MenuItem::with_id(app, "about", text("about"), true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", text("quit"), true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
// Inside a Store package the update check never runs, so the item that opens its result
// would only ever say there is nothing to check. It is not offered at all there.
@ -365,6 +382,15 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
let _ = DOCK_MENU_ITEM.set(capacity_dock);
let _ = USAGE_MENU_ITEM.set(usage);
let _ = THEME_MENU_ITEM.set(theme);
// `OnceLock::set` cannot run twice, so a language change updates these handles in place.
let _ = OPEN_MENU_ITEM.set(open);
let _ = REFRESH_MENU_ITEM.set(refresh);
let _ = SETTINGS_MENU_ITEM.set(settings);
let _ = DOCK_SETTINGS_MENU_ITEM.set(dock_settings);
let _ = REPORT_MENU_ITEM.set(report);
let _ = UPDATES_MENU_ITEM.set(updates);
let _ = ABOUT_MENU_ITEM.set(about);
let _ = QUIT_MENU_ITEM.set(quit);
tray.set_menu(Some(menu.clone()))?;
tray.set_show_menu_on_left_click(false)?;
@ -621,13 +647,31 @@ fn next_theme(current: &str) -> &'static str {
}
/// The item names the state it moves to rather than the one it is in, because a menu item
/// reads as a verb.
/// reads as a verb. The sentence is the same English key the popover's More menu uses.
fn theme_menu_text(current: &str) -> String {
match next_theme(current) {
"light" => "Switch to Light Theme".into(),
"dark" => "Switch to Dark Theme".into(),
_ => "Switch to System Theme".into(),
}
let key = match next_theme(current) {
"light" => "Switch to Light Theme",
"dark" => "Switch to Dark Theme",
_ => "Switch to System Theme",
};
i18n::lookup(key)
}
/// Static tray titles, in menu order. The usage row is absent: it is dynamic, and a
/// language change leaves it for the webview to send again.
fn localized_static_labels(theme: &str) -> Vec<(&'static str, String)> {
vec![
("open", i18n::lookup("Open CodeBurn")),
("refresh", i18n::lookup("Refresh")),
("toggle_theme", theme_menu_text(theme)),
("settings", i18n::lookup("Settings…")),
("dock_settings", i18n::lookup("Capacity Dock Settings…")),
("toggle_dock", i18n::lookup("Show Capacity Dock")),
("report", i18n::lookup("Open Full Report")),
("check_updates", i18n::lookup("Check for Updates")),
("about", i18n::lookup("About CodeBurn")),
("quit", i18n::lookup("Quit CodeBurn")),
]
}
fn theme_choice() -> String {
@ -668,6 +712,46 @@ pub fn sync_theme_menu_item() {
#[cfg(target_os = "linux")]
pub fn sync_theme_menu_item() {}
/// Rewrites every static tray title from the current language. The usage row is not one of
/// them. Called from the same place `sync_theme_menu_item` used to run after a settings write.
pub fn sync_localized_menu_items() {
#[cfg(not(target_os = "linux"))]
apply_localized_menu_items();
}
#[cfg(not(target_os = "linux"))]
fn apply_localized_menu_items() {
for (id, text) in localized_static_labels(&theme_choice()) {
match id {
"open" => set_menu_text(OPEN_MENU_ITEM.get(), &text),
"refresh" => set_menu_text(REFRESH_MENU_ITEM.get(), &text),
"toggle_theme" => set_menu_text(THEME_MENU_ITEM.get(), &text),
"settings" => set_menu_text(SETTINGS_MENU_ITEM.get(), &text),
"dock_settings" => set_menu_text(DOCK_SETTINGS_MENU_ITEM.get(), &text),
"toggle_dock" => set_check_text(DOCK_MENU_ITEM.get(), &text),
"report" => set_menu_text(REPORT_MENU_ITEM.get(), &text),
"check_updates" => set_menu_text(UPDATES_MENU_ITEM.get(), &text),
"about" => set_menu_text(ABOUT_MENU_ITEM.get(), &text),
"quit" => set_menu_text(QUIT_MENU_ITEM.get(), &text),
_ => {}
}
}
}
#[cfg(not(target_os = "linux"))]
fn set_menu_text(item: Option<&MenuItem<tauri::Wry>>, text: &str) {
if let Some(item) = item {
let _ = item.set_text(text);
}
}
#[cfg(not(target_os = "linux"))]
fn set_check_text(item: Option<&CheckMenuItem<tauri::Wry>>, text: &str) {
if let Some(item) = item {
let _ = item.set_text(text);
}
}
#[cfg(not(target_os = "linux"))]
fn set_tray_usage_text(text: &str) {
if let Some(item) = USAGE_MENU_ITEM.get() {
@ -772,7 +856,7 @@ fn reload_settings(app: &AppHandle) {
let _ = item.set_checked(enabled);
}
settings::broadcast(app, &settings::read());
sync_theme_menu_item();
sync_localized_menu_items();
}
fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
@ -1109,7 +1193,7 @@ mod commands {
}
/// The usage row at the top of the tray menu: disabled, and there only to say what today
/// cost without opening the popover.
/// cost without opening the popover. The native menu sizes itself to the translated text.
#[tauri::command]
pub fn set_tray_usage(text: String) {
super::set_tray_usage_text(&text);
@ -1309,10 +1393,22 @@ mod commands {
) -> Result<Value, String> {
let merged = crate::settings::patch(patch).map_err(|e| e.to_string())?;
crate::settings::broadcast(&app, &merged);
crate::sync_theme_menu_item();
crate::sync_localized_menu_items();
Ok(Value::Object(merged))
}
/// The resolved catalog. A missing key is the English sentence, which the page returns
/// when the map has no entry. Placeholder fill is `i18n_format`, not this map.
#[tauri::command]
pub fn i18n_catalog() -> std::collections::BTreeMap<String, String> {
crate::i18n::active_catalog()
}
#[tauri::command]
pub fn i18n_format(key: String, args: Vec<serde_json::Value>) -> String {
crate::i18n::format_message(&key, &args)
}
/// The consoles the settings window offers, each marked with whether it is on this
/// machine, so the "(not installed)" hint stays honest.
#[tauri::command]
@ -1522,4 +1618,47 @@ mod tests {
fn only_an_exact_flag_counts() {
assert_eq!(parse_second_launch(&argv(&[r"C:\tools\--quit\app.exe"])), SecondLaunch::ShowPopover);
}
#[test]
fn a_language_change_rewrites_static_titles_and_leaves_the_usage_row() {
let _lock = crate::i18n::LocaleLock::acquire("zh-Hans");
let labels = super::localized_static_labels("light");
let text = |id: &str| {
labels
.iter()
.find(|(key, _)| *key == id)
.map(|(_, value)| value.as_str())
.unwrap_or("")
};
assert!(labels.iter().all(|(id, _)| *id != "usage"));
assert_eq!(
labels.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
vec![
"open",
"refresh",
"toggle_theme",
"settings",
"dock_settings",
"toggle_dock",
"report",
"check_updates",
"about",
"quit",
]
);
assert_eq!(text("quit"), "退出 CodeBurn");
assert_eq!(text("toggle_dock"), "显示容量 Dock");
assert_eq!(text("settings"), "设置…");
assert_eq!(text("dock_settings"), "容量 Dock 设置…");
assert_eq!(text("toggle_theme"), super::theme_menu_text("light"));
assert_eq!(text("toggle_theme"), crate::i18n::lookup("Switch to Dark Theme"));
assert_eq!(
super::theme_menu_text("dark"),
crate::i18n::lookup("Switch to System Theme")
);
assert_eq!(
super::theme_menu_text("system"),
crate::i18n::lookup("Switch to Light Theme")
);
}
}

View file

@ -4,14 +4,15 @@ import { listen } from '@tauri-apps/api/event'
import type { MenubarPayload } from './lib/payload'
import type { CurrencyState } from './lib/currency'
import { USD, formatCurrency, formatTokens, plural, trayBadgeText } from './lib/currency'
import { USD, formatCurrency, formatTokens, trayBadgeText } from './lib/currency'
import { PayloadCache, sameSelection, selectionKey, type Selection } from './lib/cache'
import { relativePast } from './lib/dates'
import { applyTheme, readSetting, writeSetting } from './lib/settings'
import {
DEFAULT_SETTINGS, MENUBAR_PERIODS, MENUBAR_SUFFIX, cacheThemeAndAccent, nextTheme, subscribeSettings, themeCycleLabel,
DEFAULT_SETTINGS, MENUBAR_PERIODS, MENUBAR_SUFFIX, cacheThemeAndAccent, nextTheme, subscribeSettings,
writeSettings, type AppSettings, type ThemeChoice,
} from './lib/appSettings'
import { formatMessage, t, useI18nRevision } from './lib/i18n'
import { TRAY_BADGE_SUPPORTED } from './lib/platform'
import { usageRefreshPlan } from './lib/refresh'
import { EMPTY_QUOTA, refreshQuota, refreshQuotaIfDue, subscribeQuota, worstSeverity, type QuotaState } from './lib/quota'
@ -133,6 +134,7 @@ export function App() {
// Every preference the settings window owns arrives here through one store, so a change
// made in that window reaches this one without a reload.
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
const i18nRevision = useI18nRevision()
const [menubarPayload, setMenubarPayload] = useState<MenubarPayload | null>(null)
const accent: AccentPreset = accentById(settings.accent)
const trayBadge = TRAY_BADGE_SUPPORTED && settings.trayBadge
@ -434,13 +436,26 @@ export function App() {
useEffect(() => {
if (trayFigure === null) return
const devices = trayShortfall
? ` · ${trayShortfall.reachable} of ${trayShortfall.total} devices reporting`
: ''
invoke('set_tray_tooltip', { text: `CodeBurn · ${trayFigure}${traySuffix}${devices}` }).catch(() => {})
let live = true
const figure = `${trayFigure}${traySuffix}`
void (async () => {
try {
const text = trayShortfall
? await formatMessage('CodeBurn %1$@ · %2$lld of %3$lld devices reporting', [
figure,
trayShortfall.reachable,
trayShortfall.total,
])
: `CodeBurn · ${figure}`
if (live) await invoke('set_tray_tooltip', { text })
} catch {
// The previous tooltip stays until the next figure arrives.
}
})()
return () => { live = false }
// Only the counts matter here, not the object identity a render makes fresh every time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trayFigure, traySuffix, trayShortfall?.reachable, trayShortfall?.total])
}, [trayFigure, traySuffix, trayShortfall?.reachable, trayShortfall?.total, i18nRevision])
useEffect(() => {
if (!TRAY_BADGE_SUPPORTED) return
@ -469,14 +484,35 @@ export function App() {
}, [quota, todayCost, todayTokens, budgets])
useEffect(() => {
const span = MENUBAR_PERIODS.find(p => p.id === settings.menubarPeriod)?.label ?? 'Today'
const devices = trayShortfall ? ` · ${trayShortfall.reachable}/${trayShortfall.total} devices` : ''
const text = trayCurrent
? `${span} · ${trayFigure} · ${plural(trayCurrent.calls, 'call')}${devices}`
: `${span} · no usage yet`
invoke('set_tray_usage', { text }).catch(() => {})
let live = true
const periodLabel = MENUBAR_PERIODS.find(p => p.id === settings.menubarPeriod)?.label ?? 'Today'
const figure = `${trayFigure}`
void (async () => {
try {
let text: string
if (!trayCurrent) {
text = settings.menubarPeriod === 'today'
? t('Today · no usage yet')
: await formatMessage('%@ · no usage yet', [t(periodLabel)])
} else {
const calls = trayCurrent.calls === 1
? t('1 call')
: await formatMessage('%lld calls', [trayCurrent.calls])
text = settings.menubarPeriod === 'today'
? await formatMessage('Today · %1$@ · %2$@', [figure, calls])
: `${t(periodLabel)} · ${figure} · ${calls}`
if (trayShortfall) {
text += ` · ${await formatMessage('%1$lld of %2$lld devices', [trayShortfall.reachable, trayShortfall.total])}`
}
}
if (live) await invoke('set_tray_usage', { text })
} catch {
// The row already on the menu stays until the webview can send another.
}
})()
return () => { live = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trayCurrent, trayFigure, settings.menubarPeriod, trayShortfall?.reachable, trayShortfall?.total])
}, [trayCurrent, trayFigure, settings.menubarPeriod, trayShortfall?.reachable, trayShortfall?.total, i18nRevision])
const chooseAccent = (preset: AccentPreset) => {
applyAccent(preset)
@ -710,7 +746,7 @@ export function App() {
onOpenReport={() => openTerminal(['report'])}
onToggleTheme={cycleTheme}
onQuit={() => invoke('quit_app').catch(() => {})}
themeLabel={themeCycleLabel(settings.theme)}
theme={settings.theme}
trayBadge={trayBadge}
onToggleTrayBadge={() => setTrayBadgePref(!trayBadge)}
onOpenSettings={openSettingsWindow}

View file

@ -1,5 +1,7 @@
import type { CurrencyState } from '../lib/currency'
import { CURRENCY_CODES } from '../lib/currency'
import { themeCycleLabel, type ThemeChoice } from '../lib/appSettings'
import { t, useI18nRevision } from '../lib/i18n'
import { TRAY_BADGE_SUPPORTED } from '../lib/platform'
import { DropMenu } from './DropMenu'
import { CoinIcon, DownloadIcon, EllipsisIcon, RefreshIcon, TerminalIcon } from './Icons'
@ -13,7 +15,7 @@ type Props = {
onOpenReport: () => void
onToggleTheme: () => void
onQuit: () => void
themeLabel: string
theme: ThemeChoice
footnote: string
trayBadge: boolean
onToggleTrayBadge: () => void
@ -21,9 +23,10 @@ type Props = {
}
export function FooterBar({
currency, onCurrency, loading, onRefresh, onExport, onOpenReport, onToggleTheme, onQuit, themeLabel, footnote,
currency, onCurrency, loading, onRefresh, onExport, onOpenReport, onToggleTheme, onQuit, theme, footnote,
trayBadge, onToggleTrayBadge, onOpenSettings,
}: Props) {
useI18nRevision()
return (
<footer className="footer">
<DropMenu
@ -63,12 +66,12 @@ export function FooterBar({
label={<EllipsisIcon size={12} />}
className="dropmenu-more"
items={[
{ id: 'settings', label: 'Settings...' },
{ id: 'settings', label: t('Settings…') },
...(TRAY_BADGE_SUPPORTED
? [{ id: 'badge', label: "Show today's cost in tray", checked: trayBadge, separatorBefore: true }]
: []),
{ id: 'theme', label: themeLabel },
{ id: 'quit', label: 'Quit CodeBurn', separatorBefore: true },
{ id: 'theme', label: themeCycleLabel(theme) },
{ id: 'quit', label: t('Quit CodeBurn'), separatorBefore: true },
]}
footnote={footnote}
onSelect={id => {

View file

@ -12,6 +12,7 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { t } from './i18n'
import { readSetting, writeSetting } from './settings'
/// What the tray shows beside the flame. The mac's fifth choice, Credits (Codex), is not
@ -55,13 +56,19 @@ export function nextTheme(current: ThemeChoice): ThemeChoice {
return current === 'light' ? 'dark' : current === 'dark' ? 'system' : 'light'
}
export function themeCycleLabel(current: ThemeChoice): string {
/// The English sentence `theme_menu_text` looks up in Rust. The footer and the tray item
/// both name the theme the control moves to, so they have to share this key.
export function themeMenuKey(current: ThemeChoice): string {
const next = nextTheme(current)
if (next === 'light') return 'Switch to Light Theme'
if (next === 'dark') return 'Switch to Dark Theme'
return 'Switch to System Theme'
}
export function themeCycleLabel(current: ThemeChoice): string {
return t(themeMenuKey(current))
}
/// UsageRefreshCadence. Auto is the adaptive default, manual never auto-spawns.
export const USAGE_CADENCES: Array<{ id: number; label: string }> = [
// The mac's label promises "less on battery", which is the adaptive refresh that comes

68
windows/src/lib/i18n.ts Normal file
View file

@ -0,0 +1,68 @@
/// The tray's resolved catalog, cached from Rust.
///
/// Rust owns the catalogs and the `%@` / `%lld` / `%1$@` substitution. This module only
/// remembers the map for the language already chosen and reloads it when settings change.
/// A missing key is the English sentence, which is also the catalog key.
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useEffect, useState } from 'react'
type Catalog = Record<string, string>
let catalog: Catalog = {}
let listeners: Array<() => void> = []
let unlisten: (() => void) | null = null
let reloadGeneration = 0
function publish() {
for (const listener of listeners) listener()
}
async function reload() {
const generation = ++reloadGeneration
try {
const next = await invoke<Catalog>('i18n_catalog')
if (generation !== reloadGeneration) return
catalog = next ?? {}
} catch {
if (generation !== reloadGeneration) return
catalog = {}
}
publish()
}
/// Subscribes to catalog reloads. The first subscriber starts the settings listener.
export function subscribeI18n(listener: () => void): () => void {
listeners.push(listener)
if (listeners.length === 1) {
void reload()
void listen('codeburn://settings-changed', () => {
void reload()
}).then(fn => { unlisten = fn })
}
return () => {
listeners = listeners.filter(item => item !== listener)
if (listeners.length === 0 && unlisten) {
unlisten()
unlisten = null
}
}
}
/// The translated sentence, or `key` itself when this language has no entry.
export function t(key: string): string {
return catalog[key] ?? key
}
/// Fills a glossary format string in Rust. Arguments stay in the order the placeholders name.
export function formatMessage(key: string, args: Array<string | number>): Promise<string> {
return invoke<string>('i18n_format', { key, args })
}
/// Bumps when the catalog is replaced, so a sentence read through `t` renders again.
export function useI18nRevision(): number {
const [revision, setRevision] = useState(0)
useEffect(() => subscribeI18n(() => setRevision(value => value + 1)), [])
return revision
}