From a805a5f4e6b236dcdeb53bbed954902ebc125972 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 23:14:15 +0200 Subject: [PATCH 1/2] 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/dialogs.rs | 12 +- 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 | 14 + 15 files changed, 1099 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/effects_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..2cdc1f32 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Newest first. - **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. - **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. - **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. +- **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 0a701998..10da62c0 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -706,7 +706,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, @@ -738,7 +738,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 aa5a2cc0..de642a04 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -522,6 +522,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) @@ -1469,6 +1478,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| { @@ -3327,3 +3466,45 @@ mod spread_target_tests { assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); } } + +#[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 a664c27a..35c0c089 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/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af..ea1a7af4 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" => { @@ -1742,8 +1745,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// 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 app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { - app.session.cancel_interaction(); + if let Some(d) = app.ui.dialog.take() { + if d.id == crate::effects_dialog::ID { + crate::effects_dialog::cancel(app, &d); + } else if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } } app.cancel_pdf_import(); } @@ -2172,6 +2179,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")})), STEP_AND_REPEAT => { diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs new file mode 100644 index 00000000..f1523b2c --- /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 5252c388..ae5308a0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -57,6 +57,45 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), + ("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 a182d09e..33e9ea9f 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -32,6 +32,35 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "لصق"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), + ("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 75ece03f..cd90efd1 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -30,6 +30,35 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Incolla"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), + ("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 642b4a4c..50b1d88a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -32,6 +32,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "ペースト"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), + ("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 982dfb20..6476421d 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -33,6 +33,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Colar"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), + ("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 f3bc8fb3..d44fc194 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -33,6 +33,35 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Вставити"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), + ("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 fc7a3e63..950641c2 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 872cb81d..95776ded 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"), "{}"), @@ -503,6 +509,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", @@ -914,6 +921,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") { @@ -1880,6 +1891,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 d9c74e40..58de2218 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2128,6 +2128,20 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.transparencyGroup", json!({"knockout": ko})); } }); + // 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) { From 48f7ebfd5052c927acac58aae7a45d3ac30829e0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 15:08:54 +0200 Subject: [PATCH 2/2] fix(ui): Effects dialog: an empty field path sets nothing instead of panicking set() sliced `&path[..path.len() - 1]`, which panics on an empty path; split_last() returns early instead. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/effects_dialog.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index f1523b2c..9a0e9862 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -167,17 +167,18 @@ fn num(d: &Dialog, path: &[&str]) -> f64 { v.as_f64().unwrap_or(0.0) } +/// Set the value at `path` in the dialog's effects, making the objects on the way (an empty path +/// sets nothing). fn set(d: &mut Dialog, path: &[&str], value: Value) { + let Some((last, parents)) = path.split_last() else { return }; let Some(mut v) = d.fields.get_mut("fx") else { return }; - for k in &path[..path.len() - 1] { + for k in parents { if !v[*k].is_object() { v[*k] = json!({}); } v = &mut v[*k]; } - if let Some(last) = path.last() { - v[*last] = value; - } + v[*last] = value; } /// A distance in the document's unit (stored in points). @@ -607,6 +608,16 @@ mod tests { texts } + #[test] + fn set_makes_the_path_and_ignores_an_empty_one() { + let mut d = Dialog::new(ID, json!({"fx": {}})); + set(&mut d, &["featherOptions", "choke"], json!(20)); + assert_eq!(d.fields["fx"]["featherOptions"]["choke"], json!(20)); + let before = d.fields.clone(); + set(&mut d, &[], json!(1)); + assert_eq!(d.fields, before, "an empty path changes nothing (and doesn't panic)"); + } + #[test] fn the_effects_dialog_opens_on_the_chosen_effect_previews_and_applies_once() { let (mut app, id) = app_with_frame();