mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
Merge pull request #11671 from davidmann4/integration-batch-20
Integration batch 20: 7 i18n PRs (Thai, Hebrew, Urdu, Tamil, Azerbaijani, Romanian, Korean)
This commit is contained in:
@@ -25,6 +25,7 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/az.tsv` | Azerbaijani (Latin script) UI translations | PhotoCraft contributors; AI contributor: OpenAI Codex | PhotoCraft English labels; Google Translate drafts with edited core terminology, contexts and plurals | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/bs.tsv` | Bosnian (Latin, Ijekavian) UI translations | Nine (@nine-codes) with Claude Code, AI-assisted; not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
@@ -32,6 +33,11 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/da.tsv` | Danish UI translations | @pechnikov2026-star and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/he.tsv` | Hebrew UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ur.tsv` | Urdu UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai UI translations | @Thanawatttt and PhotoCraft contributors, with opencode (DeepSeek) as an AI contributor | Machine-assisted first draft from PhotoCraft's English labels, reviewed for UI terminology | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ta.tsv` | Tamil UI translations | @newsbaba24x7 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
|
||||
Generated
+25
@@ -3064,6 +3064,28 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_casemap"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9"
|
||||
dependencies = [
|
||||
"icu_casemap_data",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_casemap_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
@@ -3072,6 +3094,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"serde",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -5579,6 +5602,8 @@ dependencies = [
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
"flate2",
|
||||
"icu_casemap",
|
||||
"icu_locale_core",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
|
||||
@@ -40,6 +40,9 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Display order of right-to-left labels (`bidi_text.rs`); already linked through usvg.
|
||||
unicode-bidi = "0.3"
|
||||
# Context-sensitive dotted/dotless I mapping for Azerbaijani and Turkish UI text.
|
||||
icu_casemap = "2.3"
|
||||
icu_locale_core = "2.3"
|
||||
base64 = "0.23"
|
||||
# Inflating the translation catalogs, stored deflated (build.rs); already linked through photocraft-io.
|
||||
flate2 = { workspace = true }
|
||||
|
||||
@@ -439,7 +439,15 @@ fn drop_taken_text(ctx: &Context) {
|
||||
ctx.data_mut(|d| d.remove_temp::<(char, u64)>(taken_key_id()));
|
||||
return;
|
||||
}
|
||||
let same = |t: &str| t.chars().eq(c.to_lowercase()) || t.chars().eq(c.to_uppercase());
|
||||
let same = |t: &str| {
|
||||
if c == 'I' {
|
||||
// The consumed logical I key may emit either Turkic letter on the active keyboard,
|
||||
// whatever the interface language (an English UI on a Turkish keyboard emits `ı`).
|
||||
matches!(t, "I" | "i" | "İ" | "ı")
|
||||
} else {
|
||||
t.chars().eq(c.to_lowercase()) || t.chars().eq(c.to_uppercase())
|
||||
}
|
||||
};
|
||||
let found = ctx.input_mut(|i| {
|
||||
let at = i.events.iter().position(|e| matches!(e, egui::Event::Text(t) if same(t)));
|
||||
at.map(|at| i.events.remove(at)).is_some()
|
||||
@@ -558,6 +566,37 @@ fn on_pasteboard(app: &crate::PhotocraftApp, ctx: &Context, pos: egui::Pos2) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whether `text` survives `drop_taken_text` after the menu took the logical key `key`.
|
||||
fn text_survives(key: char, text: &str) -> bool {
|
||||
let ctx = Context::default();
|
||||
ctx.begin_pass(egui::RawInput { events: vec![egui::Event::Text(text.into())], ..Default::default() });
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(taken_key_id(), (key, pass)));
|
||||
drop_taken_text(&ctx);
|
||||
let survives = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))));
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
survives
|
||||
}
|
||||
|
||||
/// A menu mnemonic consumes the text emitted by the same logical key on Turkic keyboards. The
|
||||
/// keyboard layout is not the interface language: an English UI on a Turkish keyboard too.
|
||||
#[test]
|
||||
fn menu_i_key_does_not_leak_dotless_text_into_zoom_in_any_ui_language() {
|
||||
for code in ["az", "tr", "en", "de"] {
|
||||
let lang = crate::i18n::Lang::from_code(code).unwrap();
|
||||
crate::i18n::with_language(lang, || {
|
||||
for text in ["I", "i", "İ", "ı"] {
|
||||
assert!(!text_survives('I', text), "{code}: {text} leaked after the I key");
|
||||
}
|
||||
// Only the I key folds the Turkic letters; another key's text is not taken.
|
||||
for text in ["İ", "ı", "j"] {
|
||||
assert!(text_survives('K', text), "{code}: {text} was taken for the K key");
|
||||
}
|
||||
assert!(!text_survives('K', "k"), "{code}: k leaked after the K key");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_reads_one_separator_and_the_number_before_a_second_one() {
|
||||
let number = |t: &str| Entry::Number(t.into());
|
||||
|
||||
@@ -414,7 +414,8 @@ fn button_label(mac: bool, label: &str, key: Option<Key>) -> String {
|
||||
/// The labels of one dialog's buttons, each with its key in the same style: "(Y)es" when every
|
||||
/// translation starts with its key, otherwise appended to all ("Ja (Y)", "Nein (N)") (#5564).
|
||||
fn button_labels(mac: bool, buttons: &[(&str, Option<Key>)]) -> Vec<String> {
|
||||
let starts_with_key = |label: &str, k: Key| label.split_at_checked(1).is_some_and(|(first, _)| first.eq_ignore_ascii_case(k.name()));
|
||||
let starts_with_key =
|
||||
|label: &str, k: Key| label.chars().next().and_then(crate::menu_keys::label_key).is_some_and(|c| k.name().chars().eq(std::iter::once(c)));
|
||||
let inline = buttons.iter().all(|&(label, key)| key.is_none_or(|k| starts_with_key(tl!(label), k)));
|
||||
buttons
|
||||
.iter()
|
||||
@@ -423,7 +424,7 @@ fn button_labels(mac: bool, buttons: &[(&str, Option<Key>)]) -> Vec<String> {
|
||||
match key {
|
||||
None => text.to_string(),
|
||||
Some(_) if mac => text.to_string(),
|
||||
Some(k) => match text.split_at_checked(1) {
|
||||
Some(k) => match text.chars().next().and_then(|c| text.split_at_checked(c.len_utf8())) {
|
||||
Some((first, rest)) if inline => format!("({first}){rest}"),
|
||||
_ => format!("{text} ({})", k.name()),
|
||||
},
|
||||
|
||||
@@ -188,6 +188,17 @@ fn mnemonic_labels_bracket_the_key() {
|
||||
assert_eq!(button_label(false, "保存", Some(Key::S)), "保存 (S)");
|
||||
}
|
||||
|
||||
/// Fixed ASCII access keys do not mistake a dotted Azerbaijani i for dotless I.
|
||||
#[test]
|
||||
fn azerbaijani_dialog_button_mnemonics_use_locale_casing() {
|
||||
let az = crate::i18n::Lang::from_code("az").unwrap();
|
||||
crate::i18n::with_language(az, || {
|
||||
assert_eq!(button_label(false, "i", Some(Key::I)), "i (I)");
|
||||
assert_eq!(button_label(false, "ı", Some(Key::I)), "(ı)");
|
||||
assert_eq!(button_label(false, "İ", Some(Key::I)), "İ (I)");
|
||||
});
|
||||
}
|
||||
|
||||
type Prompted = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
/// The unsaved-changes prompt for `command` over `app`'s dirty documents, as `os` draws it (with
|
||||
|
||||
@@ -7,14 +7,19 @@ use crate::{PhotocraftApp, icons};
|
||||
|
||||
/// Case-insensitive subsequence match score (higher is better); None = no match.
|
||||
pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
|
||||
fuzzy_score_in(crate::i18n::Lang::EN, query, text)
|
||||
}
|
||||
|
||||
/// Score a subsequence after converting both strings in the text's language.
|
||||
fn fuzzy_score_in(lang: crate::i18n::Lang, query: &str, text: &str) -> Option<i32> {
|
||||
if query.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
let t: Vec<char> = text.to_lowercase().chars().collect();
|
||||
let t: Vec<char> = lang.lowercase(text).chars().collect();
|
||||
let mut score = 0;
|
||||
let mut ti = 0;
|
||||
let mut last = None;
|
||||
for qc in query.to_lowercase().chars() {
|
||||
for qc in lang.lowercase(query).chars() {
|
||||
let mut found = false;
|
||||
while ti < t.len() {
|
||||
if t[ti] == qc {
|
||||
@@ -39,8 +44,20 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
|
||||
/// Bonus that puts a command or tool whose name is exactly the query ahead of every fuzzy match
|
||||
/// ("settings" finds Settings before Color Settings).
|
||||
fn exact_bonus(query: &str, names: &[&str]) -> i32 {
|
||||
exact_bonus_in(crate::i18n::Lang::EN, query, names)
|
||||
}
|
||||
|
||||
/// Favor a complete displayed name, ignoring its trailing ellipsis and localized case.
|
||||
fn exact_bonus_in(lang: crate::i18n::Lang, query: &str, names: &[&str]) -> i32 {
|
||||
let q = query.trim();
|
||||
if !q.is_empty() && names.iter().any(|n| n.trim_end_matches('…').trim().to_lowercase() == q.to_lowercase()) { 1000 } else { 0 }
|
||||
if !q.is_empty() && names.iter().any(|n| lang.lowercase(n.trim_end_matches('…').trim()) == lang.lowercase(q)) { 1000 } else { 0 }
|
||||
}
|
||||
|
||||
/// Rank the UI wording in its language and the documented English aliases independently.
|
||||
fn bilingual_score(lang: crate::i18n::Lang, query: &str, local: &str, english: &str, local_name: &str, english_name: &str) -> Option<i32> {
|
||||
let local = fuzzy_score_in(lang, query, local).map(|s| s + exact_bonus_in(lang, query, &[local_name]));
|
||||
let english = fuzzy_score(query, english).map(|s| s + exact_bonus(query, &[english_name]));
|
||||
local.into_iter().chain(english).max()
|
||||
}
|
||||
|
||||
/// How many recently run commands the palette remembers.
|
||||
@@ -105,7 +122,7 @@ fn menu_hits(menus: Vec<crate::menus::MenuItem>, q: &str, lang: crate::i18n::Lan
|
||||
let path = m.path.iter().map(|p| crate::i18n::tr(lang, p)).collect::<Vec<_>>().join(" › ");
|
||||
let label = crate::i18n::tr_id(lang, &m.id, &m.label);
|
||||
// Match what is shown and the English name (commands are documented in English).
|
||||
let s = fuzzy_score(q, &format!("{label} {path} {} {path_en}", m.label))? + exact_bonus(q, &[label, &m.label]);
|
||||
let s = bilingual_score(lang, q, &format!("{label} {path}"), &format!("{} {path_en}", m.label), label, &m.label)?;
|
||||
Some((
|
||||
s,
|
||||
m.id,
|
||||
@@ -176,8 +193,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if !empty || hits.is_empty() {
|
||||
hits.extend(menu_hits(menus, &q, lang));
|
||||
for tool in crate::state::Tool::ALL {
|
||||
if let Some(s) = fuzzy_score(&q, &format!("{} {}", tl!(tool.label()), tool.label())) {
|
||||
let s = s + exact_bonus(&q, &[tl!(tool.label()).as_ref(), tool.label()]);
|
||||
if let Some(s) = bilingual_score(lang, &q, tl!(tool.label()).as_ref(), tool.label(), tl!(tool.label()).as_ref(), tool.label()) {
|
||||
hits.push((
|
||||
s + 2,
|
||||
format!("tool:{tool:?}"),
|
||||
@@ -267,6 +283,16 @@ mod tests {
|
||||
use super::{exact_bonus, fuzzy_score, push_recent};
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Localized fuzzy and exact matches use Turkic casing while English aliases use root casing.
|
||||
#[test]
|
||||
fn azerbaijani_palette_keeps_dotted_i_and_english_aliases() {
|
||||
let az = crate::i18n::Lang::from_code("az").unwrap();
|
||||
assert!(super::bilingual_score(az, "ŞƏKİL", "Şəkil", "Image", "Şəkil", "Image").unwrap() >= 1000);
|
||||
assert_eq!(super::bilingual_score(az, "ŞƏKIL", "Şəkil", "Image", "Şəkil", "Image"), None);
|
||||
assert!(super::bilingual_score(az, "IMAGE", "Şəkil", "Image", "Şəkil", "Image").unwrap() >= 1000);
|
||||
assert!(super::fuzzy_score_in(az, "I\u{307}ŞIQ", "İşıq").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_palette_interrupts_the_ime_only_when_it_takes_focus() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
//! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`];
|
||||
//! translators may reorder placeholders freely.
|
||||
|
||||
mod casing;
|
||||
mod catalog;
|
||||
mod system;
|
||||
|
||||
@@ -104,6 +105,20 @@ fn plural_pt(n: u64) -> usize {
|
||||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// Romanian (CLDR): 1 → one; 0, or n % 100 in 1–19 → few (these take the bare plural noun, e.g.
|
||||
/// "2 straturi", "0 straturi", "119 straturi"); everything else (20, 100, 121, ...) → other (these
|
||||
/// take "de" plus the plural noun, e.g. "20 de straturi", "121 de straturi"), which matches
|
||||
/// Romanian's grammatical rule for when counted nouns need "de" inserted before them.
|
||||
fn plural_romanian(n: u64) -> usize {
|
||||
if n == 1 {
|
||||
0
|
||||
} else if n == 0 || (1..=19).contains(&(n % 100)) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// Polish: 1 → one; 2–4, except 12–14 → few; everything else → many.
|
||||
fn plural_polish(n: u64) -> usize {
|
||||
let last = n % 10;
|
||||
@@ -201,7 +216,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 28] = [
|
||||
pub static LANGUAGES: [LangInfo; 34] = [
|
||||
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -236,8 +251,13 @@ pub static LANGUAGES: [LangInfo; 28] = [
|
||||
LangInfo { code: "hu", name: "Magyar", source: catalog!("hu"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Turkish; `tr`, `tr-TR` and `tr_TR` locales resolve here.
|
||||
LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Azerbaijani (Latin script); regional and explicit Latin-script tags resolve here.
|
||||
LangInfo { code: "az", name: "Azərbaycanca", source: catalog!("az"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Vietnamese; `vi-VN` resolves here.
|
||||
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Thai; `th`, `th-TH` and `th_TH.UTF-8` locales resolve here. Thai has no plural inflection,
|
||||
// so counts use one form.
|
||||
LangInfo { code: "th", name: "ไทย", source: catalog!("th"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Hindi; `hi`, `hi-IN` locales resolve here.
|
||||
LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The
|
||||
@@ -253,6 +273,17 @@ pub static LANGUAGES: [LangInfo; 28] = [
|
||||
// Norwegian Bokmål; `nb-NO`, and the macrolanguage `no` and Nynorsk `nn` from POSIX locales,
|
||||
// resolve here (see `candidates`).
|
||||
LangInfo { code: "nb", name: "Norsk bokmål", source: catalog!("nb"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Hebrew; `he-il` and `he_IL` resolve here. The chrome mirrors right to left (`dir_for_code`),
|
||||
// like Arabic. Modern Hebrew software UI collapses CLDR's one/two/many/other
|
||||
// into a plain one|other, so this uses `plural_one_other`.
|
||||
LangInfo { code: "he", name: "עברית", source: catalog!("he"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Urdu; written right to left like Arabic (`dir_for_code`). CLDR plural: one|other.
|
||||
LangInfo { code: "ur", name: "اردو", source: catalog!("ur"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Romanian; `ro-RO` and `ro-MD` resolve here. Standard left-to-right; see `plural_romanian`
|
||||
// for the one/few/other rule.
|
||||
LangInfo { code: "ro", name: "Română", source: catalog!("ro"), plural: plural_romanian, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Tamil; `ta-IN`, `ta-LK` and `ta_IN.UTF-8` resolve here. CLDR plural: one|other.
|
||||
LangInfo { code: "ta", name: "தமிழ்", source: catalog!("ta"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech,
|
||||
Turkish, Hungarian, Serbian, Russian, Swedish, Finnish, Danish, Norwegian Bokmål and Bosnian UI
|
||||
translation catalogs are licensed under MIT OR Apache-2.0, at your option.
|
||||
Turkish, Hungarian, Serbian, Russian, Swedish, Finnish, Danish, Norwegian Bokmål, Bosnian, Hebrew,
|
||||
Urdu, Romanian, Azerbaijani, Thai and Tamil UI translation catalogs are licensed under MIT OR
|
||||
Apache-2.0, at your option.
|
||||
The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
|
||||
|
||||
Azerbaijani (Latin script) translations: machine-assisted drafts of PhotoCraft's
|
||||
English labels using Google Translate, with core menu/tool terminology, contextual meanings and
|
||||
plural messages edited by OpenAI Codex. No proprietary application localization resources were
|
||||
used. Native-speaker review is pending; no human contributor name is inferred or assigned.
|
||||
|
||||
Korean translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
@@ -61,3 +67,21 @@ translations of PhotoCraft's English labels, and no proprietary translation reso
|
||||
Bosnian translations: Nine (@nine-codes) with Claude Code (AI-assisted), original translations of
|
||||
PhotoCraft's English labels, shared with storytold/craft-i18n (locales/bs/); no proprietary
|
||||
translation resources were used. Not yet reviewed by a native speaker.
|
||||
Hebrew translations: machine-translated for @tronicum with Claude (Anthropic) as an AI
|
||||
contributor and not yet reviewed by a native speaker; original translations of PhotoCraft's
|
||||
English labels, and no proprietary translation resources were used.
|
||||
Urdu translations: machine-translated for @tronicum with Claude (Anthropic) as an AI
|
||||
contributor and not yet reviewed by a native speaker; original translations of PhotoCraft's
|
||||
English labels, and no proprietary translation resources were used.
|
||||
Romanian translations: machine-translated for @tronicum with Claude (Anthropic) as an AI
|
||||
contributor and not yet reviewed by a native speaker; original translations of PhotoCraft's
|
||||
English labels, and no proprietary translation resources were used.
|
||||
|
||||
Thai translations: PhotoCraft contributors, with opencode (DeepSeek) as an AI contributor; a
|
||||
machine-assisted first draft from PhotoCraft's English labels, reviewed for UI terminology. The
|
||||
resulting catalog is licensed under MIT OR Apache-2.0 as stated above, and no proprietary
|
||||
localisation resource was used.
|
||||
|
||||
Tamil translations: @newsbaba24x7 and PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels written from their plain meaning; no proprietary localisation
|
||||
resource was used.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
//! Case conversion of translated UI text; command IDs and English aliases use `Lang::EN`.
|
||||
|
||||
use icu_casemap::CaseMapper;
|
||||
use icu_locale_core::{LanguageIdentifier, langid};
|
||||
|
||||
use super::Lang;
|
||||
|
||||
impl Lang {
|
||||
/// Locales that tailor Unicode's dotted/dotless I mappings in this UI.
|
||||
fn case_locale(self) -> Option<LanguageIdentifier> {
|
||||
match self.code() {
|
||||
"az" => Some(langid!("az")),
|
||||
"tr" => Some(langid!("tr")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase UI text in its language, including Turkic I and combining-dot contexts.
|
||||
pub fn lowercase(self, text: &str) -> String {
|
||||
match self.case_locale() {
|
||||
Some(locale) => CaseMapper::new().lowercase_to_string(text, &locale).into_owned(),
|
||||
None => text.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase UI text in its language (`i` → `İ`, `ı` → `I` in Azerbaijani and Turkish).
|
||||
pub fn uppercase(self, text: &str) -> String {
|
||||
match self.case_locale() {
|
||||
Some(locale) => CaseMapper::new().uppercase_to_string(text, &locale).into_owned(),
|
||||
None => text.to_uppercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Match visible text without case distinctions in this language.
|
||||
pub fn contains_ignore_case(self, text: &str, query: &str) -> bool {
|
||||
self.lowercase(text).contains(&self.lowercase(query))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Unicode Turkic mappings keep the two I pairs distinct, including decomposed input.
|
||||
#[test]
|
||||
fn azerbaijani_and_turkish_case_mapping_handles_combining_dots() {
|
||||
for code in ["az", "tr"] {
|
||||
let lang = Lang::from_code(code).unwrap();
|
||||
assert_eq!(lang.lowercase("Iİıi"), "ıiıi");
|
||||
assert_eq!(lang.uppercase("Iİıi"), "IİIİ");
|
||||
assert_eq!(lang.lowercase("I\u{307} I\u{323}\u{307} I\u{301}"), "i i\u{323} ı\u{301}");
|
||||
assert!(lang.contains_ignore_case("İşıq", "İŞIQ"));
|
||||
assert!(!lang.contains_ignore_case("İşıq", "IŞIQ"));
|
||||
assert!(!lang.contains_ignore_case("ı", "i"));
|
||||
}
|
||||
assert_eq!(Lang::EN.lowercase("Iİıi"), "ii\u{307}ıi");
|
||||
assert_eq!(Lang::EN.uppercase("iı"), "II");
|
||||
assert_eq!(Lang::EN.lowercase("ΟΣ"), "ος", "keep contextual final sigma");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3831,3 +3831,349 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Pick a different document for each channel. 채널마다 다른 문서를 선택하십시오.
|
||||
A picked document is no longer open. 선택한 문서 중 하나가 더 이상 열려 있지 않습니다.
|
||||
A picked document is no longer a grayscale document of the same size. 선택한 문서 중 하나가 더 이상 같은 크기의 회색 음영 문서가 아닙니다.
|
||||
|
||||
# Rows that #8185 adds on top of the catalog above.
|
||||
Working CMY Plate 작업용 CMY 판
|
||||
Legacy Macintosh RGB 기존 Macintosh RGB
|
||||
Internet Standard RGB 인터넷 표준 RGB
|
||||
New Window for Document 문서의 새 창
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 오른쪽 클릭하여 삭제.
|
||||
@id edit.stroke 획
|
||||
@id edit.transform.scale 크기 조절
|
||||
@id edit.purge.undo 실행 취소 기록
|
||||
cameraRaw Red 빨강
|
||||
cameraRaw Orange 주황
|
||||
cameraRaw Yellow 노랑
|
||||
cameraRaw Green 초록
|
||||
cameraRaw Aqua 청록
|
||||
cameraRaw Blue 파랑
|
||||
cameraRaw Purple 보라
|
||||
cameraRaw Magenta 자홍
|
||||
cameraRaw Luminance 휘도
|
||||
cameraRaw Temperature 색온도
|
||||
3D 3D
|
||||
About {app} {app} 정보
|
||||
Adjustment 조정
|
||||
Adjustment Properties 조정 속성
|
||||
Adobe RGB (1998) Adobe RGB (1998)
|
||||
Advanced Blending 고급 혼합
|
||||
Apply Layer Comp 레이어 구성 적용
|
||||
Arc 호
|
||||
Arch 아치
|
||||
Artist 아티스트
|
||||
Back 뒤로
|
||||
Before 적용 전
|
||||
Bulge 볼록하게
|
||||
Clipping Path 클리핑 패스
|
||||
Close Window 창 닫기
|
||||
Color Swatches 색상 견본
|
||||
Content-Aware Move 내용 인식 이동
|
||||
Default Foreground and Background Colors 기본 전경색 및 배경색
|
||||
Delete Path 패스 삭제
|
||||
Documents 문서
|
||||
Duplicate Layer Comp 레이어 구성 복제
|
||||
Duplicate Path 패스 복제
|
||||
F F
|
||||
Filename 파일 이름
|
||||
Fill Path 패스 칠
|
||||
Fish 물고기
|
||||
Flag 깃발
|
||||
Group · {n} layer 그룹 · 레이어 {n}개
|
||||
Hide 숨기기
|
||||
Hide Others 다른 앱 숨기기
|
||||
Hide {app} {app} 숨기기
|
||||
ISO Speed ISO 감도
|
||||
Inflate 팽창
|
||||
Invert Mask 마스크 반전
|
||||
Justification 양쪽 정렬
|
||||
Layer Comp 레이어 구성
|
||||
Layer Properties 레이어 속성
|
||||
Loop 반복
|
||||
Magnetic Lasso 자석 올가미
|
||||
Make 제조사
|
||||
Make Work Path 작업 패스 만들기
|
||||
Move Layer 레이어 이동
|
||||
New Channel 새 채널
|
||||
New Layer Comp 새 레이어 구성
|
||||
Patch 패치
|
||||
Pencil Tool 연필 도구
|
||||
Perspective Crop 원근 자르기
|
||||
Pixel 픽셀
|
||||
Punctuation 문장 부호
|
||||
Quick Selection 빠른 선택
|
||||
Quit 종료
|
||||
Quit {app} {app} 종료
|
||||
RGB RGB
|
||||
Recovery 복구
|
||||
Rectangle 사각형
|
||||
Rectangular Selection 사각형 선택
|
||||
Rename Layer Comp 레이어 구성 이름 바꾸기
|
||||
Reorder Layer 레이어 순서 변경
|
||||
Reset Preferences 환경 설정 초기화
|
||||
Rise 상승
|
||||
Ruler 눈금자
|
||||
Search menus 메뉴 검색
|
||||
Select All Layers 모든 레이어 선택
|
||||
Select Inverse 선택 영역 반전
|
||||
Select Layer 레이어 선택
|
||||
Services 서비스
|
||||
Set Colors 색상 설정
|
||||
Shell Lower 아래쪽 조개껍데기
|
||||
Shell Upper 위쪽 조개껍데기
|
||||
Show All 모두 표시
|
||||
Software 소프트웨어
|
||||
Squeeze 압축
|
||||
Stroke Path 패스에 획 적용
|
||||
Switch Foreground and Background Colors 전경색과 배경색 전환
|
||||
Symbols 기호
|
||||
Twist 비틀기
|
||||
Unknown 알 수 없음
|
||||
Update Layer Comp 레이어 구성 업데이트
|
||||
Vertical Type Tool 세로 문자 도구
|
||||
sRGB IEC61966-2.1 sRGB IEC61966-2.1
|
||||
shortcut 단축키
|
||||
cameraRaw Color Detail 색상 세부 정보
|
||||
cameraRaw Color Grading 색상 등급
|
||||
cameraRaw Darks 어두운 영역
|
||||
cameraRaw Global 전체
|
||||
cameraRaw Hue 색조
|
||||
cameraRaw Luminance Detail 휘도 세부 정보
|
||||
cameraRaw Masking 마스킹
|
||||
cameraRaw Roughness 거칠기
|
||||
cameraRaw Tint 색조 보정
|
||||
cameraRaw Vibrance 활기
|
||||
cameraRaw Whites 흰색 계열
|
||||
layerLabel No Color 색상 없음
|
||||
Make Work Path… 작업 패스 만들기…
|
||||
Create Vector Mask 벡터 마스크 만들기
|
||||
Make Selection… 선택 영역 만들기…
|
||||
Fill Path… 패스 칠…
|
||||
Clipping Path… 클리핑 패스…
|
||||
Free Transform Path 패스 자유 변형
|
||||
Copy Fill 칠 복사
|
||||
Copy Complete Stroke 전체 획 복사
|
||||
Paste Fill 칠 붙여넣기
|
||||
Paste Complete Stroke 전체 획 붙여넣기
|
||||
Make Symmetry Path 대칭 패스 만들기
|
||||
Disable Symmetry Path 대칭 패스 비활성화
|
||||
Dark Gray 진한 회색
|
||||
Medium Gray 중간 회색
|
||||
Select Custom Color… 사용자 지정 색상 선택…
|
||||
Color Picker (Custom Canvas Color) 색상 선택기(사용자 지정 캔버스 색상)
|
||||
Rename Channel… 채널 이름 바꾸기…
|
||||
Convert to Alpha Channel 알파 채널로 변환
|
||||
Exit Quick Mask 빠른 마스크 종료
|
||||
Color Indicates Masked Areas 색상으로 마스크 영역 표시
|
||||
Color Indicates Selected Areas 색상으로 선택 영역 표시
|
||||
Up 위로
|
||||
To 대상
|
||||
Shallow 얕게
|
||||
Deep 깊게
|
||||
Blend Interior Effects as Group 내부 효과를 그룹으로 혼합
|
||||
Blend Clipped Layers as Group 클리핑된 레이어를 그룹으로 혼합
|
||||
Transparency Shapes Layer 투명도로 레이어 모양 결정
|
||||
Layer Mask Hides Effects 레이어 마스크로 효과 숨기기
|
||||
Vector Mask Hides Effects 벡터 마스크로 효과 숨기기
|
||||
General Blending 일반 혼합
|
||||
This Layer: 현재 레이어:
|
||||
Nothing to paste: the clipboard holds no image 붙여넣을 이미지가 클립보드에 없습니다.
|
||||
Preferences saved 환경 설정을 저장했습니다.
|
||||
Invalid panel layout; restored the default dock 패널 배치가 잘못되어 기본 도킹 배치로 복원했습니다.
|
||||
Preferences were reset: {error} 환경 설정을 초기화했습니다: {error}
|
||||
Recovered {name} {name}을(를) 복구했습니다.
|
||||
Couldn't save preferences: {error}. Retrying… 환경 설정을 저장할 수 없습니다: {error}. 다시 시도하는 중…
|
||||
Autosave failed: {error} 자동 저장 실패: {error}
|
||||
Exported presets to {path} 사전 설정을 {path}(으)로 내보냈습니다.
|
||||
Ran script {name} {name} 스크립트를 실행했습니다.
|
||||
Sent to the printer ({spooler}) 프린터로 전송했습니다({spooler}).
|
||||
Printed to {path} {path}(으)로 인쇄했습니다.
|
||||
Exported {path} ({size}) {path}을(를) 내보냈습니다({size}).
|
||||
Exported {path} {path}을(를) 내보냈습니다.
|
||||
Added swatch {name} {name} 견본을 추가했습니다.
|
||||
Step {step} ({id}) failed: {error} {step}단계({id}) 실패: {error}
|
||||
Could not rename the action: {error} 액션 이름을 바꿀 수 없습니다: {error}
|
||||
Action playback failed: {error} 액션 재생 실패: {error}
|
||||
Clone source set at {x}, {y} 복제 소스를 {x}, {y}에 설정했습니다.
|
||||
Color management: {error} 색상 관리: {error}
|
||||
Opened {url} {url}을(를) 열었습니다.
|
||||
No custom shape "{name}": pick one in the options bar or Window › Shapes 사용자 지정 도형 "{name}"이(가) 없습니다. 옵션 막대 또는 창 › 도형에서 선택하세요.
|
||||
Saved a copy as {path} 사본을 {path}(으)로 저장했습니다.
|
||||
Exported contents to {path} 내용을 {path}(으)로 내보냈습니다.
|
||||
{label} is not available: {reason} {label}을(를) 사용할 수 없습니다: {reason}
|
||||
Saved {path}; image assets: {n} in {dir} {path} 저장 완료 · 이미지 에셋: {dir}에 {n}개
|
||||
{message} (notes: {n}; {warning}) {message}(참고: {n}개; {warning})
|
||||
Preferences can't be saved 환경 설정을 저장할 수 없습니다.
|
||||
PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft가 계속 다시 시도합니다. 저장에 성공하기 전까지는 종료 시 환경 설정 변경 사항이 사라집니다.
|
||||
GPU retry scheduled GPU 재시도가 예약되었습니다.
|
||||
Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. 작업을 저장하고 PhotoCraft를 다시 시작하면 GPU 가속을 다시 시도합니다. 현재 세션에서는 CPU 렌더링을 계속 사용합니다.
|
||||
GPU device was lost; using the CPU renderer. GPU 장치 연결이 끊어져 CPU 렌더러를 사용합니다.
|
||||
GPU error; using the CPU renderer. GPU 오류로 CPU 렌더러를 사용합니다.
|
||||
Couldn't open {name}: {error} {name}을(를) 열 수 없습니다: {error}
|
||||
Toggle the Brush Settings panel 브러시 설정 패널 표시 전환
|
||||
New Brush Preset 새 브러시 사전 설정
|
||||
{verb} {mask} {layer} {layer} {mask} {verb}
|
||||
Unlink 연결 해제
|
||||
Link 연결
|
||||
layer mask 레이어 마스크
|
||||
vector mask 벡터 마스크
|
||||
Commit transform 변형 적용
|
||||
Cancel transform 변형 취소
|
||||
Scroll menu up 메뉴 위로 스크롤
|
||||
Scroll menu down 메뉴 아래로 스크롤
|
||||
This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? 계속하려면 이 문자 레이어를 래스터화해야 합니다. 이후에는 텍스트를 편집할 수 없습니다. 문자를 래스터화할까요?
|
||||
This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? 계속하려면 이 도형 레이어를 래스터화해야 합니다. 이후에는 도형으로 편집할 수 없습니다. 도형을 래스터화할까요?
|
||||
This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? 계속하려면 이 고급 개체를 래스터화해야 합니다. 이후에는 내용을 편집할 수 없습니다. 고급 개체를 래스터화할까요?
|
||||
This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? 계속하려면 이 칠 레이어를 래스터화해야 합니다. 이후에는 칠 내용을 편집할 수 없습니다. 레이어를 래스터화할까요?
|
||||
This layer must be rasterized before proceeding. 계속하려면 이 레이어를 래스터화해야 합니다.
|
||||
Color Picker (Background Color) 색상 선택기(배경색)
|
||||
Color Picker (Foreground Color) 색상 선택기(전경색)
|
||||
Adwaita (Light) Adwaita(밝게)
|
||||
Adwaita (Dark) Adwaita(어둡게)
|
||||
Breeze Light Breeze 밝게
|
||||
Breeze Dark Breeze 어둡게
|
||||
Solarized Dark Solarized 어둡게
|
||||
Open Recent is unavailable on the web 웹에서는 최근 파일 열기를 사용할 수 없습니다.
|
||||
Graphics adapter: {adapter} 그래픽 어댑터: {adapter}
|
||||
Backend: {backend} ({type}) 백엔드: {backend}({type})
|
||||
Driver: {driver} 드라이버: {driver}
|
||||
GPU backend preference: {backend} 선호 GPU 백엔드: {backend}
|
||||
Selected at launch: {backend} 시작 시 선택된 백엔드: {backend}
|
||||
Image compositor: {processor} 이미지 합성 처리기: {processor}
|
||||
Fallback: {reason} 대체 사용 사유: {reason}
|
||||
This session: {reason} 현재 세션: {reason}
|
||||
Window renderer: {renderer} 창 렌더러: {renderer}
|
||||
Software adapter 소프트웨어 어댑터
|
||||
Graphics adapter 그래픽 어댑터
|
||||
unknown 알 수 없음
|
||||
unknown type 알 수 없는 유형
|
||||
auto 자동
|
||||
Platform: {os} {arch} 플랫폼: {os} {arch}
|
||||
previous reading kept; the last re-read failed: {reason} 이전 값을 유지합니다. 마지막으로 다시 읽기에 실패했습니다: {reason}
|
||||
Monitor profile: {profile} 모니터 프로파일: {profile}
|
||||
Display profiles: {reason} 디스플레이 프로파일: {reason}
|
||||
Monitor profile in use on {display}: {profile} {display}에서 사용 중인 모니터 프로파일: {profile}
|
||||
Monitor profile in use: {profile} 사용 중인 모니터 프로파일: {profile}
|
||||
The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. 캔버스를 sRGB로 표시합니다. 편집 › 색상 설정에서 모니터 프로파일을 선택하세요.
|
||||
The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. {display}의 캔버스를 sRGB로 표시합니다. 편집 › 색상 설정에서 모니터 프로파일을 선택하세요.
|
||||
Display profile not used 디스플레이 프로파일 사용 안 함
|
||||
Monitor profile on {display} (main window): {profile} {display}의 모니터 프로파일(주 창): {profile}
|
||||
Monitor profile on {display}: {profile} {display}의 모니터 프로파일: {profile}
|
||||
manual 수동
|
||||
fallback 대체
|
||||
Importing {what} from {file}… {file}에서 {what} 가져오는 중…
|
||||
brushes 브러시
|
||||
gradients 그레이디언트
|
||||
@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file}에서 브러시 {n}개를 가져왔습니다.
|
||||
@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file}에서 그레이디언트 {n}개를 가져왔습니다.
|
||||
@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file}에서 패턴 {n}개를 가져왔습니다.
|
||||
Data Set: {name} ({index}/{total}) 데이터 세트: {name} ({index}/{total})
|
||||
(none) (없음)
|
||||
navigator Zoom 확대/축소
|
||||
Apply {option}: {state} {option} 적용: {state}
|
||||
on 켜짐
|
||||
off 꺼짐
|
||||
This adjustment ({key}) is kept as imported and can't be edited yet. 이 조정({key})은 가져온 상태로 유지되며 아직 편집할 수 없습니다.
|
||||
{index} of {total} {total}개 중 {index}번째
|
||||
{shortcut} assigned. {shortcut}을(를) 할당했습니다.
|
||||
{shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut}은(는) 이미 {commands}에서 사용 중입니다. 확인을 누르면 기존 할당이 해제됩니다.
|
||||
Export As: {name} 다른 형식으로 내보내기: {name}
|
||||
Color Sampler Tool 색상 샘플러 도구
|
||||
Proof Setup… 교정 설정…
|
||||
Toggle Layer Mask Overlay 레이어 마스크 오버레이 전환
|
||||
Increase Type Size 문자 크기 늘리기
|
||||
Decrease Type Size 문자 크기 줄이기
|
||||
Increase Type Size by 10 문자 크기 10 늘리기
|
||||
Decrease Type Size by 10 문자 크기 10 줄이기
|
||||
Make Selection from Path 패스에서 선택 영역 만들기
|
||||
Target Composite Channel 합성 채널 지정
|
||||
Target Channel 3 채널 3 지정
|
||||
Target Channel 4 채널 4 지정
|
||||
Target Channel 5 채널 5 지정
|
||||
Target Channel 6 채널 6 지정
|
||||
Target Channel 7 채널 7 지정
|
||||
Target Channel 8 채널 8 지정
|
||||
Target Channel 9 채널 9 지정
|
||||
Fill with Foreground Color 전경색으로 칠
|
||||
Fill with Background Color 배경색으로 칠
|
||||
Fill with Foreground Color, Preserve Transparency 전경색으로 칠, 투명도 유지
|
||||
Fill with Background Color, Preserve Transparency 배경색으로 칠, 투명도 유지
|
||||
Temporary 임시
|
||||
Space 스페이스
|
||||
Hand Tool (hold) 손 도구(누르고 유지)
|
||||
Zoom In (hold, drag to scrub) 확대(누르고 유지, 드래그로 조절)
|
||||
Zoom Out (hold) 축소(누르고 유지)
|
||||
Erase with Brush or Pencil (hold) 브러시 또는 연필로 지우기(누르고 유지)
|
||||
Warming Filter (85) 따뜻한 필터(85)
|
||||
W: 너비:
|
||||
A: 각도:
|
||||
Pattern Fill… 패턴 칠…
|
||||
Checkerboard 체커보드
|
||||
Arc Lower 아래쪽 호
|
||||
Arc Upper 위쪽 호
|
||||
Bend 구부리기
|
||||
Lock Transparent Pixels 투명 픽셀 잠금
|
||||
Detect Faces 얼굴 감지
|
||||
Delete Snapshot 스냅샷 삭제
|
||||
Snapshot 1 스냅샷 1
|
||||
ProPhoto RGB ProPhoto RGB
|
||||
@id file.close 닫기
|
||||
@id layer.layerMask.apply 레이어 마스크 적용
|
||||
@id edit.clear 지우기
|
||||
layerLabel Red 빨강
|
||||
layerLabel Magenta 자홍
|
||||
Pick Color From Image 이미지에서 색상 선택
|
||||
Arrows 화살표
|
||||
Basic Latin 기본 라틴 문자
|
||||
Bloat 볼록하게
|
||||
Currency 통화 기호
|
||||
Cyrillic 키릴 문자
|
||||
Forward Warp 앞으로 뒤틀기
|
||||
Freeze Lasso 고정 올가미
|
||||
Freeze Mask 고정 마스크
|
||||
Greek 그리스 문자
|
||||
Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) 커닝: 메트릭, 광학 또는 1/1000 em 단위 값(커서 위치에서 Alt+←/→)
|
||||
Latin 1 라틴 문자 1
|
||||
Latin Extended-A 라틴 문자 확장-A
|
||||
Latin Extended-B 라틴 문자 확장-B
|
||||
Mask None 마스크 없음
|
||||
Math Symbols 수학 기호
|
||||
Numbers 숫자
|
||||
Pucker 오목하게
|
||||
Push Left 왼쪽으로 밀기
|
||||
Reconstruct All 모두 복원
|
||||
Save Path 패스 저장
|
||||
Thaw Mask 마스크 고정 해제
|
||||
Twirl Clockwise 시계 방향 돌리기
|
||||
Twirl Counterclockwise 반시계 방향 돌리기
|
||||
blue 파랑
|
||||
gray 회색
|
||||
green 초록
|
||||
none 없음
|
||||
orange 주황
|
||||
red 빨강
|
||||
violet 보라
|
||||
yellow 노랑
|
||||
cameraRaw Balance 균형
|
||||
cameraRaw Clarity 명료도
|
||||
cameraRaw Color Mixer 색상 혼합기
|
||||
cameraRaw Contrast 대비
|
||||
cameraRaw Curve 곡선
|
||||
cameraRaw Dehaze 안개 제거
|
||||
cameraRaw Detail 세부 정보
|
||||
cameraRaw Effects 효과
|
||||
cameraRaw Exposure 노출
|
||||
cameraRaw Feather 페더
|
||||
cameraRaw Highlights 밝은 영역
|
||||
cameraRaw Midpoint 중간 지점
|
||||
cameraRaw Midtones 중간톤
|
||||
cameraRaw Radius 반경
|
||||
cameraRaw Roundness 원형률
|
||||
cameraRaw Saturation 채도
|
||||
cameraRaw Shadows 어두운 영역
|
||||
cameraRaw Size 크기
|
||||
cameraRaw Texture 텍스처
|
||||
colorRange Invert 반전
|
||||
document Orientation 방향
|
||||
layers Fill 칠
|
||||
layers Fill: 칠:
|
||||
shape Appearance 화면 모양
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -30,6 +30,7 @@ fn requested_languages_resolve_regional_preferences() {
|
||||
("id-ID", "id"),
|
||||
("vi-VN", "vi"),
|
||||
("sr-RS", "sr"),
|
||||
("az-Latn-AZ", "az"),
|
||||
("bs-BA", "bs"),
|
||||
] {
|
||||
assert_eq!(Lang::from_pref(tag), lang(code), "{tag}");
|
||||
@@ -39,6 +40,80 @@ fn requested_languages_resolve_regional_preferences() {
|
||||
assert_eq!(lang_from_tag("ko"), Some(lang("ko")));
|
||||
}
|
||||
|
||||
/// Switching redraws the menu and persists through the shell's save/load services on restart.
|
||||
#[test]
|
||||
fn azerbaijani_draw_after_switching_and_restore_after_restart() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
let code = "az";
|
||||
let file = "Fayl";
|
||||
with_language(Lang::EN, || {
|
||||
let saved = Rc::new(RefCell::new(None::<String>));
|
||||
let (load, store) = (saved.clone(), saved.clone());
|
||||
let services = Services {
|
||||
load_prefs: Some(Box::new(move || load.borrow().clone())),
|
||||
save_prefs: Some(Box::new(move |text| {
|
||||
*store.borrow_mut() = Some(text.to_owned());
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut h = harness_with_services(services);
|
||||
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language");
|
||||
h.run_steps(4);
|
||||
assert_eq!(current(), lang(code));
|
||||
assert_eq!(tr(current(), "File"), file);
|
||||
assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))));
|
||||
let persisted = saved.borrow().clone().expect("shell saved preferences");
|
||||
let value: serde_json::Value = serde_json::from_str(&persisted).expect("saved JSON");
|
||||
assert_eq!(value["interface"]["language"], code);
|
||||
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "en"})).expect("switch back");
|
||||
h.run_steps(4);
|
||||
assert_eq!(current(), Lang::EN);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&saved.borrow().clone().expect("saved English preferences")).expect("saved JSON")["interface"]["language"],
|
||||
"en"
|
||||
);
|
||||
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language again");
|
||||
h.run_steps(4);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&saved.borrow().clone().expect("saved language preferences")).expect("saved JSON")["interface"]["language"],
|
||||
code
|
||||
);
|
||||
drop(h);
|
||||
// The restarted shell, not the old one's drawing language, must bring Azerbaijani back.
|
||||
set_current(Lang::EN);
|
||||
let services = Services { load_prefs: Some(Box::new(move || saved.borrow().clone())), ..Default::default() };
|
||||
let restarted = harness_with_services(services);
|
||||
assert_eq!(restarted.state().session.prefs().interface.language, code);
|
||||
assert_eq!(current(), lang(code));
|
||||
assert!(drawn_text(&restarted).iter().any(|text| *text == file_title(lang(code))));
|
||||
});
|
||||
}
|
||||
|
||||
/// Preferences draw the catalog terms verbatim and the bundled UI font covers their letters.
|
||||
#[test]
|
||||
fn azerbaijani_preferences_draw_terms_and_glyphs() {
|
||||
let code = "az";
|
||||
let interface = "İnterfeys";
|
||||
let light = "Açıq mövzu";
|
||||
with_language(Lang::EN, || {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language");
|
||||
crate::prefs_ui::open_preferences(h.state_mut(), "interface");
|
||||
h.run_steps(4);
|
||||
let text = drawn_text(&h);
|
||||
for expected in [interface, light, tr(lang(code), "UI font size"), tr(lang(code), "UI scale")] {
|
||||
assert!(text.iter().any(|text| text == expected), "{code}: missing {expected:?}");
|
||||
h.ctx.fonts_mut(|fonts| {
|
||||
for c in expected.chars().chain("ƏəĞğIıİiÖöŞşÜüÇç".chars()).filter(|c| c.is_alphabetic()) {
|
||||
assert!(fonts.has_glyph(&egui::FontId::proportional(12.0), c), "{code}: missing glyph {c}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_native_app_menu_keeps_product_and_language_names() {
|
||||
with_language(Lang::EN, || {
|
||||
@@ -231,6 +306,7 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, P
|
||||
harness
|
||||
}
|
||||
|
||||
/// Auto draws the detected system language on first launch while keeping the Auto preference.
|
||||
#[test]
|
||||
fn first_launch_renders_the_system_language_without_overwriting_auto() {
|
||||
for (tag, code) in [
|
||||
@@ -245,6 +321,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() {
|
||||
("fi-FI", "fi"),
|
||||
("da-DK", "da"),
|
||||
("nb-NO", "nb"),
|
||||
("az-AZ", "az"),
|
||||
] {
|
||||
system::with_system_tags(&[tag], || {
|
||||
with_language(Lang::EN, || {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,34 @@ fn vietnamese_tags_resolve() {
|
||||
assert_eq!(Lang::from_pref("VI"), VI());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_tags_resolve() {
|
||||
let th = Lang::from_code("th").expect("th registered");
|
||||
for tag in ["th", "TH", "th-TH", "th_TH", "th_TH.UTF-8"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(th), "{tag}");
|
||||
assert_eq!(Lang::from_pref(tag), th, "{tag}");
|
||||
}
|
||||
assert_eq!(th.name(), "ไทย");
|
||||
assert_eq!((th.0.plural)(5), 0, "Thai has one plural form");
|
||||
assert_eq!(tr(th, "File"), "ไฟล์");
|
||||
assert_eq!(tr(th, "Layer"), "เลเยอร์");
|
||||
}
|
||||
|
||||
/// Tamil: regional tags resolve, counts take one|other, and placeholders stay untranslated.
|
||||
#[test]
|
||||
fn tamil_tags_plurals_and_placeholders() {
|
||||
let ta = Lang::from_code("ta").expect("ta registered");
|
||||
for tag in ["ta", "ta-IN", "ta_LK", "ta_IN.UTF-8"] {
|
||||
assert_eq!(Lang::from_pref(tag), ta, "{tag}");
|
||||
}
|
||||
let forms: Vec<usize> = [0, 1, 2, 21].into_iter().map(ta.0.plural).collect();
|
||||
assert_eq!(forms, [1, 0, 1, 1]);
|
||||
assert_eq!(trn(ta, 1, "{n} layer", "{n} layers"), "1 அடுக்கு");
|
||||
assert_eq!(trn(ta, 3, "{n} layer", "{n} layers"), "3 அடுக்குகள்");
|
||||
assert!(tr(ta, "Scratch: {size}").contains("{size}"));
|
||||
assert_ne!(tr(ta, "Zoom In (hold, drag to scrub)"), "Zoom In (hold, drag to scrub)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_lists_english_first_then_names_alphabetically() {
|
||||
let order = Lang::for_picker();
|
||||
@@ -239,6 +267,16 @@ fn lookups_fall_back_to_english() {
|
||||
assert_eq!(trn(JA(), 3, "{n} no such message", "{n} no such messages"), "3 no such messages");
|
||||
}
|
||||
|
||||
/// Romanian: 1 is one; 0 and counts ending in 01–19 are few ("2 straturi", "119 straturi");
|
||||
/// the rest take "de" ("20 de straturi", "121 de straturi").
|
||||
#[test]
|
||||
fn romanian_plural_rule() {
|
||||
let ro = Lang::from_code("ro").expect("ro registered");
|
||||
let counts = [0, 1, 2, 19, 20, 21, 100, 101, 119, 120, 121, 1001];
|
||||
let forms: Vec<usize> = counts.into_iter().map(ro.0.plural).collect();
|
||||
assert_eq!(forms, [1, 0, 1, 1, 2, 2, 2, 1, 1, 2, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_plural_rule() {
|
||||
let ru = Lang::from_code("ru").expect("ru registered");
|
||||
@@ -302,6 +340,63 @@ fn serbian_plural_rule_handles_teens_and_compound_counts() {
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2, 1]);
|
||||
}
|
||||
|
||||
/// The catalog resolves regional tags, exposes its native name and falls back to English.
|
||||
#[test]
|
||||
fn azerbaijani_resolves_locales_and_preferences() {
|
||||
let lang = Lang::from_code("az").expect("az registered");
|
||||
assert_eq!(lang.name(), "Azərbaycanca");
|
||||
assert_eq!(lang.dir(), Dir::Ltr);
|
||||
assert!(lang.complete_menus());
|
||||
for tag in ["az", "AZ", "az-AZ", "az_AZ.UTF-8", "az-Latn", "AZ-LATN-AZ"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(lang), "{tag}");
|
||||
assert_eq!(Lang::from_pref(tag), lang, "{tag}");
|
||||
}
|
||||
assert_eq!(tr(lang, "unknown translation"), "unknown translation");
|
||||
}
|
||||
|
||||
/// Integer plural boundaries select one only at 1, including large counts without overflow.
|
||||
#[test]
|
||||
fn azerbaijani_plural_counts_include_hundreds_and_large_values() {
|
||||
let az = Lang::from_code("az").expect("az registered");
|
||||
let counts = [0, 1, 2, 11, 19, 20, 21, 99, 100, 101, 102, 119, 120, 1000, 1001, u64::MAX];
|
||||
assert_eq!(counts.map(az.0.plural), [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
||||
for n in counts {
|
||||
assert_eq!(trn(az, n, "{n} layer", "{n} layers"), format!("{n} qat"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact lookups retain contextual meanings, dotted/dotless letters and product placeholders.
|
||||
#[test]
|
||||
fn azerbaijani_keeps_context_placeholders_and_command_names() {
|
||||
let lang = Lang::from_code("az").expect("az registered");
|
||||
for (source, translated) in [("File", "Fayl"), ("Layer", "Qat"), ("Kind", "Növ"), ("Type Layer", "Mətn qatı"), ("Light Theme", "Açıq mövzu")] {
|
||||
assert_eq!(tr(lang, source), translated);
|
||||
}
|
||||
assert_eq!(tr_ctx(lang, "cameraRaw", "Light"), "İşıq");
|
||||
assert_eq!(tr_ctx(lang, "fontWeight", "Light"), "İncə");
|
||||
assert_ne!(tr_id(lang, "edit.clear", "Clear"), "Clear");
|
||||
assert!(fmt(tr(lang, "About {app}"), &[("app", "PhotoCraft")]).contains("PhotoCraft"));
|
||||
}
|
||||
|
||||
/// Every menu label, submenu segment and intentional command-ID override has a catalog entry.
|
||||
#[test]
|
||||
fn azerbaijani_covers_every_menu_label_and_path() {
|
||||
let lang = Lang::from_code("az").expect("az registered");
|
||||
for &(path, label, _, _) in crate::menu_catalog::CATALOG {
|
||||
for source in path.iter().copied().chain(std::iter::once(label)).filter(|source| *source != "---") {
|
||||
assert!(lang.catalog().plain(source).is_some(), "az: missing menu translation for {source:?}");
|
||||
}
|
||||
}
|
||||
for source in ["Convert to Frame…", "Replace Image…", "Import Image…", "Fit Content to Frame", "Fit Frame to Content"] {
|
||||
assert!(lang.catalog().plain(source).is_some(), "az: missing frame translation for {source:?}");
|
||||
}
|
||||
for id in
|
||||
["edit.clear", "edit.purge.undo", "edit.stroke", "edit.transform.scale", "file.close", "filter.sharpen.sharpen", "layer.layerMask.apply", "select.all"]
|
||||
{
|
||||
assert!(lang.catalog().id(id).is_some(), "az: missing @id translation for {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Bosnian locales, with or without region, script or encoding, select the Latin catalog.
|
||||
#[test]
|
||||
fn bosnian_resolves_locales_and_preferences() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,19 @@ fn key_char(key: Key) -> Option<char> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ASCII access key a displayed letter can represent. Turkic `i` uppercases to `İ`,
|
||||
/// which is not an ASCII key; dotless `ı` can represent `I`. Key codes themselves stay ASCII.
|
||||
pub(crate) fn label_key(c: char) -> Option<char> {
|
||||
let lang = crate::i18n::current();
|
||||
let upper = if matches!(lang.code(), "az" | "tr") && matches!(c, 'i' | 'ı' | 'İ') {
|
||||
let mut bytes = [0; 4];
|
||||
lang.uppercase(c.encode_utf8(&mut bytes)).chars().next()?
|
||||
} else {
|
||||
c.to_ascii_uppercase()
|
||||
};
|
||||
upper.is_ascii_alphanumeric().then_some(upper)
|
||||
}
|
||||
|
||||
/// Mnemonics for `labels`, unique within the group: the preferred letter when the label has it,
|
||||
/// else the first unused ASCII letter or digit, trying the first character of each word before
|
||||
/// the others. Compared case-insensitively; the returned letter is upper case.
|
||||
@@ -116,13 +129,13 @@ pub fn assign(labels: &[&str], preferred: &[Option<char>]) -> Vec<Option<char>>
|
||||
let mut used: Vec<char> = Vec::new();
|
||||
let mut out = Vec::with_capacity(labels.len());
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let upper: Vec<char> = label.chars().map(|c| c.to_ascii_uppercase()).collect();
|
||||
let upper: Vec<char> = label.chars().filter_map(label_key).collect();
|
||||
let has = |c: char| upper.contains(&c.to_ascii_uppercase());
|
||||
let mut pick = preferred.get(i).copied().flatten().map(|c| c.to_ascii_uppercase()).filter(|&c| has(c) && !used.contains(&c));
|
||||
if pick.is_none() {
|
||||
let starts = label.split(|c: char| !c.is_alphanumeric()).filter_map(|w| w.chars().next());
|
||||
let rest = label.chars();
|
||||
pick = starts.chain(rest).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).find(|c| !used.contains(c));
|
||||
pick = starts.chain(rest).filter_map(label_key).find(|c| !used.contains(c));
|
||||
}
|
||||
if let Some(c) = pick {
|
||||
used.push(c);
|
||||
@@ -139,13 +152,13 @@ pub fn assign(labels: &[&str], preferred: &[Option<char>]) -> Vec<Option<char>>
|
||||
/// do in those languages ("文件(F)", #5863). Mnemonics stay unique within the group, and a shown
|
||||
/// "(F)" is always the row's key.
|
||||
pub fn localized(labels: &[String], english: &[&str], preferred: &[Option<char>]) -> (Vec<String>, Vec<Option<char>>) {
|
||||
let typed = |l: &str| l.chars().any(|c| c.is_ascii_alphanumeric());
|
||||
let typed = |l: &str| l.chars().any(|c| label_key(c).is_some());
|
||||
// The key goes before a trailing ellipsis, as Windows writes it: "新建(N)…".
|
||||
let with_key = |l: &str, k: char| match l.strip_suffix('…').map(|b| (b, "…")).or_else(|| l.strip_suffix("...").map(|b| (b, "..."))) {
|
||||
Some((base, tail)) => format!("{base}({k}){tail}"),
|
||||
None => format!("{l}({k})"),
|
||||
};
|
||||
let english_keys = assign(english, preferred);
|
||||
let english_keys = crate::i18n::with_language(crate::i18n::Lang::EN, || assign(english, preferred));
|
||||
let mut shown = Vec::with_capacity(labels.len());
|
||||
let mut pref = Vec::with_capacity(labels.len());
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
@@ -181,7 +194,7 @@ pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option<char>,
|
||||
let color = color.unwrap_or(Color32::PLACEHOLDER);
|
||||
let font = egui::TextStyle::Button.resolve(ui.style());
|
||||
let plain = egui::TextFormat { font_id: font.clone(), color, ..Default::default() };
|
||||
let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8())));
|
||||
let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| label_key(*c) == Some(m)).map(|(i, c)| (i, c.len_utf8())));
|
||||
let Some((i, len)) = at else {
|
||||
return egui::RichText::new(format!("{prefix}{label}")).color(color).into();
|
||||
};
|
||||
@@ -225,7 +238,7 @@ pub fn menu_row_text(ui: &Ui, checked: bool, label: &str, mnemonic: Option<char>
|
||||
job.append(CHECK_MARK, 0.0, plain.clone());
|
||||
lead = 0.0;
|
||||
}
|
||||
let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8())));
|
||||
let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| label_key(*c) == Some(m)).map(|(i, c)| (i, c.len_utf8())));
|
||||
let parts = match at {
|
||||
Some((i, len)) => [label.get(..i), label.get(i..i + len), label.get(i + len..)],
|
||||
None => [Some(label), None, None],
|
||||
|
||||
@@ -280,6 +280,34 @@ fn underlined_text_marks_the_mnemonic_once_and_keeps_the_prefix() {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Access keys preserve visible spelling and underline only the locale-correct I pair.
|
||||
#[test]
|
||||
fn azerbaijani_and_turkish_mnemonics_keep_the_two_i_pairs_distinct() {
|
||||
for code in ["az", "tr"] {
|
||||
let lang = crate::i18n::Lang::from_code(code).unwrap();
|
||||
crate::i18n::with_language(lang, || {
|
||||
assert_eq!(crate::menu_keys::assign(&["ix", "ı"], &[Some('I'), Some('I')]), [Some('X'), Some('I')]);
|
||||
assert_eq!(crate::menu_keys::localized(&["i".into()], &["Image"], &[Some('I')]), (vec!["i(I)".into()], vec![Some('I')]));
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
for text in [crate::menu_keys::mnemonic_text(ui, "", "İıi", Some('I'), None), crate::menu_keys::menu_row_text(ui, false, "İıi", Some('I'))]
|
||||
{
|
||||
let egui::WidgetText::LayoutJob(job) = text else { panic!("an access key makes a layout job") };
|
||||
assert!(job.text.ends_with("İıi"), "visible text is retained verbatim");
|
||||
let underlined: Vec<&str> = job
|
||||
.sections
|
||||
.iter()
|
||||
.filter(|s| s.format.underline != egui::Stroke::NONE)
|
||||
.map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0])
|
||||
.collect();
|
||||
assert_eq!(underlined, ["ı"]);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole app: with the bar focused a letter opens a menu instead of picking a tool, and the
|
||||
/// arrows don't nudge the layer.
|
||||
#[test]
|
||||
|
||||
@@ -5,18 +5,24 @@ use super::state::translated_menu_label;
|
||||
/// Most results the Help menu search lists.
|
||||
pub(super) const HELP_SEARCH_MAX: usize = 15;
|
||||
|
||||
/// How well `query` (lowercase) matches a menu item: its label starting with it, then a word of
|
||||
/// How well `query` matches an English menu item without case distinctions: its label starting with it, then a word of
|
||||
/// the label starting with it, then anywhere in the label, then anywhere in its menu path.
|
||||
/// `None` when it doesn't match at all.
|
||||
pub fn search_rank(query: &str, label: &str, path: &[String]) -> Option<u8> {
|
||||
let l = label.to_lowercase();
|
||||
if l.starts_with(query) {
|
||||
search_rank_in(crate::i18n::Lang::EN, query, label, path)
|
||||
}
|
||||
|
||||
/// Compare the query, label and path under the same language's casing rules.
|
||||
fn search_rank_in(lang: crate::i18n::Lang, query: &str, label: &str, path: &[String]) -> Option<u8> {
|
||||
let query = lang.lowercase(query);
|
||||
let l = lang.lowercase(label);
|
||||
if l.starts_with(&query) {
|
||||
Some(0)
|
||||
} else if l.split(|c: char| !c.is_alphanumeric()).any(|w| w.starts_with(query)) {
|
||||
} else if l.split(|c: char| !c.is_alphanumeric()).any(|w| w.starts_with(&query)) {
|
||||
Some(1)
|
||||
} else if l.contains(query) {
|
||||
} else if l.contains(&query) {
|
||||
Some(2)
|
||||
} else if path.iter().any(|p| p.to_lowercase().contains(query)) {
|
||||
} else if path.iter().any(|p| lang.contains_ignore_case(p, &query)) {
|
||||
Some(3)
|
||||
} else {
|
||||
None
|
||||
@@ -27,14 +33,14 @@ pub fn search_rank(query: &str, label: &str, path: &[String]) -> Option<u8> {
|
||||
/// labels and their `lang` translations match, so a search works in the UI language and in
|
||||
/// English; untranslated strings are simply their English text.
|
||||
pub fn search_items<'a>(items: &'a [MenuItem], query: &str, lang: crate::i18n::Lang) -> Vec<&'a MenuItem> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let rank = |it: &MenuItem| {
|
||||
let english = search_rank(&q, &it.label, &it.path);
|
||||
let english = search_rank(q, &it.label, &it.path);
|
||||
let path: Vec<String> = it.path.iter().map(|p| crate::i18n::tr(lang, p).to_string()).collect();
|
||||
let local = search_rank(&q, &translated_menu_label(lang, it), &path);
|
||||
let local = search_rank_in(lang, q, &translated_menu_label(lang, it), &path);
|
||||
english.into_iter().chain(local).min()
|
||||
};
|
||||
let mut hits: Vec<(u8, usize, &MenuItem)> =
|
||||
|
||||
@@ -310,6 +310,19 @@ fn help_search_matches_the_ui_language_and_english() {
|
||||
assert!(ids("zzzzqqq").is_empty());
|
||||
}
|
||||
|
||||
/// Azerbaijani menu text and paths keep dotted I distinct; English aliases still work.
|
||||
#[test]
|
||||
fn azerbaijani_help_search_uses_the_language_of_each_alias() {
|
||||
let az = crate::i18n::Lang::from_code("az").unwrap();
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let items = menu_items(&app);
|
||||
let hits = |query: &str| search_items(&items, query, az).iter().map(|item| item.id.clone()).collect::<Vec<_>>();
|
||||
assert!(!hits("ŞƏKİL").is_empty(), "uppercase dotted I matches Şəkil paths");
|
||||
assert_eq!(hits("ŞƏKİL"), hits("şəkil"));
|
||||
assert!(hits("ŞƏKIL").is_empty(), "dotless I must not match dotted i");
|
||||
assert!(hits("DISTORT").contains(&"edit.transform.distort".to_string()), "English aliases keep English casing");
|
||||
}
|
||||
|
||||
/// #1447, #1438: a click on Help leaves its search field focused, so typing searches at once,
|
||||
/// and clicking into the field and typing keeps the menu open. Whole app, real clicks (press
|
||||
/// and release in separate frames), with the custom title bar (Windows, Linux) and without.
|
||||
|
||||
@@ -151,26 +151,27 @@ pub(super) fn color_of(s: &str) -> Color32 {
|
||||
/// Search the same visible, editable preference labels used in the dialog.
|
||||
pub(super) fn preference_matches(section: &str, key: &str, query: &str) -> bool {
|
||||
let label = humanize(key);
|
||||
format!("{section}.{key}").to_lowercase().contains(query)
|
||||
|| label.to_lowercase().contains(query)
|
||||
|| tl!(&label).to_lowercase().contains(query)
|
||||
let en = crate::i18n::Lang::EN;
|
||||
en.contains_ignore_case(&format!("{section}.{key}"), query)
|
||||
|| en.contains_ignore_case(&label, query)
|
||||
|| crate::i18n::current().contains_ignore_case(tl!(&label), query)
|
||||
// The Tablet input row and its Pen Tablet note are the visible terms, not just the key.
|
||||
|| (cfg!(target_os = "windows") && section == "tools" && key == "tabletInput" && "windows ink pen tablet wintab tablet input".contains(query))
|
||||
|| (cfg!(target_os = "windows") && section == "tools" && key == "tabletInput" && en.contains_ignore_case("windows ink pen tablet wintab tablet input", query))
|
||||
}
|
||||
|
||||
pub(super) fn preference_sections(values: &Value, search: &str) -> Vec<(&'static str, &'static str)> {
|
||||
let query = search.trim().to_lowercase();
|
||||
let query = search.trim();
|
||||
SECTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(id, title)| {
|
||||
query.is_empty()
|
||||
|| title.to_lowercase().contains(&query)
|
||||
|| tl!(title).to_lowercase().contains(&query)
|
||||
|| crate::i18n::Lang::EN.contains_ignore_case(title, query)
|
||||
|| crate::i18n::current().contains_ignore_case(tl!(title), query)
|
||||
|| values
|
||||
.get(*id)
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|fields| fields.keys().any(|key| !prefs::is_hidden(&format!("{id}.{key}")) && preference_matches(id, key, &query)))
|
||||
.is_some_and(|fields| fields.keys().any(|key| !prefs::is_hidden(&format!("{id}.{key}")) && preference_matches(id, key, query)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -237,8 +238,8 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>, system: Option<egui
|
||||
let title = SECTIONS.iter().find(|(id, _)| *id == section).map_or("General", |(_, t)| *t);
|
||||
ui.label(RichText::new(tl!(&title)).font(crate::theme::semibold(14.0)).color(t.text));
|
||||
let show_all = search.trim().is_empty()
|
||||
|| title.to_lowercase().contains(&search.trim().to_lowercase())
|
||||
|| tl!(&title).to_lowercase().contains(&search.trim().to_lowercase());
|
||||
|| crate::i18n::Lang::EN.contains_ignore_case(title, search.trim())
|
||||
|| lang.contains_ignore_case(tl!(&title), search.trim());
|
||||
let filter = if show_all { "" } else { search.trim() };
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().max_height(content_height - 30.0).id_salt("prefs-scroll").show(ui, |ui| {
|
||||
@@ -404,23 +405,23 @@ pub(super) fn section_fields(
|
||||
system: Option<egui::Theme>,
|
||||
search: &str,
|
||||
) {
|
||||
let query = search.to_lowercase();
|
||||
let query = search.trim();
|
||||
let mut keys: Vec<String> = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect();
|
||||
keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned());
|
||||
if section == "interface" {
|
||||
interface_groups(ui, obj, &keys, lang, system, &query);
|
||||
interface_groups(ui, obj, &keys, lang, system, query);
|
||||
return;
|
||||
}
|
||||
if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) {
|
||||
if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", query)) {
|
||||
rendering_mode_row(ui, obj);
|
||||
}
|
||||
if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", &query)) {
|
||||
if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", query)) {
|
||||
memory_usage_row(ui, obj);
|
||||
}
|
||||
// WinTab is an explicit Windows choice; Windows Ink stays the default everywhere else.
|
||||
if section == "tools"
|
||||
&& cfg!(target_os = "windows")
|
||||
&& (query.is_empty() || preference_matches(section, "tabletInput", &query))
|
||||
&& (query.is_empty() || preference_matches(section, "tabletInput", query))
|
||||
&& obj.contains_key("tabletInput")
|
||||
{
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -440,7 +441,7 @@ pub(super) fn section_fields(
|
||||
);
|
||||
});
|
||||
}
|
||||
keys.retain(|k| field_visible(section, obj, k, &query));
|
||||
keys.retain(|k| field_visible(section, obj, k, query));
|
||||
egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,18 @@ fn field(h: &Harness<'_, PhotocraftApp>, id: u64, name: &str) -> Value {
|
||||
h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// The actual shortcut grid matches Azerbaijani uppercase text and English command aliases.
|
||||
#[test]
|
||||
fn azerbaijani_shortcuts_search_handles_dotted_i_and_english() {
|
||||
let (mut h, id) = dialog(1.0, "image.imageSize", "ŞƏKİL ÖLÇÜSÜ");
|
||||
h.state_mut().run("prefs.set", json!({"path":"interface.language", "value":"az"})).unwrap();
|
||||
h.run_steps(4);
|
||||
h.get_by_label(" Şəkil ölçüsü…");
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("filter".into(), json!("IMAGE SIZE"));
|
||||
h.run_steps(3);
|
||||
h.get_by_label(" Şəkil ölçüsü…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_shortcut_can_be_captured_and_restored_on_restart() {
|
||||
let (mut h, id) = dialog(1.0, "tools.select.pencil", "Pencil Tool");
|
||||
|
||||
@@ -324,7 +324,7 @@ pub(super) fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut
|
||||
}
|
||||
}
|
||||
}
|
||||
let needle = filter.to_ascii_lowercase();
|
||||
let needle = filter.trim();
|
||||
egui::ScrollArea::vertical().max_height(420.0).id_salt("shortcuts-scroll").show(ui, |ui| {
|
||||
let mut last_top = String::new();
|
||||
egui::Grid::new("shortcut-grid").num_columns(3).spacing([12.0, 3.0]).striped(true).show(ui, |ui| {
|
||||
@@ -336,10 +336,12 @@ pub(super) fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut
|
||||
let alts = eff_alts(&alt_overrides, id, cur.as_deref());
|
||||
let (shown_path, shown_label) = shown(id, label, path);
|
||||
// Match what is shown and the English name (commands are documented in English).
|
||||
let hay =
|
||||
format!("{} {} {} {} {} {}", shown_path.join(" "), shown_label, path.join(" "), label, cur.clone().unwrap_or_default(), alts.join(" "))
|
||||
.to_lowercase();
|
||||
if !needle.is_empty() && !hay.contains(&needle) && !id.to_ascii_lowercase().contains(&needle) {
|
||||
let local = format!("{} {}", shown_path.join(" "), shown_label);
|
||||
let english = format!("{} {} {} {} {id}", path.join(" "), label, cur.clone().unwrap_or_default(), alts.join(" "));
|
||||
if !needle.is_empty()
|
||||
&& !crate::i18n::current().contains_ignore_case(&local, needle)
|
||||
&& !crate::i18n::Lang::EN.contains_ignore_case(&english, needle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if tab == 0 && path.is_empty() && def.is_none() && cur.is_none() && alts.is_empty() {
|
||||
|
||||
@@ -142,6 +142,19 @@ fn preferences_search_matches_editable_fields_across_sections() {
|
||||
assert_eq!(preference_sections(&values, "").len(), SECTIONS.len());
|
||||
}
|
||||
|
||||
/// The preview language controls translated search; English schema paths keep their aliases.
|
||||
#[test]
|
||||
fn azerbaijani_preferences_search_keeps_dotted_i_and_schema_aliases() {
|
||||
let az = crate::i18n::Lang::from_code("az").unwrap();
|
||||
crate::i18n::with_language(az, || {
|
||||
let values = prefs::Preferences::default().to_json();
|
||||
assert!(preference_sections(&values, "İNTERFEYS").iter().any(|(id, _)| *id == "interface"));
|
||||
assert!(super::fields::preference_matches("interface", "language", "DİL"));
|
||||
assert!(!super::fields::preference_matches("interface", "language", "DIL"));
|
||||
assert!(super::fields::preference_matches("interface", "uiScale", "INTERFACE.UISCALE"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_dialog_width_follows_its_resizable_area() {
|
||||
let prefs = serde_json::json!({"__prefsui": "prefs"});
|
||||
|
||||
@@ -54,6 +54,11 @@ pub fn direction_scope(dir: Dir) -> impl Drop {
|
||||
}
|
||||
|
||||
fn env_override() -> Option<Dir> {
|
||||
// Unit tests follow the language or a `direction_scope`, whatever the developer's shell sets
|
||||
// (the value is cached per process, so a test can't clear it).
|
||||
if cfg!(test) {
|
||||
return None;
|
||||
}
|
||||
static ENV: OnceLock<Option<Dir>> = OnceLock::new();
|
||||
*ENV.get_or_init(|| parse_dir(&std::env::var("PHOTOCRAFT_UI_DIRECTION").ok()?))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
use crate::i18n::{self, Lang};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_text::scripts;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -17,9 +18,10 @@ fn right_to_left_scripts_by_language_code() {
|
||||
for code in ["en", "ja", "zh-hans", "pt-br", "ku", "arn", ""] {
|
||||
assert_eq!(i18n::dir_for_code(code), Dir::Ltr, "{code}");
|
||||
}
|
||||
// Arabic is the one right-to-left catalog; every other registered language stays left to right.
|
||||
// Arabic, Hebrew and Urdu are the right-to-left catalogs; every other registered language stays
|
||||
// left to right.
|
||||
for lang in Lang::all() {
|
||||
let want = if lang.code() == "ar" { Dir::Rtl } else { Dir::Ltr };
|
||||
let want = if matches!(lang.code(), "ar" | "he" | "ur") { Dir::Rtl } else { Dir::Ltr };
|
||||
assert_eq!(lang.dir(), want, "{}", lang.code());
|
||||
}
|
||||
}
|
||||
@@ -62,7 +64,11 @@ fn env_values_parse_strictly() {
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
harness_with(Harness::builder())
|
||||
}
|
||||
|
||||
fn harness_with(builder: egui_kittest::HarnessBuilder<PhotocraftApp>) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = builder.with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ProMedium);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).expect("new document");
|
||||
@@ -113,6 +119,45 @@ fn the_shell_mirrors_in_a_right_to_left_ui() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Choosing a right-to-left catalog (not just forcing the direction) mirrors the shell, and the
|
||||
/// shell's fonts draw its letters; English mirrors back. Panel interiors and dialogs stay
|
||||
/// left-to-right for now (module docs), so only the dock side is checked.
|
||||
#[test]
|
||||
fn hebrew_and_urdu_mirror_the_shell_and_draw_their_letters() {
|
||||
let _lang = i18n::language_scope(Lang::EN);
|
||||
for (code, letter) in [("he", 'ש'), ("ur", 'ر')] {
|
||||
let lang = Lang::from_code(code).expect("registered language");
|
||||
// Builds with craft-fonts (CI: `CRAFT_FONTS_REQUIRED`) draw these letters from the first
|
||||
// frame and check them. Without it the host's fonts decide, so the harness allows tofu
|
||||
// and only the mirroring is checked.
|
||||
let fonts = craft_font_covers(letter);
|
||||
if !fonts {
|
||||
eprintln!("{code}: no craft-fonts face for {letter}; glyphs not checked (build with CRAFT_FONTS_DIR)");
|
||||
}
|
||||
let mut h = if fonts { harness() } else { harness_with(Harness::builder().allow_missing_glyphs()) };
|
||||
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language");
|
||||
h.run_steps(4);
|
||||
assert_eq!(i18n::current(), lang);
|
||||
assert!(is_rtl(), "{code} draws right-to-left");
|
||||
assert!(dock_x(&h) < 600.0, "{code}: the dock moves to the left");
|
||||
for label in ["File", "Help", "Layers"] {
|
||||
let text = i18n::tr(lang, label);
|
||||
assert!(!text.is_ascii(), "{code}: {label} is translated");
|
||||
assert!(!fonts || h.ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(12.0), text)), "{code}: {text} has glyphs");
|
||||
}
|
||||
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "en"})).expect("back to English");
|
||||
h.run_steps(4);
|
||||
assert!(!is_rtl() && dock_x(&h) > 600.0, "{code} -> en: the shell mirrors back");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether craft-fonts has a face for `letter`. Only those are served in the first frame (the
|
||||
/// eager provider); installed fonts arrive a frame later through the lazy loader, which the
|
||||
/// harness's strict missing-glyph policy doesn't wait for (`cjk_fonts` tests).
|
||||
fn craft_font_covers(letter: char) -> bool {
|
||||
photocraft_text::craft_fonts::CRAFT_FONTS.iter().any(|f| !f.bytes.is_empty() && scripts::covering_face(f.bytes, letter).is_some())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_step_through_the_menu_bar_in_screen_order() {
|
||||
let _lang = i18n::language_scope(Lang::EN);
|
||||
|
||||
@@ -26,12 +26,16 @@ pub fn list_open(ctx: &egui::Context) -> bool {
|
||||
pub fn keys(ui: &mut Ui, id: Id, current: Option<usize>, labels: &[&str]) -> Option<usize> {
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| d.insert_temp(open_id(), pass));
|
||||
let lang = crate::i18n::current();
|
||||
// One entry per list, tagged with the language its letters were typed in: a prefix typed in
|
||||
// another language never carries over, however quickly the language comes back.
|
||||
let key = id.with("type-ahead");
|
||||
// A list opened anew starts with no letters typed.
|
||||
// A list opened anew starts with no letters typed, and so does a frame in another language.
|
||||
let shown_id = key.with("pass");
|
||||
ui.data_mut(|d| {
|
||||
if d.get_temp::<u64>(shown_id).is_none_or(|seen| seen.saturating_add(2) < pass) {
|
||||
d.remove::<(String, f64)>(key);
|
||||
let other_language = d.get_temp::<(String, f64, String)>(key).is_some_and(|(_, _, typed_in)| typed_in != lang.code());
|
||||
if other_language || d.get_temp::<u64>(shown_id).is_none_or(|seen| seen.saturating_add(2) < pass) {
|
||||
d.remove::<(String, f64, String)>(key);
|
||||
}
|
||||
d.insert_temp(shown_id, pass);
|
||||
});
|
||||
@@ -52,32 +56,32 @@ pub fn keys(ui: &mut Ui, id: Id, current: Option<usize>, labels: &[&str]) -> Opt
|
||||
if typed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut prefix, last) = ui.data(|d| d.get_temp::<(String, f64)>(key)).unwrap_or_default();
|
||||
let (mut prefix, last, _) = ui.data(|d| d.get_temp::<(String, f64, String)>(key)).unwrap_or_default();
|
||||
// A pause (or a clock that went backwards) starts over.
|
||||
if !(now >= last && now - last <= PAUSE) {
|
||||
prefix.clear();
|
||||
}
|
||||
let lower: Vec<String> = labels.iter().map(|l| l.to_lowercase()).collect();
|
||||
let lower: Vec<String> = labels.iter().map(|l| lang.lowercase(l)).collect();
|
||||
let mut pick = None;
|
||||
for c in typed.chars().filter(|c| !c.is_control()) {
|
||||
// A space only counts inside a prefix ("linear b"), never to start one.
|
||||
if c.is_whitespace() && prefix.is_empty() {
|
||||
continue;
|
||||
}
|
||||
prefix.extend(c.to_lowercase());
|
||||
prefix.push(c);
|
||||
let from = pick.or(current);
|
||||
let mut found = find(&lower, &prefix, from);
|
||||
let mut found = find(&lower, &lang.lowercase(&prefix), from);
|
||||
// A letter that leads nowhere after the others starts a new prefix ("m" then "d" is D).
|
||||
if found.is_none() {
|
||||
prefix.clear();
|
||||
if !c.is_whitespace() {
|
||||
prefix.extend(c.to_lowercase());
|
||||
found = find(&lower, &prefix, from);
|
||||
prefix.push(c);
|
||||
found = find(&lower, &lang.lowercase(&prefix), from);
|
||||
}
|
||||
}
|
||||
pick = found.or(pick);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, (prefix, now)));
|
||||
ui.data_mut(|d| d.insert_temp(key, (prefix, now, lang.code().to_owned())));
|
||||
pick
|
||||
}
|
||||
|
||||
@@ -101,6 +105,33 @@ fn find(lower: &[String], prefix: &str, from: Option<usize>) -> Option<usize> {
|
||||
mod tests {
|
||||
use super::find;
|
||||
|
||||
/// Actual dropdown text input keeps dotted/dotless prefixes and decomposed I+dot distinct.
|
||||
#[test]
|
||||
fn azerbaijani_dropdown_type_ahead_handles_both_i_pairs() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let az = crate::i18n::Lang::from_code("az").unwrap();
|
||||
crate::i18n::with_language(az, || {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(300.0, 400.0)).build_ui_state(
|
||||
|ui, selected: &mut usize| {
|
||||
crate::widgets::dropdown(ui, "az-i-pairs", "Mode", selected, &[(0, "Normal"), (1, "İşıq"), (2, "ıraq")], 120.0);
|
||||
},
|
||||
0,
|
||||
);
|
||||
h.get_by_role(Role::ComboBox).click();
|
||||
h.run();
|
||||
h.event(egui::Event::Text("İ".into()));
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 1);
|
||||
h.event(egui::Event::Text("I".into()));
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 2);
|
||||
h.event(egui::Event::Text("\u{307}".into()));
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 1, "the combining dot completes the original I prefix");
|
||||
});
|
||||
}
|
||||
|
||||
fn labels(l: &[&str]) -> Vec<String> {
|
||||
l.iter().map(|s| s.to_lowercase()).collect()
|
||||
}
|
||||
@@ -167,6 +198,39 @@ mod tests {
|
||||
assert_eq!(*h.state(), 3);
|
||||
}
|
||||
|
||||
/// Letters typed before the language changed never extend a prefix typed after it, even when
|
||||
/// the language comes back within the pause.
|
||||
#[test]
|
||||
fn a_language_change_forgets_the_letters_typed_before() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let en = crate::i18n::Lang::EN;
|
||||
let de = crate::i18n::Lang::from_code("de").unwrap();
|
||||
// Short frames: the letters arrive well within the pause, so only the language change resets.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(300.0, 400.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, (selected, lang): &mut (usize, crate::i18n::Lang)| {
|
||||
crate::i18n::with_language(*lang, || {
|
||||
let options = [(0, "Normal"), (1, "Dissolve"), (2, "Darken"), (3, "Add")];
|
||||
crate::widgets::dropdown(ui, "lang-switch", "Mode", selected, &options, 120.0);
|
||||
});
|
||||
},
|
||||
(0, en),
|
||||
);
|
||||
h.get_by_role(Role::ComboBox).click();
|
||||
h.run();
|
||||
type_key(&h, egui::Key::D, "d");
|
||||
h.run();
|
||||
assert_eq!(h.state().0, 1);
|
||||
h.state_mut().1 = de;
|
||||
h.step();
|
||||
h.state_mut().1 = en;
|
||||
h.step();
|
||||
assert!(super::list_open(&h.ctx), "the list stays open across the language change");
|
||||
type_key(&h, egui::Key::A, "a");
|
||||
h.run();
|
||||
assert_eq!(h.state().0, 3, "a starts a new prefix (Add); the d typed before the change would make it da (Darken)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_and_scripts_beyond_ascii_match() {
|
||||
let l = labels(&["Normal", "Éclaircir", "Ébène", "Нормальный", "Затемнение"]);
|
||||
|
||||
@@ -15,6 +15,9 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_
|
||||
for lang in i18n::Lang::all() {
|
||||
let gpu = std::env::var_os("PHOTOCRAFT_LOCALE_SNAPSHOTS").is_some();
|
||||
let builder = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1440.0, 1000.0));
|
||||
// A script the central manifest does not ship yet may have no face on a CI runner: the
|
||||
// app then draws tofu (its production policy), and the label/state checks below still run.
|
||||
let builder = if AWAITING_CENTRAL_FONT.iter().any(|(code, _)| *code == lang.code()) { builder.allow_missing_glyphs() } else { builder };
|
||||
let builder = if gpu { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
@@ -116,6 +119,10 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_
|
||||
}
|
||||
}
|
||||
|
||||
/// UI languages whose script has no face in the pinned central font manifest yet (code, ISO 15924).
|
||||
/// Tamil arrives with storytold/craft-fonts#63; drop the row once the pin ships it.
|
||||
const AWAITING_CENTRAL_FONT: &[(&str, &str)] = &[("ta", "Taml")];
|
||||
|
||||
/// Check central font fixtures independently of the language-switching UI assertions.
|
||||
fn assert_required_camera_raw_fonts() {
|
||||
// Required central-font acceptance must not pass only because the host supplies Hindi,
|
||||
@@ -123,6 +130,12 @@ fn assert_required_camera_raw_fonts() {
|
||||
// test renders comes from a real face in the central manifest. The all-language loop then
|
||||
// checks the actual translated labels under egui_kittest's strict missing-glyph policy.
|
||||
if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() {
|
||||
for (code, script) in AWAITING_CENTRAL_FONT {
|
||||
assert!(
|
||||
!photocraft_text::craft_fonts::CRAFT_FONTS.iter().any(|f| !f.bytes.is_empty() && f.scripts.contains(script)),
|
||||
"the central manifest now ships {script}: remove {code} from AWAITING_CENTRAL_FONT so its glyphs are checked strictly"
|
||||
);
|
||||
}
|
||||
for (family, script) in [("BIZ UDPGothic", "Jpan"), ("Noto Sans CJK SC", "Hans"), ("NanumGothic", "Kore"), ("Noto Sans Devanagari", "Deva")] {
|
||||
let faces: Vec<_> = photocraft_text::craft_fonts::CRAFT_FONTS
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Azerbaijani UI catalog
|
||||
|
||||
Azerbaijani (Latin script) is offered as **Azərbaycanca** (`az`). Regional preferences and
|
||||
system locales such as `az-AZ`, `az_Latn_AZ.UTF-8` and `AZ-LATN-AZ` use the existing locale
|
||||
matcher. English remains the source and fallback. The runtime registry has 29 languages
|
||||
including English.
|
||||
|
||||
The catalog contains 4,606 entries, including 32 plural messages, 118 contextual entries and
|
||||
eight command-ID overrides. The 2026-11-05 merge added the 29 new OpenType menu keys from
|
||||
current `main`. The follow-up also adds five frame conversion/tool labels; all menu labels and the eight intentional command-ID overrides are checked.
|
||||
The 2026-11-01 source scan covered 2,204/2,204 keys. Catalog completeness does not establish linguistic
|
||||
quality or include untranslated engine errors.
|
||||
|
||||
## Terminology and provenance
|
||||
|
||||
| English | Azerbaijani |
|
||||
|---|---|
|
||||
| Layer | Qat |
|
||||
| Layer mask | Qat maskası |
|
||||
| Stroke (outline) | Kontur |
|
||||
| Brush stroke | Fırça izi |
|
||||
| Crop | Kəsmə |
|
||||
| Rulers | Xətkeşlər |
|
||||
| Path | Yol |
|
||||
| Artboard | İş lövhəsi |
|
||||
| Preset | Hazır ayar |
|
||||
| Light (Camera Raw) | İşıq |
|
||||
| Light (font weight) | İncə |
|
||||
|
||||
Google Translate supplied drafts of PhotoCraft's English labels. Core terminology, contexts
|
||||
and plural messages were edited with OpenAI Codex. No proprietary localization resources were
|
||||
used. The translations are MIT OR Apache-2.0, with attribution in `ATTRIBUTION.md` and
|
||||
`crates/ui-egui/src/i18n/LICENSE-translations.txt`.
|
||||
|
||||
**Native Azerbaijani speaker review is requested before merge and has not been received.**
|
||||
Please check the terminology, long help text, contextual meanings, counted nouns and the
|
||||
natural wording of the catalog. Automated checks and AI-assisted edits do not replace that review.
|
||||
|
||||
## UI case-conversion audit (2026-11-06)
|
||||
|
||||
The catalog and rendered labels retain their original spelling, including `ƏəĞğIıİiÖöŞşÜüÇç`.
|
||||
Search and access-key comparisons use the selected UI language. Azerbaijani and Turkish use
|
||||
ICU4X case mapping: `i` ↔ `İ` and `ı` ↔ `I`, including contextual combining-dot handling.
|
||||
English command names, menu paths used as English aliases and machine IDs keep English casing.
|
||||
|
||||
| UI path | Handling |
|
||||
|---|---|
|
||||
| Help menu search (`menus/search.rs`) | Localized labels and paths use their language; English aliases are ranked separately. |
|
||||
| Command palette (`dialogs/palette.rs`) | Localized fuzzy and exact matches use their language; English aliases remain available. |
|
||||
| Preferences and Keyboard Shortcuts (`prefs/prefs_ui/`) | Translated labels and section titles use localized casing; English schema paths, IDs and shortcut syntax stay invariant. |
|
||||
| Dropdown typing (`widgets/type_ahead.rs`) | Labels and typed prefixes use localized casing; raw prefix text is retained so a following combining dot can complete an I. |
|
||||
| Menu and dialog access keys (`menus/menu_keys.rs`, `dialogs/discard_ui.rs`) | Compare displayed letters using localized uppercase. Only ASCII key codes are assigned; an English key suffix is shown when required. Labels are not rewritten. |
|
||||
| A menu key over the zoom field (`canvas/zoom_field.rs`) | Consume the text emitted by the logical I key for either Turkic keyboard letter, preventing it from leaking into the zoom field. |
|
||||
|
||||
The audit searched `ui-egui` and the native/web app shells for lower/uppercase operations,
|
||||
case-insensitive comparisons and mnemonic handling. English schema/command humanization runs
|
||||
before translation and remains language-independent. Native macOS menu labels are passed through
|
||||
verbatim (only the mnemonic escape character is escaped). Language-picker sort keys are a fixed
|
||||
accent-folded ordering of native names. File extensions, color/format tokens, font metadata,
|
||||
and user-provided document/layer/preset names are not catalog translations and retain their
|
||||
existing matching/identity rules. This does not audit third-party widget internals or document-text casing.
|
||||
|
||||
References: [Unicode special casing](https://www.unicode.org/faq/casemap_charprop.html),
|
||||
[ICU4X case mapper](https://docs.rs/icu_casemap/2.3.0/icu_casemap/struct.CaseMapperBorrowed.html).
|
||||
## Plurals
|
||||
|
||||
The existing one/other integer rule selects `one` at 1 and `other` at all other `u64` counts.
|
||||
Counted nouns remain singular in both forms: `1 qat`, `20 qat`. Tests include zero, teens,
|
||||
hundreds, thousands and `u64::MAX`.
|
||||
|
||||
## Validation
|
||||
|
||||
Current catalog scan (2026-11-06, merged main `7904eee76`): 4,606 entries, all 669 canonical
|
||||
menu keys and eight intentional command-ID overrides. Current source coverage is
|
||||
2,268/2,358 keys (96.18%); 90 newer strings fall back to English. The merge added six current
|
||||
menu labels so the existing menu coverage regression remains complete.
|
||||
|
||||
Current case-conversion regressions exercise the actual shortcut grid and dropdown input,
|
||||
menu underline layout, localized/English search, preference matching, combining dots and
|
||||
the two distinct I pairs. Executed on Windows x64 with Rust 1.99.1/MSVC:
|
||||
|
||||
- All 15 Azerbaijani-focused checks passed: the six original language checks plus nine
|
||||
casing/input regressions. The Turkic helper and mnemonic layout also test Turkish.
|
||||
- All eight additional regression groups passed: 40 i18n unit checks, 22 menu-key checks,
|
||||
four Help-search checks, eight palette checks, four dropdown checks, 69 discard-dialog
|
||||
checks, six zoom-field checks and one existing Preferences-search check (154 checks;
|
||||
some overlap with the Azerbaijani-focused run).
|
||||
- Formatting, Clippy (`--all-targets -- -D warnings`), 31-crate layering, wasm (24 crates
|
||||
plus optional HEIF) and the offscreen snapshot build passed.
|
||||
- Visually inspected before/after CPU captures at 1440 × 900 logical pixels and 1.5× scale.
|
||||
Before the fix, `interfeys` found no Preferences section and `ŞƏKİL ÖLÇÜSÜ` found no
|
||||
shortcut row. After the fix, they show Interface settings and Image Size respectively.
|
||||
- The full UI gate was rerun with one test thread and is **not green**: unit and integration
|
||||
processes again exited `0xc0000005` at `omitting_an_occluded_checker_preserves_cpu_canvas_pixels`
|
||||
and `batch_and_processor_real_controls_write_the_chosen_webp_quality`, the same points
|
||||
observed on 2026-11-05. The example test and doctest targets passed.
|
||||
|
||||
### Earlier full-suite limitations
|
||||
|
||||
On 2026-11-05 the six Azerbaijani regressions, 40 i18n unit checks, formatting, Clippy,
|
||||
layering, wasm and snapshot build passed. Editor/preferences CPU captures were visually
|
||||
inspected. The complete i18n subset had 61 passed and eight CJK/Arabic missing-glyph failures
|
||||
without the optional craft-fonts input. The full UI unit/integration processes exited
|
||||
`0xc0000005` (`STATUS_ACCESS_VIOLATION`) even with one test thread. These are historical
|
||||
results, not a successful full-suite validation of the current revision. Native-speaker
|
||||
review remains pending. CPU captures do not establish hardware-GPU or native window/input acceptance.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-30 · **Change:** minor (Bosnian UI catalog; previously minor: Swedish, Finnish, Danish and Norwegian Bokmål UI catalogs; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** minor (Azerbaijani UI catalog merged with current languages; native review pending; Bosnian UI catalog; previously minor: Swedish, Finnish, Danish and Norwegian Bokmål UI catalogs; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
|
||||
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
|
||||
@@ -24,7 +24,7 @@ Vietnamese UI; Arabic and Hebrew users get English "Middle East" builds with the
|
||||
composer, and its text engine handles RTL, Devanagari and Indic scripts.
|
||||
|
||||
**Summary.** 7 of the 12 key languages at 100% of measured keys (status `partial` because of the
|
||||
engine messages), 2 absent (Hindi, Vietnamese), plus 8 other languages. Weighted by
|
||||
engine messages), 2 absent (Hindi, Vietnamese), plus 17 other registered languages. Weighted by
|
||||
speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorded only for Italian,
|
||||
2026-10-12).
|
||||
|
||||
@@ -47,11 +47,13 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
|
||||
|
||||
## Other shipped languages
|
||||
|
||||
8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional
|
||||
17 registered languages are outside the twelve-language table. The following eight have
|
||||
1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional
|
||||
Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the
|
||||
other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522),
|
||||
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the
|
||||
other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr`
|
||||
other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Hungarian
|
||||
`hu` (4,347 rows) is also registered. Turkish `tr`
|
||||
(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every
|
||||
measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse
|
||||
the Russian rule. Swedish `sv` (4,552 rows, added 2026-10-28) covers every measured key and the
|
||||
@@ -65,6 +67,13 @@ Ijekavian, added 2026-10-30) covers the same keys; its one/few/other plurals reu
|
||||
rule, and Cyrillic `bs` locales read it too. It is AI-assisted, shares its wording with
|
||||
storytold/craft-i18n `locales/bs/`, and has not had a native-speaker review yet.
|
||||
|
||||
Azerbaijani `az` (Latin script) brings the runtime registry to 29 languages including
|
||||
English. Its catalog has 4,606 entries, including the initial dynamic-label key union, current OpenType menus and five frame labels
|
||||
and 32 plural messages. It uses one/other integer forms, with singular counted nouns.
|
||||
Complete menu keys, regional locale matching and English fallback are covered by tests.
|
||||
Native-speaker review remains pending; validation results and limits are recorded in the
|
||||
[language notes](localization-az.md). Readiness estimates and G13 remain unchanged.
|
||||
|
||||
Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and
|
||||
transform accessibility names, rasterization prompts, profile mismatch messages and preset
|
||||
counts now translate at their render/assignment sites. Navigator's zoom level uses a context
|
||||
@@ -89,6 +98,9 @@ engine localization or native review is complete.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | Azerbaijani/Turkish UI search, dropdown prefixes and access keys use locale-aware I casing; 4,606 Azerbaijani entries and current menu coverage; native review requested and pending |
|
||||
| 2026-11-05 | minor | Preserved Bosnian while merging current main; 29 registered languages, 17 outside the table; Azerbaijani has 4,606 entries, including 29 current OpenType menu keys and checks for all eight command-ID overrides; native review pending |
|
||||
| 2026-11-01 | minor | Azerbaijani UI catalog, 4,566 entries; regional tags and one/other plurals; PR narrowed to Azerbaijani, 28 registered languages and 16 outside the table; native review pending; validation limits in the language notes; readiness estimates unchanged |
|
||||
| 2026-10-10 | major | First version, measured from the catalogs with the `i18n-coverage` rules |
|
||||
| 2026-10-10 | minor | Right-to-left shell layout (menu bar, title, options and status bars, toolbox and dock sides) |
|
||||
| 2026-10-11 | minor | Czech dynamic status/notice and hardware text, render labels and Navigator zoom context; engine messages remain English |
|
||||
|
||||
@@ -27,6 +27,8 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
|
||||
| Nederlands | `nl` | one / other |
|
||||
| Magyar | `hu` | one form |
|
||||
| Tiếng Việt | `vi` | one form |
|
||||
| ไทย | `th` | one form |
|
||||
| தமிழ் | `ta` | one / other |
|
||||
| Srpski | `sr` | one / few / many (teens use many) |
|
||||
| Bosanski | `bs` | one / few / other (teens use other) |
|
||||
| हिन्दी | `hi` | one / other |
|
||||
@@ -34,6 +36,16 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
|
||||
| Suomi | `fi` | one / other (partitive after every count but 1) |
|
||||
| Dansk | `da` | one / other |
|
||||
| Norsk bokmål | `nb` | one / other |
|
||||
| עברית | `he` | one / other |
|
||||
| اردو | `ur` | one / other |
|
||||
| Română | `ro` | one / few (0, 2–19, 101–119 …) / other (20–100, 120 …) |
|
||||
| Azərbaycanca | `az` | one (1) / other (all other integer counts) |
|
||||
|
||||
Azerbaijani (`az`, Latin script) is registered with a complete menu catalog. Regional tags
|
||||
such as `az-AZ` and `az-Latn-AZ` select it. The catalog includes the existing catalogs'
|
||||
dynamic labels, contextual translations and two integer plural forms. Machine-assisted
|
||||
drafts have edited core terminology; native-speaker review is pending. See
|
||||
[`localization-az.md`](localization-az.md) for terminology, provenance and validation limits.
|
||||
|
||||
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
|
||||
mode names, and generated preference labels. A string a catalog lacks shows in English:
|
||||
@@ -44,6 +56,10 @@ keys. `complete_menus` remains false while generated/dynamic long-tail labels ar
|
||||
review. This does not include every engine error or status message: those remain English, as do
|
||||
automation command IDs and user-provided document, layer and preset names.
|
||||
|
||||
UI search and access-key comparisons use locale-aware dotted/dotless I rules for Azerbaijani
|
||||
and Turkish. English command aliases remain searchable using English casing. See the
|
||||
[Azerbaijani case-conversion audit](localization-az.md#ui-case-conversion-audit-2026-11-06).
|
||||
|
||||
## Switch without restarting
|
||||
|
||||
Open **Edit > Preferences > Interface > Language** and choose a language. The Preferences
|
||||
|
||||
@@ -218,6 +218,7 @@ Languages shipped (`complete_menus` marks a catalog that covers every menu strin
|
||||
- French (`fr`), complete, with two plural forms (0 and 1 use the singular).
|
||||
- Czech (`cs`), complete, with three plural forms.
|
||||
- Indonesian (`id`), complete, with one plural form.
|
||||
- Azerbaijani (`az`, Latin script), complete, with two integer plural forms (1 / other).
|
||||
|
||||
- `tr(lang, s)` plain strings; `tr_ctx` when one English word needs different translations;
|
||||
`tr_id(lang, command_id, label)` for menu items (keyed by command id, English label as the
|
||||
|
||||
Reference in New Issue
Block a user