From 4769cadb777b5a52c7bb5216fd4bf2290b0fe858 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 23:14:15 +0200 Subject: [PATCH] feat(ui): The Effects dialog, as in InDesign Object > Effects (Transparency..., Drop Shadow... and the rest, and their shortcuts) and the Effects panel's fx button open the Effects dialog on the chosen effect, turned on: the effect list with checkboxes on the left and a summary line, the chosen page's settings on the right in the document's units (Blending, Position with X/Y offsets, Options...), Preview on the page while it is open, Cancel putting the page back, and OK as one undo step. - engine: object.effects {effects (merged, kept in range), opacity, blend} - doc: drop and inner shadows default to InDesign's 120 degree light - ui: dialogs::cancel (a previewing dialog reverts) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 4 +- crates/engine/src/cmd/object.rs | 181 +++++++ crates/render/tests/effects.rs | 2 + crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 20 +- crates/ui-egui/src/effects_dialog.rs | 682 ++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 39 ++ crates/ui-egui/src/i18n/ar.rs | 29 + crates/ui-egui/src/i18n/it.rs | 29 + crates/ui-egui/src/i18n/ja.rs | 25 + crates/ui-egui/src/i18n/pt_br.rs | 37 ++ crates/ui-egui/src/i18n/uk.rs | 29 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 18 + crates/ui-egui/src/panels/properties.rs | 15 + 16 files changed, 1107 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/effects_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..01d766b7b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · The Effects dialog, as in InDesign: Object › Effects (Transparency…, Drop Shadow… and the rest, and their shortcuts) and the Effects panel's fx button open it on the chosen effect, turned on. Transparency and every effect in one window, in the document's units, with X/Y offsets, Preview on the page, Cancel putting things back, and OK as one undo step (`object.effects`). Shadows default to InDesign's 120° light angle. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe2..a1723151b 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -702,7 +702,7 @@ impl Default for DropShadow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, - angle: 135.0, + angle: 120.0, distance: 7.0, size: 5.0, spread: 0.0, @@ -734,7 +734,7 @@ impl Default for InnerShadow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, - angle: 135.0, + angle: 120.0, distance: 7.0, size: 7.0, choke: 0.0, diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..efcf13da9 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -504,6 +504,15 @@ pub fn specs() -> Vec { ) } ), + cmd!( + "object.effects", + "Effects", + [], + None, + "{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, feather? (width), directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step", + has_selection, + set_effects + ), cmd!("object.feather", "Basic Feather", ["Object", "Effects"], None, "{width (0 = off), ids?}", has_selection, |s, p| { let w = f64_or(p, "width", 9.0).max(0.0); set_flag(s, p, move |i| i.effects.feather = w, false) @@ -1389,6 +1398,136 @@ fn ungroup(s: &mut Session, p: &Value) -> Result { }) } +/// `patch` merged into `base`: objects field by field (recursively), anything else replaced. +fn merge_json(base: &mut Value, patch: &Value) { + match (base, patch) { + (Value::Object(b), Value::Object(p)) => { + for (k, v) in p { + merge_json(b.entry(k.clone()).or_insert(Value::Null), v); + } + } + (b, p) => *b = p.clone(), + } +} + +/// Keep effect values in their ranges: opacities 0–1, sizes and distances ≥ 0, percentages 0–100. +fn sanitize_effects(e: &mut designcraft_doc::Effects) -> Result<()> { + const ID: &str = "object.effects"; + let len = |v: &mut f64| -> Result<()> { + if !v.is_finite() { + return Err(bad(ID, "values must be finite")); + } + *v = v.clamp(0.0, 10_000.0); + Ok(()) + }; + let pct = |v: &mut f64| -> Result<()> { + if !v.is_finite() { + return Err(bad(ID, "values must be finite")); + } + *v = v.clamp(0.0, 100.0); + Ok(()) + }; + let ang = |v: &mut f64| -> Result<()> { + if !v.is_finite() { + return Err(bad(ID, "values must be finite")); + } + *v = (*v + 180.0).rem_euclid(360.0) - 180.0; + Ok(()) + }; + let op = |v: &mut f32| -> Result<()> { + if !v.is_finite() { + return Err(bad(ID, "values must be finite")); + } + *v = v.clamp(0.0, 1.0); + Ok(()) + }; + let ds = &mut e.drop_shadow; + op(&mut ds.opacity)?; + ang(&mut ds.angle)?; + len(&mut ds.distance)?; + len(&mut ds.size)?; + pct(&mut ds.spread)?; + let is = &mut e.inner_shadow; + op(&mut is.opacity)?; + ang(&mut is.angle)?; + len(&mut is.distance)?; + len(&mut is.size)?; + pct(&mut is.choke)?; + let og = &mut e.outer_glow; + op(&mut og.opacity)?; + len(&mut og.size)?; + pct(&mut og.spread)?; + let ig = &mut e.inner_glow; + op(&mut ig.opacity)?; + len(&mut ig.size)?; + pct(&mut ig.choke)?; + let bv = &mut e.bevel; + len(&mut bv.size)?; + len(&mut bv.depth)?; + ang(&mut bv.angle)?; + op(&mut bv.highlight_opacity)?; + op(&mut bv.shadow_opacity)?; + let sa = &mut e.satin; + op(&mut sa.opacity)?; + ang(&mut sa.angle)?; + len(&mut sa.distance)?; + len(&mut sa.size)?; + len(&mut e.feather)?; + for w in &mut e.directional_feather.widths { + len(w)?; + } + let gf = &mut e.gradient_feather; + ang(&mut gf.angle)?; + op(&mut gf.start)?; + op(&mut gf.end)?; + Ok(()) +} + +fn set_effects(s: &mut Session, p: &Value) -> Result { + const ID: &str = "object.effects"; + let patch = match p.get("effects") { + None | Some(Value::Null) => None, + Some(v @ Value::Object(_)) => Some(v.clone()), + Some(_) => return Err(bad(ID, "effects must be an object")), + }; + let opacity = match p.get("opacity") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad(ID, "opacity must be a number"))?.clamp(0.0, 1.0) as f32), + }; + let blend = match p.get("blend") { + None | Some(Value::Null) => None, + Some(v) => { + let raw = v.as_str().ok_or_else(|| bad(ID, "blend must be a string"))?; + Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?) + } + }; + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked { + continue; + } + if let Some(patch) = &patch { + let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?; + merge_json(&mut cur, patch); + let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?; + sanitize_effects(&mut fx)?; + it.effects = fx; + } + if let Some(o) = opacity { + it.opacity = o; + } + if let Some(b) = blend { + it.blend = b; + } + changed += 1; + } + Ok(json!({"changed": changed})) + }) +} + fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result { let ids = targets(s, p)?; s.edit(|d, sel| { @@ -2813,3 +2952,45 @@ mod swatch_name_tests { } } } + +#[cfg(test)] +mod effects_command_tests { + use designcraft_doc::ItemId; + use serde_json::json; + + #[test] + fn effects_set_together_in_one_undo_step_and_keep_what_is_not_given() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "distance": 10, "ids": [id]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s + .execute( + "object.effects", + &json!({"ids": [id], "opacity": 0.5, "blend": "multiply", + "effects": {"innerShadow": {"on": true, "angle": 120, "distance": 7, "size": 7}, "dropShadow": {"size": 9}, "feather": 4}}), + ) + .unwrap(); + assert_eq!(r["changed"], 1); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + let it = st.doc.item(ItemId(id)).unwrap(); + let e = &it.effects; + assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0); + assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay"); + assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply)); + // Out of range values are kept in range; garbage changes nothing. + s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap(); + let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone(); + assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0)); + let before = s.doc().unwrap().doc.clone(); + for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})] + { + let mut p = bad.clone(); + p["ids"] = json!([id]); + assert!(s.execute("object.effects", &p).is_err(), "{bad}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } +} diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index a664c27a8..35c0c0896 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -123,6 +123,7 @@ fn drop_shadow_of_text_follows_the_glyphs() { d.story_mut(sid).unwrap().format_chars(0..6, |f| f.over.size = Some(40.0)); let ds = &mut d.item_mut(fid).unwrap().effects.drop_shadow; ds.on = true; + ds.angle = 135.0; ds.distance = 4.0; ds.size = 3.0; let img = render(&d); @@ -155,6 +156,7 @@ fn inner_shadow_darkens_inside_the_lit_edge() { let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=0 M=0 Y=100 K=0"); let s = &mut d.item_mut(id).unwrap().effects.inner_shadow; s.on = true; + s.angle = 135.0; s.distance = 8.0; s.size = 8.0; s.opacity = 1.0; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 12df640da..378bb5cba 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -302,8 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { - app.cancel_pdf_import(); - app.ui.dialog = None; + crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..4a3537462 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1150,6 +1150,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"), "keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"), "colorPicker" => crate::i18n::tr(&app.ui.language, "Color Picker"), + crate::effects_dialog::ID => crate::i18n::tr(&app.ui.language, "Effects"), id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')), None => crate::i18n::tr(&app.ui.language, "Dialog"), @@ -1161,6 +1162,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, "closeDocument" => 380.0, + crate::effects_dialog::ID => 720.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -1396,6 +1398,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), + crate::effects_dialog::ID => crate::effects_dialog::show(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { @@ -1733,14 +1736,22 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => { - app.ui.dialog = None; - app.cancel_pdf_import(); - } + Some(false) => cancel(app), None => {} } } +/// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing +/// dialog puts the document back as it was. +pub fn cancel(app: &mut DesignApp) { + if let Some(d) = app.ui.dialog.take() + && d.id == crate::effects_dialog::ID + { + crate::effects_dialog::cancel(app, &d); + } + app.cancel_pdf_import(); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2165,6 +2176,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("footnote.options", p) } "alert" => Ok(Value::Null), + crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), id if id.starts_with("cmd:") => { diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs new file mode 100644 index 000000000..f1523b2c4 --- /dev/null +++ b/crates/ui-egui/src/effects_dialog.rs @@ -0,0 +1,682 @@ +//! Object › Effects (and the Effects panel's fx button): InDesign's Effects dialog. Transparency +//! and every effect in one window: the list on the left (a checkbox turns an effect on, a click on +//! its name shows its settings and turns it on), the settings on the right in the document's +//! units, Preview on the page while it is open, and OK as one undo step (`object.effects`). + +use designcraft_geom::{Unit, format_measure, units::parse_measure}; +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::dialogs::Dialog; +use crate::theme::semibold; + +/// The dialog's id. +pub const ID: &str = "effects"; +/// The params a previewing dialog shows on the page (its interaction is open while it is set). +const PREVIEWED: &str = "_previewed"; + +/// The dialog's pages: (key in `Effects` JSON, label). Transparency is the object's own. +pub const PAGES: [(&str, &str); 10] = [ + ("transparency", "Transparency"), + ("dropShadow", "Drop Shadow"), + ("innerShadow", "Inner Shadow"), + ("outerGlow", "Outer Glow"), + ("innerGlow", "Inner Glow"), + ("bevel", "Bevel and Emboss"), + ("satin", "Satin"), + ("feather", "Basic Feather"), + ("directionalFeather", "Directional Feather"), + ("gradientFeather", "Gradient Feather"), +]; + +/// The page an effect command opens (Object › Effects › Drop Shadow…, its shortcut). +pub fn page_of_command(id: &str) -> Option<&'static str> { + Some(match id { + "object.dropShadow" => "dropShadow", + "object.innerShadow" => "innerShadow", + "object.outerGlow" => "outerGlow", + "object.innerGlow" => "innerGlow", + "object.bevel" => "bevel", + "object.satin" => "satin", + "object.feather" => "feather", + "object.directionalFeather" => "directionalFeather", + "object.gradientFeather" => "gradientFeather", + _ => return None, + }) +} + +/// Open the dialog on `page` for the selected objects, the page's effect turned on (as InDesign +/// does when an effect is chosen from a menu). Nothing selected: a status message. +pub fn open(app: &mut DesignApp, page: &str) { + let Some(st) = app.session.active() else { return }; + let ids: Vec = st.selection.items.iter().map(|i| i.0).collect(); + let Some(it) = st.selection.items.first().and_then(|i| st.doc.item(*i)) else { + app.status(crate::i18n::tr(&app.ui.language, "Select an object.").to_string()); + return; + }; + let mut fx = effects_json(&it.effects); + let fields = json!({"ids": ids, "fx": Value::Null, "opacity": it.opacity, "blend": it.blend.label(), "preview": true, "page": "transparency"}); + let mut d = Dialog::new(ID, fields); + let page = PAGES.iter().find(|p| p.0 == page).map_or("transparency", |p| p.0); + turn_on(&mut fx, page); + d.fields.insert("fx".into(), fx); + d.fields.insert("page".into(), json!(page)); + app.ui.dialog = Some(d); +} + +/// Every effect's settings, those still at their defaults included (the document leaves them out). +fn effects_json(e: &designcraft_doc::Effects) -> Value { + use designcraft_doc::{Bevel, DirectionalFeather, DropShadow, GradientFeather, InnerGlow, InnerShadow, OuterGlow, Satin}; + let mut fx = serde_json::to_value(e).unwrap_or_else(|_| json!({})); + let defaults = [ + ("dropShadow", serde_json::to_value(DropShadow::default())), + ("innerShadow", serde_json::to_value(InnerShadow::default())), + ("outerGlow", serde_json::to_value(OuterGlow::default())), + ("innerGlow", serde_json::to_value(InnerGlow::default())), + ("bevel", serde_json::to_value(Bevel::default())), + ("satin", serde_json::to_value(Satin::default())), + ("directionalFeather", serde_json::to_value(DirectionalFeather::default())), + ("gradientFeather", serde_json::to_value(GradientFeather::default())), + ]; + for (k, v) in defaults { + if !fx[k].is_object() + && let Ok(v) = v + { + fx[k] = v; + } + } + fx +} + +/// Turn the effect of `page` on, as choosing it does (Basic Feather: its default width). +fn turn_on(fx: &mut Value, page: &str) { + match page { + "transparency" => {} + "feather" => { + if fx["feather"].as_f64().unwrap_or(0.0) <= 0.0 { + fx["feather"] = json!(9.0); + } + } + _ => { + if fx[page].is_object() { + fx[page]["on"] = json!(true); + } + } + } +} + +fn is_on(fx: &Value, page: &str) -> bool { + match page { + "transparency" => true, + "feather" => fx["feather"].as_f64().unwrap_or(0.0) > 0.0, + _ => fx[page]["on"].as_bool().unwrap_or(false), + } +} + +/// The `object.effects` params the dialog stands for. +pub fn params(d: &Dialog) -> Value { + json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})), + "opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")}) +} + +/// Apply: the preview goes and the settings are made once, as one undo step. +pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + app.run("object.effects", params(d)) +} + +/// Close without applying: the page shows what it showed before the dialog opened. +pub fn cancel(app: &mut DesignApp, d: &Dialog) { + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } +} + +/// Preview on: the effects show on the page (redone when a value changes); off: they go. +fn preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.fields.get("preview").and_then(Value::as_bool).unwrap_or(false) { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + let p = params(d); + if shown.as_ref() == Some(&p) { + return; + } + if shown.is_none() && app.session.begin_interaction("Effects").is_err() { + return; + } + match app.session.preview("object.effects", p.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), p); +} + +/// A number of `fx` at `path` (`["dropShadow", "distance"]`). +fn num(d: &Dialog, path: &[&str]) -> f64 { + let mut v = d.fields.get("fx").unwrap_or(&Value::Null); + for k in path { + v = &v[*k]; + } + v.as_f64().unwrap_or(0.0) +} + +fn set(d: &mut Dialog, path: &[&str], value: Value) { + let Some(mut v) = d.fields.get_mut("fx") else { return }; + for k in &path[..path.len() - 1] { + if !v[*k].is_object() { + v[*k] = json!({}); + } + v = &mut v[*k]; + } + if let Some(last) = path.last() { + v[*last] = value; + } +} + +/// A distance in the document's unit (stored in points). +fn measure(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], unit: Unit) { + let mut v = num(d, path); + let w = egui::DragValue::new(&mut v) + .range(0.0..=10_000.0) + .speed(0.25) + .custom_formatter(move |n, _| format_measure(n, unit)) + .custom_parser(move |s| parse_measure(s, unit).ok()); + if ui.add_sized([90.0, 20.0], w).changed() { + set(d, path, json!(v)); + } +} + +/// An opacity (0–1, shown as %). +fn percent_of_one(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { + let mut v = num(d, path) * 100.0; + if ui.add_sized([70.0, 20.0], egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%").max_decimals(0)).changed() { + set(d, path, json!(v / 100.0)); + } +} + +/// A percentage (0–100). +fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { + let mut v = num(d, path); + if ui.add_sized([70.0, 20.0], egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%").max_decimals(0)).changed() { + set(d, path, json!(v)); + } +} + +fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global: f64) { + let mut v = if enabled { num(d, path) } else { global }; + let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); + if r.changed() { + set(d, path, json!(v)); + } +} + +fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { + let mut v = { + let mut cur = d.fields.get("fx").unwrap_or(&Value::Null); + for k in path { + cur = &cur[*k]; + } + cur.as_bool().unwrap_or(false) + }; + if ui.checkbox(&mut v, crate::rtl::widget(ui, label)).changed() { + set(d, path, json!(v)); + } +} + +/// A swatch of the document. +fn swatch(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { + let cur = { + let mut v = d.fields.get("fx").unwrap_or(&Value::Null); + for k in path { + v = &v[*k]; + } + v.as_str().unwrap_or("[Black]").to_string() + }; + let names: Vec = app + .session + .active() + .map_or_else(Vec::new, |st| st.doc.swatches.iter().filter(|w| !w.hidden || w.name == cur).map(|w| w.name.clone()).collect()); + egui::ComboBox::from_id_salt(("fx_swatch", path.join("."))).selected_text(&cur).width(150.0).show_ui(ui, |ui| { + for n in names { + if ui.selectable_label(n == cur, &n).clicked() { + set(d, path, json!(n)); + } + } + }); +} + +fn heading(ui: &mut egui::Ui, text: &str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(text).font(semibold(12.0))); +} + +/// Distance and angle as an X (`y`: false) or Y offset: InDesign's, the shadow falls away from +/// the light. Editing an offset sets the distance and (without Global Light) the angle. +fn offset(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, global: f64, y_axis: bool) { + let gl = d.fields.get("fx").is_some_and(|f| f[key]["globalLight"].as_bool().unwrap_or(false)); + let a = if gl { global } else { num(d, &[key, "angle"]) }.to_radians(); + let dist = num(d, &[key, "distance"]); + let (mut x, mut y) = (-dist * a.cos(), dist * a.sin()); + let v = if y_axis { &mut y } else { &mut x }; + let w = + egui::DragValue::new(v).speed(0.25).custom_formatter(move |n, _| format_measure(n, unit)).custom_parser(move |s| parse_measure(s, unit).ok()); + if ui.add_sized([90.0, 20.0], w).changed() { + set(d, &[key, "distance"], json!(x.hypot(y))); + if !gl { + set(d, &[key, "angle"], json!(y.atan2(-x).to_degrees())); + } + } +} + +/// The settings of `page`. +fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units); + let global = app.session.active().map_or(120.0, |st| st.doc.settings.global_light); + let row = |ui: &mut egui::Ui, l: &str| crate::rtl::label(ui, tr(l)); + crate::rtl::label(ui, egui::RichText::new(tr(PAGES.iter().find(|p| p.0 == page).map_or("Transparency", |p| p.1))).font(semibold(14.0))); + let grid = |id: &str| egui::Grid::new(("fx_grid", id.to_string())).num_columns(4).spacing([8.0, 6.0]); + match page { + "transparency" => { + heading(ui, &tr("Basic Blending")); + grid("t").show(ui, |ui| { + row(ui, "Mode:"); + let cur = d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string(); + egui::ComboBox::from_id_salt("fx_blend").selected_text(tr(&cur)).width(150.0).show_ui(ui, |ui| { + for m in designcraft_color::BlendMode::ALL { + if ui.selectable_label(m.label() == cur, tr(m.label())).clicked() { + d.fields.insert("blend".into(), json!(m.label())); + } + } + }); + row(ui, "Opacity:"); + let mut o = d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) * 100.0; + if ui.add_sized([70.0, 20.0], egui::DragValue::new(&mut o).range(0.0..=100.0).suffix("%").max_decimals(0)).changed() { + d.fields.insert("opacity".into(), json!(o / 100.0)); + } + ui.end_row(); + }); + } + "dropShadow" | "innerShadow" => { + let key = page; + heading(ui, &tr("Blending")); + grid("b").show(ui, |ui| { + row(ui, "Color:"); + swatch(app, ui, d, &[key, "color"]); + row(ui, "Opacity:"); + percent_of_one(ui, d, &[key, "opacity"]); + ui.end_row(); + }); + heading(ui, &tr("Position")); + grid("p").show(ui, |ui| { + row(ui, "Distance:"); + measure(ui, d, &[key, "distance"], unit); + row(ui, "X Offset:"); + offset(ui, d, key, unit, global, false); + ui.end_row(); + row(ui, "Angle:"); + let gl = d.fields.get("fx").is_some_and(|f| f[key]["globalLight"].as_bool().unwrap_or(false)); + angle(ui, d, &[key, "angle"], !gl, global); + row(ui, "Y Offset:"); + offset(ui, d, key, unit, global, true); + ui.end_row(); + crate::rtl::label(ui, ""); + flag(ui, d, &[key, "globalLight"], &tr("Use Global Light")); + ui.end_row(); + }); + heading(ui, &tr("Options")); + grid("o").show(ui, |ui| { + row(ui, "Size:"); + measure(ui, d, &[key, "size"], unit); + if key == "dropShadow" { + row(ui, "Spread:"); + percent(ui, d, &[key, "spread"]); + } else { + row(ui, "Choke:"); + percent(ui, d, &[key, "choke"]); + } + ui.end_row(); + }); + } + "outerGlow" | "innerGlow" => { + let key = page; + heading(ui, &tr("Blending")); + grid("b").show(ui, |ui| { + row(ui, "Color:"); + swatch(app, ui, d, &[key, "color"]); + row(ui, "Opacity:"); + percent_of_one(ui, d, &[key, "opacity"]); + ui.end_row(); + }); + heading(ui, &tr("Options")); + grid("o").show(ui, |ui| { + row(ui, "Size:"); + measure(ui, d, &[key, "size"], unit); + if key == "outerGlow" { + row(ui, "Spread:"); + percent(ui, d, &[key, "spread"]); + } else { + row(ui, "Choke:"); + percent(ui, d, &[key, "choke"]); + ui.end_row(); + row(ui, "Source:"); + let mut center = d.fields.get("fx").is_some_and(|f| f["innerGlow"]["center"].as_bool().unwrap_or(false)); + let label = |c: bool| tr(if c { "Center" } else { "Edge" }); + egui::ComboBox::from_id_salt("fx_source").selected_text(label(center)).show_ui(ui, |ui| { + for c in [false, true] { + if ui.selectable_value(&mut center, c, label(c)).clicked() { + set(d, &["innerGlow", "center"], json!(c)); + } + } + }); + } + ui.end_row(); + }); + } + "bevel" => { + heading(ui, &tr("Structure")); + grid("s").show(ui, |ui| { + row(ui, "Size:"); + measure(ui, d, &["bevel", "size"], unit); + row(ui, "Depth:"); + let mut v = num(d, &["bevel", "depth"]); + if ui.add_sized([70.0, 20.0], egui::DragValue::new(&mut v).range(1.0..=1000.0).suffix("%").max_decimals(0)).changed() { + set(d, &["bevel", "depth"], json!(v)); + } + ui.end_row(); + }); + heading(ui, &tr("Shading")); + grid("sh").show(ui, |ui| { + row(ui, "Angle:"); + let gl = d.fields.get("fx").is_some_and(|f| f["bevel"]["globalLight"].as_bool().unwrap_or(false)); + angle(ui, d, &["bevel", "angle"], !gl, global); + flag(ui, d, &["bevel", "globalLight"], &tr("Use Global Light")); + ui.end_row(); + row(ui, "Highlight:"); + swatch(app, ui, d, &["bevel", "highlight"]); + row(ui, "Opacity:"); + percent_of_one(ui, d, &["bevel", "highlightOpacity"]); + ui.end_row(); + row(ui, "Shadow:"); + swatch(app, ui, d, &["bevel", "shadow"]); + row(ui, "Opacity:"); + percent_of_one(ui, d, &["bevel", "shadowOpacity"]); + ui.end_row(); + }); + } + "satin" => { + heading(ui, &tr("Blending")); + grid("b").show(ui, |ui| { + row(ui, "Color:"); + swatch(app, ui, d, &["satin", "color"]); + row(ui, "Opacity:"); + percent_of_one(ui, d, &["satin", "opacity"]); + ui.end_row(); + }); + heading(ui, &tr("Options")); + grid("o").show(ui, |ui| { + row(ui, "Angle:"); + angle(ui, d, &["satin", "angle"], true, global); + row(ui, "Distance:"); + measure(ui, d, &["satin", "distance"], unit); + ui.end_row(); + row(ui, "Size:"); + measure(ui, d, &["satin", "size"], unit); + crate::rtl::label(ui, ""); + flag(ui, d, &["satin", "invert"], &tr("Invert")); + ui.end_row(); + }); + } + "feather" => { + heading(ui, &tr("Options")); + grid("o").show(ui, |ui| { + row(ui, "Feather Width:"); + measure(ui, d, &["feather"], unit); + ui.end_row(); + }); + } + "directionalFeather" => { + heading(ui, &tr("Feather Widths")); + grid("w").show(ui, |ui| { + for (i, l) in ["Top:", "Left:", "Bottom:", "Right:"].into_iter().enumerate() { + row(ui, l); + let mut v = d.fields.get("fx").and_then(|f| f["directionalFeather"]["widths"][i].as_f64()).unwrap_or(0.0); + let w = egui::DragValue::new(&mut v) + .range(0.0..=10_000.0) + .speed(0.25) + .custom_formatter(move |n, _| format_measure(n, unit)) + .custom_parser(move |s| parse_measure(s, unit).ok()); + if ui.add_sized([90.0, 20.0], w).changed() { + let mut ws: Vec = + (0..4).map(|k| d.fields.get("fx").and_then(|f| f["directionalFeather"]["widths"][k].as_f64()).unwrap_or(0.0)).collect(); + if let Some(slot) = ws.get_mut(i) { + *slot = v; + } + set(d, &["directionalFeather", "widths"], json!(ws)); + } + if i % 2 == 1 { + ui.end_row(); + } + } + }); + } + "gradientFeather" => { + heading(ui, &tr("Gradient Stops")); + grid("g").show(ui, |ui| { + row(ui, "Start Opacity:"); + percent_of_one(ui, d, &["gradientFeather", "start"]); + row(ui, "End Opacity:"); + percent_of_one(ui, d, &["gradientFeather", "end"]); + ui.end_row(); + }); + heading(ui, &tr("Options")); + grid("o").show(ui, |ui| { + row(ui, "Type:"); + let mut radial = d.fields.get("fx").is_some_and(|f| f["gradientFeather"]["radial"].as_bool().unwrap_or(false)); + let label = |r: bool| tr(if r { "Radial" } else { "Linear" }); + egui::ComboBox::from_id_salt("fx_gtype").selected_text(label(radial)).show_ui(ui, |ui| { + for r in [false, true] { + if ui.selectable_value(&mut radial, r, label(r)).clicked() { + set(d, &["gradientFeather", "radial"], json!(r)); + } + } + }); + row(ui, "Angle:"); + angle(ui, d, &["gradientFeather", "angle"], true, global); + ui.end_row(); + }); + } + _ => {} + } +} + +/// One line per applied level, as in InDesign's summary: "OBJECT: Normal 100%; Drop Shadow". +fn summary(lang: &str, d: &Dialog) -> String { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + let blend = d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal"); + let opacity = d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + let on: Vec = PAGES.iter().skip(1).filter(|p| is_on(&fx, p.0)).map(|p| crate::i18n::tr(lang, p.1).to_string()).collect(); + let effects = if on.is_empty() { crate::i18n::tr(lang, "(no effects)").to_string() } else { on.join(", ") }; + format!("{}: {} {:.0}%; {}", crate::i18n::tr(lang, "OBJECT").to_uppercase(), crate::i18n::tr(lang, blend), opacity * 100.0, effects) +} + +/// The dialog's body. +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Settings for:")); + // Stroke, Fill and Text levels aren't in the document model yet: the object only. + ui.add_enabled_ui(false, |ui| { + egui::ComboBox::from_id_salt("fx_level").selected_text(tr("Object")).width(110.0).show_ui(ui, |_| {}); + }); + }); + ui.add_space(6.0); + let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("transparency").to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(180.0); + for (key, label) in PAGES { + ui.horizontal(|ui| { + if key == "transparency" { + ui.add_space(24.0); + } else { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + let mut on = is_on(&fx, key); + if ui.checkbox(&mut on, "").changed() { + if on { + if let Some(f) = d.fields.get_mut("fx") { + turn_on(f, key); + } + } else if key == "feather" { + set(d, &["feather"], json!(0.0)); + } else { + set(d, &[key, "on"], json!(false)); + } + } + } + if ui.selectable_label(page == key, tr(label)).clicked() { + d.fields.insert("page".into(), json!(key)); + if let Some(f) = d.fields.get_mut("fx") { + turn_on(f, key); + } + } + }); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(summary(&lang, d)).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + }); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_min_width(380.0); + page_ui(app, ui, d, &page); + }); + }); + ui.add_space(10.0); + let mut p = d.fields.get("preview").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut p, crate::rtl::widget(ui, tr("Preview"))).changed() { + d.fields.insert("preview".into(), json!(p)); + } + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + preview(app, d); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_frame() -> (DesignApp, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "inches"})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"swatch": "[Paper]"})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + (app, id) + } + + fn frame(app: &mut DesignApp, ctx: &egui::Context) -> Vec { + let mut texts = Vec::new(); + for _ in 0..2 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: egui::epaint::Shape, texts: &mut Vec) { + match s { + egui::epaint::Shape::Text(t) => texts.push(t.galley.text().to_string()), + egui::epaint::Shape::Vec(v) => v.into_iter().for_each(|s| walk(s, texts)), + _ => {} + } + } + texts.clear(); + for c in out.shapes { + walk(c.shape, &mut texts); + } + } + texts + } + + #[test] + fn the_effects_dialog_opens_on_the_chosen_effect_previews_and_applies_once() { + let (mut app, id) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let fx = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().effects.clone(); + let u0 = undo(&app); + // Object › Effects › Inner Shadow… (as the menu activates it): the dialog, on that page. + crate::menus::activate(&mut app, "object.innerShadow", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(ID)); + let texts = frame(&mut app, &ctx); + for t in [ + "Effects", + "Settings for:", + "Transparency", + "Inner Shadow", + "Drop Shadow", + "Blending", + "Position", + "Distance:", + "Angle:", + "Use Global Light", + "Options", + "Size:", + "Choke:", + "Preview", + "OK", + "Cancel", + ] { + assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); + } + assert!(texts.iter().any(|x| x.contains("0.097 in")), "distance in inches: {texts:?}"); + // Preview: the shadow is on the page while the dialog is open, without an undo step. + frame(&mut app, &ctx); + assert!(fx(&app).inner_shadow.on); + assert_eq!(undo(&app), u0); + // Cancel: as before. + crate::dialogs::cancel(&mut app); + assert!(!fx(&app).inner_shadow.on); + assert_eq!(undo(&app), u0); + // Again, with a change, then OK: one undo step. + crate::menus::activate(&mut app, "object.innerShadow", &Value::Null); + frame(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + set(d, &["innerShadow", "size"], json!(9.0)); + d.fields.insert("opacity".into(), json!(0.8)); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let e = fx(&app); + assert!(e.inner_shadow.on && e.inner_shadow.size == 9.0); + assert_eq!(undo(&app), u0 + 1, "one undo step"); + assert!((app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().opacity - 0.8).abs() < 1e-6); + app.run("edit.undo", json!({})).unwrap(); + assert!(!fx(&app).inner_shadow.on); + } + + #[test] + fn offsets_follow_distance_and_angle_as_in_indesign() { + let (mut app, _) = app_with_frame(); + open(&mut app, "dropShadow"); + let d = app.ui.dialog.as_mut().unwrap(); + set(d, &["dropShadow", "distance"], json!(7.0)); + set(d, &["dropShadow", "angle"], json!(120.0)); + // InDesign shows 7 pt at 120° as X 0.0486 in, Y 0.0842 in. + let a = 120f64.to_radians(); + assert!(((-7.0 * a.cos()) / 72.0 - 0.0486).abs() < 1e-4 && ((7.0 * a.sin()) / 72.0 - 0.0842).abs() < 1e-4); + let p = params(d); + assert_eq!(p["effects"]["dropShadow"]["on"], true, "chosen from the menu: on"); + assert_eq!(summary("en", d), "OBJECT: Normal 100%; Drop Shadow"); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..e59d4977a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,45 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Effects…", ["Effekte…", "Effets…", "Efectos…", "効果…", "效果…"]), + ("Settings for:", ["Einstellungen für:", "Paramètres pour :", "Ajustes para:", "設定対象:", "设置:"]), + ("Transparency", ["Transparenz", "Transparence", "Transparencia", "透明", "透明度"]), + ("Transparency…", ["Transparenz…", "Transparence…", "Transparencia…", "透明…", "透明度…"]), + ("Basic Blending", ["Grundlegendes Füllen", "Fusion de base", "Fusión básica", "基本的な描画モード", "基本混合"]), + ("Mode:", ["Modus:", "Mode :", "Modo:", "描画モード:", "模式:"]), + ("Opacity:", ["Deckkraft:", "Opacité :", "Opacidad:", "不透明度:", "不透明度:"]), + ("Blending", ["Füllmethode", "Fusion", "Fusión", "描画モード", "混合"]), + ("Distance:", ["Abstand:", "Distance :", "Distancia:", "距離:", "距离:"]), + ("X Offset:", ["X-Versatz:", "Décalage X :", "Desplazamiento X:", "X オフセット:", "X 位移:"]), + ("Y Offset:", ["Y-Versatz:", "Décalage Y :", "Desplazamiento Y:", "Y オフセット:", "Y 位移:"]), + ("Spread:", ["Größe ändern:", "Étendue :", "Extensión:", "スプレッド:", "扩展:"]), + ("Choke:", ["Unterfüllen:", "Contraction :", "Contracción:", "チョーク:", "收缩:"]), + ("Source:", ["Quelle:", "Source :", "Origen:", "ソース:", "源:"]), + ("Edge", ["Kante", "Contour", "Borde", "エッジ", "边缘"]), + ("Depth:", ["Tiefe:", "Profondeur :", "Profundidad:", "深さ:", "深度:"]), + ("Highlight:", ["Lichter:", "Lumière :", "Iluminación:", "ハイライト:", "高光:"]), + ("Shadow:", ["Tiefen:", "Ombre :", "Sombra:", "シャドウ:", "阴影:"]), + ("Feather Width:", ["Breite der weichen Kante:", "Largeur du contour progressif :", "Anchura de desvanecimiento:", "ぼかしの幅:", "羽化宽度:"]), + ("Feather Widths", ["Breiten der weichen Kante", "Largeurs du contour progressif", "Anchuras de desvanecimiento", "ぼかしの幅", "羽化宽度"]), + ("Top:", ["Oben:", "Haut :", "Superior:", "上:", "上:"]), + ("Left:", ["Links:", "Gauche :", "Izquierda:", "左:", "左:"]), + ("Bottom:", ["Unten:", "Bas :", "Inferior:", "下:", "下:"]), + ("Right:", ["Rechts:", "Droite :", "Derecha:", "右:", "右:"]), + ("Gradient Stops", ["Verlaufsregler", "Arrêts de dégradé", "Paradas de degradado", "グラデーション分岐点", "渐变色标"]), + ("Start Opacity:", ["Anfangsdeckkraft:", "Opacité de début :", "Opacidad inicial:", "開始の不透明度:", "起始不透明度:"]), + ("End Opacity:", ["Enddeckkraft:", "Opacité de fin :", "Opacidad final:", "終了の不透明度:", "结束不透明度:"]), + ("(no effects)", ["(keine Effekte)", "(aucun effet)", "(sin efectos)", "(効果なし)", "(无效果)"]), + ("OBJECT", ["OBJEKT", "OBJET", "OBJETO", "オブジェクト", "对象"]), + ( + "Add an object effect to the selected target", + [ + "Dem ausgewählten Ziel einen Objekteffekt hinzufügen", + "Ajouter un effet d’objet à la cible sélectionnée", + "Añadir un efecto de objeto al destino seleccionado", + "選択した対象にオブジェクト効果を追加", + "为所选目标添加对象效果", + ], + ), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..5556b13ba 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,35 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Effects…", "التأثيرات…"), + ("Settings for:", "الإعدادات لـ:"), + ("Transparency…", "الشفافية…"), + ("Basic Blending", "المزج الأساسي"), + ("Mode:", "الوضع:"), + ("Opacity:", "العتامة:"), + ("Blending", "المزج"), + ("Distance:", "المسافة:"), + ("X Offset:", "إزاحة X:"), + ("Y Offset:", "إزاحة Y:"), + ("Spread:", "الانتشار:"), + ("Choke:", "الانكماش:"), + ("Source:", "المصدر:"), + ("Edge", "الحافة"), + ("Depth:", "العمق:"), + ("Highlight:", "الإبراز:"), + ("Shadow:", "الظل:"), + ("Feather Width:", "عرض التدرج:"), + ("Feather Widths", "عروض التدرج"), + ("Top:", "أعلى:"), + ("Left:", "يسار:"), + ("Bottom:", "أسفل:"), + ("Right:", "يمين:"), + ("Gradient Stops", "نقاط التدرج"), + ("Start Opacity:", "عتامة البداية:"), + ("End Opacity:", "عتامة النهاية:"), + ("(no effects)", "(بلا تأثيرات)"), + ("OBJECT", "الكائن"), + ("Add an object effect to the selected target", "إضافة تأثير كائن إلى الهدف المحدد"), ("Clear", "مسح"), ("Delete", "حذف"), ("Duplicate", "تكرار"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..6baae0ad0 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,35 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Effects…", "Effetti…"), + ("Settings for:", "Impostazioni per:"), + ("Transparency…", "Trasparenza…"), + ("Basic Blending", "Fusione di base"), + ("Mode:", "Metodo:"), + ("Opacity:", "Opacità:"), + ("Blending", "Fusione"), + ("Distance:", "Distanza:"), + ("X Offset:", "Spostamento X:"), + ("Y Offset:", "Spostamento Y:"), + ("Spread:", "Estensione:"), + ("Choke:", "Contrazione:"), + ("Source:", "Origine:"), + ("Edge", "Bordo"), + ("Depth:", "Profondità:"), + ("Highlight:", "Luce:"), + ("Shadow:", "Ombra:"), + ("Feather Width:", "Larghezza sfumatura:"), + ("Feather Widths", "Larghezze sfumatura"), + ("Top:", "Superiore:"), + ("Left:", "Sinistra:"), + ("Bottom:", "Inferiore:"), + ("Right:", "Destra:"), + ("Gradient Stops", "Interruzioni sfumatura"), + ("Start Opacity:", "Opacità iniziale:"), + ("End Opacity:", "Opacità finale:"), + ("(no effects)", "(nessun effetto)"), + ("OBJECT", "OGGETTO"), + ("Add an object effect to the selected target", "Aggiungi un effetto oggetto all'obiettivo selezionato"), ("Clear", "Cancella"), ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..f68664560 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Effects…", "効果…"), + ("Settings for:", "設定対象:"), + ("Transparency…", "透明…"), + ("Basic Blending", "基本的な描画モード"), + ("Mode:", "描画モード:"), + ("Opacity:", "不透明度:"), + ("Blending", "描画モード"), + ("Distance:", "距離:"), + ("X Offset:", "X オフセット:"), + ("Y Offset:", "Y オフセット:"), + ("Spread:", "スプレッド:"), + ("Choke:", "チョーク:"), + ("Source:", "ソース:"), + ("Edge", "エッジ"), + ("Depth:", "深さ:"), + ("Highlight:", "ハイライト:"), + ("Shadow:", "シャドウ:"), + ("Feather Width:", "ぼかしの幅:"), + ("Feather Widths", "ぼかしの幅"), + ("Gradient Stops", "グラデーション分岐点"), + ("Start Opacity:", "開始の不透明度:"), + ("End Opacity:", "終了の不透明度:"), + ("(no effects)", "(効果なし)"), + ("OBJECT", "オブジェクト"), + ("Add an object effect to the selected target", "選択した対象にオブジェクト効果を追加"), ("Clear", "消去"), ("Duplicate", "複製"), ("Select All", "すべてを選択"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..0036d0943 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Effects…", "Efeitos…"), + ("Settings for:", "Configurações para:"), + ("Transparency…", "Transparência…"), + ("Basic Blending", "Mesclagem básica"), + ("Mode:", "Modo:"), + ("Opacity:", "Opacidade:"), + ("Blending", "Mesclagem"), + ("Distance:", "Distância:"), + ("X Offset:", "Deslocamento X:"), + ("Y Offset:", "Deslocamento Y:"), + ("Spread:", "Espalhamento:"), + ("Choke:", "Contração:"), + ("Source:", "Origem:"), + ("Edge", "Borda"), + ("Depth:", "Profundidade:"), + ("Highlight:", "Realce:"), + ("Shadow:", "Sombra:"), + ("Feather Width:", "Largura do difuminado:"), + ("Feather Widths", "Larguras do difuminado"), + ("Top:", "Superior:"), + ("Left:", "Esquerda:"), + ("Bottom:", "Inferior:"), + ("Right:", "Direita:"), + ("Gradient Stops", "Paradas de gradiente"), + ("Start Opacity:", "Opacidade inicial:"), + ("End Opacity:", "Opacidade final:"), + ("(no effects)", "(sem efeitos)"), + ("OBJECT", "OBJETO"), + ("Add an object effect to the selected target", "Adicionar um efeito de objeto ao alvo selecionado"), + ("Inner Shadow", "Sombra interna"), + ("Outer Glow", "Brilho externo"), + ("Inner Glow", "Brilho interno"), + ("Bevel and Emboss", "Chanfro e entalhe"), + ("Satin", "Cetim"), + ("Basic Feather", "Difuminado básico"), + ("Directional Feather", "Difuminado direcional"), + ("Gradient Feather", "Difuminado em gradiente"), ("Clear", "Limpar"), ("Delete", "Excluir"), ("Duplicate", "Duplicar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..f31eb4152 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,35 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Effects…", "Ефекти…"), + ("Settings for:", "Параметри для:"), + ("Transparency…", "Прозорість…"), + ("Basic Blending", "Базове змішування"), + ("Mode:", "Режим:"), + ("Opacity:", "Непрозорість:"), + ("Blending", "Змішування"), + ("Distance:", "Відстань:"), + ("X Offset:", "Зсув X:"), + ("Y Offset:", "Зсув Y:"), + ("Spread:", "Розмах:"), + ("Choke:", "Стискання:"), + ("Source:", "Джерело:"), + ("Edge", "Край"), + ("Depth:", "Глибина:"), + ("Highlight:", "Світло:"), + ("Shadow:", "Тінь:"), + ("Feather Width:", "Ширина розмиття:"), + ("Feather Widths", "Ширина розмиття"), + ("Top:", "Угорі:"), + ("Left:", "Ліворуч:"), + ("Bottom:", "Унизу:"), + ("Right:", "Праворуч:"), + ("Gradient Stops", "Точки градієнта"), + ("Start Opacity:", "Початкова непрозорість:"), + ("End Opacity:", "Кінцева непрозорість:"), + ("(no effects)", "(без ефектів)"), + ("OBJECT", "ОБ'ЄКТ"), + ("Add an object effect to the selected target", "Додати ефект об'єкта до вибраної цілі"), ("Clear", "Очистити"), ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0fa1dff0..d5b9e6d3d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod dock; +pub mod effects_dialog; pub mod i18n; pub mod icons; pub mod menus; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..a4f320d4e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -14,6 +14,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), + ( + "effects.dialog", + "Effects…", + None, + "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", + ), ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), @@ -515,6 +521,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.cornerOptions", "-", ">Effects", + "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", "cmd:object.dropShadow", "cmd:object.innerShadow", "cmd:object.outerGlow", @@ -886,6 +893,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } + "effects.dialog" => { + crate::effects_dialog::open(app, p.get("page").and_then(Value::as_str).unwrap_or("transparency")); + Ok(Value::Null) + } "app.loadSwatches" => { if let Some(pick) = app.services.pick_open.as_mut() { return Some(match pick("swatches") { @@ -1837,6 +1848,13 @@ pub fn close_document(app: &mut DesignApp, index: Option) { /// A menu item was chosen. An engine command whose label ends in "…" and that takes parameters /// opens a dialog built from its parameter documentation (see [`crate::dialogs::command_fields`]). pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { + // Object › Effects › Drop Shadow… (and its shortcut): the Effects dialog on that page. + if params.is_null() + && let Some(page) = crate::effects_dialog::page_of_command(id) + { + crate::effects_dialog::open(app, page); + return; + } if params.is_null() && id == "file.close" { close_document(app, None); return; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..ccadff304 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2030,6 +2030,21 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.end_row(); }); } + divider(ui); + // The fx button, as at the bottom of InDesign's Effects panel: each effect opens the Effects + // dialog on its page. + let fx = ui + .add(egui::Button::new(egui::RichText::new("fx").italics().strong()).min_size(vec2(28.0, 20.0))) + .on_hover_text(crate::i18n::tr(&app.ui.language, "Add an object effect to the selected target")); + egui::Popup::menu(&fx).show(|ui| { + for (page, label) in crate::effects_dialog::PAGES { + let text = format!("{}…", crate::i18n::tr(&app.ui.language, label)); + if ui.button(crate::rtl::widget(ui, text)).clicked() { + crate::effects_dialog::open(app, page); + ui.close(); + } + } + }); } pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) {