mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
tonguyenducmanh
Claude Opus 5.5
parent
649b1d5d92
commit
dbca08cd20
@@ -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) |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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" }}),
|
||||
),
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user