mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-10-13 23:08:16 +00:00
i18n: add Japanese (ja) interface language (#177)
* Add a Polish interface - pl.tsv: the full catalog (287 rows), written clean-room from the English labels; plurals use the same one|few|many rule as Russian. - Registered as the seventh language: "Polski" in the picker, locales pl, pl-PL and pl_PL.UTF-8, complete_menus = true so the completeness tests cover it. - tests/polish.rs: the Polish UI test, modeled on the French one. - README and AGENTS updated for the new language. * Add Hungarian localization and fix responsive settings layout * i18n: add Czech (cs) interface language Czech catalog with every key of the most complete catalog (287 rows), the cs language entry with locale detection (cs-CZ, cs_CZ.UTF-8, ?lang=cs), a Czech plural rule (1 / 2-4 / other) with a unit test, and the README language list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n: add Japanese (ja) interface language ja.tsv translates the whole interface, the tray menu and tooltip, each app's tagline and the backend's install/update messages into Japanese, and Settings > Language offers 日本語. Japanese locales (ja, ja-JP, ja_JP.UTF-8…) select it; Japanese has one plural form. Japanese is a CJK language, so cjk_font.rs now looks for a Japanese system font (Hiragino Sans on macOS; Yu Gothic, Meiryo or MS Gothic on Windows; Noto Sans CJK JP, Noto Sans JP, IPAGothic, Takao or VL Gothic on Linux and FreeBSD), falling back to a Chinese one, which draws kana and most kanji, where the system has none. The font is loaded per language, so switching between Japanese and Chinese reloads the right face. A fallback face is nudged onto the body font's baseline: egui puts a fallback glyph's baseline at that face's own ascent and only centres the two line boxes, which left a face whose metrics differ from Inter's riding high. On Windows, Yu Gothic's line gap is half an em, so its kana and kanji sat about 0.29 em above the Latin text beside them (Microsoft YaHei, used for Chinese, happens to match). The nudge, applied through a FontTweak, is the difference in ascent and line height, and helps the Chinese faces too. The README and AGENTS list the new language. Tests: the complete-catalog checks cover ja.tsv (placeholders, plural forms, every tl! literal, taglines, package formats), plus a locale/plural test, a Japanese glyph-coverage test, a baseline test and tests/japanese.rs (grid, table, dates, a backend message). * i18n: make the CJK font choice language-neutral The CJK comments, the missing-font note and the plural-rule docs named the languages, so each new one meant editing them all; they now say "CJK". Each language's writing system (its `script`) now lives in the registry, and a language is drawn only with a face for its own script — never another's, which would leave characters as tofu — so a language the system has no font for stays in English, like any other. Adding Korean or Traditional Chinese is a registry row and a candidate list, not a special case. * i18n(pl): use Polish plural rule, so 21 is "21 aplikacji" plural_east_slavic treats 21, 31 and 101 as "one", which is right for Russian but not Polish, where only 1 itself takes the singular. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n(cs): translate "Installed · latest {latest}" (added by #195) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n(pl): translate "Installed · latest {latest}" (added by #195) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n(hu): translate "Installed · latest {latest}" (added by #195) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n(ja): translate "Installed · latest {latest}" (added by #195) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hungarian test: count only the settings body's links, not the cards' Website links behind the modal #198 gave every card a Website link, and Hungarian labels both Webhely. * hungarian test: card links are 14px text, settings icons 24px; filter on that --------- Co-authored-by: DzikStar <82441193+DzikStar@users.noreply.github.com> Co-authored-by: ferivoq <ferikegameplay@gmail.com> Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
13 files changed
+921
-113
No files matched your search
@@ -80,8 +80,8 @@ means adding its row to every catalog marked `complete_menus` (the `*.tsv` files
|
||||
`crates/ui-egui/src/i18n/`), which the i18n tests check. Translate clean-room from the meaning of
|
||||
the English text, never through another catalog. 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.
|
||||
A CJK language 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
|
||||
|
||||
|
||||
@@ -85,22 +85,23 @@ The web version shows the same catalog in the browser, with the latest download
|
||||
## Interface languages
|
||||
|
||||
The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French
|
||||
(Français), German (Deutsch), Hungarian (Magyar), Polish (Polski), Russian (Русский), Simplified
|
||||
Chinese (简体中文) or Spanish (Español). **Auto**, the default, follows the system or browser
|
||||
language; **Settings → Language** picks one and switches at once, and the choice is kept between
|
||||
runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, German ones (`de-DE`, `de-AT`,
|
||||
`de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones (`hu`,
|
||||
`hu-HU`, `hu_HU.UTF-8`…) Hungarian, Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones
|
||||
(`es-ES`, `es-MX`, `es-419`…) Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now)
|
||||
Brazilian Portuguese. 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=pt-BR`, or add
|
||||
`?lang=ru` to the web address.
|
||||
(Français), German (Deutsch), Hungarian (Magyar), Japanese (日本語), Polish (Polski), Russian
|
||||
(Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the default, follows the
|
||||
system or browser language; **Settings → Language** picks one and switches at once, and the choice
|
||||
is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, German ones (`de-DE`,
|
||||
`de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones
|
||||
(`hu`, `hu-HU`, `hu_HU.UTF-8`…) Hungarian, Japanese ones (`ja`, `ja-JP`, `ja_JP.UTF-8`…) Japanese,
|
||||
Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, `es-419`…)
|
||||
Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. 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=pt-BR`, 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.
|
||||
The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS,
|
||||
Yu Gothic, Meiryo or Microsoft YaHei on Windows, Noto Sans CJK, IPA, Takao or WenQuanYi on Linux and
|
||||
FreeBSD). Where the system has no font for one, 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
|
||||
|
||||
|
||||
+15
-14
@@ -109,9 +109,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,
|
||||
/// The CJK language whose system font is installed, if any.
|
||||
/// Set when such a language comes into use, then kept for the session.
|
||||
pub(crate) cjk_font: Option<crate::i18n::Lang>,
|
||||
sent_options: Option<BackendOptions>,
|
||||
/// Unix seconds of the last look at what's installed (startup counts).
|
||||
last_detect: i64,
|
||||
@@ -122,12 +122,12 @@ pub struct LauncherUi {
|
||||
pub(crate) auto_update_attempted: std::collections::BTreeSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// The language to draw in: the preference, or English where it needs a Chinese font the system
|
||||
/// The language to draw in: the preference, or English where it needs a CJK 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.
|
||||
/// CJK font gets loaded: when such a language 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 }
|
||||
if lang.needs_cjk_font() && !theme::cjk_font_available(lang) { crate::i18n::Lang::EN } else { lang }
|
||||
}
|
||||
|
||||
/// How often to look again at what's installed, to notice apps installed or removed outside
|
||||
@@ -138,8 +138,9 @@ const RESCAN_ON_FOCUS_AFTER: i64 = 30;
|
||||
|
||||
impl LauncherUi {
|
||||
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
|
||||
let cjk_fonts = effective_lang(&settings.language).needs_cjk_font();
|
||||
theme::install_fonts(ctx, cjk_fonts);
|
||||
let lang = effective_lang(&settings.language);
|
||||
let cjk_font = lang.needs_cjk_font().then_some(lang);
|
||||
theme::install_fonts(ctx, cjk_font);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
let caps = backend.capabilities();
|
||||
let platform = backend.platform();
|
||||
@@ -163,7 +164,7 @@ impl LauncherUi {
|
||||
started: false,
|
||||
applied_theme: None,
|
||||
fonts_ready: false,
|
||||
cjk_fonts,
|
||||
cjk_font,
|
||||
sent_options: None,
|
||||
last_detect: 0,
|
||||
was_focused: true,
|
||||
@@ -174,7 +175,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, self.cjk_fonts);
|
||||
theme::install_fonts(ctx, self.cjk_font);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
self.fonts_ready = false;
|
||||
self.applied_theme = None;
|
||||
@@ -444,10 +445,10 @@ impl LauncherUi {
|
||||
// Set the current language for this frame based on settings.
|
||||
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;
|
||||
// `effective_lang` only picks a CJK language once its font has loaded.
|
||||
if lang.needs_cjk_font() && self.cjk_font != Some(lang) {
|
||||
theme::install_fonts(&ctx, Some(lang));
|
||||
self.cjk_font = Some(lang);
|
||||
self.fonts_ready = false;
|
||||
}
|
||||
if !self.fonts_ready {
|
||||
|
||||
+189
-18
@@ -1,43 +1,86 @@
|
||||
//! 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.
|
||||
//! A CJK font from the operating system, for the CJK interface languages. 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 them already has. Desktop only.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use egui::epaint::text::VariationCoords;
|
||||
use egui::{FontData, FontTweak};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{FontRef, MetadataProvider, string::StringId};
|
||||
|
||||
use crate::i18n::Script;
|
||||
|
||||
/// 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.
|
||||
/// The bundled body font (Inter), whose baseline a fallback face is nudged onto.
|
||||
const BODY_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
|
||||
/// `(ascent, ascent + |descent| + leading) / units_per_em` of face `index` in `data`.
|
||||
///
|
||||
/// egui puts a fallback glyph's baseline at that face's own ascent and only centres the two line
|
||||
/// boxes (`galley_from_rows`), so a face whose metrics differ from the body font's rides off the
|
||||
/// baseline: a face with a half-em line gap, for one, sat about a quarter em high. [`load`]
|
||||
/// cancels the difference with a `FontTweak`.
|
||||
fn face_metrics(data: &[u8], index: u32) -> (f32, f32) {
|
||||
let Ok(font) = FontRef::from_index(data, index) else { return (0.0, 0.0) };
|
||||
let metrics = font.metrics(Size::unscaled(), LocationRef::default());
|
||||
let upem = f32::from(metrics.units_per_em);
|
||||
if upem <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
(metrics.ascent / upem, (metrics.ascent - metrics.descent + metrics.leading) / upem)
|
||||
}
|
||||
|
||||
/// The script's name, for the log lines.
|
||||
fn script_name(script: Script) -> &'static str {
|
||||
match script {
|
||||
Script::Chinese => "Chinese",
|
||||
Script::Japanese => "Japanese",
|
||||
}
|
||||
}
|
||||
|
||||
/// The families looked for, for the "none found" log line.
|
||||
fn script_families(script: Script) -> &'static str {
|
||||
match script {
|
||||
Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC and WenQuanYi",
|
||||
Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic",
|
||||
}
|
||||
}
|
||||
|
||||
/// A font file that may be installed, and the family of its face for this script.
|
||||
struct Candidate {
|
||||
path: PathBuf,
|
||||
family: &'static str,
|
||||
}
|
||||
|
||||
/// The system's Simplified Chinese font, or `None` (logged) when it has none this knows about.
|
||||
/// The system's font for `script`, 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() {
|
||||
pub(crate) fn load(script: Script) -> Option<FontData> {
|
||||
for Candidate { path, family } in candidates(script) {
|
||||
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() };
|
||||
log::info!("{} text uses {family} from {} (face {index})", script_name(script), path.display());
|
||||
// Regular weight, in case the face is variable (Noto Sans CJK VF), and a nudge onto the
|
||||
// body font's baseline (see `face_metrics`).
|
||||
let (body_ascent, body_height) = face_metrics(BODY_FONT, 0);
|
||||
let (face_ascent, face_height) = face_metrics(&bytes, index);
|
||||
let tweak = FontTweak {
|
||||
coords: VariationCoords::new([(*b"wght", 400.0)]),
|
||||
y_offset_factor: (body_ascent - face_ascent) + 0.5 * (face_height - body_height),
|
||||
..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)"
|
||||
);
|
||||
log::warn!("no {} font found on this system (looked for {})", script_name(script), script_families(script));
|
||||
None
|
||||
}
|
||||
|
||||
@@ -75,8 +118,16 @@ pub(crate) fn face_index(data: &[u8], family: &str) -> Option<u32> {
|
||||
first
|
||||
}
|
||||
|
||||
/// Where each system keeps a font for `script`, best first.
|
||||
fn candidates(script: Script) -> Vec<Candidate> {
|
||||
match script {
|
||||
Script::Chinese => chinese_candidates(),
|
||||
Script::Japanese => japanese_candidates(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where each system keeps a Simplified Chinese font, best first.
|
||||
fn candidates() -> Vec<Candidate> {
|
||||
fn chinese_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")]
|
||||
@@ -129,6 +180,81 @@ fn candidates() -> Vec<Candidate> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Where each system keeps a Japanese font, best first.
|
||||
fn japanese_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")]
|
||||
{
|
||||
// Hiragino Sans (its file keeps the Japanese name); it may be a downloaded font asset.
|
||||
for file in ["ヒラギノ角ゴシック W3.ttc", "ヒラギノ角ゴシック W4.ttc", "ヒラギノ角ゴシック W6.ttc"] {
|
||||
add(Path::new("/System/Library/Fonts").join(file), "Hiragino Sans");
|
||||
for path in mac_font_assets(file) {
|
||||
add(path, "Hiragino Sans");
|
||||
}
|
||||
}
|
||||
add("/System/Library/Fonts/ヒラギノ丸ゴ ProN W4.ttc".into(), "Hiragino Maru Gothic ProN");
|
||||
}
|
||||
#[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("YuGothR.ttc"), "Yu Gothic");
|
||||
add(root.join("Fonts").join("meiryo.ttc"), "Meiryo");
|
||||
add(root.join("Fonts").join("msgothic.ttc"), "MS Gothic");
|
||||
}
|
||||
#[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", "NotoSansCJKjp-Regular.otf", "NotoSansCJK-VF.ttc"] {
|
||||
for dir in NOTO_DIRS {
|
||||
add(Path::new(dir).join(file), "Noto Sans CJK JP");
|
||||
}
|
||||
}
|
||||
// Standalone Noto Sans JP, and the classic Japanese faces (ipafont-gothic, Takao, VL).
|
||||
const JP_DIRS: [&str; 8] = [
|
||||
"/usr/share/fonts/opentype/noto",
|
||||
"/usr/share/fonts/truetype/noto",
|
||||
"/usr/share/fonts/noto",
|
||||
"/usr/share/fonts/truetype/ipafont-gothic",
|
||||
"/usr/share/fonts/truetype/ipafont",
|
||||
"/usr/share/fonts/truetype/takao-gothic",
|
||||
"/usr/share/fonts/truetype/vlgothic",
|
||||
"/usr/local/share/fonts",
|
||||
];
|
||||
for file in ["NotoSansJP-Regular.otf", "NotoSansJP-VF.ttf"] {
|
||||
for dir in JP_DIRS {
|
||||
add(Path::new(dir).join(file), "Noto Sans JP");
|
||||
}
|
||||
}
|
||||
for (file, family) in [("ipag.ttf", "IPAGothic"), ("ipagp.ttf", "IPAPGothic")] {
|
||||
for dir in JP_DIRS {
|
||||
add(Path::new(dir).join(file), family);
|
||||
}
|
||||
}
|
||||
for (file, family) in [("TakaoGothic.ttf", "TakaoGothic"), ("TakaoPGothic.ttf", "TakaoPGothic")] {
|
||||
for dir in JP_DIRS {
|
||||
add(Path::new(dir).join(file), family);
|
||||
}
|
||||
}
|
||||
for (file, family) in [("VL-Gothic-Regular.ttf", "VL Gothic"), ("VL-PGothic-Regular.ttf", "VL PGothic")] {
|
||||
for dir in JP_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")]
|
||||
@@ -163,6 +289,49 @@ mod tests {
|
||||
assert_eq!(face_index(&[0u8; 64], "PingFang SC"), None);
|
||||
}
|
||||
|
||||
/// The baseline nudge reads a face's ascent and height; the reference is Inter's.
|
||||
#[test]
|
||||
fn face_metrics_reads_font_metrics() {
|
||||
let (ascent, height) = face_metrics(BODY_FONT, 0);
|
||||
assert!((ascent - 0.9688).abs() < 0.005, "{ascent}");
|
||||
assert!((height - 1.21).abs() < 0.005, "{height}");
|
||||
assert_eq!(face_metrics(b"not a font", 0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// A system CJK face sits on the Latin baseline, thanks to the nudge in [`load`]. Without a
|
||||
/// CJK font (CI, the web) there is nothing to check.
|
||||
#[test]
|
||||
fn cjk_sits_on_the_latin_baseline() {
|
||||
use egui::{Color32, FontFamily, FontId};
|
||||
for info in crate::i18n::LANGUAGES.iter().filter(|l| l.script.is_some()) {
|
||||
let Some(lang) = crate::i18n::Lang::from_code(info.code) else { continue };
|
||||
let Some(font) = crate::theme::cjk_font(lang) else { continue };
|
||||
let y_offset_factor = font.tweak.y_offset_factor;
|
||||
// A character the language's own catalog uses, so the sample fits its script: the face
|
||||
// must draw it, on the Latin baseline.
|
||||
let Some(sample) =
|
||||
info.source.lines().filter_map(|line| line.split('\t').nth(2)).flat_map(str::chars).find(|c| !c.is_ascii() && c.is_alphabetic())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, Some(lang));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let size = 20.0;
|
||||
let font_id = FontId::new(size, FontFamily::Proportional);
|
||||
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(format!("A{sample}"), font_id.clone(), Color32::WHITE));
|
||||
assert!(ctx.fonts_mut(|f| f.has_glyphs(&font_id, &sample.to_string())), "{}: no glyph for {sample:?}", lang.code());
|
||||
let glyphs = &galley.rows[0].row.glyphs;
|
||||
let pos = |c: char| glyphs.iter().find(|g| g.chr == c).map(|g| g.pos.y);
|
||||
// `pos.y` is the baseline before the nudge (which moves the ink, not `pos`).
|
||||
if let (Some(latin), Some(cjk)) = (pos('A'), pos(sample)) {
|
||||
let rendered = cjk + y_offset_factor * size;
|
||||
assert!((rendered - latin).abs() < 1.0, "{}: latin {latin}, cjk {rendered}, nudge {y_offset_factor}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_index_finds_families_in_real_fonts() {
|
||||
// The bundled Latin faces stand in for system fonts the test machine may not have.
|
||||
@@ -174,10 +343,12 @@ mod tests {
|
||||
/// 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());
|
||||
for script in Script::ALL {
|
||||
if let Some(font) = load(script) {
|
||||
let family = candidates(script).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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,11 +242,12 @@ impl LauncherUi {
|
||||
};
|
||||
|
||||
let mut lang_code = self.settings.language.clone();
|
||||
let installed = self.cjk_font;
|
||||
row(ui, tl!("Language"), 180.0, &mut |ui| {
|
||||
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code)).width(180.0).show_ui(ui, |ui| {
|
||||
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code, installed)).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(), Settings::language_label(lang.code()));
|
||||
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -254,10 +255,11 @@ impl LauncherUi {
|
||||
self.settings.language = lang_code;
|
||||
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() {
|
||||
let pref = crate::i18n::Lang::from_pref(&self.settings.language);
|
||||
if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
|
||||
// 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.";
|
||||
let note = "No font for this language was found on this system, so the interface stays in English. \
|
||||
Installing one (for example Noto Sans CJK) and restarting fixes it.";
|
||||
ui.add(egui::Label::new(RichText::new(note).size(12.0).color(t.warn)).wrap());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# Japanese (ja) catalog for ArtCraft Launcher.
|
||||
# Format: context<TAB>source<TAB>translation
|
||||
# Empty context = plain UI string (tl!, t).
|
||||
# @id = command-id keyed (tr_id).
|
||||
# @plural = plural forms: one|other in English, one form for Japanese.
|
||||
# @msg = status/error message template (message, msg).
|
||||
# Other context = disambiguation for tr_ctx.
|
||||
#
|
||||
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Japanese,
|
||||
# using the terms Japanese-speaking users of creative software know. The user is addressed with
|
||||
# the neutral polite form (です・ます), as in most current Japanese software.
|
||||
#
|
||||
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
|
||||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite",
|
||||
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub", "Discord".
|
||||
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
# Japanese has one plural form. Shown with the system's Japanese font (see cjk_font.rs); where the
|
||||
# system has none, the interface stays in English.
|
||||
|
||||
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 リリースノート
|
||||
Show in Finder Finder で表示
|
||||
Show in Explorer エクスプローラーで表示
|
||||
Show in Files ファイルマネージャーで表示
|
||||
Reinstall 再インストール
|
||||
Uninstall… アンインストール…
|
||||
All releases すべてのリリース
|
||||
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} が利用可能です。
|
||||
Dismiss 閉じる
|
||||
Update and restart 更新して再起動
|
||||
A new version is available. Would you like to relaunch with the updated version? 新しいバージョンがあります。更新後のバージョンで再起動しますか?
|
||||
Later 後で
|
||||
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 手動で確認したときのみ
|
||||
Automatically download and install updates 更新を自動でダウンロードしてインストール
|
||||
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}日
|
||||
|
||||
Automatically 自動
|
||||
Every Craft app すべての Craft アプリ
|
||||
Apps on this computer このコンピューターのアプリ
|
||||
Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ
|
||||
Apps you can install インストールできるアプリ
|
||||
Checked {ago} · using {sources} 最終確認: {ago} · 取得元: {sources}
|
||||
{a} and {b} {a} と {b}
|
||||
Join the Discord Discord に参加
|
||||
Learn more 詳細を見る
|
||||
Minimize 最小化
|
||||
Maximize 最大化
|
||||
Restore 元に戻す
|
||||
New 新着
|
||||
OK OK
|
||||
Open link リンクを開く
|
||||
Update 更新
|
||||
{name} icon {name} のアイコン
|
||||
Loading ArtCraft Launcher… ArtCraft Launcher を読み込み中…
|
||||
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) tarball (.tar.gz)
|
||||
|
||||
# Contextual: each app's tagline, keyed by app id
|
||||
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 オーディオの録音、編集、ミックス
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
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} 分後に詳細が表示されます
|
||||
GitHub limit hit; back 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} {name} の ArtCraft
|
||||
Couldn't open {name}: {e} {name} を開けませんでした: {e}
|
||||
Download cancelled ダウンロードをキャンセルしました
|
||||
Available to install インストール可能
|
||||
Available 利用可能
|
||||
Installed · up to date インストール済み · 最新
|
||||
Update available 更新あり
|
||||
Installed インストール済み
|
||||
installed インストール済み
|
||||
Not available here このプラットフォームでは利用不可
|
||||
Updating… 更新中…
|
||||
Update paused 更新を一時停止
|
||||
Download paused ダウンロードを一時停止
|
||||
{version} installed → {latest} released {version} インストール済み → {latest} 公開
|
||||
Version {version} バージョン {version}
|
||||
{installed} installed · latest {latest} {installed} インストール済み · 最新 {latest}
|
||||
Installed · latest {latest} インストール済み · 最新 {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}
|
||||
|
||||
# Contextual: table column headers
|
||||
column App アプリ
|
||||
column Installed インストール済みバージョン
|
||||
column Latest 最新バージョン
|
||||
column Released 公開日
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed インストール済み
|
||||
|
||||
# Plural forms for Japanese (one form: Japanese nouns don't inflect for number)
|
||||
@plural {n} app|{n} apps {n} 個のアプリ
|
||||
@plural {n} min ago|{n} min ago {n} 分前
|
||||
@plural {n} h ago|{n} h ago {n} 時間前
|
||||
@plural {n} day ago|{n} days ago {n} 日前
|
||||
|
||||
# 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} が実行中です。終了してから再試行してください。
|
||||
|
||||
# Command IDs for menu items (@id)
|
||||
# Format: @id<TAB>command_id<TAB>translation
|
||||
@id app.install インストール
|
||||
@id app.update {v} に更新
|
||||
@id app.open 開く
|
||||
@id app.download {os} 版をダウンロード
|
||||
@id app.reveal Finder で表示
|
||||
@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 外観
|
||||
|
||||
# Tray (menu bar) menu and tooltip
|
||||
Show ArtCraft Launcher ArtCraft Launcher を表示
|
||||
Check for Updates 更新を確認
|
||||
Visit getartcraft.com getartcraft.com を開く
|
||||
ArtCraft on GitHub GitHub の ArtCraft
|
||||
Join the ArtCraft Discord ArtCraft Discord に参加
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} 件の更新があります
|
||||
|
Can't render this file because it contains an unexpected character in line 14 and column 44.
|
@@ -5,23 +5,38 @@ use std::sync::OnceLock;
|
||||
use super::catalog::Catalog;
|
||||
use super::detect::system_lang;
|
||||
|
||||
/// The writing system a language is drawn in, and so the system font it needs. Languages that
|
||||
/// share one can share a face; Simplified and Traditional Chinese do not (they want different
|
||||
/// faces), so each script is its own. The bundled faces have no CJK glyphs, so a language with a
|
||||
/// script borrows a system font for it (`crate::theme::cjk_font`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Script {
|
||||
Chinese,
|
||||
Japanese,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
/// Every script a language can be written in.
|
||||
pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese];
|
||||
}
|
||||
|
||||
/// One supported UI language.
|
||||
pub struct LangInfo {
|
||||
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`). Also
|
||||
/// the `interfaceLanguage` value.
|
||||
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`).
|
||||
/// 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,
|
||||
/// The writing system this language is drawn in, or `None` if it needs no system CJK font.
|
||||
pub script: Option<Script>,
|
||||
/// Catalog file contents (empty for the built-in English).
|
||||
pub source: &'static str,
|
||||
/// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other; Russian:
|
||||
/// 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only for 1,
|
||||
/// 1 = few, 2 = many; French: 0 = one for 0 and 1; Chinese: always 0).
|
||||
/// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other;
|
||||
/// Russian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only
|
||||
/// for 1, 1 = few, 2 = many; French: 0 = one for 0 and 1; a language whose nouns don't inflect
|
||||
/// for number: always 0).
|
||||
pub plural: fn(u64) -> usize,
|
||||
/// Number of plural forms the catalog's `@plural` rows carry (checked by tests).
|
||||
pub plural_forms: usize,
|
||||
@@ -39,7 +54,7 @@ fn plural_zero_one(n: u64) -> usize {
|
||||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// Languages whose nouns don't inflect for number (Chinese): one form for every count.
|
||||
/// A language whose nouns don't inflect for number: one form for every count.
|
||||
fn plural_none(_: u64) -> usize {
|
||||
0
|
||||
}
|
||||
@@ -79,12 +94,12 @@ pub(super) fn plural_polish(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 10] = [
|
||||
pub static LANGUAGES: [LangInfo; 11] = [
|
||||
LangInfo {
|
||||
code: "en",
|
||||
name: "English",
|
||||
english_name: "English",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: "",
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
@@ -95,7 +110,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "ru",
|
||||
name: "Русский",
|
||||
english_name: "Russian",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("ru.tsv"),
|
||||
plural: plural_east_slavic,
|
||||
plural_forms: 3,
|
||||
@@ -106,7 +121,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "zh-CN",
|
||||
name: "简体中文",
|
||||
english_name: "Simplified Chinese",
|
||||
cjk: true,
|
||||
script: Some(Script::Chinese),
|
||||
source: include_str!("zh-CN.tsv"),
|
||||
plural: plural_none,
|
||||
plural_forms: 1,
|
||||
@@ -117,7 +132,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "de",
|
||||
name: "Deutsch",
|
||||
english_name: "German",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("de.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
@@ -128,7 +143,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "fr",
|
||||
name: "Français",
|
||||
english_name: "French",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("fr.tsv"),
|
||||
plural: plural_zero_one,
|
||||
plural_forms: 2,
|
||||
@@ -139,7 +154,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "pt-BR",
|
||||
name: "Português (Brasil)",
|
||||
english_name: "Portuguese (Brazil)",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("pt-BR.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
@@ -150,7 +165,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "es",
|
||||
name: "Español",
|
||||
english_name: "Spanish",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("es.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
@@ -161,7 +176,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "cs",
|
||||
name: "Čeština",
|
||||
english_name: "Czech",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("cs.tsv"),
|
||||
plural: plural_czech,
|
||||
plural_forms: 3,
|
||||
@@ -172,7 +187,7 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "pl",
|
||||
name: "Polski",
|
||||
english_name: "Polish",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("pl.tsv"),
|
||||
plural: plural_polish,
|
||||
plural_forms: 3,
|
||||
@@ -183,13 +198,24 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
code: "hu",
|
||||
name: "Magyar",
|
||||
english_name: "Hungarian",
|
||||
cjk: false,
|
||||
script: None,
|
||||
source: include_str!("hu.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "ja",
|
||||
name: "日本語",
|
||||
english_name: "Japanese",
|
||||
script: Some(Script::Japanese),
|
||||
source: include_str!("ja.tsv"),
|
||||
plural: plural_none,
|
||||
plural_forms: 1,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -227,6 +253,7 @@ impl Lang {
|
||||
pub const CS: Lang = Lang(&LANGUAGES[7]);
|
||||
pub const PL: Lang = Lang(&LANGUAGES[8]);
|
||||
pub const HU: Lang = Lang(&LANGUAGES[9]);
|
||||
pub const JA: Lang = Lang(&LANGUAGES[10]);
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
self.0.code
|
||||
@@ -259,7 +286,12 @@ impl Lang {
|
||||
|
||||
/// Does drawing this language need a CJK font from the system?
|
||||
pub fn needs_cjk_font(self) -> bool {
|
||||
self.0.cjk
|
||||
self.0.script.is_some()
|
||||
}
|
||||
|
||||
/// The writing system this language is drawn in, if it needs a system CJK font.
|
||||
pub fn script(self) -> Option<Script> {
|
||||
self.0.script
|
||||
}
|
||||
|
||||
/// Does this language's catalog claim to cover every `tl!` literal?
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! catalog, cover every UI literal and plural key; the tests enforce that claim. A translation
|
||||
//! that isn't finished yet starts with `complete_menus: false`: it still shows in
|
||||
//! Preferences, strings without a row fall back to English, and the completeness tests
|
||||
//! skip it until every row is done.
|
||||
//! skip it until every row is done. A CJK language also sets `script` and takes its system
|
||||
//! font from `crate::cjk_font`.
|
||||
//!
|
||||
//! That is all: the Preferences dropdown, the system-locale match and the catalog tests
|
||||
//! (parse, placeholders, plural forms, coverage of every `tl!` literal) pick it up from the
|
||||
@@ -34,5 +35,5 @@ mod translate;
|
||||
mod tests;
|
||||
|
||||
pub use detect::{detect_system_lang_in_background, set_system_locales, set_test_lang, system_lang};
|
||||
pub use lang::{LANGUAGES, Lang, LangInfo, lang_from_tag};
|
||||
pub use lang::{LANGUAGES, Lang, LangInfo, Script, lang_from_tag};
|
||||
pub use translate::{current, fmt, has, message, msg, set_current, t, t_id, tn, tr, tr_ctx, tr_id, trn};
|
||||
@@ -365,16 +365,34 @@ fn chinese_counts_and_english_fallback_work() {
|
||||
assert_eq!(message(Lang::ZH_CN, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_locales_and_counts_work() {
|
||||
for tag in ["ja", "ja-JP", "ja_JP.UTF-8", "JA_jp"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(Lang::JA), "{tag}");
|
||||
}
|
||||
assert_eq!(super::detect::first_supported(["xx-XX", "ja-JP", "en-US"]), Some(Lang::JA));
|
||||
// Japanese has one plural form.
|
||||
for n in [0, 1, 2, 11, 100] {
|
||||
assert_eq!(trn(Lang::JA, n, "{n} app", "{n} apps"), format!("{n} 個のアプリ"));
|
||||
assert_eq!(trn(Lang::JA, n, "{n} day ago", "{n} days ago"), format!("{n} 日前"));
|
||||
}
|
||||
assert_eq!(trn(Lang::JA, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items");
|
||||
assert_eq!(tr(Lang::JA, "Untranslated diagnostic"), "Untranslated diagnostic");
|
||||
assert_eq!(super::tr_ctx(Lang::JA, "month", "Mar"), "3月");
|
||||
assert_eq!(tr(Lang::JA, "{month} {day}, {year}"), "{year}年{month}{day}日");
|
||||
assert_eq!(message(Lang::JA, "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() {
|
||||
if !crate::theme::cjk_font_available(Lang::ZH_CN) {
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, true);
|
||||
crate::theme::install_fonts(&ctx, Some(Lang::ZH_CN));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let text: String = Lang::ZH_CN
|
||||
@@ -399,22 +417,54 @@ fn system_font_covers_chinese_in_every_ui_family() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With a system CJK font installed, every UI family can draw the whole Japanese catalog.
|
||||
/// Machines without one (CI, the web) skip this.
|
||||
#[test]
|
||||
fn system_font_covers_japanese_in_every_ui_family() {
|
||||
use egui::{FontFamily, FontId};
|
||||
if !crate::theme::cjk_font_available(Lang::JA) {
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, Some(Lang::JA));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let text: String = Lang::JA
|
||||
.0
|
||||
.source
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(2))
|
||||
.flat_map(str::chars)
|
||||
.filter(|c| ('\u{3040}'..='\u{30ff}').contains(c) || ('\u{3400}'..='\u{9fff}').contains(c))
|
||||
.collect();
|
||||
assert!(text.chars().count() > 200);
|
||||
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().filter(|l| !l.needs_cjk_font()) {
|
||||
assert_eq!(Settings::language_label(lang.code()), lang.name());
|
||||
assert_eq!(Settings::language_label(lang.code(), None), 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");
|
||||
// A CJK language is named in English, and its own name is added only while its face is the one
|
||||
// in the font stack: no other face draws it.
|
||||
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), None), "Simplified Chinese");
|
||||
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), Some(Lang::ZH_CN)), "Simplified Chinese (简体中文)");
|
||||
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::ZH_CN)), "Japanese");
|
||||
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::JA)), "Japanese (日本語)");
|
||||
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO, None), "Auto");
|
||||
assert_eq!(Settings::language_label("xx", None), "Auto");
|
||||
}
|
||||
|
||||
// The parser.
|
||||
|
||||
@@ -117,13 +117,13 @@ 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)). 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
|
||||
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English,
|
||||
/// with its own name added only while `installed` — the CJK face in the current font stack — is
|
||||
/// that language (no other face draws it); "Auto" for `auto` and unknown codes, like
|
||||
/// [`Lang::from_pref`](crate::i18n::Lang::from_pref).
|
||||
pub fn language_label(code: &str) -> String {
|
||||
pub fn language_label(code: &str, installed: Option<crate::i18n::Lang>) -> 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() && installed == Some(lang) => 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(),
|
||||
|
||||
+33
-15
@@ -8,6 +8,8 @@ use egui::epaint::text::VariationCoords;
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::i18n::Lang;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ThemeChoice {
|
||||
/// Follow the system's light or dark setting.
|
||||
@@ -245,7 +247,9 @@ 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, cjk: bool) {
|
||||
/// `cjk`: the CJK language to draw, whose system font is added behind everything else, or `None`
|
||||
/// for a language that needs no CJK font.
|
||||
pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
|
||||
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,8 +272,11 @@ pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
|
||||
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() {
|
||||
// The CJK face, behind everything else, from the system: only when asked for, as the font is
|
||||
// large.
|
||||
if let Some(lang) = cjk
|
||||
&& let Some(font) = cjk_font(lang)
|
||||
{
|
||||
fonts.font_data.insert("CJK".to_owned(), font);
|
||||
for stack in fonts.families.values_mut() {
|
||||
stack.push("CJK".to_owned());
|
||||
@@ -278,36 +285,47 @@ pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
|
||||
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>> {
|
||||
/// The system's font for a CJK language, read once per language (tens of MB). Desktop only: the
|
||||
/// web build has none, so CJK text can't be drawn there.
|
||||
pub fn cjk_font(lang: Lang) -> Option<Arc<FontData>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
CJK_FONT.get_or_init(|| crate::cjk_font::load().map(Arc::new)).clone()
|
||||
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(lang.code()).or_insert_with(|| load_cjk(lang)).clone()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = lang;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's Chinese font once [`cjk_font`] has looked for it.
|
||||
/// Loads the system font for the CJK language `lang`: the face for its script, or `None` where the
|
||||
/// system has none. Nothing falls back to another script's face — a face for a different script
|
||||
/// leaves its characters as tofu — so a language without its font stays in English, like any other.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static CJK_FONT: std::sync::OnceLock<Option<Arc<FontData>>> = std::sync::OnceLock::new();
|
||||
fn load_cjk(lang: Lang) -> Option<Arc<FontData>> {
|
||||
crate::cjk_font::load(lang.script()?).map(Arc::new)
|
||||
}
|
||||
|
||||
/// Has the system's Chinese font already been loaded? Never loads it.
|
||||
pub fn cjk_font_loaded() -> bool {
|
||||
/// The system fonts looked for so far, by language code.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static CJK_FONTS: std::sync::Mutex<std::collections::BTreeMap<&'static str, Option<Arc<FontData>>>> =
|
||||
std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
|
||||
/// Has the system font for `lang` already been loaded? Never loads it.
|
||||
pub fn cjk_font_loaded(lang: Lang) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
CJK_FONT.get().is_some_and(Option::is_some)
|
||||
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(lang.code()).is_some_and(Option::is_some)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = lang;
|
||||
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()
|
||||
/// Can the CJK language `lang` be drawn? Loads the system font on the first call ([`cjk_font`]).
|
||||
pub fn cjk_font_available(lang: Lang) -> bool {
|
||||
cjk_font(lang).is_some()
|
||||
}
|
||||
@@ -155,13 +155,13 @@ fn simplified_chinese_switches_persists_and_keeps_install_actions() {
|
||||
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());
|
||||
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN));
|
||||
h.get_by_label("Simplified Chinese").click();
|
||||
h.run_steps(3);
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available() {
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN) {
|
||||
// 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_contains("No font for this language was found");
|
||||
h.get_by_label("Notify me when an installed app has an update");
|
||||
snapshot(&mut h, "settings-zh-CN-no-font");
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! The Japanese UI, in its own test binary: the UI language is process-wide, so it can't share one
|
||||
//! with the English UI tests. Japanese is drawn with the system's Japanese font; where there is
|
||||
//! none (CI, minimal Linux installs), the UI stays in English and says 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 japanese_switches_persists_and_keeps_install_actions() {
|
||||
use artcraft_launcher_ui_egui::i18n::Lang;
|
||||
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) Japanese font: the picker names it in English.
|
||||
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(Lang::JA));
|
||||
// The list is long enough to scroll, so the last option may be out of view: click it through
|
||||
// accesskit, which reaches widgets that aren't visible.
|
||||
h.get_by_label("Japanese").click_accesskit();
|
||||
h.run_steps(3);
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available(Lang::JA) {
|
||||
// No font for Japanese: choosing it keeps the UI in English, and Settings says why.
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.language, "ja");
|
||||
h.get_by_label_contains("No font for this language was found");
|
||||
h.get_by_label("Notify me when an installed app has an update");
|
||||
snapshot(&mut h, "settings-ja-no-font");
|
||||
return;
|
||||
}
|
||||
// Choosing it loaded the font: now the picker shows both names.
|
||||
h.get_by_value("Japanese (日本語)");
|
||||
h.get_by_label("インストール済みアプリに更新があるときに通知する");
|
||||
snapshot(&mut h, "settings-ja-dark");
|
||||
let settings = h.state().as_ref().unwrap().settings.clone();
|
||||
assert_eq!(settings.language, "ja");
|
||||
let saved = serde_json::to_string(&settings).unwrap();
|
||||
let restored: Settings = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, "ja");
|
||||
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-ja-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-ja-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);
|
||||
}
|
||||
Reference in new issue
Block a user