From f4181890bdeb1bc9d6a26d8f1a2140d9b3006953 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 08:20:35 +0700 Subject: [PATCH 1/4] i18n review follow-ups: names are never translated, palette cache, locale probe off the UI thread Follow-ups from the review of #205. - Names that are user, file or system data are shown as they are: menu items for recent files, saved views, recent fonts and Type > Font, user swatch and graphic style libraries, saved perspective presets, custom workspaces (and Reset ) and plug-ins no longer go through the catalog (menus::shows_a_name). widgets::dropdown_names, menu_item_name and dim_name are the non-translating forms of the shared widgets, for the panels and dialogs that list such names. - The command palette builds its entries (shown label, search text) once per language and plug-in set instead of translating ~700 labels every frame while a query is typed. - The locale probe (reg.exe / defaults) runs on a background thread at startup instead of on the first frame. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 1 + crates/ui-egui/src/i18n/mod.rs | 13 +++++ crates/ui-egui/src/menus.rs | 50 ++++++++++++++++-- crates/ui-egui/src/palette.rs | 92 ++++++++++++++++++++++++++-------- crates/ui-egui/src/widgets.rs | 31 ++++++++++-- 5 files changed, 160 insertions(+), 27 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c203b22d..a1e7bcf1 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -246,6 +246,7 @@ fn app_icon() -> egui::IconData { const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); fn main() -> eframe::Result { + vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); let mut control_port: Option = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut args = std::env::args().skip(1); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index ebacd8b9..5e7add93 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -168,6 +168,19 @@ pub fn lang_from_tag(tag: &str) -> Option { cands.iter().find_map(|c| Lang::from_code(c)) } +/// Work out the system language on a background thread, so the first frame doesn't wait for the +/// locale probe (which runs `reg.exe` on Windows and `defaults` on macOS). Call it at startup; a +/// frame that needs the language before the probe finishes waits for it. A no-op on wasm. +pub fn detect_system_lang_in_background() { + #[cfg(not(target_arch = "wasm32"))] + { + // A failed spawn leaves the probe to the first frame that needs it. + let _ = std::thread::Builder::new().name("locale-probe".into()).spawn(|| { + system_lang(); + }); + } +} + /// The system language (cached). English when it can't be determined. pub fn system_lang() -> Lang { // Tests drive the UI by its English labels whatever the developer's locale is. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d58b2e38..125113ee 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1258,9 +1258,29 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { } } +/// Does the menu item `id` show a name that is user, file or system data rather than an interface +/// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a +/// plug-in? Such names are shown as they are, never translated (a workspace the user calls +/// "Layers" stays "Layers"). +fn shows_a_name(id: &str, label: &str) -> bool { + const SLOTS: [&str; 6] = [ + "file.openRecent", + "view.goto", + "type.recentFont", + crate::panels::swatches::USER_SLOT, + crate::panels::graphic_styles::USER_SLOT, + crate::dialogs::perspective_presets::SLOT, + ]; + SLOTS.iter().any(|s| id.starts_with(s)) + || matches!(id, "text.setStyle" | "plugin.dialog") + || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) + || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) +} + /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled /// around a name (Undo *Move*, Reset *Essentials*, Last Effect: *Drop Shadow*) are translated as -/// templates so the name can move; names that are user data (files, views, fonts) pass through. +/// templates so the name can move; names that are user data (files, views, fonts, custom +/// workspaces, plug-ins: [`shows_a_name`]) pass through. pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { let lang = crate::i18n::current(); let fmt = crate::i18n::fmt; @@ -1273,7 +1293,10 @@ pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { None => tl!(if id == "edit.undo" { "Undo" } else { "Redo" }).to_string(), } } - "window.workspace.reset" => fmt(tl!("Reset {name}"), &[("name", tl!(&app.ui.workspace))]), + "window.workspace.reset" => { + let name = &app.ui.workspace; + fmt(tl!("Reset {name}"), &[("name", if crate::workspaces::is_builtin(name) { tl!(name) } else { name })]) + } "effect.last" => match &app.last_effect { Some((e, _)) => { let name = vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str()); @@ -1281,7 +1304,10 @@ pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { } None => crate::i18n::tr_id(lang, id, label).to_string(), }, - _ => crate::i18n::tr_id(lang, id, &dynamic_label(app, id, label)).to_string(), + _ => { + let shown = dynamic_label(app, id, label); + if shows_a_name(id, &shown) { shown } else { crate::i18n::tr_id(lang, id, &shown).to_string() } + } } } @@ -2896,6 +2922,24 @@ pub fn menu_strings() -> std::collections::BTreeSet { mod tests { use super::*; + #[test] + fn names_in_menus_are_not_interface_labels() { + for (id, label) in [ + ("file.openRecent1", "Layers.svg"), + ("view.goto2", "Layers"), + ("type.recentFont1", "Regular"), + ("text.setStyle", "Black"), + ("window.workspace", "Layers"), + ("plugin.dialog", "Group"), + ("window.userSwatchLibrary3", "Default"), + ] { + assert!(shows_a_name(id, label), "{id} {label}"); + } + for (id, label) in [("object.group", "Group"), ("window.workspace", "Essentials"), ("effect.apply", "Drop Shadow")] { + assert!(!shows_a_name(id, label), "{id} {label}"); + } + } + #[test] fn last_effect_dialog_and_view_toggles() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index aa7699c3..9378828a 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -1,5 +1,7 @@ //! The command palette: fuzzy search over every command and tool. +use std::sync::Arc; + use serde_json::json; use crate::theme::Tokens; @@ -23,10 +25,46 @@ pub fn items() -> Vec<(String, String, String)> { items } -/// A palette label in the UI language: each `›`-separated menu segment and the command label -/// translate on their own. -fn shown_label(label: &str) -> String { - label.split(" › ").map(crate::i18n::t).collect::>().join(" › ") +/// A palette label in `lang`: each `›`-separated menu segment and the command label translate on +/// their own. +fn shown_label(lang: crate::i18n::Lang, label: &str) -> String { + label.split(" › ").map(|s| crate::i18n::tr(lang, s)).collect::>().join(" › ") +} + +/// One palette entry, with what it shows and what a query matches worked out once. +struct Entry { + id: String, + shortcut: String, + /// The label in the UI language. + shown: String, + /// The English label (what agents document), the shown label and the id, lowercased, one per + /// line: a query word (which has no line break) matches within one of them. + haystack: String, +} + +/// The palette's entries in `lang`. They are built when the language or the installed plug-ins +/// change, not on every frame while a query is typed. +fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc> { + type Cached = ((&'static str, u64), Arc>); + let key = (lang.code(), menus::plugin_revision()); + let id = egui::Id::new("palette-entries"); + if let Some((k, v)) = ctx.data(|d| d.get_temp::(id)) + && k == key + { + return v; + } + let built: Arc> = Arc::new( + items() + .into_iter() + .map(|(label, id, shortcut)| { + let shown = shown_label(lang, &label); + let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase()); + Entry { id, shortcut, shown, haystack } + }) + .collect(), + ); + ctx.data_mut(|d| d.insert_temp::(id, (key, built.clone()))); + built } pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { @@ -35,18 +73,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); let q = app.ui.palette_query.to_lowercase(); - let items = items(); - let matches: Vec<&(String, String, String)> = items - .iter() - .filter(|(l, id, _)| { - q.is_empty() || { - // Match the English text (what agents document), the shown text and the id. - let shown = shown_label(l).to_lowercase(); - q.split_whitespace().all(|w| l.to_lowercase().contains(w) || shown.contains(w) || id.to_lowercase().contains(w)) - } - }) - .take(14) - .collect(); + let entries = entries(ctx, crate::i18n::current()); + let matches: Vec<&Entry> = entries.iter().filter(|e| q.split_whitespace().all(|w| e.haystack.contains(w))).take(14).collect(); let mut run: Option = None; egui::Area::new(egui::Id::new("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| { @@ -54,21 +82,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let r = ui.add(egui::TextEdit::singleline(&mut app.ui.palette_query).hint_text(tl!("Search commands and tools…")).desired_width(500.0)); r.request_focus(); ui.add_space(6.0); - for (i, (label, id, sc)) in matches.iter().enumerate() { + for (i, e) in matches.iter().enumerate() { let resp = ui.add( - egui::Button::new(shown_label(label)) - .shortcut_text(menus::pretty_shortcut(sc)) + egui::Button::new(e.shown.as_str()) + .shortcut_text(menus::pretty_shortcut(&e.shortcut)) .min_size(egui::vec2(500.0, 24.0)) .selected(i == 0), ); if resp.clicked() { - run = Some(id.clone()); + run = Some(e.id.clone()); } } if ui.input(|i| i.key_pressed(egui::Key::Enter)) && let Some(first) = matches.first() { - run = Some(first.1.clone()); + run = Some(first.id.clone()); } }); }); @@ -81,3 +109,25 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::Lang; + + #[test] + fn entries_are_built_once_per_language_and_match_english_shown_text_and_ids() { + let ctx = egui::Context::default(); + let zh = Lang::from_code("zh-hant").unwrap(); + let en = entries(&ctx, Lang::EN); + assert!(Arc::ptr_eq(&en, &entries(&ctx, Lang::EN)), "kept while the language stays"); + let zh_entries = entries(&ctx, zh); + assert!(!Arc::ptr_eq(&en, &zh_entries), "rebuilt for another language"); + let group = zh_entries.iter().find(|e| e.id == "object.group").unwrap(); + assert!(group.shown.ends_with(crate::i18n::tr(zh, "Group")), "{}", group.shown); + // A query matches the English label, the shown label or the id. + for word in ["group", crate::i18n::tr(zh, "Group"), "object.group"] { + assert!(group.haystack.contains(&word.to_lowercase()), "{word}"); + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0a7d9ef7..b547671a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -88,8 +88,14 @@ pub fn divider(ui: &mut Ui) { } pub fn dim_label(ui: &mut Ui, text: &str) -> Response { + dim_name(ui, tl!(text)) +} + +/// [`dim_label`] for a name that is user or file data (a swatch, style, artboard or font name): +/// shown as it is, never translated. +pub fn dim_name(ui: &mut Ui, text: &str) -> Response { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!(text)).color(t.text).size(12.5)) + ui.label(egui::RichText::new(text).color(t.text).size(12.5)) } /// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) @@ -395,11 +401,26 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool } } -/// A compact dropdown. Returns the chosen index. +/// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index. pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { dropdown_with(ui, id, current, options, width, |_| true) } +/// [`dropdown`] of names that are user, file or system data (font styles, artboards, presets the +/// user saved, printers, profiles), shown as they are, never translated. For a list that mixes +/// built-in labels with such names, translate the built-in ones (`tl!`) before passing them. +pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { + combo(ui, id, current, width, false, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.add(egui::Button::selectable(*o == current, *o)).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + /// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). pub fn dropdown_with( ui: &mut Ui, @@ -1057,7 +1078,11 @@ pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { /// A menu row for panel (≡) menus: label, optional check mark, disabled when not implemented. pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { - let label = tl!(label); + menu_item_name(ui, tl!(label), enabled, checked) +} + +/// [`menu_item`] for a name that is user or file data (a library, a recent URL), never translated. +pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { let text = if checked { format!("✓ {label}") } else { format!(" {label}") }; ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame(false)).clicked() } From cf7a43d7865fcdf0e78405324caa0aa9421e7612 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 08:36:04 +0700 Subject: [PATCH 2/4] i18n: dialogs show user, file and system names as they are Lists and headings that mix our labels with names no longer send the names through the catalog, so a preset, printer or plug-in called "Regular" or "Black" keeps its name in every language. - dialogs::shown_names / mixed_dropdown: translate only a list's built-in entries and show the rest with widgets::dropdown_names (same indices). - Artboard lists (Save for Office, Save for Web, save options) and plug-in choices: dropdown_names. - Mixed lists translate only their built-in entries: Recolor's Limit to Library, Save for Web, Print, Save PDF and Export for Screens presets, printers (Default Printer, PDF File), colour profiles and flattener presets (profile_pick, flatten::preset_dropdown), perspective presets, New Document's preset cards and Size, and the shortcut set. - Plug-in dialogs and plug-in effects are headed by the plug-in's name as it is; plug-in parameter labels are not translated. - The confirm dialog shows its message and detail as given, and the shared frame no longer translates headings again: each heading is translated by its spec. File > Revert translates its template before filling in the document's name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/confirm.rs | 10 +++--- crates/ui-egui/src/dialogs/effect.rs | 5 ++- .../ui-egui/src/dialogs/export_for_screens.rs | 4 ++- crates/ui-egui/src/dialogs/flatten.rs | 19 ++++++++-- crates/ui-egui/src/dialogs/form.rs | 7 ++-- crates/ui-egui/src/dialogs/mod.rs | 29 +++++++++++++-- crates/ui-egui/src/dialogs/new_document.rs | 29 ++++++++++++--- crates/ui-egui/src/dialogs/office_export.rs | 2 +- .../ui-egui/src/dialogs/perspective_grid.rs | 6 +++- crates/ui-egui/src/dialogs/plugin.rs | 3 +- crates/ui-egui/src/dialogs/print.rs | 33 +++++++++-------- crates/ui-egui/src/dialogs/recolor.rs | 17 +++++---- crates/ui-egui/src/dialogs/save_for_web.rs | 9 +++-- crates/ui-egui/src/dialogs/save_options.rs | 2 +- crates/ui-egui/src/dialogs/save_pdf.rs | 35 +++++++++++++++---- crates/ui-egui/src/dialogs/tests.rs | 25 +++++++++++++ crates/ui-egui/src/io.rs | 4 +-- crates/ui-egui/src/shortcut_editor.rs | 8 +++-- 18 files changed, 188 insertions(+), 59 deletions(-) diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 1c29bbec..4e02feb0 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -2,7 +2,8 @@ //! as deleting swatches. //! //! Fields: `message` (the question, shown as the heading), `detail` (an optional line under it), -//! `__command` and `__params` (the command OK runs). +//! `__command` and `__params` (the command OK runs). Both texts are shown as given: callers +//! translate their template before filling in a name, so the name is never translated. use serde_json::{Value, json}; @@ -15,11 +16,11 @@ use crate::theme::Tokens; pub const KIND: &str = "confirm"; pub(super) const SPEC: DialogSpec = DialogSpec { - heading: |d| tl!(&d.str("message")).to_string(), + heading: |d| d.str("message"), body: |_, ui, d| { let detail = d.str("detail"); if !detail.is_empty() { - ui.label(egui::RichText::new(tl!(&detail)).color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(detail).color(Tokens::get(ui.ctx()).text_dim)); } false }, @@ -28,7 +29,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ..DialogSpec::FORM }; -/// Ask `message` (with an optional `detail` line); OK runs `command` with `params`. +/// Ask `message` (with an optional `detail` line), both in the UI language; OK runs `command` +/// with `params`. pub fn ask(app: &mut VectorcraftApp, message: &str, detail: &str, command: &str, params: Value) { let fields = json!({"message": message, "detail": detail, "__command": command, "__params": params}); app.ui.dialog = Some(Dialog::new(KIND, fields)); diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 27adf9cb..f201ccc9 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -20,9 +20,12 @@ use crate::{VectorcraftApp, widgets}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| heading_label(d), body, confirm, preview: true, ..DialogSpec::FORM }; /// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or -/// without its trailing ellipsis. +/// without its trailing ellipsis. A plug-in effect's name is shown as it is. fn heading_label(d: &Dialog) -> String { let label = d.str("__label"); + if vectorcraft_plugins::effect::plugin_id(&d.str("__effect")).is_some() { + return label; + } let dotted = format!("{label}…"); let shown = tl!(&dotted); if shown != dotted { shown.trim_end_matches('…').to_string() } else { tl!(&label).to_string() } diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 92cdc42d..f37d05c5 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -558,7 +558,9 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) { d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("PDF Preset:")).color(t.text_dim)); - if let Some(p) = widgets::dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0).and_then(|i| labels.get(i)) { + // The built-in presets are ours (translated); the saved ones are names. + let builtin = |k: usize| labels.get(k).is_some_and(|n| super::save_pdf::is_builtin_preset(n)); + if let Some(p) = super::mixed_dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0, builtin).and_then(|i| labels.get(i)) { s.fields.insert("preset".into(), json!(p)); } }); diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index c30b267d..9d73431d 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -153,7 +153,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } /// The flattener preset dropdown: `presets` by name, showing `name` while `options` are that -/// preset's, else [Custom]. Returns the preset chosen. +/// preset's, else [Custom]. The built-in presets are ours (translated); the saved ones are names. +/// Returns the preset chosen. pub(crate) fn preset_dropdown<'a>( ui: &mut egui::Ui, id: &str, @@ -162,9 +163,14 @@ pub(crate) fn preset_dropdown<'a>( options: Option<&FlattenOptions>, width: f32, ) -> Option<&'a FlattenerPreset> { - let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(CUSTOM, |p| p.name.as_str()); + let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(tl!(CUSTOM), |p| p.name.as_str()); let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); - widgets::dropdown(ui, id, shown, &names, width).map(|i| &presets[i]) + super::mixed_dropdown(ui, id, shown, &names, width, |k| names.get(k).is_some_and(|n| is_builtin(n))).and_then(|i| presets.get(i)) +} + +/// Is `name` a built-in flattener preset's? +fn is_builtin(name: &str) -> bool { + FlattenOptions::PRESETS.iter().any(|id| FlattenOptions::preset_label(id) == Some(name)) } /// The width of the option value fields. @@ -230,6 +236,13 @@ mod tests { use super::*; use vectorcraft_engine::Session; + /// The built-in presets are told from saved ones by name (only theirs are translated). + #[test] + fn built_in_presets_are_known_by_name() { + assert!(FlattenOptions::builtin_presets().iter().all(|p| is_builtin(&p.name))); + assert!(!is_builtin("Regular") && !is_builtin("high")); + } + fn frame(app: &mut VectorcraftApp) { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 16a4c894..3e125c81 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -207,14 +207,15 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn /// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers /// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns -/// true when a value changed. +/// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's +/// text, shown as they are. pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool { use vectorcraft_plugins::ParamSpec; let t = Tokens::get(ui.ctx()); let mut changed = false; egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { for (k, spec) in specs { - ui.label(egui::RichText::new(humanized(k)).color(t.text)); + ui.label(egui::RichText::new(humanize(k)).color(t.text)); let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value()); let new = match spec { ParamSpec::Number { min, max, .. } => { @@ -232,7 +233,7 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, } ParamSpec::Choice { options, .. } => { let labels: Vec<&str> = options.iter().map(String::as_str).collect(); - crate::widgets::dropdown(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0) + crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0) .and_then(|i| options.get(i)) .map(|o| json!(o)) } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9a973602..593205c3 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -108,7 +108,8 @@ const EDGE_GAP: f32 = 8.0; pub(crate) struct DialogSpec { /// Draws its own window instead of the shared frame (the frame fields below are then unused). pub window: Option, - /// The heading (and window title). + /// The heading (and window title), in the UI language: the spec translates its own text and + /// leaves names in it (a document's, a plug-in's) as they are. pub heading: fn(&Dialog) -> String, /// Draws the fields. Returns true to close the dialog as Cancel would. pub body: fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> bool, @@ -131,7 +132,7 @@ impl DialogSpec { /// A text field per value; OK just closes. Also the fallback for unregistered kinds. pub const FORM: Self = Self { window: None, - heading: |_| "Dialog".into(), + heading: |_| tl!("Dialog").into(), body: |app, ui, d| { form::grid(ui, d, app.session.general_unit()); false @@ -340,7 +341,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP); ui.set_min_width(spec.min_width.min(room)); ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room))); - ui.label(egui::RichText::new(tl!(heading.as_str())).font(theme::semibold(16.0)).color(t.text)); + ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); cancel = (spec.body)(app, ui, &mut d); ui.add_space(16.0); @@ -379,6 +380,28 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// What a dropdown shows for `names`, a list mixing built-in labels (`builtin(index)`: translated +/// into `lang`) with names the user saved or a file or the system supplied (shown as they are). +pub(crate) fn shown_names<'a>(lang: crate::i18n::Lang, names: &[&'a str], builtin: impl Fn(usize) -> bool) -> Vec<&'a str> { + names.iter().enumerate().map(|(k, n)| if builtin(k) { crate::i18n::tr(lang, n) } else { *n }).collect() +} + +/// [`widgets::dropdown_names`] over such a mixed list ([`shown_names`] in the UI language), showing +/// `current` as its entry reads; a `current` that isn't among `names` is shown as given (a caller +/// translates its own [Custom]). Returns the index chosen in `names`. +pub(crate) fn mixed_dropdown( + ui: &mut egui::Ui, + id: impl std::hash::Hash + std::fmt::Debug, + current: &str, + names: &[&str], + width: f32, + builtin: impl Fn(usize) -> bool, +) -> Option { + let shown = shown_names(crate::i18n::current(), names, builtin); + let current = names.iter().position(|n| *n == current).and_then(|i| shown.get(i).copied()).unwrap_or(current); + widgets::dropdown_names(ui, id, current, &shown, width) +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 8c2afb42..e2e90a63 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -252,10 +252,22 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// Is `name` a built-in preset's? Those are ours (translated); Recent and Saved hold the names of +/// the user's documents and presets, shown as they are. +fn is_builtin_preset(name: &str) -> bool { + newdoc::CATEGORIES.iter().any(|(_, _, _, presets)| presets.iter().any(|(n, ..)| *n == name)) +} + +/// A preset's name as shown: a built-in one translated, the user's as it is. +fn preset_name(name: &str) -> &str { + if is_builtin_preset(name) { tl!(name) } else { name } +} + /// A preset card: a page-proportioned thumbnail, the name and the size (New Document, the Home /// screen). Returns the card's response. pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = preset_name(&s.name); let (r, resp) = ui.allocate_exact_size(egui::vec2(146.0, 150.0), egui::Sense::click()); let p = ui.painter(); p.rect_filled(r, egui::CornerRadius::same(6), if resp.hovered() || selected { t.hover } else { t.panel_darker }); @@ -271,7 +283,7 @@ pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui:: p.rect_filled(page, 0.0, egui::Color32::WHITE); // The name on up to two lines, then the size. let mut job = egui::text::LayoutJob::single_section( - tl!(&s.name).to_string(), + label.to_string(), egui::TextFormat { font_id: theme::semibold(11.5), color: t.text_strong, ..Default::default() }, ); job.halign = egui::Align::Center; @@ -280,7 +292,7 @@ pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui:: let name_h = name.size().y; p.galley(egui::pos2(r.center().x, r.top() + 94.0), name, t.text_strong); p.text(egui::pos2(r.center().x, r.top() + 98.0 + name_h), egui::Align2::CENTER_TOP, s.size_label(), egui::FontId::proportional(11.0), t.text_dim); - resp.on_hover_text(tl!(&s.name)) + resp.on_hover_text(label) } /// The Preset Details column. @@ -535,8 +547,9 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) { let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect(); let preset = d.str("preset"); widgets::label_row(ui, tl!("Size:"), L, |ui| { - let shown = if preset.is_empty() { "Custom" } else { preset.as_str() }; - if let Some(s) = widgets::dropdown(ui, "newdoc-size", shown, &size_names, 220.0).and_then(|i| sizes.get(i)) { + // The profile's sizes are built in; the preset may be one the user saved. + let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) }; + if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) { apply(&mut d, s); } }); @@ -589,6 +602,14 @@ mod tests { use crate::VectorcraftApp; + /// Cards translate the built-in presets' names only: Recent and Saved show the user's names. + #[test] + fn only_built_in_preset_names_are_translated() { + assert!(super::is_builtin_preset("Letter") && super::is_builtin_preset("Phone 390×844")); + assert!(!super::is_builtin_preset("Black") && !super::is_builtin_preset("Untitled-1")); + assert_eq!(super::preset_name("Black"), "Black"); + } + fn frame(app: &mut VectorcraftApp) -> egui::FullOutput { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); diff --git a/crates/ui-egui/src/dialogs/office_export.rs b/crates/ui-egui/src/dialogs/office_export.rs index 27dd4056..59d6d1a5 100644 --- a/crates/ui-egui/src/dialogs/office_export.rs +++ b/crates/ui-egui/src/dialogs/office_export.rs @@ -39,7 +39,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if doc.artboards.len() > 1 { label(ui, tl!("Artboard:")); let names: Vec<&str> = doc.artboards.iter().map(|a| a.name.as_str()).collect(); - if let Some(i) = widgets::dropdown(ui, "office-artboard", names.get(board).copied().unwrap_or_default(), &names, 160.0) { + if let Some(i) = widgets::dropdown_names(ui, "office-artboard", names.get(board).copied().unwrap_or_default(), &names, 160.0) { d.fields.insert("artboard".into(), json!(i)); } ui.end_row(); diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index b6eb5ff6..b6d6af22 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -14,6 +14,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Unit; +use vectorcraft_tools::distort::perspective::define::is_builtin; use vectorcraft_tools::distort::perspective::{GridDefinition, Rgb}; use super::{DialogSpec, form}; @@ -219,8 +220,11 @@ fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } let shown = current.map_or(CUSTOM, |p| p.name.as_str()); let names: Vec<&str> = std::iter::once(CUSTOM).chain(presets.iter().map(|p| p.name.as_str())).collect(); + // [Custom] and the built-in presets are ours (translated); the saved ones are names. + let builtin = |k: usize| k == 0 || names.get(k).is_some_and(|n| is_builtin(n)); widgets::label_row(ui, tl!("Preset:"), LABEL, |ui| { - if let Some(p) = widgets::dropdown(ui, "persp-preset", shown, &names, 200.0).and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) { + let picked = super::mixed_dropdown(ui, "persp-preset", shown, &names, 200.0, builtin); + if let Some(p) = picked.and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) { let mut fields = json!(p); fields[SHOWN] = fields["units"].clone(); if let Some(o) = fields.as_object() { diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index bd158ec2..d2e9cad2 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -23,8 +23,9 @@ const CMD: &str = "plugin.run"; /// Object › Plug-ins' header while no filter plug-in is installed. pub const NO_FILTERS: &str = "No filter plug-ins installed"; +/// Headed by the plug-in's name, as it is (plug-in text is never translated). pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM }; + DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM }; /// A menu label for plug-in `name`: with an ellipsis when it opens a dialog. fn label(name: &str, has_params: bool) -> &'static str { diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 808d7087..41c7cbc9 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -264,8 +264,9 @@ fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let asking = d.fields.contains_key(SAVE_AS); let mut ask = false; row_with(ui, tl!("Print Preset:"), TOP_LABEL_WIDTH, |ui| { - picked = - widgets::dropdown(ui, "print-preset", if same { &name } else { CUSTOM }, &names, 300.0).and_then(|i| names.get(i)).map(|n| n.to_string()); + // [Default] is ours (translated); the saved presets are names. + let current = if same { name.as_str() } else { tl!(CUSTOM) }; + picked = super::mixed_dropdown(ui, "print-preset", current, &names, 300.0, |k| k == 0).and_then(|i| names.get(i)).map(|n| n.to_string()); let save = ui.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, tl!("Save Preset…"), 96.0)).inner; ask = save.on_hover_text(tl!("Save these settings as a print preset")).clicked(); }); @@ -317,23 +318,25 @@ fn printer_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { entries.push(Destination::Default); } entries.push(Destination::File); - let label = |e: &Destination| match e { - Destination::Printer(name) => name.clone(), - Destination::Default => DEFAULT_PRINTER.into(), - Destination::File => PDF_FILE.into(), - }; - let labels: Vec = entries.iter().map(label).collect(); + // The system's printers by their names; the default printer and PDF File are ours. + let options: Vec<&str> = entries + .iter() + .map(|e| match e { + Destination::Printer(name) => name.as_str(), + Destination::Default => tl!(DEFAULT_PRINTER), + Destination::File => tl!(PDF_FILE), + }) + .collect(); let printer = d.str("printer"); let current = if d.bool("toFile") { - PDF_FILE + tl!(PDF_FILE) } else if printer.is_empty() { - DEFAULT_PRINTER + tl!(DEFAULT_PRINTER) } else { printer.as_str() }; row_with(ui, tl!("Printer:"), TOP_LABEL_WIDTH, |ui| { - let options: Vec<&str> = labels.iter().map(String::as_str).collect(); - match widgets::dropdown(ui, "print-printer", current, &options, 300.0).and_then(|i| entries.get(i)) { + match widgets::dropdown_names(ui, "print-printer", current, &options, 300.0).and_then(|i| entries.get(i)) { Some(Destination::Printer(name)) => { d.fields.insert("printer".into(), json!(name)); d.fields.insert("toFile".into(), json!(false)); @@ -733,7 +736,7 @@ fn graphics(ui: &mut egui::Ui, d: &mut Dialog) { fn color(ui: &mut egui::Ui, d: &mut Dialog) { let profiles = vectorcraft_color::cms::profiles(); let names: Vec<&str> = std::iter::once(SAME_AS_SOURCE).chain(profiles.iter().map(|p| p.name.as_str())).collect(); - row(ui, tl!("Printer profile:"), |ui| super::save_pdf::profile_pick(ui, d, "color.profile", &names, true)); + row(ui, tl!("Printer profile:"), |ui| super::save_pdf::profile_pick(ui, d, "color.profile", &names, 1, true)); note(ui, tl!("Composite colours are converted to it; separations separate with it when it is a CMYK profile.")); row(ui, tl!("Rendering intent:"), |ui| { let current = serde_json::from_value::(get(d, "color.intent").clone()).unwrap_or_default(); @@ -760,7 +763,9 @@ fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); let presets = app.session.flattener_presets(); let names: Vec<&str> = std::iter::once(KEEP_TRANSPARENCY).chain(presets.iter().map(|p| p.name.as_str())).collect(); - row(ui, tl!("Preset:"), |ui| super::save_pdf::profile_pick(ui, d, "advanced.flattenerPreset", &names, true)); + // None and the built-in presets are ours; the saved presets are names. + let builtins = names.len().saturating_sub(app.session.prefs.flattener_presets.len()); + row(ui, tl!("Preset:"), |ui| super::save_pdf::profile_pick(ui, d, "advanced.flattenerPreset", &names, builtins, true)); note( ui, tl!( diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index e58e296f..ede5546f 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -28,7 +28,7 @@ use vectorcraft_engine::cmd::swatchlib; use super::{DialogSpec, form}; use crate::panels::c32; -use crate::panels::swatches::{colour_libraries, limit_key, limit_name}; +use crate::panels::swatches::{DOCUMENT_SWATCHES, colour_libraries, limit_key}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, widgets}; @@ -433,15 +433,18 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut } ui.end_row(); ui.label(egui::RichText::new(tl!("Limit to Library:")).color(t.text_dim)); - // None, the document's swatches, then the colour libraries. + // None, the document's swatches, then the colour libraries: the built-in ones (at the top + // level of the library menu) translated, those the user saved or loaded by their names. let libs = colour_libraries(app); let keys: Vec<&str> = ["", swatchlib::DOCUMENT_SWATCHES].into_iter().chain(libs.iter().map(|l| l.id.as_str())).collect(); - let labels: Vec = keys.iter().map(|k| limit_name(app, k).unwrap_or_else(|| tl!("None").into())).collect(); + let names: Vec<&str> = ["None", DOCUMENT_SWATCHES].into_iter().chain(libs.iter().map(|l| l.name.as_str())).collect(); + let builtin = |k: usize| k.checked_sub(2).is_none_or(|i| libs.get(i).is_some_and(|l| l.submenu.is_none())); + let shown = super::shown_names(crate::i18n::current(), &names, builtin); let cur = d.str("limitTo"); - let name = keys.iter().position(|k| *k == cur).map_or(tl!("None"), |i| labels[i].as_str()); - let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown(ui, "recolor-library", name, &labels, 180.0) { - d.fields.insert("limitTo".into(), json!(keys[i])); + let at = keys.iter().position(|k| *k == cur).unwrap_or(0); + let current = shown.get(at).copied().unwrap_or_default(); + if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) { + d.fields.insert("limitTo".into(), json!(k)); } ui.end_row(); }); diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 44404ddb..60bbc57b 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -570,10 +570,13 @@ fn presets_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: O let saved = app.session.prefs.web_export_presets.len(); let current = s.and_then(|s| presets.iter().rposition(|p| p.settings == *s)); let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); + // The built-in presets come first (translated); the saved ones are names. + let builtins = presets.len().saturating_sub(saved); + let labels = super::shown_names(crate::i18n::current(), &names, |k| k < builtins); let mut run: Option<(&str, Value)> = None; widgets::label_row(ui, tl!("Preset:"), LABEL_W, |ui| { - let shown = current.and_then(|i| names.get(i).copied()).unwrap_or(tl!("[Unnamed]")); - if let Some(i) = widgets::dropdown(ui, "sfw-preset", shown, &names, FIELD_W - 60.0) + let shown = current.and_then(|i| labels.get(i).copied()).unwrap_or(tl!("[Unnamed]")); + if let Some(i) = widgets::dropdown_names(ui, "sfw-preset", shown, &labels, FIELD_W - 60.0) && let Some(p) = presets.get(i) { d.fields.extend(settings_fields(&p.settings)); @@ -828,7 +831,7 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect(); let at = s.and_then(|s| s.artboard).unwrap_or(0).min(names.len() - 1); widgets::label_row(ui, tl!("Artboard:"), LABEL_W, |ui| { - if let Some(i) = widgets::dropdown(ui, "sfw-artboard", names.get(at).copied().unwrap_or_default(), &names, FIELD_W) { + if let Some(i) = widgets::dropdown_names(ui, "sfw-artboard", names.get(at).copied().unwrap_or_default(), &names, FIELD_W) { d.fields.insert("artboard".into(), json!(i)); } }); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 9ebdd1ab..b5d82295 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -164,7 +164,7 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma if let Some(boards) = artboards { let i = value.as_u64().unwrap_or(0) as usize; let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect(); - if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), names.get(i).copied().unwrap_or(""), &names, 254.0) { + if let Some(i) = widgets::dropdown_names(ui, ("save-option", o.name), names.get(i).copied().unwrap_or(""), &names, 254.0) { d.fields.insert(o.name.into(), json!(i)); } } else { diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 1c8cb011..3c7bc79c 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -50,6 +50,13 @@ pub(super) const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Ble /// The default settings as JSON: what a field an agent left out reads as. static DEFAULTS: LazyLock = LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default()); +/// Is `name` a built-in PDF preset's? Those are ours (translated where listed); the saved ones are +/// names. +pub(super) fn is_builtin_preset(name: &str) -> bool { + static NAMES: LazyLock> = LazyLock::new(|| vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).collect()); + NAMES.iter().any(|n| n == name) +} + /// Trim mark weights offered (pt). const WEIGHTS: [f64; 3] = [0.125, 0.25, 0.5]; const WEIGHT_LABELS: [&str; 3] = ["0.125 pt", "0.25 pt", "0.5 pt"]; @@ -394,7 +401,11 @@ fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { row_with(ui, tl!("Preset:"), TOP_LABEL_WIDTH, |ui| { let presets = pdf::presets(&app.session); let names: Vec<&str> = presets.iter().map(String::as_str).collect(); - let chosen = widgets::dropdown(ui, "pdf-preset", &d.str("preset"), &names, 300.0).and_then(|i| names.get(i)).map(|p| p.to_string()); + // The built-in presets come first (translated); the saved ones are names. + let builtins = names.len().saturating_sub(app.session.prefs.pdf_presets.len()); + let chosen = super::mixed_dropdown(ui, "pdf-preset", &d.str("preset"), &names, 300.0, |k| k < builtins) + .and_then(|i| names.get(i)) + .map(|p| p.to_string()); if let Some(name) = chosen { apply_preset(app, d, &name); } @@ -560,12 +571,13 @@ pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut } /// A dropdown of names such as colour profiles or flattener presets (`names`, the first one -/// standing for "" at `path`), showing the name at `path` (one that isn't among them too). -pub(super) fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) { +/// standing for "" at `path`), showing the name at `path` (one that isn't among them too). The +/// first `builtins` entries are ours (translated); the others (profiles, saved presets) are names. +pub(super) fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], builtins: usize, enabled: bool) { let blank = names.first().copied().unwrap_or_default(); let current = get(d, path).as_str().filter(|s| !s.is_empty()).unwrap_or(blank).to_string(); ui.add_enabled_ui(enabled, |ui| { - if let Some(i) = widgets::dropdown(ui, path, ¤t, names, 300.0) { + if let Some(i) = super::mixed_dropdown(ui, path, ¤t, names, 300.0, |k| k < builtins) { set(d, path, json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() })); } }); @@ -581,7 +593,7 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog) { }); // PDF/X-1a converts to CMYK even without a conversion. let converting = get(d, "output.conversion") != ColorConversion::None.id() || standard.cmyk_only(); - row(ui, tl!("Destination:"), |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), converting)); + row(ui, tl!("Destination:"), |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), 1, converting)); row(ui, tl!("Profile inclusion:"), |ui| { // A standard decides whether colours are tagged. ui.add_enabled_ui(standard == Standard::None, |ui| pick::(ui, d, "output.profiles", 300.0, |_| true)); @@ -595,7 +607,7 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog) { // PDF/A files carry their own output intent; PDF/X files always have one. let own = standard != Standard::PdfA2b; let blank = if standard.is_pdfx() { PDFX_INTENT } else { NO_PROFILE }; - row(ui, tl!("Output intent profile:"), |ui| profile_pick(ui, d, "output.outputIntent", &names(blank), own)); + row(ui, tl!("Output intent profile:"), |ui| profile_pick(ui, d, "output.outputIntent", &names(blank), 1, own)); for (p, label) in [ ("output.outputCondition", tl!("Output condition:")), ("output.outputConditionId", tl!("Condition identifier:")), @@ -627,11 +639,13 @@ fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { row(ui, tl!("Flattener preset:"), |ui| { let presets = app.session.flattener_presets(); let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); + // The built-in presets come first (translated); the saved ones are names. + let builtins = names.len().saturating_sub(app.session.prefs.flattener_presets.len()); let name = get(d, "flattenerPreset").as_str().map(str::trim).filter(|n| !n.is_empty()).unwrap_or(pdf::DEFAULT_FLATTENER); // A built-in preset named by id (`high`) shows as its name. let current = FlattenOptions::preset_label(&name.to_ascii_lowercase()).unwrap_or(name).to_string(); ui.add_enabled_ui(flat, |ui| { - if let Some(n) = widgets::dropdown(ui, "flattenerPreset", ¤t, &names, 200.0).and_then(|i| names.get(i)) { + if let Some(n) = super::mixed_dropdown(ui, "flattenerPreset", ¤t, &names, 200.0, |k| k < builtins).and_then(|i| names.get(i)) { set(d, "flattenerPreset", json!(n)); } }); @@ -801,6 +815,13 @@ mod tests { type Log = Rc)>>>; + /// The built-in presets are told from saved ones by name (only theirs are translated). + #[test] + fn built_in_presets_are_known_by_name() { + assert!(vectorcraft_pdf::builtin_presets().iter().all(|p| is_builtin_preset(&p.name))); + assert!(is_builtin_preset(pdf::DEFAULT_PRESET) && !is_builtin_preset("Default") && !is_builtin_preset("My Preset")); + } + /// An app with two artboards whose writer and URL opener record what they get. fn app() -> (VectorcraftApp, Log, Log) { let (written, opened) = (Log::default(), Log::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 94c89b45..039a12a8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -273,3 +273,28 @@ fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() { let narrow = dialog_rect(&mut app, 360.0); assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width()); } + +/// A list mixing built-in labels with names translates only the built-in entries: names that +/// happen to be catalog keys ("Black", "Regular") are shown as they are. +#[test] +fn mixed_lists_translate_only_their_built_in_entries() { + use crate::i18n::{Lang, tr}; + let zh = Lang::from_code("zh-hant").unwrap(); + for key in ["None", "Black", "Regular", "Default"] { + assert_ne!(tr(zh, key), key, "{key} must be a catalog key for this test"); + } + let names = ["None", "Black", "Regular", "Default"]; + assert_eq!(shown_names(zh, &names, |k| k == 0 || k == 3), [tr(zh, "None"), "Black", "Regular", tr(zh, "Default")]); + assert_eq!(shown_names(zh, &names, |_| false), names); + assert_eq!(shown_names(Lang::EN, &names, |_| true), names); +} + +/// Confirmations and plug-in dialogs are headed by the text they are given (callers translate +/// their own templates; a plug-in's name is its own). +#[test] +fn given_headings_are_shown_as_they_are() { + let d = Dialog::new(confirm::KIND, json!({"message": "Delete “Black”?", "detail": "Regular"})); + assert_eq!((spec(&d.kind).heading)(&d), "Delete “Black”?"); + let d = Dialog::new(plugin::KIND, json!({"__label": "Black", "__plugin": "x"})); + assert_eq!((spec(&d.kind).heading)(&d), "Black"); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 4feef1f4..b4e42089 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -529,8 +529,8 @@ pub fn ask_revert(app: &mut VectorcraftApp) -> Result { let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?; (c.enabled)(&app.session)?; let name = app.session.active().map(|d| d.title()).unwrap_or_default(); - let message = format!("Revert to the saved version of “{name}”?"); - dialogs::confirm::ask(app, &message, "Changes made since it was last saved will be lost.", "file.revert", json!({ "confirmed": true })); + let message = crate::i18n::fmt(tl!("Revert to the saved version of “{name}”?"), &[("name", &name)]); + dialogs::confirm::ask(app, &message, tl!("Changes made since it was last saved will be lost."), "file.revert", json!({ "confirmed": true })); Ok(json!({ "pending": dialogs::confirm::KIND })) } diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 234477e7..4aedb63a 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -538,11 +538,13 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if !PRESETS.contains(&cur.as_str()) { opts.push(CUSTOM); } - if let Some(i) = widgets::dropdown(ui, "kbset", &cur, &opts, 200.0) - && let Some(p) = preset(opts[i]) + // The sets listed are ours (translated); a set read from an imported file shows + // its name as it is. + if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i)) + && let Some(p) = preset(name) { ov = p; - d.fields.insert("set".into(), json!(opts[i])); + d.fields.insert("set".into(), json!(name)); d.fields.insert("__message".into(), json!("")); } ui.add_space(12.0); From 6572636d62056626391b42d7fdb0c4bec1c3a13e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 08:34:39 +0700 Subject: [PATCH 3/4] i18n: panels show user, file and system names as they are Panels passed names to the translating widgets, so a name that happened to be a catalog key was translated (a font style "Regular", a workspace called "Layers"). Font styles, pattern names, recent URLs, ICC profiles, saved width profiles, user and loaded libraries, plug-in effects and their parameters, Document Info's artboard/style/font/image rows, text-object contents in Layers, user text styles in brackets, the Artboard tool's canvas label and the app-bar workspace switcher now show names verbatim; built-in entries (Working CMYK, (closest), built-in libraries and effects, [Normal ... Style]) still translate. Formatted tips around a name are translated as templates only (panels::label_or_name, library_name). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 22 +++++++++- crates/ui-egui/src/find_font.rs | 9 ++-- crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/appearance.rs | 39 +++++++++++++---- crates/ui-egui/src/panels/attributes.rs | 2 +- crates/ui-egui/src/panels/character.rs | 2 +- crates/ui-egui/src/panels/color_guide.rs | 6 ++- crates/ui-egui/src/panels/color_themes.rs | 3 +- crates/ui-egui/src/panels/doc_info.rs | 45 ++++++++++++++++++-- crates/ui-egui/src/panels/glyphs.rs | 2 +- crates/ui-egui/src/panels/gradient.rs | 2 +- crates/ui-egui/src/panels/layers.rs | 41 +++++++++++++++--- crates/ui-egui/src/panels/library_panel.rs | 23 ++++++++-- crates/ui-egui/src/panels/mod.rs | 8 ++++ crates/ui-egui/src/panels/pattern_options.rs | 2 +- crates/ui-egui/src/panels/separations.rs | 13 +++--- crates/ui-egui/src/panels/stroke.rs | 3 +- crates/ui-egui/src/panels/text_styles.rs | 5 ++- crates/ui-egui/src/workspaces.rs | 6 +-- 19 files changed, 190 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 45574f52..12fa00f4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1201,6 +1201,12 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { }); } +/// Is overlay label `text` the Artboard tool's "01 - "? It holds a name, so it is +/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours. +fn names_an_artboard(text: &str) -> bool { + text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit())) +} + fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) { for o in overlays { match o { @@ -1236,7 +1242,13 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) { } Overlay::Label { p: pt, text, color } => { let sp = xf.to_screen(*pt) + vec2(8.0, -14.0); - p.text(sp, egui::Align2::LEFT_TOP, tl!(text), egui::FontId::proportional(11.0), c32(*color)); + p.text( + sp, + egui::Align2::LEFT_TOP, + crate::panels::label_or_name(text, !names_an_artboard(text)), + egui::FontId::proportional(11.0), + c32(*color), + ); } Overlay::Highlight { quad, color } => { let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); @@ -1415,6 +1427,14 @@ mod tests { out.textures_delta.clear(); } + /// The Artboard tool's label holds the artboard's name (never translated); the tools' own + /// labels are interface text. + #[test] + fn artboard_labels_name_the_artboard() { + assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy")); + assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer")); + } + fn middle(pos: Pos2, pressed: bool) -> egui::Event { egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() } } diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 6d969f7e..743b57ac 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -76,10 +76,13 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { d.fields.insert("style".into(), json!("")); } let styles = vectorcraft_text::FontDb::global().styles(&d.str("family")); - let mut opts: Vec<&str> = vec!["(closest)"]; + // "(closest)" is ours; the font's style names are shown as they are. + let closest = tl!("(closest)"); + let mut opts: Vec<&str> = vec![closest]; opts.extend(styles.iter().map(String::as_str)); - let cur = if d.str("style").is_empty() { "(closest)".to_string() } else { d.str("style") }; - if let Some(i) = widgets::dropdown(ui, "ff-style", &cur, &opts, 120.0) { + let style = d.str("style"); + let cur = if style.is_empty() { closest } else { style.as_str() }; + if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) { d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() })); } }); diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d3a3ba7d..4a0642b6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -20,6 +20,7 @@ — missing — 遺失 to search every command 以搜尋所有指令 + (closest) (最接近) + Add Scale + 新增縮放比例 0.2 (quality) to 100 (speed) 0.2 (品質) 至 100 (速度) 1 Color Job… 1 色印刷作業… diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 32d0d990..87f440da 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -154,6 +154,20 @@ pub fn effect_label(id: &str) -> String { .unwrap_or_else(|| id.rsplit('.').next().unwrap_or(id).to_string()) } +/// Is `id` one of our own effects? A plug-in's effect name and parameters come from the plug-in +/// and are shown as they are. +fn built_in_effect(id: &str) -> bool { + catalog().iter().any(|c| c.0 == id) +} + +/// [`effect_label`] as shown: a built-in effect's in the UI language, a plug-in's as it is. +fn shown_effect_label(id: &str) -> String { + match catalog().iter().find(|c| c.0 == id) { + Some(c) => tl!(&c.1).to_string(), + None => effect_label(id), + } +} + /// "Opacity: Default" or "Opacity: 50% Multiply". pub fn opacity_text(opacity: f32, blend: BlendMode) -> String { if (opacity - 1.0).abs() < 1e-4 && blend == BlendMode::Normal { @@ -320,7 +334,7 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { let fx = |ui: &mut Ui, e: &Effect, id: (Option, usize)| { let (r, _) = row(ui, false); eye(ui, r, ("ap-def-fx", id), e.visible, false); - text(ui, pos2(r.left() + EYE_W + 24.0 + if id.0.is_some() { 12.0 } else { 0.0 }, r.center().y), tl!(&effect_label(&e.id)), false); + text(ui, pos2(r.left() + EYE_W + 24.0 + if id.0.is_some() { 12.0 } else { 0.0 }, r.center().y), &shown_effect_label(&e.id), false); icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); }; for (i, it) in ap.items.iter().enumerate().rev() { @@ -686,7 +700,7 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi set_pstate(ui.ctx(), &open_key, !open); } let lx = r.left() + EYE_W + 24.0; - if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), tl!(&effect_label(&e.id))) { + if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &shown_effect_label(&e.id)) { select_row(app, ui.ctx(), this); if has_options(&e.id) { app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok(); @@ -724,6 +738,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } } let mut change: Option<(String, Value)> = None; + let built_in = built_in_effect(&e.id); let (unit, relative) = (app.session.general_unit(), params.get("relative").and_then(Value::as_bool).unwrap_or(false)); egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin { left: (EYE_W + 12.0) as i8, right: 6, top: 4, bottom: 4 }).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 3.0; @@ -732,8 +747,10 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } for (key, v) in ¶ms { ui.horizontal(|ui| { + // A plug-in's parameter names are its own. let label = humanize(key); - ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(tl!(&label)).size(11.5).color(t.text)).truncate()); + let label = super::label_or_name(&label, built_in); + ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate()); if let Some(cur) = widgets::blend_param(key, v) { if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) { change = Some((key.clone(), m)); @@ -874,18 +891,20 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { let mut groups: Vec<(String, Vec<(String, String)>)> = vec![]; let plugins: Vec<_> = vectorcraft_effects::plugin_effects().into_iter().map(catalog_entry).collect(); - for (id, label, menu) in catalog().iter().chain(&plugins) { + // Built-in effects in the UI language; a plug-in's by the name it gives. + let built_in = catalog().iter().map(|(id, label, menu)| (id, tl!(label), menu)); + for (id, label, menu) in built_in.chain(plugins.iter().map(|(id, label, menu)| (id, label.as_str(), menu))) { let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into()); match groups.iter_mut().find(|(n, _)| *n == g) { - Some((_, v)) => v.push((id.clone(), label.clone())), - None => groups.push((g, vec![(id.clone(), label.clone())])), + Some((_, v)) => v.push((id.clone(), label.to_string())), + None => groups.push((g, vec![(id.clone(), label.to_string())])), } } for (g, items) in groups { ui.menu_button(tl!(&g), |ui| { crate::widgets::menu_scroll(ui, |ui| { for (id, label) in items { - if ui.button(format!("{}…", tl!(&label))).clicked() { + if ui.button(format!("{label}…")).clicked() { app.run("effect.dialog", json!({"effect": id})).ok(); ui.close(); } @@ -932,7 +951,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let redefine = style .as_ref() .map_or_else(|| tl!("Redefine Graphic Style").into(), |n| crate::i18n::fmt(tl!("Redefine Graphic Style “{name}”"), &[("name", n)])); - if menu_item(ui, &redefine, style.is_some(), false) { + // Translated above, around the style's name. + if widgets::menu_item_name(ui, &redefine, style.is_some(), false) { app.run("graphicStyle.redefine", json!({})).ok(); } if menu_item(ui, tl!("Show All Hidden Attributes"), node.as_ref().is_some_and(|n| n.appearance.has_hidden()), false) { @@ -957,6 +977,9 @@ mod tests { assert_eq!(humanize("blur"), "Blur"); assert!(!effect_label("stylize.dropShadow").contains('…')); assert_eq!(effect_label("x.unknownThing"), "unknownThing"); + // Only our own effects are interface labels; a plug-in's name is its own. + assert!(built_in_effect("stylize.dropShadow") && !built_in_effect("plugin.example.glow")); + assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow")); } #[test] diff --git a/crates/ui-egui/src/panels/attributes.rs b/crates/ui-egui/src/panels/attributes.rs index 19239733..58fdfb7b 100644 --- a/crates/ui-egui/src/panels/attributes.rs +++ b/crates/ui-egui/src/panels/attributes.rs @@ -140,7 +140,7 @@ fn recent_urls(app: &mut VectorcraftApp, ui: &mut Ui) { egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(220.0); for u in &app.session.recent_urls { - if menu_item(ui, u, true, false) { + if widgets::menu_item_name(ui, u, true, false) { chosen = Some(u.clone()); } } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 8853f968..2e9d3fac 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -246,7 +246,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown(ui, "ch-style", &s.font_style, &snames, w - 4.0) { + if let Some(i) = widgets::dropdown_names(ui, "ch-style", &s.font_style, &snames, w - 4.0) { style(app, json!({"style": snames[i]})); } ui.add_space(2.0); diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 89e4f5a1..c9c8a478 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -200,7 +200,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } let (left, right) = app.ui.color_guide.variation.sides(); - let limit = limit_name(app, &app.ui.color_guide_limit); + let key = &app.ui.color_guide_limit; + let limit = limit_name(app, key).map(|n| library_panel::library_name(key, &n).to_string()); ui.horizontal(|ui| { widgets::dim_label(ui, left); // The library the colours are limited to, centred between the ends. @@ -214,7 +215,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Some(name) => crate::i18n::fmt(tl!("Limit colors to swatch library: {name}"), &[("name", name)]), None => tl!("Limit colors to swatch library").into(), }; - let lr = widgets::icon_button(ui, "library", &tip, limit.is_some(), 24.0); + // The tip is translated around the library's name, not looked up whole. + let lr = widgets::icon_button(ui, "library", "", limit.is_some(), 24.0).on_hover_text(tip); egui::Popup::menu(&lr).show(|ui| limit_menu(app, ui)); ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); // Recolor Artwork with the harmony colours as the new colours: the selected art's, or diff --git a/crates/ui-egui/src/panels/color_themes.rs b/crates/ui-egui/src/panels/color_themes.rs index 29caf04f..751f8dfd 100644 --- a/crates/ui-egui/src/panels/color_themes.rs +++ b/crates/ui-egui/src/panels/color_themes.rs @@ -211,7 +211,8 @@ fn create(app: &mut VectorcraftApp, ui: &mut Ui) { Some(n) => crate::i18n::fmt(tl!("Save changes to {name}"), &[("name", n)]), None => tl!("Save theme to My Themes").into(), }; - if widgets::icon_button(ui, "save", &tip, false, 24.0).clicked() { + // The tip is translated around the theme's name, not looked up whole. + if widgets::icon_button(ui, "save", "", false, 24.0).on_hover_text(tip).clicked() { save(app, &ctx); } let doc = app.session.active().is_some(); diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index 9559ae00..88febc71 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -18,10 +18,12 @@ struct Cache { info: Value, } +/// A `label: value` row. `label` is shown as given: translate an interface label first, a name +/// stays as it is. pub(crate) fn row(ui: &mut Ui, label: &str, value: String) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(label)).size(12.0).color(t.text_dim)); + ui.label(egui::RichText::new(label).size(12.0).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Long values (paths) are cut to the panel's width; hovering shows them whole. ui.add(egui::Label::new(egui::RichText::new(value).size(12.0).color(t.text)).truncate()); @@ -70,10 +72,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if rows.is_empty() { widgets::dim_label(ui, tl!("None")); } - for r in rows { + let named = named_rows(s["id"].as_str().unwrap_or_default(), rows); + for (k, r) in rows.iter().enumerate() { let (label, value) = (r[0].as_str().unwrap_or_default(), r[1].as_str().unwrap_or_default()); + let label = super::label_or_name(label, !named.contains(&k)); if value.is_empty() { - widgets::dim_label(ui, label); + widgets::dim_name(ui, label); } else { row(ui, label, value.into()); } @@ -82,6 +86,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } +/// The rows of Document Info section `id` labelled by a name (an artboard, a style, a swatch, a +/// font, an image) rather than an interface label: every row of the lists, and in Document the +/// artboards, which follow the Artboards count. Names are shown as they are. +fn named_rows(id: &str, rows: &[Value]) -> std::ops::Range { + match id { + "objects" => 0..0, + "document" => { + let Some(at) = rows.iter().position(|r| r[0] == "Artboards") else { return 0..0 }; + let count = rows.get(at).and_then(|r| r[1].as_str()).and_then(|n| n.parse::().ok()).unwrap_or(0); + at + 1..(at + 1).saturating_add(count).min(rows.len()) + } + _ => 0..rows.len(), + } +} + /// `docInfo.save {path?, selectionOnly?}`: write the text report to `path`, else a picked file /// (the web downloads it) → `{path}`. pub(crate) fn save_report(app: &mut VectorcraftApp, p: &Value) -> Result { @@ -136,6 +155,26 @@ mod tests { out.textures_delta.clear(); } + /// Artboard, font and other names in the rows are shown as they are, the labels in the UI + /// language: an artboard named like a label ("Units") is still a name. + #[test] + fn names_in_rows_are_told_from_labels() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 0, "name": "Units"})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let info = app.session.execute("document.info", &json!({})).unwrap(); + let rows = |id: &str| info["sections"].as_array().unwrap().iter().find(|s| s["id"] == id).unwrap()["rows"].as_array().unwrap().clone(); + let doc = rows("document"); + let named: Vec<&str> = named_rows("document", &doc).map(|k| doc[k][0].as_str().unwrap()).collect(); + assert_eq!(named, ["Units"], "{doc:?}"); + assert_eq!(doc.iter().filter(|r| r[0] == "Units").count(), 2, "the label and the artboard"); + assert_eq!(named_rows("objects", &rows("objects")), 0..0); + let fonts = rows("fonts"); + assert!(!fonts.is_empty()); + assert_eq!(named_rows("fonts", &fonts), 0..fonts.len()); + } + #[test] fn categories_filter_the_panel_and_save_writes_the_report() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 81fc527c..a41e611c 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -205,7 +205,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let styles = db.styles(&family); let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown(ui, "gl-style", &style, &snames, (w * 0.38 - 8.0).max(60.0)) { + if let Some(i) = widgets::dropdown_names(ui, "gl-style", &style, &snames, (w * 0.38 - 8.0).max(60.0)) { set_pstate(ui.ctx(), "gl-font", Some((family.clone(), snames[i].to_string()))); } }); diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index b644af0f..3c0cc95d 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -408,7 +408,7 @@ fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: boo Some(n) => format!("{n} {}%", vectorcraft_color::tint_percent(s.tint)), None => s.color.to_hex().to_uppercase(), }; - widgets::dim_label(ui, &label); + widgets::dim_name(ui, &label); if resp.on_hover_text(tl!("Edit the stop")).clicked() { open_popover(app, i, r.left_bottom() + vec2(0.0, 4.0)); } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 2e792586..3a08447c 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -22,12 +22,13 @@ fn expanded_id() -> egui::Id { egui::Id::new("layers-expanded") } -/// The name painted for a row: a generated `` name is translated, anything else is user data. -fn painted_name(n: &Node, name: &str) -> String { - if n.name.is_none() - && let Some(inner) = name.strip_prefix('<').and_then(|s| s.strip_suffix('>')) - { - return format!("<{}>", crate::i18n::t(inner)); +/// The name painted for a row in `lang`: a generated `` name is translated, anything else is +/// user data (an unnamed text object shows its text, which can look like ``; only an empty +/// one is called ``). +fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String { + let generated = n.name.is_none() && !matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| !r.text.is_empty())); + if generated && let Some(inner) = name.strip_prefix('<').and_then(|s| s.strip_suffix('>')) { + return format!("<{}>", crate::i18n::tr(lang, inner)); } name.to_string() } @@ -244,7 +245,11 @@ fn row( _ => { let painter = ui.painter().with_clip_rect(name_rect); // Generated names ("", "") are translated where painted; the stored name stays English. - let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { tl!("").to_string() } else { painted_name(n, &name) }; + let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { + tl!("").to_string() + } else { + painted_name(n, &name, crate::i18n::current()) + }; let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, t.text); // A clipping path's name is underlined, a masked object's with a dashed line. if clip_path { @@ -464,6 +469,28 @@ mod tests { out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 3.2 && cs.fill != Color32::TRANSPARENT)).count() } + /// A generated `` name is translated where painted; an unnamed text object's text never + /// is, even when it reads like one. + #[test] + fn only_generated_names_are_translated() { + let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let rect = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let text = run(&mut app, "text.create", json!({"x": 10, "y": 40, "text": ""}))["id"].as_u64().unwrap(); + let doc = &app.session.active().unwrap().doc; + let names = |id: u64| { + let n = doc.node(NodeId(id)).unwrap(); + (n.display_name(), painted_name(n, &n.display_name(), zh)) + }; + assert_eq!(names(text), ("".to_string(), "".to_string())); + let (stored, painted) = names(rect); + let inner = stored.trim_start_matches('<').trim_end_matches('>'); + assert_eq!(painted, format!("<{}>", crate::i18n::tr(zh, inner))); + assert_ne!(painted, stored, "translated"); + } + #[test] fn target_circle_fills_for_non_default_transparency() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 0812a358..bd68193d 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -113,6 +113,17 @@ pub(crate) fn submenu(category: &str) -> Option<&'static str> { } } +/// Is library `id` built in? A library the user saved to the User Defined folder or loaded from a +/// file (`user/…`, `loaded/…` ids) is named by its file. +fn builtin_library(id: &str) -> bool { + !(id.starts_with("user/") || id.starts_with("loaded/")) +} + +/// Library `id`'s name as shown: a built-in library's in the UI language, any other's as it is. +pub(crate) fn library_name<'a>(id: &str, name: &'a str) -> &'a str { + super::label_or_name(name, builtin_library(id)) +} + /// The opener of library `K`'s libraries (`window.swatchLibrary {library}`): open library `library` /// (an id or a name; `list`: the command listing them) in the panel, `null` closing it. `found` /// looks a library up: its id, name and item count. → {open, name, count} @@ -174,8 +185,9 @@ fn window(app: &mut VectorcraftApp, ctx: &egui::Context, id: &st ui.set_width(256.0); let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker); - let label = egui::RichText::new(tl!(&name)).font(theme::semibold(12.0)); - let galley = ui.painter().layout_no_wrap(tl!(&name).to_string(), theme::semibold(12.0), t.text); + let name = library_name(id, &name); + let label = egui::RichText::new(name).font(theme::semibold(12.0)); + let galley = ui.painter().layout_no_wrap(name.to_string(), theme::semibold(12.0), t.text); let tab = Rect::from_min_size(strip.min, vec2((galley.size().x + 24.0).min(190.0), 26.0)); ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); ui.put(tab.shrink2(vec2(12.0, 0.0)), egui::Label::new(label.color(t.text)).truncate().selectable(false)); @@ -359,7 +371,7 @@ pub(crate) fn library_menu(app: &mut VectorcraftApp, ui: &mut Ui pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&str>) -> Option { let mut chosen = None; let mut item = |ui: &mut Ui, l: &LibraryRef| { - if menu_item(ui, &l.name, true, current == Some(l.id.as_str())) { + if widgets::menu_item_name(ui, library_name(&l.id, &l.name), true, current == Some(l.id.as_str())) { chosen = Some(l.id.clone()); } }; @@ -598,5 +610,10 @@ mod tests { assert_eq!(app.session.documents().len(), 1, "not opened as a document"); let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); assert_eq!(other.submenu, Some("Other Libraries")); + // Built-in libraries' names are interface labels; a file's is shown as it is. + for l in SwatchLibraries::list(&app) { + assert_eq!(builtin_library(&l.id), !matches!(l.submenu, Some("User Defined" | "Other Libraries")), "{}", l.id); + } + assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES)); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1dcef79d..cc0c909b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -372,6 +372,14 @@ pub(crate) fn set_pstate(ctx: &egui::Context, ctx.data_mut(|d| d.insert_temp(egui::Id::new(("panel-state", key)), v)); } +/// An entry of a list that mixes interface labels with names (user, file or system data) as +/// shown: a built-in entry in the UI language, a name exactly as it is (a profile or library the +/// user calls "Layers" stays "Layers"). Pass the result to the non-translating widgets +/// (`dropdown_names`, `menu_item_name`, `dim_name`). +pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { + if built_in { tl!(s) } else { s } +} + /// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint /// command feeds); clicking one applies it to the active proxy (Alt: the inactive one). pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index cf33bc7a..6a9c348d 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -169,7 +169,7 @@ fn idle(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.horizontal(|ui| { let labels: Vec<&str> = names.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown(ui, "po-pick", &chosen, &labels, 150.0) { + if let Some(i) = widgets::dropdown_names(ui, "po-pick", &chosen, &labels, 150.0) { set_pstate(ui.ctx(), "po-chosen", names[i].clone()); } if widgets::flat_button(ui, tl!("Edit Pattern"), 90.0).clicked() diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 39c80ea2..c6bc2dc3 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -73,11 +73,13 @@ fn plates_section(app: &mut VectorcraftApp, ui: &mut Ui) { }); } +/// A dropdown of the `kind` profiles after `extra` (a built-in entry, shown in the UI language); +/// profile names are shown as they are. Returns the chosen name. fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, extra: Option<&str>) -> Option { let names: Vec = extra.map(str::to_string).into_iter().chain(cms::profiles().into_iter().filter(|p| p.kind == kind).map(|p| p.name)).collect(); - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - widgets::dropdown(ui, id, current, &refs, 210.0).map(|i| names[i].clone()) + let refs: Vec<&str> = names.iter().map(|n| super::label_or_name(n, extra == Some(n.as_str()))).collect(); + widgets::dropdown_names(ui, id, super::label_or_name(current, extra == Some(current)), &refs, 210.0).and_then(|i| names.get(i).cloned()) } fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -143,9 +145,10 @@ fn proof_section(app: &mut VectorcraftApp, ui: &mut Ui) { let v = proof::view(); widgets::section_header(ui, tl!("Proof Setup")); let cur = v.setup.target.id(); - let label = PROOF_TARGETS.iter().find(|(id, _)| *id == cur).map_or(cur.as_str(), |(_, l)| l); - let labels: Vec<&str> = PROOF_TARGETS.iter().map(|(_, l)| *l).collect(); - if let Some(i) = widgets::dropdown(ui, "proof-target", label, &labels, 230.0) { + // A target that isn't one of ours (a loaded profile) shows its name as it is. + let label = PROOF_TARGETS.iter().find(|(id, _)| *id == cur).map_or(cur.as_str(), |(_, l)| tl!(*l)); + let labels: Vec<&str> = PROOF_TARGETS.iter().map(|(_, l)| tl!(*l)).collect(); + if let Some(i) = widgets::dropdown_names(ui, "proof-target", label, &labels, 230.0) { run(app, "view.proofSetup", json!({"target": PROOF_TARGETS[i].0})); } ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 63e887fb..7fd3c3b2 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -405,7 +405,8 @@ pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&W ui.painter().text( row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, - tl!(e.label), + // A saved profile's name is the user's. + super::label_or_name(e.label, e.built_in), egui::FontId::proportional(11.5), t.text, ); diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 5f34153a..4df4fb55 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -98,8 +98,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - // The built-in "[Normal … Style]" names are translated where painted; other names are user data. - let shown = if name.starts_with('[') && name.ends_with(']') { tl!(name) } else { name.as_str() }; + // The built-in "[Normal … Style]" (listed first) is translated where painted; other names + // are user data, brackets or not. + let shown = super::label_or_name(name, *name == normal); let label = if overridden { format!("{shown}+") } else { shown.to_string() }; ui.painter().text(r.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); if resp.clicked() { diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 17bb0409..472f4074 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -239,8 +239,8 @@ pub fn popup(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ui.separator(); } crate::menus::Item::Cmd(label, id, p) => { - let label = crate::menus::dynamic_label(app, id, label); - let label = tl!(&label); + // As the Window menu draws it: custom workspace names (and Reset ) as they are. + let label = crate::menus::display_label(app, id, label); let text = match crate::menus::checked(app, id, &p) { Some(true) => format!("✓ {label}"), Some(false) => format!(" {label}"), @@ -338,7 +338,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.set_width(ui.available_width()); for n in names(&app.ui) { let builtin = is_builtin(&n); - let shown = if builtin { tl!(&n) } else { n.as_str() }; + let shown = crate::panels::label_or_name(&n, builtin); let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) }; if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin { d.fields.insert("selected".into(), json!(n)); From 0e5e093dd9b841de39a7782128984981de1a49d6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 08:37:43 +0700 Subject: [PATCH 4/4] i18n: one label_or_name for lists mixing labels and names; normal style rows panels::label_or_name and dialogs::shown_names applied the same rule; it now lives once as i18n::label_or_name (explicit language, testable) and both delegate to it. zh-hant gains the two normal text style names the Character and Paragraph Styles panels translate. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/mod.rs | 2 +- crates/ui-egui/src/i18n/mod.rs | 8 ++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/panels/mod.rs | 7 ++----- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 593205c3..66ef5113 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -383,7 +383,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { /// What a dropdown shows for `names`, a list mixing built-in labels (`builtin(index)`: translated /// into `lang`) with names the user saved or a file or the system supplied (shown as they are). pub(crate) fn shown_names<'a>(lang: crate::i18n::Lang, names: &[&'a str], builtin: impl Fn(usize) -> bool) -> Vec<&'a str> { - names.iter().enumerate().map(|(k, n)| if builtin(k) { crate::i18n::tr(lang, n) } else { *n }).collect() + names.iter().enumerate().map(|(k, n)| crate::i18n::label_or_name(lang, n, builtin(k))).collect() } /// [`widgets::dropdown_names`] over such a mixed list ([`shown_names`] in the UI language), showing diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 5e7add93..6a74ca8a 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -312,6 +312,14 @@ pub fn trn(lang: Lang, n: u64, one: &str, other: &str) -> String { fmt(text, &[("n", &n.to_string())]) } +/// An entry of a list that mixes interface labels with names (user, file or system data) as +/// shown: a built-in entry in `lang`, a name exactly as it is (a library the user calls "Layers" +/// stays "Layers"). Show the result with the non-translating widgets (`dropdown_names`, +/// `menu_item_name`, `dim_name`). +pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str { + if built_in { tr(lang, s) } else { s } +} + /// [`trn`] in the current language. pub fn tn(n: u64, one: &str, other: &str) -> String { trn(current(), n, one, other) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4a0642b6..f34400d1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2898,3 +2898,5 @@ Release Compound Path 釋放複合路徑 — substituted by {style} — 以 {style} 取代 — {n} characters from another font — {n} 個字符來自其他字體 + [Normal Character Style] [正常字元樣式] + [Normal Paragraph Style] [正常段落樣式] diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index cc0c909b..945b9f1a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -372,12 +372,9 @@ pub(crate) fn set_pstate(ctx: &egui::Context, ctx.data_mut(|d| d.insert_temp(egui::Id::new(("panel-state", key)), v)); } -/// An entry of a list that mixes interface labels with names (user, file or system data) as -/// shown: a built-in entry in the UI language, a name exactly as it is (a profile or library the -/// user calls "Layers" stays "Layers"). Pass the result to the non-translating widgets -/// (`dropdown_names`, `menu_item_name`, `dim_name`). +/// [`crate::i18n::label_or_name`] in the UI language. pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { - if built_in { tl!(s) } else { s } + crate::i18n::label_or_name(crate::i18n::current(), s, built_in) } /// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint