Add Vietnamese localization (#2636)

Adds the Vietnamese (vi) UI translation and turns on its coverage gate. A maintainer pass merged main, added 1,029 rows from @tonguyenducmanh's catalog (#3453) and 33 new ones, and set complete_menus.

Co-authored-by: tonguyenducmanh <63176793+tonguyenducmanh@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trần Nhật Duy
2026-10-12 03:05:18 -04:00
committed by GitHub
co-authored by tonguyenducmanh Claude Opus 5.5
parent 649b1d5d92
commit dbca08cd20
14 changed files with 3614 additions and 22 deletions
+1
View File
@@ -19,6 +19,7 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese 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/uk.tsv` | Ukrainian 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/cs.tsv` | Czech 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/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) |
| `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) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
+6 -1
View File
@@ -174,7 +174,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let italic = matches!(row, Row::QuickMask | Row::LayerMask) || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
let mut job = egui::text::LayoutJob::default();
let font = if selected && !t.pro { theme::medium(12.5) } else { egui::FontId::proportional(12.0) };
job.append(&name, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
// Built-in labels only: alpha/spot names and "{layer} Mask" stay verbatim.
let label = match row {
Row::Composite | Row::Color(_) | Row::QuickMask => tl!(&name).to_owned(),
Row::Alpha(_) | Row::LayerMask => name.clone(),
};
job.append(&label, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
let galley = painter.layout_job(job);
let text_pos = pos2(cell.right() + 10.0, rect.center().y - galley.size().y / 2.0);
painter.galley(text_pos, galley, t.text);
+2 -1
View File
@@ -540,7 +540,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
});
ui.add_space(8.0);
let mut inv = flag(f, "invert");
if widgets::checkbox(ui, &mut inv, tl!("Invert")).changed() {
// Selection invert (≠ Image › Adjustments › Invert → Đảo màu).
if widgets::checkbox(ui, &mut inv, crate::i18n::tr_ctx(crate::i18n::current(), "colorRange", "Invert")).changed() {
f.insert("invert".into(), json!(inv));
}
});
+11 -3
View File
@@ -10,9 +10,17 @@ fn lang(code: &str) -> Lang {
#[test]
fn requested_languages_resolve_regional_preferences() {
for (tag, code) in
[("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr"), ("id-ID", "id")]
{
for (tag, code) in [
("en-US", "en"),
("zh-CN", "zh-hans"),
("ja-JP", "ja"),
("ko-KR", "ko"),
("ru-RU", "ru"),
("uk-UA", "uk"),
("fr-CA", "fr"),
("id-ID", "id"),
("vi-VN", "vi"),
] {
assert_eq!(Lang::from_pref(tag), lang(code), "{tag}");
}
assert_eq!(lang_from_tag("ko_KR.UTF-8"), Some(lang("ko")));
+15 -1
View File
@@ -141,7 +141,7 @@ fn inflate(source: &[u8]) -> Result<String, String> {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 17] = [
pub static LANGUAGES: [LangInfo; 18] = [
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() },
@@ -164,6 +164,8 @@ pub static LANGUAGES: [LangInfo; 17] = [
// Dutch; `nl-NL` and `nl-BE` locales both resolve here.
LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "it", name: "Italiano", source: catalog!("it"), 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() },
];
impl LangInfo {
@@ -379,6 +381,7 @@ mod tests {
const ZH: fn() -> Lang = || Lang::from_code("zh-hant").expect("zh-hant registered");
const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered");
const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered");
const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered");
#[test]
fn indonesian_tags_resolve() {
@@ -388,6 +391,17 @@ mod tests {
assert_eq!(ID().name(), "Bahasa Indonesia");
}
#[test]
fn vietnamese_tags_resolve() {
for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] {
assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}");
}
assert_eq!(VI().name(), "Tiếng Việt");
assert!(VI().complete_menus(), "Vietnamese translates every source key");
assert_eq!(Lang::from_pref("vi"), VI());
assert_eq!(Lang::from_pref("VI"), VI());
}
#[test]
fn tags_map_to_languages() {
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA()));
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -545,7 +545,9 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
ui.set_width(260.0);
ui.label(RichText::new(tl!("PRESET DETAILS")).size(11.0).color(t.text_faint));
ui.add_space(4.0);
let mut name = get_s(f, "name", tl!("Untitled-1"));
// Keep the canonical English default in state; only the TextEdit shows a translation.
let raw = get_s(f, "name", "Untitled-1");
let mut name = if raw == "Untitled-1" { tl!("Untitled-1").to_string() } else { raw };
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(250.0).font(egui::FontId::proportional(15.0)));
if r.changed() {
f.insert("name".into(), json!(name));
@@ -584,9 +586,11 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
set_size(f, "height", h, &unit, ppi);
}
ui.add_space(6.0);
small_label(ui, tl!("Orientation"));
// Page orientation (≠ Type › Orientation → Hướng chữ).
small_label(ui, crate::i18n::tr_ctx(crate::i18n::current(), "document", "Orientation"));
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
for (icon, portrait) in [("rectangle-vertical", true), ("rectangle-horizontal", false)] {
// Tooltip strings are looked up via `icons::button` → `tl!`; enum/state is aspect swap only.
if icons::button(ui, icon, 24.0, (h > w) == portrait, if portrait { "Portrait" } else { "Landscape" }).clicked() && (h > w) != portrait {
swap_size(f);
}
+7 -1
View File
@@ -178,7 +178,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
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()]);
hits.push((s + 2, format!("tool:{tool:?}"), tl!(tool.label()).into(), Some(format!("Tool {}", tool.key())), true));
hits.push((
s + 2,
format!("tool:{tool:?}"),
tl!(tool.label()).into(),
Some(format!("{} {}", crate::i18n::tr(lang, "Tool"), tool.key())),
true,
));
}
}
}
+4 -2
View File
@@ -2126,7 +2126,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
ui.horizontal(|ui| {
let lock_label = if t.pro { tl!("Lock:") } else { tl!("Lock") };
let fill_label = if t.pro { tl!("Fill:") } else { tl!("Fill") };
// Layer fill opacity (≠ Edit › Fill / path fill → Tô).
let lang = crate::i18n::current();
let fill_label = if t.pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") };
// In a narrow panel with long translated labels, drop the "Lock:" text (the icons keep
// their tooltips) rather than let the Fill label run over the lock icons.
let gap = ui.spacing().item_spacing.x;
@@ -3176,7 +3178,7 @@ fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "opacity": o / 100.0}));
}
let mut f = layer.fill_opacity * 100.0;
let r = widgets::slider_row(ui, tl!("Fill"), &mut f, 0.0..=100.0, "%", None);
let r = widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "layers", "Fill"), &mut f, 0.0..=100.0, "%", None);
if r.drag_stopped() || (r.changed() && !r.dragged()) {
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "fill": f / 100.0}));
}
+13 -3
View File
@@ -454,9 +454,19 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
ui.add_enabled_ui(on, |ui| match kind {
"align" => {
let mut a = v.as_str().unwrap_or("Left").to_string();
let opts = [tl!("Left"), tl!("Center"), tl!("Right"), "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyAll"]
.map(|o| (o.to_string(), o));
let opts: Vec<(String, &str)> = opts.to_vec();
// English values for the engine; labels go through `dropdown`'s `tl!`.
let opts: Vec<(String, &str)> = [
("Left", "Left"),
("Center", "Center"),
("Right", "Right"),
("JustifyLeft", "Justify last left"),
("JustifyCenter", "Justify last centered"),
("JustifyRight", "Justify last right"),
("JustifyAll", "Justify all"),
]
.into_iter()
.map(|(v, l)| (v.to_string(), l))
.collect();
if crate::widgets::dropdown(ui, &format!("style-align-{id}"), &mut a, &opts, 110.0) {
set = Some(json!({ "align": a }));
}
+5 -2
View File
@@ -1848,6 +1848,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
axis_sliders(app, ui, &c);
row(ui, &mut |ui| {
let k = shown_scale(app);
// Hover tips stay English until wired through `tl!` in a multi-locale change
// (`every_tl_literal_is_translated` requires every complete catalog).
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
app.ui.tool_options.type_size = v;
apply(app, ui.ctx(), json!({"size": v, "metricsAsShown": true}));
@@ -1904,18 +1906,19 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
});
// Faux styles: T (bold) T (italic) TT Tᴛ T̲ T̶, sharing the row evenly.
let caps = c.caps;
// Tip strings are English keys; `toggle_cell` runs them through `tl!`.
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
(
"TT",
tl!("All Caps"),
"All Caps",
caps == photocraft_doc::text::Caps::AllCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
),
(
"Tᴛ",
tl!("Small Caps"),
"Small Caps",
caps == photocraft_doc::text::Caps::SmallCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
),
+2 -1
View File
@@ -1193,7 +1193,8 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
let gesture = paint_gesture(ui, gesture_key);
let coalesce = |field: &str| format!("{}:{}:{gesture}", key(field), gesture_key.value());
// The shared collapsible section headers (#155).
if crate::props_layout::section(ui, "appearance", tl!("Appearance")) {
// Prefs "Appearance" is theme chrome (Giao diện); shape fill/stroke is Diện mạo.
if crate::props_layout::section(ui, "appearance", crate::i18n::tr_ctx(crate::i18n::current(), "shape", "Appearance")) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Fill")).color(t.text_dim).size(12.0));
if let Some(change) = swatch(ui, sh.fill.as_ref(), app.session.tools.foreground, tl!("Set shape fill type")) {
+8 -5
View File
@@ -25,11 +25,14 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
| Polski | `pl` | one / few (2–4, not 12–14) / many |
| Ελληνικά | `el` | one / other |
| Nederlands | `nl` | one / other |
| Tiếng Việt | `vi` | one form |
Every non-English catalog covers the current menu labels, `tl!` literals, blend mode names,
and generated preference labels. Tests enforce that coverage. 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.
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is
selectable, resolves regional tags such as `vi-VN`, and covers all current stable localization
keys. `complete_menus` remains false while generated/dynamic long-tail labels are still under
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.
## Switch without restarting
@@ -41,7 +44,7 @@ documents, undo history and tools remain available throughout a language change.
The existing preference store saves the selection for future launches. `auto` follows the
native system locale; an unsupported code follows the same fallback. Regional tags such as
`fr-CA`, `ko-KR` and `zh-CN` resolve to the corresponding registered catalog. Traditional and
`fr-CA`, `ko-KR`, `vi-VN` and `zh-CN` resolve to the corresponding registered catalog. Traditional and
Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the
Brazilian Portuguese catalog.
+1
View File
@@ -579,6 +579,7 @@ mod tests {
("el", "el.tsv"),
("uk", "uk.tsv"),
("nl", "nl.tsv"),
("vi", "vi.tsv"),
] {
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);
let text = fs::read_to_string(&catalog_path).unwrap_or_default();