mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
42 files changed
+545
-130
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¤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::<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", ¤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<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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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() }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ¶ms {
|
||||
ui.horizontal(|ui| {
|
||||
// A plug-in's parameter names are its own.
|
||||
let label = humanize(key);
|
||||
ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(tl!(&label)).size(11.5).color(t.text)).truncate());
|
||||
let label = super::label_or_name(&label, built_in);
|
||||
ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate());
|
||||
if let Some(cur) = widgets::blend_param(key, v) {
|
||||
if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) {
|
||||
change = Some((key.clone(), m));
|
||||
@@ -874,18 +891,20 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel:
|
||||
pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let mut groups: Vec<(String, Vec<(String, String)>)> = vec![];
|
||||
let plugins: Vec<_> = vectorcraft_effects::plugin_effects().into_iter().map(catalog_entry).collect();
|
||||
for (id, label, menu) in catalog().iter().chain(&plugins) {
|
||||
// Built-in effects in the UI language; a plug-in's by the name it gives.
|
||||
let built_in = catalog().iter().map(|(id, label, menu)| (id, tl!(label), menu));
|
||||
for (id, label, menu) in built_in.chain(plugins.iter().map(|(id, label, menu)| (id, label.as_str(), menu))) {
|
||||
let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into());
|
||||
match groups.iter_mut().find(|(n, _)| *n == g) {
|
||||
Some((_, v)) => v.push((id.clone(), label.clone())),
|
||||
None => groups.push((g, vec![(id.clone(), label.clone())])),
|
||||
Some((_, v)) => v.push((id.clone(), label.to_string())),
|
||||
None => groups.push((g, vec![(id.clone(), label.to_string())])),
|
||||
}
|
||||
}
|
||||
for (g, items) in groups {
|
||||
ui.menu_button(tl!(&g), |ui| {
|
||||
crate::widgets::menu_scroll(ui, |ui| {
|
||||
for (id, label) in items {
|
||||
if ui.button(format!("{}…", tl!(&label))).clicked() {
|
||||
if ui.button(format!("{label}…")).clicked() {
|
||||
app.run("effect.dialog", json!({"effect": id})).ok();
|
||||
ui.close();
|
||||
}
|
||||
@@ -932,7 +951,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let redefine = style
|
||||
.as_ref()
|
||||
.map_or_else(|| tl!("Redefine Graphic Style").into(), |n| crate::i18n::fmt(tl!("Redefine Graphic Style “{name}”"), &[("name", n)]));
|
||||
if menu_item(ui, &redefine, style.is_some(), false) {
|
||||
// Translated above, around the style's name.
|
||||
if widgets::menu_item_name(ui, &redefine, style.is_some(), false) {
|
||||
app.run("graphicStyle.redefine", json!({})).ok();
|
||||
}
|
||||
if menu_item(ui, tl!("Show All Hidden Attributes"), node.as_ref().is_some_and(|n| n.appearance.has_hidden()), false) {
|
||||
@@ -957,6 +977,9 @@ mod tests {
|
||||
assert_eq!(humanize("blur"), "Blur");
|
||||
assert!(!effect_label("stylize.dropShadow").contains('…'));
|
||||
assert_eq!(effect_label("x.unknownThing"), "unknownThing");
|
||||
// Only our own effects are interface labels; a plug-in's name is its own.
|
||||
assert!(built_in_effect("stylize.dropShadow") && !built_in_effect("plugin.example.glow"));
|
||||
assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user