lightcraft/crates/ui-egui/src/i18n.rs

971 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
//!
//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the
//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its
//! message catalog (`locales/<code>.json`, embedded at build time). Adding a language is that entry
//! plus its catalogs — every menu and settings surface is built from the table, so nothing else in
//! the UI changes.
use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// The language table.
///
/// The source language comes first and alone: it is the text the UI is authored in, which every
/// other language translates. `name` is written in the language itself (an endonym, so a user who
/// cannot read the current UI language can still find their own).
macro_rules! language_table {
($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => {
/// A UI language.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum Locale {
/// The language the UI is written in.
#[default]
$source,
$($variant,)*
}
impl Locale {
/// Every language, in menu order (the source language first).
pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*];
pub const fn code(self) -> &'static str {
match self { Self::$source => $source_code, $(Self::$variant => $code,)* }
}
/// The language's own name, as shown in the Language menus.
pub const fn name(self) -> &'static str {
match self { Self::$source => "English", $(Self::$variant => $name,)* }
}
/// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…):
/// the craft-fonts faces for it are the ones this language needs.
pub const fn script(self) -> &'static str {
match self { Self::$source => "Latn", $(Self::$variant => $script,)* }
}
/// The language's embedded catalog, parsed once. The source language has none: its
/// lookups return the source text itself.
fn catalog(self) -> &'static BTreeMap<String, String> {
static CATALOGS: OnceLock<BTreeMap<Locale, BTreeMap<String, String>>> = OnceLock::new();
static EMPTY: OnceLock<BTreeMap<String, String>> = OnceLock::new();
// A malformed catalog logs and comes back empty: a broken translation degrades to
// English text, it never takes the app down.
let parse = |catalog: Option<&'static str>| match catalog {
Some(json) => match serde_json::from_str(json) {
Ok(messages) => messages,
Err(error) => {
log::error!("Invalid message catalog for {}: {error}", self.code());
BTreeMap::new()
}
},
None => BTreeMap::new(),
};
CATALOGS
.get_or_init(|| {
Locale::ALL
.iter()
.map(|language| {
let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None };
(*language, parse(catalog))
})
.collect()
})
.get(&self)
.unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new))
}
pub fn parse(code: &str) -> Option<Self> {
match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None }
}
/// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a
/// shipped language. A region or script the table does not ship falls back to the
/// language itself, so a user is never left with no language at all.
pub fn parse_tag(tag: &str) -> Option<Self> {
let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-");
let lower = tag.to_ascii_lowercase();
if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) {
return Some(*exact);
}
let mut parts = lower.split('-');
let language = parts.next()?;
// `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language.
let script = parts.next().and_then(|part| match part {
"hans" | "cn" | "sg" => Some("Hans"),
"hant" | "tw" | "hk" | "mo" => Some("Hant"),
_ => None,
});
Self::ALL
.iter()
.find(|candidate| {
let base = candidate.code().split('-').next().unwrap_or_default();
base == language && script.is_none_or(|script| candidate.script() == script)
})
.copied()
}
pub fn tr(self, source: &str) -> &str {
tr_in(self, source)
}
}
};
}
// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded
// catalog (`locales/<code>.json`).
language_table! {
En, "en";
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
// language can be renamed in code without invalidating anyone's settings.
impl Serialize for Locale {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.code())
}
}
impl<'de> Deserialize<'de> for Locale {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let code = String::deserialize(deserializer)?;
Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}")))
}
}
thread_local! {
static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) };
}
/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs.
pub fn default_language() -> Locale {
std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En)
}
pub fn set_language(language: Locale) {
LOCALE.with(|value| value.set(language));
}
pub fn language() -> Locale {
LOCALE.with(std::cell::Cell::get)
}
/// Translate a built-in display label, preserving unknown labels verbatim.
/// Never call this on editable user text, filenames or command identifiers.
pub fn tr(source: &str) -> &str {
tr_in(language(), source)
}
/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed
/// out for as long as the `'static` key it was looked up by.
fn tr_in(language: Locale, source: &str) -> &str {
if language.catalog().is_empty() {
return source;
}
thread_local! {
static VERBATIM: RefCell<BTreeMap<Locale, BTreeMap<String, &'static str>>> = const { RefCell::new(BTreeMap::new()) };
}
VERBATIM.with_borrow_mut(|cache| {
let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language));
verbatim.get(source).copied().unwrap_or(source)
})
}
/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow.
fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect()
}
// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from
// `locales/*-formats.json`.
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
/// exactly as its owner wrote it.
pub fn builtin_label(source: &str, builtin: bool) -> &str {
if builtin { tr(source) } else { source }
}
/// A profile's name for display: a built-in profile's is translated, an imported LUT's is shown as
/// its file named it (it may happen to match a message, like "Vivid").
pub fn profile_label<'a>(id: &str, name: &'a str) -> &'a str {
builtin_label(name, lightcraft_engine::presets::profile(id).is_some())
}
/// An Activity (history) step for display. The step's stored label stays English; generated steps
/// are translated around the names they carry, and a user preset's name is kept verbatim.
pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String {
if let Some(name) = source.strip_prefix("Preset: ") {
// A user preset may share a stock preset's name: then the step could be either, so it
// keeps the name as written.
let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name);
return tr_format!("Preset: {name}", name = builtin_label(name, builtin));
}
if let Some(name) = source.strip_prefix("Reset ") {
return tr_format!("Reset {name}", name = tr(name));
}
if let Some(name) = source.strip_prefix("Quick Develop: ") {
return tr_format!("Quick Develop: {name}", name = tr(name));
}
tr(source).to_string()
}
/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's
/// name is the user's and stays verbatim.
pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String {
let label = source.label(catalog);
if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
}
/// What the grid is titled for the session's source: [`source_label`], or a library folder's last
/// two names (`photos/travel`; the folder's name is the user's and stays verbatim).
pub fn source_title(session: &lightcraft_engine::Session) -> String {
match (session.source, session.library_folder.as_deref()) {
(lightcraft_engine::LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path),
(source, _) => source_label(source, &session.catalog),
}
}
/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full
/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the
/// catalog's own wording; every other language formats it with its `*-formats.json` date
/// patterns and weekday names. The grouping keys themselves never change.
pub fn date_group_label(key: &str, short: bool) -> String {
let label = lightcraft_catalog::dates::group_label(key);
if language() == Locale::En {
return if short {
match key.len() {
// "September 2026" → "September"
7 => label.split(' ').next().unwrap_or(&label).to_string(),
// "Sunday, 20 September 2026" → "Sunday, 20"
10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(),
_ => label,
}
} else {
label
};
}
let Some(year) = key.get(..4).and_then(|y| y.parse::<u32>().ok()) else { return tr(&label).to_string() };
if key.len() == 4 {
return tr_format!("{year}", year = year);
}
let Some(month) = key.get(5..7).and_then(|m| m.parse::<u32>().ok()).filter(|m| (1..=12).contains(m)) else {
return tr(&label).to_string();
};
if key.len() == 7 {
return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) };
}
if key.len() == 10
&& let Some(weekday) = lightcraft_catalog::dates::weekday(key)
&& let Some(day) = key.get(8..10).and_then(|d| d.parse::<u32>().ok())
{
let weekday = tr(weekday);
return if short {
tr_format!("{weekday}, {day}", weekday = weekday, day = day)
} else {
tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year)
};
}
tr(&label).to_string()
}
/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM");
/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is.
pub fn display_time(iso: &str) -> String {
if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() {
return lightcraft_catalog::dates::display_time(iso);
}
let date = date_group_label(iso.get(..10).unwrap_or(iso), false);
match iso.get(11..).filter(|time| !time.is_empty()) {
Some(time) => format!("{date} {time}"),
None => date,
}
}
/// Built-in colour names are translated; custom label names are user data.
pub fn color_label(catalog: &lightcraft_catalog::Catalog, label: lightcraft_catalog::ColorLabel) -> String {
catalog.custom_label_name(label).map_or_else(|| tr(&catalog.label_name(label)).to_string(), str::to_string)
}
/// A legacy smart-album filter summary, using the same display labels as filter chips.
pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String {
if language() == Locale::En {
filter.describe()
} else {
lightcraft_engine::filter_chips(filter, catalog)
.iter()
.map(|chip| crate::panels::chips::display_label(chip, filter, catalog))
.collect::<Vec<_>>()
.join(" · ")
}
}
/// A rule summary for display, keeping free-text rule values verbatim.
pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String {
fn describe(rules: &lightcraft_catalog::RuleSet, depth: usize) -> String {
use lightcraft_catalog::{
Match, Rule,
rules::{FIELDS, Kind, ops_for},
};
if depth >= 16 {
return tr("Nested group").to_string();
}
let parts: Vec<String> = rules
.rules
.iter()
.map(|rule| match rule {
Rule::Group { group } => format!("({})", describe(group, depth + 1)),
Rule::Field { field, op, value } => {
let Some((_, label, kind)) = FIELDS.iter().find(|entry| entry.0 == field) else { return field.clone() };
let operator = ops_for(*kind).iter().find(|entry| entry.0 == op).map_or(op.as_str(), |entry| entry.1);
let text = match kind {
Kind::Choice(_) | Kind::Bool => {
tr(value.as_str().unwrap_or(if value.as_bool() == Some(true) { "true" } else { "false" })).to_string()
}
_ if matches!(op.as_str(), "inLast" | "notInLast") => format!(
"{} {}",
value.get("n").unwrap_or(&serde_json::Value::Null),
tr(value.get("unit").and_then(serde_json::Value::as_str).unwrap_or("days"))
),
_ => value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() }),
};
format!("{} {} {text}", tr(label), tr(operator)).trim().to_string()
}
})
.collect();
let join = match rules.mode {
Match::All => tr("and"),
Match::Any | Match::None => tr("or"),
};
let text = parts.join(&format!(" {join} "));
if rules.mode == Match::None { format!("{} ({text})", tr("none of")) } else { text }
}
// Preserve the established source-language summary.
if language() == Locale::En { rules.describe() } else { describe(rules, 0) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_language_has_a_catalog_covering_the_source() {
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
for language in Locale::ALL {
assert!(!language.name().is_empty(), "{language:?} has a name");
assert!(!language.script().is_empty(), "{language:?} has a script");
assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code");
if *language == Locale::En {
continue;
}
let messages = language.catalog();
assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code());
for (key, value) in messages {
assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}");
}
}
}
/// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover
/// different sets of messages (a message a language lacks shows in English): the gaps are
/// reported, not failed, so a translation can grow at its own pace.
#[test]
fn catalogs_agree_on_placeholders_and_report_gaps() {
let fields = |text: &str| {
let mut out: Vec<String> = Vec::new();
let mut rest = text;
while let Some(start) = rest.find('{') {
let Some(end) = rest[start..].find('}') else { break };
out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string());
rest = &rest[start + end + 1..];
}
out.sort();
out
};
let translated: Vec<Locale> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect();
let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect();
for language in &translated {
let catalog = language.catalog();
for (key, value) in catalog {
assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}");
}
let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect();
if !missing.is_empty() {
eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len());
}
}
}
/// Locale tags are matched leniently, so a system locale reaches a shipped language.
#[test]
fn locale_tags_normalize() {
assert_eq!(Locale::parse_tag("en"), Some(Locale::En));
assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En));
assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja));
assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr));
for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}");
}
assert_eq!(Locale::parse_tag("xx"), None);
}
#[test]
fn switching_language_translates_and_restores() {
set_language(Locale::ZhHans);
assert_eq!(tr("Exposure"), "曝光");
assert_eq!(tr("Settings"), "设置");
// Untranslated and data-like text passes through untouched.
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
assert_eq!(tr("develop.set"), "develop.set");
assert_eq!(tr("A string no catalog has"), "A string no catalog has");
set_language(Locale::Ja);
assert_eq!(tr("Exposure"), "露出");
set_language(Locale::PtBr);
assert_eq!(tr("Exposure"), "Exposição");
assert_eq!(tr("Settings"), "Configurações");
set_language(Locale::En);
assert_eq!(tr("Exposure"), "Exposure");
}
/// Format strings carry their values in every language, and a language without a translation
/// falls back to the English format rather than dropping the values.
#[test]
fn formats_render_in_every_language() {
// Every language's format string is checked by `format!` against the call site's values.
set_language(Locale::En);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos");
set_language(Locale::ZhHans);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片");
set_language(Locale::Ja);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました");
// Format specs survive translation: precision, sign, and values a language reorders.
for language in Locale::ALL {
set_language(*language);
let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3);
assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}");
let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning");
assert!(merging.contains("42%"), "{language:?}: {merging}");
let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5);
assert!(delta.contains("+5"), "{language:?}: {delta}");
let reading = tr_format!("Reading photos… {} of {}", 3, 10);
let (three, ten) = (reading.find('3'), reading.find("10"));
assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}");
}
set_language(Locale::En);
assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10");
}
/// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation
/// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK
/// catalogs are skipped (the same degradation the Japanese UI has always had).
#[test]
fn catalog_characters_are_paintable() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
for language in Locale::ALL {
let messages = language.catalog();
if messages.is_empty() {
continue;
}
// The faces this language's script needs; a language the craft-fonts input does not
// cover (a translation added ahead of its font) is reported, not failed.
let mut faces: Vec<&str> =
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect();
faces.dedup();
if faces.is_empty() {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family.clone());
let mut missing: Vec<char> = messages
.values()
.flat_map(|message| message.chars())
.filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch))
.collect();
missing.sort_unstable();
missing.dedup();
assert!(
missing.is_empty(),
"{} in {family:?} (faces: {faces:?}) lack glyphs: {}",
language.code(),
missing.iter().collect::<String>()
);
}
});
}
}
/// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a
/// translation must not carry a symbol no installed font has (egui's defaults cover the rest).
#[test]
fn interface_symbols_are_paintable() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
// ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07).
ctx.fonts_mut(|fonts| {
for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] {
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font");
}
});
}
/// The language menu covers every language, and a language's own command selects it.
#[test]
fn language_commands_cover_every_language() {
let commands = [
("app.language.traditionalChinese", Locale::ZhHant),
("app.language.english", Locale::En),
("app.language.simplifiedChinese", Locale::ZhHans),
("app.language.japanese", Locale::Ja),
("app.language.portuguese", Locale::PtBr),
("app.language.german", Locale::De),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
for (id, language) in commands {
assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}");
assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu");
}
assert_eq!(crate::menus::language_from_command("app.language.klingon"), None);
assert_eq!(crate::menus::language_from_command("view.detail"), None);
}
#[test]
fn preferences_round_trip_and_old_settings_remain_readable() {
let old: crate::state::UiState = serde_json::from_str("{}").unwrap();
assert_eq!(old.language, Locale::En);
// Settings written before the language list grew still load.
let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap();
assert_eq!(legacy.language, Locale::Ja);
let settings = crate::state::UiState { language: Locale::ZhHans, ..old };
let saved = serde_json::to_string(&settings).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Locale::ZhHans);
}
#[test]
fn catalogs_contain_core_workflows() {
for language in Locale::ALL.iter().filter(|language| **language != Locale::En) {
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
let value = language.tr(key);
assert!(!value.is_empty() && value != key, "{language:?}: {key}");
}
}
}
#[test]
fn german_catalog_covers_catalogs_commands_controls_and_rule_labels() {
let catalog = Locale::De.catalog();
for language in Locale::ALL {
for key in language.catalog().keys() {
assert!(catalog.contains_key(key), "German lacks {key:?}");
}
}
let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect();
labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label)));
labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label));
for (_, label, kind) in lightcraft_catalog::rules::FIELDS {
labels.push(label);
labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label));
}
labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning));
labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES);
for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) {
if line.is_empty() || line.starts_with("# ") {
continue;
}
let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line);
labels.push(label);
}
for label in labels {
assert!(catalog.contains_key(label), "German lacks display label {label:?}");
}
}
#[test]
fn german_switches_via_menu_and_control_persists_and_formats_dates() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.german", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::De);
assert_eq!(tr("File"), "Datei");
assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true));
assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026");
assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20.");
assert_eq!(date_group_label("2026-09", false), "09/2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05");
assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert");
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert");
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"de""#));
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::De);
let ctx = egui::Context::default();
for code in ["de-DE", "de_AT.UTF-8", "de-CH"] {
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::De);
}
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz");
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
let rules = lightcraft_catalog::RuleSet {
mode: lightcraft_catalog::Match::All,
rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }],
};
assert_eq!(rules_label(&rules), "Stichwörter enthält Color");
let filter = lightcraft_catalog::Filter {
labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue],
..Default::default()
};
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau");
app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap();
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau");
set_language(Locale::En);
}
#[test]
fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() {
let ctx = fonts_ctx(&[]);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}");
app.ui.view = crate::state::ViewMode::People;
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains("Benannte Personen"), "{text}");
for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] {
app.ui.dialog = None;
app.run(command, serde_json::json!({})).unwrap();
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains(title), "{command}: {text}");
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "ÄÖÜäöüß„“".chars() {
assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}");
}
}
});
set_language(Locale::En);
}
#[test]
fn german_buttons_fit_the_bottom_bar_and_export_dialog() {
fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::epaint::Shape::Text(shape) => {
bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size())))
}
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)),
_ => {}
}
}
let ctx = fonts_ctx(&[]);
let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() };
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.language = Locale::De;
let mut bounds = Vec::new();
for export in [false, true] {
if export {
app.run("dialog.export", serde_json::json!({})).unwrap();
}
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
bounds.clear();
for shape in out.shapes {
collect(&shape.shape, &mut bounds);
}
}
let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1;
if export {
let dialog = rect("dialog:window");
for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] {
assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id));
}
} else {
let button = rect("button:copySettings");
let (_, text) = bounds.iter().find(|entry| entry.0 == "Bearbeitungseinstellungen kopieren").unwrap();
assert!(button.contains_rect(*text), "German copy caption overflows its button: {text:?} vs {button:?}");
assert!(!button.intersects(rect("icon:copyGear")));
}
}
set_language(Locale::En);
}
/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are.
#[test]
fn menu_labels_translate_but_user_names_survive() {
for language in Locale::ALL {
set_language(*language);
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color"));
}
set_language(Locale::ZhHans);
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色");
set_language(Locale::Ja);
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
set_language(Locale::En);
}
/// The text the whole window paints over a few frames in `language`.
fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String {
fn collect(shape: &egui::epaint::Shape, text: &mut String) {
match shape {
egui::epaint::Shape::Text(shape) => {
text.push_str(&shape.galley.job.text);
text.push('\n');
}
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)),
_ => {}
}
}
app.ui.language = language;
let mut text = String::new();
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
// This inspects shapes without a renderer; discard texture uploads explicitly.
out.textures_delta.clear();
text.clear();
for shape in out.shapes {
collect(&shape.shape, &mut text);
}
}
text
}
/// Every language is painted by the real window, switching at runtime reinstalls the fonts
/// (their CJK fallback order follows the language), and command ids never change with it.
#[test]
fn every_language_is_painted_and_menu_ids_stay_the_same() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let english_ids = ids(&app);
for language in Locale::ALL {
let text = painted_text(&ctx, &mut app, *language);
for label in ["My Photos", "All Photos"] {
assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label));
}
assert_eq!(app.font_language, *language, "the fonts follow the language");
assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent");
}
let text = painted_text(&ctx, &mut app, Locale::Ja);
assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}");
let text = painted_text(&ctx, &mut app, Locale::ZhHans);
assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}");
let text = painted_text(&ctx, &mut app, Locale::PtBr);
assert!(text.contains("Minhas fotos") && text.contains("Todas as fotos"), "{text}");
set_language(Locale::En);
}
/// The UI families, after one frame so the font definitions are loaded.
fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(craft));
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
ctx
}
/// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI
/// family, Monospace included.
#[test]
fn craft_fonts_render_cjk_in_the_ui() {
let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")];
for (language, sample) in samples {
if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
set_language(language);
let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS);
ctx.fonts_mut(|fonts| {
for family in
[egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace]
{
let font = egui::FontId::new(13.0, family);
for ch in sample.chars() {
assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}");
}
let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE);
let wide = 13.0 * (sample.chars().count() as f32 - 1.0);
assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size());
}
});
}
set_language(Locale::En);
}
/// Built without craft-fonts, the UI (in every language) still installs its fonts and runs;
/// Latin text keeps Inter.
#[test]
fn the_ui_works_without_craft_fonts() {
let ctx = fonts_ctx(&[]);
ctx.fonts_mut(|fonts| {
// (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's
// replacement-glyph face, which there is Hack, the face that draws Latin.)
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}");
}
});
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
for language in Locale::ALL {
app.ui.language = *language;
for frame in 0..3 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
}
set_language(Locale::En);
}
/// Traditional Chinese through the menu command and the control channel, persisted, with
/// localised headings and dates; ids and user text are untouched.
#[test]
fn traditional_chinese_switches_through_menu_and_control_and_persists() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::ZhHant);
// Applied at once, not on the next frame.
assert_eq!(tr("File"), "檔案");
assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片");
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
assert_eq!(date_group_label("2026-09-20", true), "20日 星期日");
assert_eq!(date_group_label("2026-09", false), "2026年9月");
assert_eq!(date_group_label("2026-09", true), "9月");
assert_eq!(date_group_label("2026", false), "2026年");
assert_eq!(date_group_label("", false), "未知日期");
assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05");
assert_eq!(display_time("a-user-value"), "a-user-value");
assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true));
assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false));
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] {
assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color");
}
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩");
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"zh-hant""#), "{saved}");
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::ZhHant);
let ctx = egui::Context::default();
app.ui.language = Locale::En;
for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] {
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
assert_eq!(reply["ok"], true, "{code}");
assert_eq!(app.ui.language, Locale::ZhHant, "{code}");
assert_eq!(language(), Locale::ZhHant, "{code} applies at once");
}
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
assert_eq!(reply["ok"], false);
assert_eq!(app.ui.language, Locale::ZhHant);
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let chinese_ids = ids(&app);
app.run("app.language.english", serde_json::json!({})).unwrap();
assert_eq!(tr("File"), "File");
assert_eq!(chinese_ids, ids(&app));
}
/// Dates follow every language's own patterns; English keeps the catalog's wording.
#[test]
fn date_headings_in_every_language() {
set_language(Locale::En);
assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026");
assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20");
assert_eq!(date_group_label("2026-09", true), "September");
assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05"));
set_language(Locale::Ja);
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)");
assert_eq!(date_group_label("2026-09", true), "9月");
set_language(Locale::ZhHans);
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
for language in Locale::ALL {
set_language(*language);
// A key that is not a date never panics and never shows a raw pattern.
for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] {
assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}");
assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}");
}
}
set_language(Locale::En);
}
/// Stock presets and panel headers are translated; a user preset keeps its name, also when it
/// shares a stock preset's name.
#[test]
fn traditional_chinese_presets_and_panel_headers_are_painted() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.presets = true;
let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone();
app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin });
for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] {
app.ui.right = panel;
let text = painted_text(&ctx, &mut app, Locale::ZhHant);
assert!(text.contains(title), "{text}");
assert!(!text.contains("History") && !text.contains("Versions"), "{text}");
assert!(text.contains("暖光"), "stock preset: {text}");
assert!(text.contains("Warm Glow"), "user preset: {text}");
assert!(text.contains("色彩"), "stock group: {text}");
}
assert_eq!(history_label("Exposure", &app.session.presets), "曝光");
assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片");
assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow");
set_language(Locale::En);
}
}