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:
Flowershangfromthebranches 2026-10-10 12:59:42 -04:00 committed by Brandon Thomas
parent cf5604dc65
commit bf452db810
16 changed files with 960 additions and 33 deletions

View File

@ -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

2
Cargo.lock generated
View File

@ -334,6 +334,8 @@ dependencies = [
"image",
"log",
"serde",
"serde_json",
"skrifa",
"sys-locale",
]

View File

@ -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

View File

@ -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));

View File

@ -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"] }

View File

@ -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();

View File

@ -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());
}
}
}

View File

@ -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"));

View File

@ -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))
}

View File

@ -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.

View File

@ -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");
}

View File

@ -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.

View File

@ -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;

View File

@ -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(),
}
}
}

View File

@ -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()
}

View File

@ -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);
}