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);