Add a Simplified Chinese interface, drawn with the system's Chinese font
zh-CN.tsv translates the whole interface, the tray menu and tooltip, each app's tagline and the backend's install/update messages into Simplified Chinese, and Settings > Language offers 简体中文. Simplified Chinese locales (zh-CN, zh-SG, zh-Hans, zh_CN.UTF-8, a bare zh) select it; an explicit Traditional script (zh-Hant-CN) or a Traditional region (zh-TW, zh-HK, zh-MO) doesn't. Chinese has one plural form. The tray's menu and tooltip now follow the interface language (Russian rows added too). The bundled faces have no Chinese glyphs, so on the desktop the launcher borrows the system's own Chinese font (cjk_font.rs): PingFang (in /System/Library/Fonts on older macOS, a downloaded font asset on newer ones), Hiragino Sans GB or Heiti on macOS; Microsoft YaHei or SimSun on Windows; Noto Sans CJK (static or variable) or WenQuanYi on Linux and FreeBSD. The face is picked by its family name (PingFang SC, Noto Sans CJK SC…), regular weight, rather than a hard-coded index in the collection. Files over 100 MB are skipped and nothing panics. The font (PingFang is 78 MB) is read only when Chinese is the language in use, chosen or through Auto, and then kept for the session. Until then the picker says "Simplified Chinese", and "Simplified Chinese (简体中文)" once the font is loaded. With no Chinese font (and on the web), the interface stays in English and Settings explains why, instead of boxes for every character. Rebased onto main from the original PR (#34). Changed from the original: - No bundled font: NotoSansSC-Regular.ttf (10.6 MB), its OFL and README, their ATTRIBUTION and NOTICE rows and the zh-CN screenshots are left out; the system font replaces them, as above. - The i18n framework fixes in that PR (trim_end in the catalog tests, the n == 1 plural fallback, 1-3 @plural forms, the registry-driven language label) landed with the Russian catalog; this adds the script-aware zh tag matching on top. - zh-CN.tsv: "Verifying checksum…" mentions the checksum (正在验证校验和…), update.check_auto keeps "automatically" (自动检查更新), "Launcher" stays in English like the rest of the product name, rows were added for every string main gained since, and rows for messages the launcher never sends were dropped. - tests/chinese.rs works with or without a system Chinese font: with one it switches to 简体中文 and checks the grid, table, web and saved setting; without one it checks the English fallback and the note. The glyph-coverage test now covers the system font, when there is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cf5604dc65
commit
bf452db810
|
|
@ -75,6 +75,8 @@ Every user-facing string goes through `crates/ui-egui/src/i18n` (`tl!("…")` fo
|
|||
for `{placeholders}`, `tn` for counts). English in the code is the lookup key; adding a string
|
||||
means adding its row to every catalog marked `complete_menus` (`ru.tsv`), which the i18n tests
|
||||
check. Product and app names (ArtCraft Launcher, Craft App Suite, PhotoCraft…) stay in English.
|
||||
Chinese is drawn with the system's own CJK font (`crates/ui-egui/src/cjk_font.rs`, desktop only;
|
||||
English where there is none): never bundle a CJK font, they are 10 MB and more.
|
||||
|
||||
### Other rules
|
||||
|
||||
|
|
|
|||
|
|
@ -334,6 +334,8 @@ dependencies = [
|
|||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
]
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -82,6 +82,20 @@ you when an update is out. It is a small native app that can sit in your menu ba
|
|||
|
||||
The web version shows the same catalog in the browser, with the latest download for each app.
|
||||
|
||||
## Interface languages
|
||||
|
||||
The interface is in English, Russian (Русский) or Simplified Chinese (简体中文). **Auto**, the
|
||||
default, follows the system or browser language; **Settings → Language** picks one and switches
|
||||
at once, and the choice is kept between runs. Simplified Chinese locales (`zh-CN`, `zh-SG`,
|
||||
`zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) select Chinese; Traditional Chinese ones don't.
|
||||
To try a language without changing the setting, start the desktop app with
|
||||
`ARTCRAFT_LAUNCHER_LOCALE=zh-CN`, or add `?lang=ru` to the web address.
|
||||
|
||||
Chinese is drawn with the system's own Chinese font (PingFang on macOS, Microsoft YaHei on
|
||||
Windows, Noto Sans CJK or WenQuanYi on Linux and FreeBSD). Where there is none, and in the web
|
||||
version, the interface stays in English and Settings says why. Product names and release notes
|
||||
stay as published; text without a translation falls back to English.
|
||||
|
||||
## Where apps are installed
|
||||
|
||||
ArtCraft Launcher installs each app where your system normally keeps applications, using the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! The tray (menu bar) icon: show the window, check for updates, quit.
|
||||
|
||||
use artcraft_launcher_ui_egui::i18n;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
|
||||
|
|
@ -20,6 +22,8 @@ pub struct Tray {
|
|||
quit: MenuId,
|
||||
rx: Receiver<TrayAction>,
|
||||
tooltip: String,
|
||||
language: String,
|
||||
labels: Vec<(MenuItem, &'static str)>,
|
||||
}
|
||||
|
||||
fn load_icon() -> Result<tray_icon::Icon, String> {
|
||||
|
|
@ -39,13 +43,13 @@ fn load_icon() -> Result<tray_icon::Icon, String> {
|
|||
impl Tray {
|
||||
/// Creates the tray icon. `wake` repaints the UI so it sees the action, even when hidden.
|
||||
pub fn new(wake: impl Fn() + Send + Sync + Clone + 'static) -> Result<Self, String> {
|
||||
let show = MenuItem::new("Show ArtCraft Launcher", true, None);
|
||||
let check = MenuItem::new("Check for Updates", true, None);
|
||||
let show = MenuItem::new(i18n::t("Show ArtCraft Launcher"), true, None);
|
||||
let check = MenuItem::new(i18n::t("Check for Updates"), true, None);
|
||||
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
|
||||
let website = MenuItem::new("Visit getartcraft.com", true, None);
|
||||
let github = MenuItem::new("ArtCraft on GitHub", true, None);
|
||||
let discord = MenuItem::new("Join the ArtCraft Discord", true, None);
|
||||
let quit = MenuItem::new("Quit ArtCraft Launcher", true, None);
|
||||
let website = MenuItem::new(i18n::t("Visit getartcraft.com"), true, None);
|
||||
let github = MenuItem::new(i18n::t("ArtCraft on GitHub"), true, None);
|
||||
let discord = MenuItem::new(i18n::t("Join the ArtCraft Discord"), true, None);
|
||||
let quit = MenuItem::new(i18n::t("Quit ArtCraft Launcher"), true, None);
|
||||
let menu = Menu::new();
|
||||
menu.append_items(&[&show, &check, &PredefinedMenuItem::separator(), &website, &github, &discord, &PredefinedMenuItem::separator(), &quit])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -86,7 +90,15 @@ impl Tray {
|
|||
wake();
|
||||
}
|
||||
}));
|
||||
Ok(Self { icon, show: show_id, check: check_id, quit: quit_id, rx, tooltip: String::new() })
|
||||
let labels = vec![
|
||||
(show, "Show ArtCraft Launcher"),
|
||||
(check, "Check for Updates"),
|
||||
(website, "Visit getartcraft.com"),
|
||||
(github, "ArtCraft on GitHub"),
|
||||
(discord, "Join the ArtCraft Discord"),
|
||||
(quit, "Quit ArtCraft Launcher"),
|
||||
];
|
||||
Ok(Self { icon, show: show_id, check: check_id, quit: quit_id, rx, tooltip: String::new(), language: String::new(), labels })
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> Vec<TrayAction> {
|
||||
|
|
@ -94,10 +106,17 @@ impl Tray {
|
|||
}
|
||||
|
||||
pub fn set_updates(&mut self, count: usize) {
|
||||
let tip = match count {
|
||||
0 => "ArtCraft Launcher".to_string(),
|
||||
1 => "ArtCraft Launcher · 1 update available".to_string(),
|
||||
n => format!("ArtCraft Launcher · {n} updates available"),
|
||||
let lang = i18n::current();
|
||||
if self.language != lang.code() {
|
||||
for (item, source) in &self.labels {
|
||||
item.set_text(i18n::tr(lang, source));
|
||||
}
|
||||
self.language = lang.code().to_string();
|
||||
}
|
||||
let tip = if count == 0 {
|
||||
"ArtCraft Launcher".to_string()
|
||||
} else {
|
||||
i18n::trn(lang, count as u64, "ArtCraft Launcher · {n} update available", "ArtCraft Launcher · {n} updates available")
|
||||
};
|
||||
if tip != self.tooltip {
|
||||
let _ = self.icon.set_tooltip(Some(&tip));
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ log = { workspace = true }
|
|||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The OS's preferred UI languages, without spawning `defaults` or `reg.exe`.
|
||||
sys-locale = "0.3"
|
||||
# Finding the Simplified Chinese face in a system font collection; egui already uses it.
|
||||
skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ pub struct LauncherUi {
|
|||
/// Fonts registered with `set_fonts` only exist from the next frame on; drawing with the named
|
||||
/// weights before then would abort.
|
||||
fonts_ready: bool,
|
||||
/// Whether the installed fonts include the system's Chinese font. Added the first time Chinese
|
||||
/// is the language in use, then kept for the session.
|
||||
cjk_fonts: bool,
|
||||
sent_options: Option<BackendOptions>,
|
||||
/// Unix seconds of the last look at what's installed (startup counts).
|
||||
last_detect: i64,
|
||||
|
|
@ -102,6 +105,14 @@ pub struct LauncherUi {
|
|||
was_focused: bool,
|
||||
}
|
||||
|
||||
/// The language to draw in: the preference, or English where it needs a Chinese font the system
|
||||
/// doesn't have (Settings says so), rather than boxes for every character. The only place the
|
||||
/// Chinese font gets loaded: when Chinese is selected, or Auto resolves to it.
|
||||
pub(crate) fn effective_lang(pref: &str) -> crate::i18n::Lang {
|
||||
let lang = crate::i18n::Lang::from_pref(pref);
|
||||
if lang.needs_cjk_font() && !theme::cjk_font_available() { crate::i18n::Lang::EN } else { lang }
|
||||
}
|
||||
|
||||
/// How often to look again at what's installed, to notice apps installed or removed outside
|
||||
/// the launcher. A detection pass lists packages and reads a few files: cheap, but not free.
|
||||
const RESCAN_EVERY: i64 = 5 * 60;
|
||||
|
|
@ -110,7 +121,8 @@ const RESCAN_ON_FOCUS_AFTER: i64 = 30;
|
|||
|
||||
impl LauncherUi {
|
||||
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
|
||||
theme::install_fonts(ctx);
|
||||
let cjk_fonts = effective_lang(&settings.language).needs_cjk_font();
|
||||
theme::install_fonts(ctx, cjk_fonts);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
let caps = backend.capabilities();
|
||||
let platform = backend.platform();
|
||||
|
|
@ -134,6 +146,7 @@ impl LauncherUi {
|
|||
started: false,
|
||||
applied_theme: None,
|
||||
fonts_ready: false,
|
||||
cjk_fonts,
|
||||
sent_options: None,
|
||||
last_detect: 0,
|
||||
was_focused: true,
|
||||
|
|
@ -143,7 +156,7 @@ impl LauncherUi {
|
|||
/// Moves the UI to another window's context: fonts, image loaders and the theme belong to a
|
||||
/// context, and toasts are timed on its clock.
|
||||
pub fn attach(&mut self, ctx: &egui::Context) {
|
||||
theme::install_fonts(ctx);
|
||||
theme::install_fonts(ctx, self.cjk_fonts);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
self.fonts_ready = false;
|
||||
self.applied_theme = None;
|
||||
|
|
@ -359,7 +372,14 @@ impl LauncherUi {
|
|||
pub fn ui(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
|
||||
let ctx = ui.ctx().clone();
|
||||
// Set the current language for this frame based on settings.
|
||||
set_current(crate::i18n::Lang::from_pref(&self.settings.language));
|
||||
let lang = effective_lang(&self.settings.language);
|
||||
set_current(lang);
|
||||
// `effective_lang` only picks Chinese once its font has loaded.
|
||||
if lang.needs_cjk_font() && !self.cjk_fonts {
|
||||
theme::install_fonts(&ctx, true);
|
||||
self.cjk_fonts = true;
|
||||
self.fonts_ready = false;
|
||||
}
|
||||
if !self.fonts_ready {
|
||||
self.fonts_ready = true;
|
||||
ctx.request_repaint();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
//! A Chinese font from the operating system, for the Simplified Chinese interface. The bundled
|
||||
//! faces have no CJK glyphs, and a CJK font is too large to ship (10 MB or more), so the desktop
|
||||
//! build borrows the one every system that can show Chinese already has. Desktop only.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use egui::epaint::text::VariationCoords;
|
||||
use egui::{FontData, FontTweak};
|
||||
use skrifa::{FontRef, MetadataProvider, string::StringId};
|
||||
|
||||
/// Larger files aren't read: PingFang, the largest candidate, is about 80 MB.
|
||||
const MAX_FONT_BYTES: u64 = 100 * 1024 * 1024;
|
||||
/// Faces looked at in one collection file (PingFang has 24).
|
||||
const MAX_FACES: u32 = 64;
|
||||
|
||||
/// A font file that may be installed, and the family of its Simplified Chinese face.
|
||||
struct Candidate {
|
||||
path: PathBuf,
|
||||
family: &'static str,
|
||||
}
|
||||
|
||||
/// The system's Simplified Chinese font, or `None` (logged) when it has none this knows about.
|
||||
/// Reads the file (once per process: the caller caches the answer).
|
||||
pub(crate) fn load() -> Option<FontData> {
|
||||
for Candidate { path, family } in candidates() {
|
||||
let Some(bytes) = read(&path) else { continue };
|
||||
let Some(index) = face_index(&bytes, family) else {
|
||||
log::debug!("no {family} face in {}", path.display());
|
||||
continue;
|
||||
};
|
||||
log::info!("Chinese text uses {family} from {} (face {index})", path.display());
|
||||
// Regular weight, in case the face is variable (Noto Sans CJK VF).
|
||||
let tweak = FontTweak { coords: VariationCoords::new([(*b"wght", 400.0)]), ..Default::default() };
|
||||
let mut font = FontData::from_owned(bytes).tweak(tweak);
|
||||
font.index = index;
|
||||
return Some(font);
|
||||
}
|
||||
log::warn!(
|
||||
"no Chinese font found on this system (looked for PingFang, Hiragino Sans GB, Heiti, Microsoft YaHei, SimSun, Noto Sans CJK and WenQuanYi)"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Option<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if !meta.is_file() {
|
||||
return None;
|
||||
}
|
||||
if meta.len() > MAX_FONT_BYTES {
|
||||
log::warn!("{} is too large to load as a font ({} bytes)", path.display(), meta.len());
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).map_err(|e| log::warn!("couldn't read {}: {e}", path.display())).ok()
|
||||
}
|
||||
|
||||
/// The face of `family` in a font file or collection: its Regular face, else the first face of
|
||||
/// that family. Matches the (typographic) family name in any language the file gives it in.
|
||||
pub(crate) fn face_index(data: &[u8], family: &str) -> Option<u32> {
|
||||
let names = |font: &FontRef<'_>, ids: [StringId; 2]| -> Vec<String> {
|
||||
ids.into_iter().flat_map(|id| font.localized_strings(id).map(|s| s.to_string())).collect()
|
||||
};
|
||||
let mut first = None;
|
||||
for index in 0..MAX_FACES {
|
||||
let Ok(font) = FontRef::from_index(data, index) else { break };
|
||||
let families = names(&font, [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]);
|
||||
if !families.iter().any(|f| f.eq_ignore_ascii_case(family)) {
|
||||
continue;
|
||||
}
|
||||
let styles = names(&font, [StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]);
|
||||
if styles.iter().any(|s| s.eq_ignore_ascii_case("Regular")) {
|
||||
return Some(index);
|
||||
}
|
||||
first.get_or_insert(index);
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// Where each system keeps a Simplified Chinese font, best first.
|
||||
fn candidates() -> Vec<Candidate> {
|
||||
let mut out = Vec::new();
|
||||
let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// PingFang moved from the system fonts into a downloaded asset in later macOS versions.
|
||||
add("/System/Library/Fonts/PingFang.ttc".into(), "PingFang SC");
|
||||
for path in mac_font_assets("PingFang.ttc") {
|
||||
add(path, "PingFang SC");
|
||||
}
|
||||
add("/System/Library/Fonts/Hiragino Sans GB.ttc".into(), "Hiragino Sans GB W3");
|
||||
add("/System/Library/Fonts/STHeiti Medium.ttc".into(), "Heiti SC");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
|
||||
add(root.join("Fonts").join("msyh.ttc"), "Microsoft YaHei");
|
||||
add(root.join("Fonts").join("simsun.ttc"), "SimSun");
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// Debian/Ubuntu, Arch, Fedora (static and variable) and FreeBSD's ports.
|
||||
const NOTO_DIRS: [&str; 8] = [
|
||||
"/usr/share/fonts/opentype/noto",
|
||||
"/usr/share/fonts/noto-cjk",
|
||||
"/usr/share/fonts/google-noto-cjk",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-fonts",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts",
|
||||
"/usr/share/fonts/noto",
|
||||
"/usr/local/share/fonts/noto",
|
||||
"/usr/local/share/fonts/noto-cjk",
|
||||
];
|
||||
for file in ["NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Regular.otf", "NotoSansCJK-VF.ttc"] {
|
||||
for dir in NOTO_DIRS {
|
||||
add(Path::new(dir).join(file), "Noto Sans CJK SC");
|
||||
}
|
||||
}
|
||||
const WQY_DIRS: [&str; 5] = [
|
||||
"/usr/share/fonts/truetype/wqy",
|
||||
"/usr/share/fonts/wenquanyi/wqy-microhei",
|
||||
"/usr/share/fonts/wqy-microhei",
|
||||
"/usr/share/fonts/wqy-zenhei",
|
||||
"/usr/local/share/fonts/wqy",
|
||||
];
|
||||
for (file, family) in [("wqy-microhei.ttc", "WenQuanYi Micro Hei"), ("wqy-zenhei.ttc", "WenQuanYi Zen Hei")] {
|
||||
for dir in WQY_DIRS {
|
||||
add(Path::new(dir).join(file), family);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `file` in macOS's downloaded font assets:
|
||||
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/*.asset/AssetData/<file>`.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn mac_font_assets(file: &str) -> Vec<PathBuf> {
|
||||
let dirs = |path: &Path| -> Vec<PathBuf> {
|
||||
std::fs::read_dir(path).map(|entries| entries.filter_map(Result::ok).map(|e| e.path()).collect()).unwrap_or_default()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for catalog in dirs(Path::new("/System/Library/AssetsV2")) {
|
||||
if !catalog.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font")) {
|
||||
continue;
|
||||
}
|
||||
for asset in dirs(&catalog) {
|
||||
let path = asset.join("AssetData").join(file);
|
||||
if asset.extension().is_some_and(|e| e == "asset") && path.is_file() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn face_index_rejects_what_isnt_a_font() {
|
||||
assert_eq!(face_index(b"", "PingFang SC"), None);
|
||||
assert_eq!(face_index(b"ttcf\0\0\0\0garbage", "PingFang SC"), None);
|
||||
assert_eq!(face_index(&[0u8; 64], "PingFang SC"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_index_finds_families_in_real_fonts() {
|
||||
// The bundled Latin faces stand in for system fonts the test machine may not have.
|
||||
let inter: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
assert_eq!(face_index(inter, "Inter"), Some(0));
|
||||
assert_eq!(face_index(inter, "PingFang SC"), None);
|
||||
}
|
||||
|
||||
/// Whatever this machine has loads without panicking, and a found face really is the family's.
|
||||
#[test]
|
||||
fn system_font_loads_when_present() {
|
||||
if let Some(font) = load() {
|
||||
let family = candidates().into_iter().find(|c| c.path.is_file()).map(|c| c.family);
|
||||
assert!(family.is_some());
|
||||
assert!(FontRef::from_index(&font.font, font.index).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,13 +156,19 @@ impl LauncherUi {
|
|||
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code)).width(180.0).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
|
||||
for lang in crate::i18n::Lang::all() {
|
||||
ui.selectable_value(&mut lang_code, lang.code().to_string(), lang.name());
|
||||
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code()));
|
||||
}
|
||||
});
|
||||
});
|
||||
if lang_code != self.settings.language {
|
||||
self.settings.language = lang_code;
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.settings.language));
|
||||
crate::i18n::set_current(crate::app::effective_lang(&self.settings.language));
|
||||
}
|
||||
if crate::i18n::Lang::from_pref(&self.settings.language).needs_cjk_font() && !theme::cjk_font_available() {
|
||||
// In English on purpose: the language asked for can't be drawn.
|
||||
let note = "No Chinese font was found on this system, so the interface stays in English. \
|
||||
Installing one (Noto Sans CJK, or WenQuanYi on Linux) and restarting fixes it.";
|
||||
ui.add(egui::Label::new(RichText::new(note).size(12.0).color(t.warn)).wrap());
|
||||
}
|
||||
|
||||
section(ui, tl!("Updates"));
|
||||
|
|
|
|||
|
|
@ -7,14 +7,19 @@ use super::detect::system_lang;
|
|||
|
||||
/// One supported UI language.
|
||||
pub struct LangInfo {
|
||||
/// Canonical BCP 47 code (`en`, `ru`). Also the `interfaceLanguage` value.
|
||||
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`). Also the `interfaceLanguage` value.
|
||||
pub code: &'static str,
|
||||
/// The language's name in itself, shown in the Preferences dropdown.
|
||||
pub name: &'static str,
|
||||
/// The language's name in English, shown instead where its own script can't be drawn.
|
||||
pub english_name: &'static str,
|
||||
/// Does its script need a CJK font? The bundled faces have none, so it comes from the
|
||||
/// system (`theme::cjk_font`).
|
||||
pub cjk: bool,
|
||||
/// Catalog file contents (empty for the built-in English).
|
||||
pub source: &'static str,
|
||||
/// Plural form index for a count (English: 0 = one, 1 = other; Russian: 0 = one, 1 = few,
|
||||
/// 2 = many).
|
||||
/// 2 = many; Chinese: always 0).
|
||||
pub plural: fn(u64) -> usize,
|
||||
/// Number of plural forms the catalog's `@plural` rows carry (checked by tests).
|
||||
pub plural_forms: usize,
|
||||
|
|
@ -27,6 +32,11 @@ fn plural_one_other(n: u64) -> usize {
|
|||
usize::from(n != 1)
|
||||
}
|
||||
|
||||
/// Languages whose nouns don't inflect for number (Chinese): one form for every count.
|
||||
fn plural_none(_: u64) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Russian and Ukrainian integer counts: 1 (but not 11) is `one`, 2–4 (but not 12–14)
|
||||
/// `few`, everything else `many`.
|
||||
pub(super) fn plural_east_slavic(n: u64) -> usize {
|
||||
|
|
@ -41,17 +51,40 @@ pub(super) fn plural_east_slavic(n: u64) -> usize {
|
|||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 2] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, plural_forms: 2, complete_menus: false, catalog: OnceLock::new() },
|
||||
pub static LANGUAGES: [LangInfo; 3] = [
|
||||
LangInfo {
|
||||
code: "en",
|
||||
name: "English",
|
||||
english_name: "English",
|
||||
cjk: false,
|
||||
source: "",
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
complete_menus: false,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "ru",
|
||||
name: "Русский",
|
||||
english_name: "Russian",
|
||||
cjk: false,
|
||||
source: include_str!("ru.tsv"),
|
||||
plural: plural_east_slavic,
|
||||
plural_forms: 3,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "zh-CN",
|
||||
name: "简体中文",
|
||||
english_name: "Simplified Chinese",
|
||||
cjk: true,
|
||||
source: include_str!("zh-CN.tsv"),
|
||||
plural: plural_none,
|
||||
plural_forms: 1,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
|
|
@ -81,6 +114,7 @@ impl Eq for Lang {}
|
|||
impl Lang {
|
||||
pub const EN: Lang = Lang(&LANGUAGES[0]);
|
||||
pub const RU: Lang = Lang(&LANGUAGES[1]);
|
||||
pub const ZH_CN: Lang = Lang(&LANGUAGES[2]);
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
self.0.code
|
||||
|
|
@ -106,6 +140,16 @@ impl Lang {
|
|||
self.0.name
|
||||
}
|
||||
|
||||
/// The language's name in English.
|
||||
pub fn english_name(self) -> &'static str {
|
||||
self.0.english_name
|
||||
}
|
||||
|
||||
/// Does drawing this language need a CJK font from the system?
|
||||
pub fn needs_cjk_font(self) -> bool {
|
||||
self.0.cjk
|
||||
}
|
||||
|
||||
/// Does this language's catalog claim to cover every `tl!` literal?
|
||||
pub fn complete_menus(self) -> bool {
|
||||
self.0.complete_menus
|
||||
|
|
@ -129,12 +173,27 @@ pub(super) fn candidates(tag: &str) -> Vec<String> {
|
|||
(1..=parts.len()).rev().filter_map(|n| parts.get(..n)).map(|p| p.join("-")).collect()
|
||||
}
|
||||
|
||||
/// The registered language for a locale tag such as `ru_RU.UTF-8`, `ru-RU`; `None` if
|
||||
/// the language isn't supported. `C`/`POSIX` mean English.
|
||||
/// The registered language for a locale tag such as `ru_RU.UTF-8`, `ru-RU`, `zh-Hans-CN`;
|
||||
/// `None` if the language isn't supported. `C`/`POSIX` mean English.
|
||||
pub fn lang_from_tag(tag: &str) -> Option<Lang> {
|
||||
let cands = candidates(tag);
|
||||
if matches!(cands.last().map(String::as_str), Some("c" | "posix")) {
|
||||
return Some(Lang::EN);
|
||||
match cands.last().map(String::as_str) {
|
||||
Some("c" | "posix") => Some(Lang::EN),
|
||||
Some("zh") => chinese(cands.first().map_or("zh", String::as_str)),
|
||||
_ => cands.iter().find_map(|c| Lang::from_code(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Chinese by script: Simplified for `zh-Hans`, and for a bare `zh` or the regions that write
|
||||
/// Simplified (mainland China, Singapore) when no script is given. An explicit script wins over
|
||||
/// the region (`zh-Hant-CN` is Traditional); Traditional Chinese isn't offered.
|
||||
fn chinese(tag: &str) -> Option<Lang> {
|
||||
let parts: Vec<&str> = tag.split('-').collect();
|
||||
if parts.contains(&"hant") {
|
||||
None
|
||||
} else if parts.contains(&"hans") || parts.len() == 1 || parts.iter().any(|p| matches!(*p, "cn" | "sg")) {
|
||||
Some(Lang::ZH_CN)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
cands.iter().find_map(|c| Lang::from_code(c))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,3 +312,11 @@ filter Installed Установлены
|
|||
@id tray.show_on_current_desktop Показывать окно на текущем рабочем столе
|
||||
@id tray.login_item Запускать ArtCraft Launcher при входе в систему
|
||||
@id appearance.theme Внешний вид
|
||||
|
||||
# Tray (menu bar) menu and tooltip
|
||||
Show ArtCraft Launcher Показать ArtCraft Launcher
|
||||
Check for Updates Проверить обновления
|
||||
Visit getartcraft.com Открыть getartcraft.com
|
||||
ArtCraft on GitHub ArtCraft на GitHub
|
||||
Join the ArtCraft Discord Присоединиться к Discord ArtCraft
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} обновление|ArtCraft Launcher · {n} обновления|ArtCraft Launcher · {n} обновлений
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 13 and column 44.
|
|
|
@ -130,6 +130,8 @@ fn complete_catalogs_cover_every_ui_literal() {
|
|||
include_str!("../settings.rs"),
|
||||
include_str!("../views.rs"),
|
||||
include_str!("../widgets.rs"),
|
||||
// The desktop shell's tray menu and tooltip.
|
||||
include_str!("../../../../apps/artcraft-launcher/src/tray.rs"),
|
||||
];
|
||||
let mut keys = BTreeSet::new();
|
||||
for source in sources {
|
||||
|
|
@ -151,11 +153,11 @@ fn complete_catalogs_cover_every_ui_literal() {
|
|||
}
|
||||
}
|
||||
assert!(keys.len() > 100, "found only {} UI literals", keys.len());
|
||||
// Plural keys: `tn(n, "one", "other")`.
|
||||
// Plural keys: `tn(n, "one", "other")`, `trn(lang, n, "one", "other")`.
|
||||
let mut plurals = BTreeSet::new();
|
||||
for source in sources {
|
||||
for (i, _) in source.match_indices("tn(") {
|
||||
if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_') {
|
||||
for (i, call) in source.match_indices("tn(").chain(source.match_indices("trn(")) {
|
||||
if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_') || source[i + call.len()..].starts_with(')') {
|
||||
continue;
|
||||
}
|
||||
let forms: Vec<&str> = source[i..].split('"').skip(1).step_by(2).take(2).collect();
|
||||
|
|
@ -227,12 +229,77 @@ fn locale_tags_resolve() {
|
|||
assert_eq!(super::detect::first_supported(["de-DE", "ru-RU", "en-US"]), Some(Lang::RU));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplified_chinese_locales_respect_the_script() {
|
||||
for tag in ["zh", "zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "ZH_cn"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(Lang::ZH_CN), "{tag}");
|
||||
}
|
||||
for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-CN", "zh-Hant-SG", "zh_TW.UTF-8"] {
|
||||
assert_eq!(lang_from_tag(tag), None, "{tag}");
|
||||
}
|
||||
// A Traditional Chinese system that also lists English gets English, not Simplified.
|
||||
assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::EN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_counts_and_english_fallback_work() {
|
||||
for n in [0, 1, 2, 11, 100] {
|
||||
assert_eq!(trn(Lang::ZH_CN, n, "{n} app", "{n} apps"), format!("{n} 个应用"));
|
||||
assert_eq!(trn(Lang::ZH_CN, n, "{n} min ago", "{n} min ago"), format!("{n} 分钟前"));
|
||||
}
|
||||
assert_eq!(trn(Lang::ZH_CN, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items");
|
||||
assert_eq!(tr(Lang::ZH_CN, "Untranslated diagnostic"), "Untranslated diagnostic");
|
||||
assert_eq!(message(Lang::ZH_CN, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0");
|
||||
}
|
||||
|
||||
/// With the system's Chinese font installed, every UI family can draw the whole Chinese catalog.
|
||||
/// Machines without one (CI, the web) skip this; the UI then stays in English.
|
||||
#[test]
|
||||
fn system_font_covers_chinese_in_every_ui_family() {
|
||||
use egui::{FontFamily, FontId};
|
||||
if !crate::theme::cjk_font_available() {
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, true);
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let text: String = Lang::ZH_CN
|
||||
.0
|
||||
.source
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(2))
|
||||
.flat_map(str::chars)
|
||||
.filter(|c| ('\u{3400}'..='\u{9fff}').contains(c))
|
||||
.collect();
|
||||
assert!(text.chars().count() > 500);
|
||||
for family in [
|
||||
FontFamily::Proportional,
|
||||
FontFamily::Monospace,
|
||||
crate::theme::medium(),
|
||||
crate::theme::semibold(),
|
||||
crate::theme::display(),
|
||||
crate::theme::mono(),
|
||||
crate::theme::mono_bold(),
|
||||
] {
|
||||
ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_labels_come_from_the_registry() {
|
||||
use crate::settings::Settings;
|
||||
for lang in Lang::all() {
|
||||
for lang in Lang::all().filter(|l| !l.needs_cjk_font()) {
|
||||
assert_eq!(Settings::language_label(lang.code()), lang.name());
|
||||
}
|
||||
// Chinese: in English until its font is loaded (which a label never does), then both.
|
||||
// (Other tests may load the font meanwhile.)
|
||||
let before = crate::theme::cjk_font_loaded();
|
||||
let label = Settings::language_label(Lang::ZH_CN.code());
|
||||
let after = crate::theme::cjk_font_loaded();
|
||||
assert!(label == "Simplified Chinese (简体中文)" || label == "Simplified Chinese", "{label}");
|
||||
assert!(!before || label.contains("简体中文"), "{label}");
|
||||
assert!(after || !label.contains("简体中文"), "{label}");
|
||||
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO), "Auto");
|
||||
assert_eq!(Settings::language_label("xx"), "Auto");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
# Simplified Chinese (zh-CN) catalog for ArtCraft Launcher.
|
||||
# Format: context<TAB>English source<TAB>translation.
|
||||
# Original translations from the English UI. Product and app names (ArtCraft Launcher and its
|
||||
# "Launcher" tag, Craft App Suite, PhotoCraft…) and third-party names stay unchanged.
|
||||
# Shown with the system's Chinese font (PingFang, Microsoft YaHei, Noto Sans CJK…): see cjk_font.rs.
|
||||
# Chinese has one plural form. Placeholders and trailing ellipses are preserved.
|
||||
|
||||
Launcher Launcher
|
||||
Craft App Suite Craft App Suite
|
||||
Open Source Tools for Creatives, Makers, and Builders 为创作者和开发者打造的开源工具
|
||||
Settings 设置
|
||||
Check for updates 检查更新
|
||||
Grid 网格
|
||||
Table 表格
|
||||
Show apps as cards 以卡片显示应用
|
||||
Show apps as a table 以表格显示应用
|
||||
All 全部
|
||||
Updates 更新
|
||||
Not installed 未安装
|
||||
Search apps 搜索应用
|
||||
Sort 排序
|
||||
Suite order 默认顺序
|
||||
Name 名称
|
||||
Inspired by 参考软件
|
||||
Status 状态
|
||||
Installed version 已安装版本
|
||||
Latest version 最新版本
|
||||
Newest release 发布日期
|
||||
Discord Discord
|
||||
GitHub GitHub
|
||||
Website 网站
|
||||
Classic apps 经典应用
|
||||
New apps 新应用
|
||||
NEW 新
|
||||
Install 安装
|
||||
Update to {v} 更新至 {v}
|
||||
Open 打开
|
||||
Download for {os} 下载 {os} 版
|
||||
Download 下载
|
||||
Coming soon 即将推出
|
||||
Checking… 正在检查…
|
||||
Not available 不可用
|
||||
Offline 离线
|
||||
Downloading {done} of {total} 正在下载 {done} / {total}
|
||||
Downloading… 正在下载…
|
||||
Reconnecting… 正在重新连接…
|
||||
Verifying… 正在校验…
|
||||
Installing, then restarting… 正在安装,完成后重启…
|
||||
Installing… 正在安装…
|
||||
Removing… 正在卸载…
|
||||
Pause download 暂停下载
|
||||
Cancel download 取消下载
|
||||
Resume download 继续下载
|
||||
Discard download 丢弃下载
|
||||
Release notes 更新说明
|
||||
Reinstall 重新安装
|
||||
Uninstall… 卸载…
|
||||
All releases 所有版本
|
||||
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} 已可更新。
|
||||
Dismiss 忽略
|
||||
Update and restart 更新并重启
|
||||
What's new 更新内容
|
||||
No notes were published with this release. 此版本未提供更新说明。
|
||||
Released {date} 发布于 {date}
|
||||
Open on GitHub 在 GitHub 上打开
|
||||
Close 关闭
|
||||
ArtCraft Launcher {version} ArtCraft Launcher {version}
|
||||
Only when I ask 仅手动检查
|
||||
Notify me when an installed app has an update 已安装应用有更新时通知我
|
||||
Include pre-releases (betas and release candidates) 包含预发布版本(测试版和候选版)
|
||||
Installing on Linux Linux 安装设置
|
||||
Install new apps as 新应用安装格式
|
||||
System package (recommended) 系统软件包(推荐)
|
||||
Updates always use the format an app was installed with. 更新时始终使用应用原来的安装格式。
|
||||
Running in the background 后台运行
|
||||
Keep running in the tray when the window is closed 关闭窗口后继续在系统托盘中运行
|
||||
Show the window on the current desktop 在当前桌面显示窗口
|
||||
Start ArtCraft Launcher when I log in 登录时启动 ArtCraft Launcher
|
||||
Community 社区
|
||||
Questions, ideas and news from the ArtCraft team 与 ArtCraft 团队交流问题、想法和动态
|
||||
Appearance 外观
|
||||
Quit ArtCraft Launcher 退出 ArtCraft Launcher
|
||||
Done 完成
|
||||
Uninstall {name}? 卸载 {name}?
|
||||
The app will be moved to the Trash. Your documents and settings stay. 应用将移到废纸篓。你的文档和设置会保留。
|
||||
Windows will remove it like any installed program. Your documents and settings stay. Windows 将按常规方式卸载此应用。你的文档和设置会保留。
|
||||
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. 系统软件包管理器将卸载此应用,可能需要输入密码。你的文档和设置会保留。
|
||||
The app will be removed. Your documents and settings stay. 应用将被卸载。你的文档和设置会保留。
|
||||
Uninstall 卸载
|
||||
Cancel 取消
|
||||
Checked {ago} 上次检查:{ago}
|
||||
just now 刚刚
|
||||
yesterday 昨天
|
||||
{month} {day}, {year} {year}年{month}{day}日
|
||||
month Jan 1月
|
||||
month Feb 2月
|
||||
month Mar 3月
|
||||
month Apr 4月
|
||||
month May 5月
|
||||
month Jun 6月
|
||||
month Jul 7月
|
||||
month Aug 8月
|
||||
month Sep 9月
|
||||
month Oct 10月
|
||||
month Nov 11月
|
||||
month Dec 12月
|
||||
Not checked yet 尚未检查
|
||||
GitHub's hourly limit was reached; full details return in {min} min 已达到 GitHub 每小时请求上限;{min} 分钟后可获取完整信息
|
||||
Installing for {platform} 安装平台:{platform}
|
||||
Downloads for {platform} 下载平台:{platform}
|
||||
No downloads for this system 此系统暂无可用下载
|
||||
Join the ArtCraft community on Discord 加入 Discord 上的 ArtCraft 社区
|
||||
ArtCraft on {name} ArtCraft 的 {name} 主页
|
||||
Available to install 可安装
|
||||
Available 可用
|
||||
Installed · up to date 已安装 · 已是最新
|
||||
Update available 有可用更新
|
||||
Installed 已安装
|
||||
Not available here 此平台不可用
|
||||
Updating… 正在更新…
|
||||
Update paused 更新已暂停
|
||||
Download paused 下载已暂停
|
||||
{version} installed → {latest} released 已安装 {version} → 最新 {latest}
|
||||
Version {version} 版本 {version}
|
||||
{installed} installed · latest {latest} 已安装 {installed} · 最新 {latest}
|
||||
Version {version} installed 已安装版本 {version}
|
||||
Version {latest} 版本 {latest}
|
||||
No release published yet 尚未发布版本
|
||||
ArtCraft Launcher ArtCraft Launcher
|
||||
App 应用
|
||||
Latest 最新
|
||||
Released 发布日期
|
||||
Auto 跟随系统
|
||||
Language 语言
|
||||
Ascending 升序
|
||||
Descending 降序
|
||||
Click to reverse the order 点击切换排序方向
|
||||
Click to sort by this column 点击按此列排序
|
||||
Like {name} 类似 {name}
|
||||
{done} of {total} {done} / {total}
|
||||
Downloading 正在下载
|
||||
Verifying checksum… 正在验证校验和…
|
||||
Resume 继续
|
||||
{done} of {total} downloaded 已下载 {done} / {total}
|
||||
{done} downloaded 已下载 {done}
|
||||
Checking for updates… 正在检查更新…
|
||||
Downloads the new version, replaces this one and opens it again 下载新版本,替换当前版本并重新打开
|
||||
Couldn't check for updates: {f} 无法检查更新:{f}
|
||||
Website (coming soon) 网站(即将上线)
|
||||
GitHub repository GitHub 仓库
|
||||
ArtCraft ArtCraft
|
||||
Update 1 app 更新 1 个应用
|
||||
Update all ({count}) 全部更新({count})
|
||||
⊞ Grid ⊞ 网格
|
||||
☰ Table ☰ 表格
|
||||
🔍 Search apps 🔍 搜索应用
|
||||
{name} {version} {name} {version}
|
||||
Match system 跟随系统
|
||||
Dark 深色
|
||||
Light 浅色
|
||||
Full changelog 完整更新日志
|
||||
link 链接
|
||||
No apps match 没有匹配的应用
|
||||
Open ArtCraft Launcher to update. 打开 ArtCraft Launcher 进行更新。
|
||||
{name} {v} is available {name} {v} 已可更新
|
||||
{count} updates are available 有 {count} 个可用更新
|
||||
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. 未能获取更新说明(已达到 GitHub 请求上限)。可前往版本发布页面查看。
|
||||
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. 开启后,从菜单栏打开启动器时,窗口会显示在当前桌面。关闭后,macOS 会切换到窗口原来所在的桌面。
|
||||
Couldn't change the login item: {e} 无法更改登录启动项:{e}
|
||||
column App 应用
|
||||
column Installed 已安装版本
|
||||
column Latest 最新版本
|
||||
column Released 发布日期
|
||||
filter Installed 已安装
|
||||
@plural {n} min ago|{n} min ago {n} 分钟前
|
||||
@plural {n} h ago|{n} h ago {n} 小时前
|
||||
@plural {n} day ago|{n} days ago {n} 天前
|
||||
@id app.install 安装
|
||||
@id app.update 更新至 {v}
|
||||
@id app.open 打开
|
||||
@id app.download 下载 {os} 版
|
||||
@id app.reveal 在访达中显示
|
||||
@id app.reinstall 重新安装
|
||||
@id app.uninstall 卸载…
|
||||
@id app.notes 更新说明
|
||||
@id app.website 网站
|
||||
@id app.github GitHub 仓库
|
||||
@id app.releases 所有版本
|
||||
@id launcher.update 更新并重启
|
||||
@id launcher.download 下载
|
||||
@id launcher.notes 更新内容
|
||||
@id launcher.dismiss 忽略
|
||||
@id settings.open 设置
|
||||
@id settings.quit 退出 ArtCraft Launcher
|
||||
@id view.grid 网格
|
||||
@id view.table 表格
|
||||
@id filter.all 全部
|
||||
@id filter.installed 已安装
|
||||
@id filter.updates 更新
|
||||
@id filter.notinstalled 未安装
|
||||
@id sort.suite 默认顺序
|
||||
@id sort.name 名称
|
||||
@id sort.inspired 参考软件
|
||||
@id sort.status 状态
|
||||
@id sort.installed 已安装版本
|
||||
@id sort.latest 最新版本
|
||||
@id sort.released 发布日期
|
||||
@id help.discord Discord
|
||||
@id help.github GitHub
|
||||
@id help.website 网站
|
||||
@id update.check 检查更新
|
||||
@id update.check_auto 自动检查更新
|
||||
@id update.notify 已安装应用有更新时通知我
|
||||
@id update.prereleases 包含预发布版本(测试版和候选版)
|
||||
@id linux.preferred_kind 新应用安装格式
|
||||
@id tray.close_to_tray 关闭窗口后继续在系统托盘中运行
|
||||
@id tray.show_on_current_desktop 在当前桌面显示窗口
|
||||
@id tray.login_item 登录时启动 ArtCraft Launcher
|
||||
@id appearance.theme 外观
|
||||
Update 更新
|
||||
New 新
|
||||
Classic 经典
|
||||
Join the Discord 加入 Discord
|
||||
Minimize 最小化
|
||||
Maximize 最大化
|
||||
Restore 还原
|
||||
Show in Finder 在访达中显示
|
||||
Show in Explorer 在资源管理器中显示
|
||||
Show ArtCraft Launcher 显示 ArtCraft Launcher
|
||||
Check for Updates 检查更新
|
||||
Visit getartcraft.com 访问 getartcraft.com
|
||||
ArtCraft on GitHub ArtCraft 的 GitHub 主页
|
||||
Join the ArtCraft Discord 加入 ArtCraft Discord 社区
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · 有 {n} 个可用更新
|
||||
@plural {n} app|{n} apps {n} 个应用
|
||||
photocraft Image editing 图像编辑
|
||||
vectorcraft Vector illustration 矢量插画
|
||||
filmcraft Video editing, color and sound 视频剪辑、调色和声音处理
|
||||
lightcraft Photo library and raw development 照片管理与 RAW 处理
|
||||
pdfcraft Reading, organizing and protecting PDFs PDF 阅读、整理和保护
|
||||
effectcraft Motion graphics and visual effects 动态图形与视觉特效
|
||||
designcraft Page layout and publishing 页面排版与出版
|
||||
wordcraft Writing and document design 写作与文档设计
|
||||
deckcraft Presentations and slide shows 演示文稿与幻灯片
|
||||
gridcraft Spreadsheets and data 电子表格与数据处理
|
||||
cadcraft Computer-aided design and drafting 计算机辅助设计与制图
|
||||
soundcraft Recording, editing and mixing audio 音频录制、编辑与混音
|
||||
|
||||
Show in Files 在文件管理器中显示
|
||||
Loading ArtCraft Launcher… 正在加载 ArtCraft Launcher…
|
||||
Download cancelled 下载已取消
|
||||
Automatically 自动
|
||||
Checked {ago} · using {sources} 上次检查:{ago} · 来源:{sources}
|
||||
{a} and {b} {a} 和 {b}
|
||||
Learn more 了解更多
|
||||
OK 确定
|
||||
Open link 打开链接
|
||||
{name} icon {name} 图标
|
||||
installed 已安装
|
||||
Every Craft app 所有 Craft 应用
|
||||
Apps on this computer 此电脑上的应用
|
||||
Installed apps with a newer version 有新版本的已安装应用
|
||||
Apps you can install 可安装的应用
|
||||
Cancel update 取消更新
|
||||
Stop waiting; the update can be started again later 停止等待;稍后可重新开始更新
|
||||
Download again 重新下载
|
||||
Install cancelled 安装已取消
|
||||
Install cancelled. The download is kept for the next try. 安装已取消。已下载的文件会保留,供下次使用。
|
||||
Update cancelled 更新已取消
|
||||
Uninstall cancelled 卸载已取消
|
||||
Retry install 重试安装
|
||||
Retry update to {version} 重试更新至 {version}
|
||||
The download is still here. It's checked again, then installed. 已下载的文件仍在。会先重新校验,然后安装。
|
||||
Waiting for another app… 正在等待其他应用…
|
||||
Waiting for other installs… 正在等待其他安装…
|
||||
|
||||
# Package formats offered for download (PackageKind::label)
|
||||
Installer (.msi) 安装程序 (.msi)
|
||||
Portable (.zip) 便携版 (.zip)
|
||||
Disk image (.dmg) 磁盘映像 (.dmg)
|
||||
Debian package (.deb) Debian 软件包 (.deb)
|
||||
RPM package (.rpm) RPM 软件包 (.rpm)
|
||||
Flatpak bundle Flatpak 包
|
||||
Tarball (.tar.gz) 压缩包 (.tar.gz)
|
||||
|
||||
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
|
||||
@msg Couldn't open {name}: {e} 无法打开 {name}:{e}
|
||||
@msg Download cancelled 下载已取消
|
||||
@msg {name} updated to {version} {name} 已更新至 {version}
|
||||
@msg {name} installed: {version} {name} 已安装:{version}
|
||||
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} 已更新至 {version}(此版本未发布校验和,因此只检查了文件大小)
|
||||
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} 已安装:{version}(此版本未发布校验和,因此只检查了文件大小)
|
||||
@msg {name} was removed {name} 已卸载
|
||||
@msg {name} is running. Quit it, then try again. {name} 正在运行。请先退出,然后重试。
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 3.
|
|
|
@ -17,6 +17,8 @@ macro_rules! tl {
|
|||
}
|
||||
|
||||
mod app;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod cjk_font;
|
||||
mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@ pub struct Settings {
|
|||
pub sort: SortKey,
|
||||
pub sort_descending: bool,
|
||||
pub theme: ThemeChoice,
|
||||
/// Interface language: `auto` (follow the system) or a language code from the registry.
|
||||
/// Interface language: `auto` (follow the system) or a language code from the registry
|
||||
/// (`en`, `ru`, `zh-CN`).
|
||||
pub language: String,
|
||||
/// Closing the window keeps the launcher in the tray (menu bar) instead of quitting.
|
||||
pub close_to_tray: bool,
|
||||
|
|
@ -114,10 +115,17 @@ impl Settings {
|
|||
pub const LANGUAGE_AUTO: &'static str = "auto";
|
||||
|
||||
/// Display name for a language `code`: a language's name in itself, from the registry
|
||||
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)), or "Auto" for `auto` and unknown codes, like
|
||||
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing the system's Chinese font is
|
||||
/// named in English, with its own name added only once that font is loaded (labels never load
|
||||
/// it: choosing the language does); "Auto" for `auto` and unknown codes, like
|
||||
/// [`Lang::from_pref`](crate::i18n::Lang::from_pref).
|
||||
pub fn language_label(code: &str) -> String {
|
||||
crate::i18n::Lang::from_code(code).map_or_else(|| t("Auto").to_string(), |lang| lang.name().to_string())
|
||||
match crate::i18n::Lang::from_code(code) {
|
||||
Some(lang) if lang.needs_cjk_font() && crate::theme::cjk_font_loaded() => format!("{} ({})", lang.english_name(), lang.name()),
|
||||
Some(lang) if lang.needs_cjk_font() => lang.english_name().to_string(),
|
||||
Some(lang) => lang.name().to_string(),
|
||||
None => t("Auto").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ pub fn mono_bold() -> FontFamily {
|
|||
type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
|
||||
|
||||
/// Registers the site's three variable fonts, each at the weights (and width) it uses there.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
|
||||
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
const ARCHIVO: &[u8] = include_bytes!("../../../assets/fonts/Archivo-Variable.ttf");
|
||||
const GEIST_MONO: &[u8] = include_bytes!("../../../assets/fonts/GeistMono-Variable.ttf");
|
||||
|
|
@ -268,5 +268,46 @@ pub fn install_fonts(ctx: &egui::Context) {
|
|||
stack.extend(fallback.iter().cloned());
|
||||
fonts.families.insert(family, stack);
|
||||
}
|
||||
// Chinese, behind everything else, from the system: only when asked for, as the font is large.
|
||||
if cjk && let Some(font) = cjk_font() {
|
||||
fonts.font_data.insert("CJK".to_owned(), font);
|
||||
for stack in fonts.families.values_mut() {
|
||||
stack.push("CJK".to_owned());
|
||||
}
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// The system's Simplified Chinese font, read once on the first call (tens of MB). Desktop only:
|
||||
/// the web build has none, so Chinese can't be drawn there.
|
||||
pub fn cjk_font() -> Option<Arc<FontData>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
CJK_FONT.get_or_init(|| crate::cjk_font::load().map(Arc::new)).clone()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's Chinese font once [`cjk_font`] has looked for it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static CJK_FONT: std::sync::OnceLock<Option<Arc<FontData>>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Has the system's Chinese font already been loaded? Never loads it.
|
||||
pub fn cjk_font_loaded() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
CJK_FONT.get().is_some_and(Option::is_some)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Can Chinese be drawn? Loads the system font on the first call ([`cjk_font`]).
|
||||
pub fn cjk_font_available() -> bool {
|
||||
cjk_font().is_some()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
//! The Simplified Chinese UI, in its own test binary: the UI language is process-wide, so it can't
|
||||
//! share one with the English UI tests. Chinese is drawn with the system's Chinese font; where
|
||||
//! there is none (CI, minimal Linux installs), the UI must stay in English and say why.
|
||||
//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with `-- --test-threads=1`).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::status::InstallSource;
|
||||
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
use artcraft_launcher_ui_egui::settings::ViewMode;
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
struct Fake {
|
||||
events: Vec<Event>,
|
||||
sent: Rc<RefCell<Vec<Command>>>,
|
||||
manage: bool,
|
||||
clock: Rc<Cell<i64>>,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn send(&mut self, command: Command) {
|
||||
self.sent.borrow_mut().push(command);
|
||||
}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }
|
||||
}
|
||||
fn platform(&self) -> Option<Platform> {
|
||||
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
|
||||
}
|
||||
fn now(&self) -> i64 {
|
||||
self.clock.get()
|
||||
}
|
||||
}
|
||||
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
|
||||
fn release(id: &str, v: &str, at: i64) -> Release {
|
||||
let name = format!("{id}-{v}-macos-universal.dmg");
|
||||
Release {
|
||||
tag: format!("v{v}"),
|
||||
version: version::parse(v),
|
||||
name: format!("v{v}"),
|
||||
published_at: Some(at),
|
||||
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
|
||||
notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(),
|
||||
prerelease: false,
|
||||
assets: vec![Asset {
|
||||
name: name.clone(),
|
||||
size: 150 << 20,
|
||||
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
|
||||
sha256: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release.
|
||||
fn events() -> Vec<Event> {
|
||||
let mut ev = Vec::new();
|
||||
let versions = [
|
||||
("photocraft", "0.5.0"),
|
||||
("vectorcraft", "0.7.0"),
|
||||
("filmcraft", "0.4.0"),
|
||||
("lightcraft", "0.4.0"),
|
||||
("pdfcraft", "0.4.0"),
|
||||
("effectcraft", "0.6.0"),
|
||||
("designcraft", "0.4.0"),
|
||||
("wordcraft", "0.3.0"),
|
||||
("deckcraft", "0.3.0"),
|
||||
("gridcraft", "0.3.0"),
|
||||
("cadcraft", "0.3.0"),
|
||||
];
|
||||
for (i, (id, v)) in versions.iter().enumerate() {
|
||||
ev.push(Event::Latest {
|
||||
app: (*id).into(),
|
||||
latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)),
|
||||
from: Origin::ArtCraftApi,
|
||||
});
|
||||
}
|
||||
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
|
||||
let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")];
|
||||
for app in &SUITE {
|
||||
let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed {
|
||||
version: version::parse(v),
|
||||
source: InstallSource::MacApp,
|
||||
location: format!("/Applications/{}.app", app.name),
|
||||
package: None,
|
||||
});
|
||||
ev.push(Event::Detected { app: app.id.into(), installed: inst });
|
||||
}
|
||||
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } });
|
||||
ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });
|
||||
ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });
|
||||
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });
|
||||
ev
|
||||
}
|
||||
|
||||
fn harness_language(
|
||||
view: ViewMode,
|
||||
theme: egui::Theme,
|
||||
manage: bool,
|
||||
language: &str,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let sent2 = sent.clone();
|
||||
let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
|
||||
let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() };
|
||||
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
let mut s = settings.clone();
|
||||
s.theme = if theme == egui::Theme::Dark {
|
||||
artcraft_launcher_ui_egui::theme::ThemeChoice::Dark
|
||||
} else {
|
||||
artcraft_launcher_ui_egui::theme::ThemeChoice::Light
|
||||
};
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
l.ui(ui, &mut NoHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
(harness, sent)
|
||||
}
|
||||
|
||||
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
|
||||
let img = h.render().unwrap();
|
||||
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
// One test: the UI language is process-wide.
|
||||
#[test]
|
||||
fn simplified_chinese_switches_persists_and_keeps_install_actions() {
|
||||
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_value("English").click();
|
||||
h.run_steps(2);
|
||||
// Opening Settings doesn't load the (large) Chinese font: the picker names it in English.
|
||||
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded());
|
||||
h.get_by_label("Simplified Chinese").click();
|
||||
h.run_steps(3);
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available() {
|
||||
// No Chinese font: choosing it keeps the UI in English, and Settings says why.
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.language, "zh-CN");
|
||||
h.get_by_label_contains("No Chinese font was found");
|
||||
h.get_by_label("Notify me when an installed app has an update");
|
||||
snapshot(&mut h, "settings-zh-CN-no-font");
|
||||
return;
|
||||
}
|
||||
// Choosing it loaded the font: now the picker shows both names.
|
||||
h.get_by_value("Simplified Chinese (简体中文)");
|
||||
h.get_by_label("已安装应用有更新时通知我");
|
||||
snapshot(&mut h, "settings-zh-CN-dark");
|
||||
let settings = h.state().as_ref().unwrap().settings.clone();
|
||||
assert_eq!(settings.language, "zh-CN");
|
||||
let saved = serde_json::to_string(&settings).unwrap();
|
||||
let restored: Settings = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, "zh-CN");
|
||||
h.get_by_label("完成").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("图像编辑");
|
||||
h.get_by_label_contains("上次检查:3 分钟前 · 来源:ArtCraft API");
|
||||
h.get_by_label("更新至 0.5.0");
|
||||
// Product names stay in English.
|
||||
h.get_by_label("Launcher");
|
||||
snapshot(&mut h, "grid-zh-CN-dark");
|
||||
h.get_by_label("全部更新(2)").click();
|
||||
h.run_steps(2);
|
||||
let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();
|
||||
assert_eq!(installs, ["photocraft", "effectcraft"]);
|
||||
|
||||
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
|
||||
table.run_steps(6);
|
||||
table.get_by_label("已安装版本");
|
||||
assert!(table.query_all_by_label_contains("年10月").count() >= 1);
|
||||
snapshot(&mut table, "table-zh-CN-light");
|
||||
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
|
||||
web.run_steps(6);
|
||||
assert!(web.query_all_by_label("下载 macOS 版").count() >= 3);
|
||||
}
|
||||
Loading…
Reference in New Issue