Files
bflatasticandClaude Opus 5.5 cf7a43d786 i18n: dialogs show user, file and system names as they are
Lists and headings that mix our labels with names no longer send the
names through the catalog, so a preset, printer or plug-in called
"Regular" or "Black" keeps its name in every language.

- dialogs::shown_names / mixed_dropdown: translate only a list's built-in
  entries and show the rest with widgets::dropdown_names (same indices).
- Artboard lists (Save for Office, Save for Web, save options) and
  plug-in choices: dropdown_names.
- Mixed lists translate only their built-in entries: Recolor's Limit to
  Library, Save for Web, Print, Save PDF and Export for Screens presets,
  printers (Default Printer, PDF File), colour profiles and flattener
  presets (profile_pick, flatten::preset_dropdown), perspective presets,
  New Document's preset cards and Size, and the shortcut set.
- Plug-in dialogs and plug-in effects are headed by the plug-in's name as
  it is; plug-in parameter labels are not translated.
- The confirm dialog shows its message and detail as given, and the
  shared frame no longer translates headings again: each heading is
  translated by its spec. File > Revert translates its template before
  filling in the document's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00

342 lines
15 KiB
Rust

//! Object → Flatten Transparency: a preset, the raster/vector balance, the resolutions and the
//! outline and preserve options, previewed live on the canvas (off at first: flattening complex
//! art takes a while); OK keeps the result as one undo step (`object.flattenTransparency`). Save
//! Preset… keeps the options as a saved preset (`flattener.presets.save`).
//!
//! Fields: `preset` (a built-in or saved preset's name: setting it loads its options), the option
//! keys of [`FlattenOptions`] (`balance`, `lineArtPpi`, `gradientPpi`, `textToOutlines`,
//! `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`)
//! and `preview`.
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::{FlattenOptions, FlattenerPreset};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Flatten Transparency.
pub const KIND: &str = "flattenTransparency";
const CMD: &str = "object.flattenTransparency";
/// The preset whose options the fields hold (a `preset` set from outside loads its options).
const APPLIED: &str = "__applied";
/// Save Preset… is open, with the name typed in `__presetName`.
const SAVING: &str = "__saving";
const PRESET_NAME: &str = "__presetName";
/// What the preset dropdown shows once the options differ from the preset's.
const CUSTOM: &str = "[Custom]";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Flatten Transparency").into(), body, confirm, preview: true, min_width: 420.0, ..DialogSpec::FORM };
/// Open Flatten Transparency for the selection with the default preset.
pub fn open(app: &mut VectorcraftApp) {
let name = FlattenOptions::preset_label(FlattenOptions::PRESETS[1]).unwrap_or_default();
let mut d = Dialog::new(KIND, json!({ "preset": name, APPLIED: name, "preview": false }));
put_options(&mut d.fields, &FlattenOptions::default());
app.ui.dialog = Some(d);
}
/// Write `o` into the option fields.
pub(crate) fn put_options(fields: &mut Map<String, Value>, o: &FlattenOptions) {
if let Value::Object(m) = serde_json::to_value(o).unwrap_or_default() {
fields.extend(m);
}
}
/// The options the fields hold (a `preset` not loaded yet: its own).
fn options(d: &Dialog, saved: &[FlattenerPreset]) -> Result<FlattenOptions, String> {
if d.str("preset") != d.str(APPLIED) {
return FlattenOptions::from_params_with(&json!({ "preset": d.str("preset") }), saved);
}
FlattenOptions::from_params_with(&Value::Object(d.fields.clone()), saved)
}
/// `object.flattenTransparency` parameters: every option.
fn params(d: &Dialog, saved: &[FlattenerPreset]) -> Result<Value, String> {
let mut fields = Map::new();
put_options(&mut fields, &options(d, saved)?);
Ok(Value::Object(fields))
}
/// Load the options of a preset set from outside (`ui.dialog.set`).
fn sync(d: &mut Dialog, saved: &[FlattenerPreset]) {
let name = d.str("preset");
if name != d.str(APPLIED) {
if let Ok(o) = options(d, saved) {
put_options(&mut d.fields, &o);
}
d.fields.insert(APPLIED.into(), json!(name));
}
}
/// Make `name` the preset the fields hold.
fn applied(d: &mut Dialog, name: &str) {
d.fields.insert("preset".into(), json!(name));
d.fields.insert(APPLIED.into(), json!(name));
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let saved = &app.session.prefs.flattener_presets;
sync(d, saved);
let current = options(d, saved);
let presets = app.session.flattener_presets();
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Preset:"));
if let Some(p) = preset_dropdown(ui, "flatten-preset", &presets, &d.str("preset"), current.as_ref().ok(), 220.0) {
put_options(&mut d.fields, &p.options);
applied(d, &p.name);
}
if widgets::flat_button(ui, tl!("Save Preset…"), 100.0).clicked() {
let open = !d.bool(SAVING);
d.fields.insert(SAVING.into(), json!(open));
d.fields.insert(PRESET_NAME.into(), json!(app.session.new_preset_name()));
}
});
if d.bool(SAVING) {
save_row(app, ui, d);
}
ui.add_space(10.0);
let mut o = current.clone().unwrap_or_default();
if options_editor(ui, "flatten-dialog", &mut o, true) {
put_options(&mut d.fields, &o);
}
if let Err(e) = &current {
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim).size(11.5));
}
// Invalid values keep the last preview.
let p = params(d, &app.session.prefs.flattener_presets).unwrap_or_else(|_| d.fields.get(form::PREVIEWED).cloned().unwrap_or_default());
form::preview(app, ui, d, "Flatten Transparency", CMD, p);
false
}
/// Save Preset…: the new preset's name, Save and Cancel (Enter in the name saves).
fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Name:"));
let r = form::text_edit(ui, d, PRESET_NAME, 180.0);
// Enter saves the preset instead of flattening.
let enter = r.lost_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
if (widgets::flat_button(ui, tl!("Save"), 60.0).clicked() || enter)
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() {
d.fields.insert(SAVING.into(), json!(false));
}
});
let name = d.str(PRESET_NAME);
if app.session.prefs.flattener_presets.iter().any(|p| p.name.eq_ignore_ascii_case(name.trim())) {
widgets::dim_label(ui, tl!("Replaces the saved preset of that name."));
}
}
/// Keep the dialog's options as the saved preset `__presetName` and select it.
fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
let mut p = params(d, &app.session.prefs.flattener_presets)?;
p["name"] = json!(d.str(PRESET_NAME));
let r = app.run("flattener.presets.save", p)?;
applied(d, r["name"].as_str().unwrap_or_default());
d.fields.insert(SAVING.into(), json!(false));
Ok(())
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let p = params(d, &app.session.prefs.flattener_presets)?;
form::commit_preview(app, CMD, p)
}
/// The flattener preset dropdown: `presets` by name, showing `name` while `options` are that
/// 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,
presets: &'a [FlattenerPreset],
name: &str,
options: Option<&FlattenOptions>,
width: f32,
) -> Option<&'a FlattenerPreset> {
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();
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.
const FIELD: f32 = 56.0;
/// The flattener options as Flatten Transparency, the preset manager and the Flattener Preview
/// panel edit them: the raster/vector balance, both resolutions and the six switches. Returns true
/// when one changed.
pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx());
let before = o.clone();
let dim = |ui: &mut egui::Ui, s: &str| {
ui.label(egui::RichText::new(tl!(s)).color(t.text_dim).size(11.5));
};
ui.add_enabled_ui(enabled, |ui| {
widgets::dim_label(ui, tl!("Raster/Vector Balance:"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
dim(ui, tl!("Rasters"));
let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0);
if let (Some(x), _) = widgets::color_slider(ui, (id, "balance-slider"), (o.balance / 100.0) as f32, rail, &|_| t.input_border) {
o.balance = (x as f64 * 100.0).round();
}
dim(ui, tl!("Vectors"));
if let Some(v) = widgets::plain_field(ui, (id, "balance"), o.balance, "", 0, FIELD - 12.0) {
o.balance = v.round().clamp(0.0, 100.0);
}
});
ui.add_space(4.0);
egui::Grid::new((id, "resolutions")).num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
for (label, key) in [(tl!("Line Art and Text Resolution:"), 0), (tl!("Gradient and Mesh Resolution:"), 1)] {
widgets::dim_label(ui, label);
ui.horizontal(|ui| {
let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi };
if let Some(n) = widgets::plain_field(ui, (id, key), *v, "", 0, FIELD) {
*v = n.round().clamp(1.0, 2400.0);
}
dim(ui, "ppi");
});
ui.end_row();
}
});
ui.add_space(4.0);
let switches: [(&str, &mut bool); 6] = [
(tl!("Convert All Text to Outlines"), &mut o.text_to_outlines),
(tl!("Convert All Strokes to Outlines"), &mut o.strokes_to_outlines),
(tl!("Clip Complex Regions"), &mut o.clip_complex_regions),
(tl!("Anti-alias Rasters"), &mut o.anti_alias),
(tl!("Preserve Alpha Transparency"), &mut o.preserve_alpha),
(tl!("Preserve Overprints and Spot Colors"), &mut o.preserve_overprints),
];
for (label, v) in switches {
if widgets::check(ui, label, *v, enabled) {
*v = !*v;
}
}
});
*o != before
}
#[cfg(test)]
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);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
/// A document with a half-transparent red square over a blue one, both selected.
fn scene() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
for (x, color) in [(10, "#0000ff"), (60, "#ff0000")] {
let id = app.run("shape.rectangle", json!({"x": x, "y": x, "width": 100, "height": 100})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"color": color, "ids": [id]})).unwrap();
app.run("paint.setStroke", json!({"none": true, "ids": [id]})).unwrap();
}
app.run("transparency.set", json!({"opacity": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
app
}
fn transparent_left(app: &VectorcraftApp) -> bool {
let mut any = false;
for l in &app.session.doc().unwrap().doc.layers {
l.walk(&mut |n| any |= n.opacity < 1.0 && !n.is_container());
}
any
}
#[test]
fn cancel_rolls_the_preview_back_and_ok_flattens_as_one_step() {
let mut app = scene();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
frame(&mut app);
assert!(!app.session.in_interaction(), "no preview until it is turned on");
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
frame(&mut app);
assert!(app.session.in_interaction() && !transparent_left(&app), "previewed on the canvas");
super::super::cancel(&mut app);
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert_eq!(app.session.doc().unwrap().doc, before, "Cancel rolls back");
// OK without a preview flattens with the dialog's options as one undo step.
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("preset".into(), json!("High Resolution"));
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("lineArtPpi", 0.0), 1200.0, "a preset set from outside loads");
app.ui.dialog.as_mut().unwrap().fields.insert("balance".into(), json!(0));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
let mut images = 0;
st.doc.layers[0].walk(&mut |n| images += matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) as usize);
assert_eq!(images, 1, "balance 0 rasterizes");
assert!(!transparent_left(&app));
}
#[test]
fn save_preset_keeps_the_options_and_lists_them() {
let mut app = scene();
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
let mut d = app.ui.dialog.clone().unwrap();
d.fields.insert("balance".into(), json!(30));
d.fields.insert(PRESET_NAME.into(), json!("Thirty"));
save_preset(&mut app, &mut d).unwrap();
assert_eq!(d.str("preset"), "Thirty");
let saved = &app.session.prefs.flattener_presets;
assert_eq!((saved.len(), saved[0].name.as_str(), saved[0].options.balance), (1, "Thirty", 30.0));
// A saved preset set from outside loads like a built-in one.
d.fields.insert("preset".into(), json!("Low Resolution"));
app.ui.dialog = Some(d);
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("balance", 0.0), 75.0);
app.ui.dialog.as_mut().unwrap().fields.insert("preset".into(), json!("Thirty"));
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("balance", 0.0), 30.0);
// Built-in presets can't be replaced.
let mut d = app.ui.dialog.clone().unwrap();
d.fields.insert(PRESET_NAME.into(), json!("High Resolution"));
assert!(save_preset(&mut app, &mut d).is_err());
}
#[test]
fn bad_values_report_and_keep_the_dialog_open() {
let mut app = scene();
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("gradientPpi".into(), json!(9000));
frame(&mut app);
assert!(super::super::confirm(&mut app).unwrap_err().contains("resolutions"));
assert!(app.ui.dialog.is_some());
}
}