Merge pull request #243 from storytold/fix/i18n-review-followups

i18n: names are never translated; palette cache; locale probe off the UI thread
This commit is contained in:
BFlat authored and GitHub committed 2026-10-07 08:43:42 +07:00
commit e960baa131
42 files changed
+545 -130

No files matched your search

+1
View File
@@ -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<u16> = 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);
+21 -1
View File
@@ -1201,6 +1201,12 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
});
}
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? 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() }
}
+6 -4
View File
@@ -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));
+4 -1
View File
@@ -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() }
@@ -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));
}
});
+16 -3
View File
@@ -153,7 +153,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
/// 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);
+4 -3
View File
@@ -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))
}
+26 -3
View File
@@ -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<fn(&mut VectorcraftApp, &egui::Context)>,
/// 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)| 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
/// `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<usize> {
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)]
+25 -4
View File
@@ -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);
+1 -1
View File
@@ -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();
@@ -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() {
+2 -1
View File
@@ -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 {
+19 -14
View File
@@ -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<String> = 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::<Intent>(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!(
+10 -7
View File
@@ -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<String> = 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();
});
+6 -3
View File
@@ -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));
}
});
+1 -1
View File
@@ -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 {
+28 -7
View File
@@ -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<Value> = 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<Vec<String>> = 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, &current, names, 300.0) {
if let Some(i) = super::mixed_dropdown(ui, path, &current, 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::<ProfileInclusion>(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", &current, &names, 200.0).and_then(|i| names.get(i)) {
if let Some(n) = super::mixed_dropdown(ui, "flattenerPreset", &current, &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<RefCell<Vec<(String, Vec<u8>)>>>;
/// 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());
+25
View File
@@ -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");
}
+6 -3
View File
@@ -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() }));
}
});
+21
View File
@@ -168,6 +168,19 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
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.
@@ -299,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)
+3
View File
@@ -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 色印刷作業…
@@ -2897,3 +2898,5 @@
Release Compound Path 釋放複合路徑
— substituted by {style} — 以 {style} 取代
— {n} characters from another font — {n} 個字符來自其他字體
[Normal Character Style] [正常字元樣式]
[Normal Paragraph Style] [正常段落樣式]
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -529,8 +529,8 @@ pub fn ask_revert(app: &mut VectorcraftApp) -> Result<Value, String> {
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 }))
}
+47 -3
View File
@@ -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<String> {
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());
+71 -21
View File
@@ -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::<Vec<_>>().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::<Vec<_>>().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<Vec<Entry>> {
type Cached = ((&'static str, u64), Arc<Vec<Entry>>);
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::<Cached>(id))
&& k == key
{
return v;
}
let built: Arc<Vec<Entry>> = 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::<Cached>(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<String> = 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}");
}
}
}
+31 -8
View File
@@ -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>, 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<usize>, 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<usize>, 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<usize>, k:
}
for (key, v) in &params {
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]
+1 -1
View File
@@ -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());
}
}
+1 -1
View File
@@ -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);
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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();
+42 -3
View File
@@ -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<usize> {
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::<usize>().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<Value, String> {
@@ -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());
+1 -1
View File
@@ -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())));
}
});
+1 -1
View File
@@ -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));
}
+34 -7
View File
@@ -22,12 +22,13 @@ fn expanded_id() -> egui::Id {
egui::Id::new("layers-expanded")
}
/// The name painted for a row: a generated `<Kind>` 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 `<Kind>` name is translated, anything else is
/// user data (an unnamed text object shows its text, which can look like `<Path>`; only an empty
/// one is called `<Text>`).
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 ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { tl!("<Opacity Mask>").to_string() } else { painted_name(n, &name) };
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
tl!("<Opacity Mask>").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 `<Kind>` 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": "<Path>"}))["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), ("<Path>".to_string(), "<Path>".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());
+20 -3
View File
@@ -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<K: LibraryKind>(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<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui
pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&str>) -> Option<String> {
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));
}
}
+5
View File
@@ -372,6 +372,11 @@ pub(crate) fn set_pstate<T: Clone + Send + Sync + 'static>(ctx: &egui::Context,
ctx.data_mut(|d| d.insert_temp(egui::Id::new(("panel-state", key)), v));
}
/// [`crate::i18n::label_or_name`] in the UI language.
pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
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
/// 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) {
+1 -1
View File
@@ -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()
+8 -5
View File
@@ -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<String> {
let names: Vec<String> =
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| {
+2 -1
View File
@@ -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,
);
+3 -2
View File
@@ -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() {
+5 -3
View File
@@ -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);
+28 -3
View File
@@ -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<usize> {
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<usize> {
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()
}
+3 -3
View File
@@ -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 <name>) 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));