mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
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:
parent
f3f6c4c3b0
commit
ec2822a147
9 changed files with 863 additions and 60 deletions
33
windows/src-tauri/locales/en.json
Normal file
33
windows/src-tauri/locales/en.json
Normal 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"
|
||||
}
|
||||
33
windows/src-tauri/locales/zh-Hans.json
Normal file
33
windows/src-tauri/locales/zh-Hans.json
Normal 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": "本周"
|
||||
}
|
||||
|
|
@ -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()));
|
||||
|
|
|
|||
475
windows/src-tauri/src/i18n.rs
Normal file
475
windows/src-tauri/src/i18n.rs
Normal 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 台设备已上报"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
68
windows/src/lib/i18n.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue