diff --git a/ROADMAP.md b/ROADMAP.md index 23aac7616..1f8e8a123 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -498,6 +498,9 @@ Newest first. - **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change. - **2026-10-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. - **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. +- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. +- **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change. +- **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1d2feb6a8..4c930c878 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -385,6 +385,23 @@ mod tests { use super::*; use designcraft_geom::shapes; + /// Shadows and glows saved before they had a blend mode read Normal, as they were drawn; + /// new ones start as InDesign's: Multiply for shadows, Screen for the outer glow. + #[test] + fn effect_blend_modes_default_as_indesign_and_keep_old_documents() { + let old: DropShadow = + serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "angle": 120, "distance": 7, "size": 5, "spread": 0}"#) + .unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + let old: OuterGlow = serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "size": 7, "spread": 0}"#).unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + assert_eq!(DropShadow::default().mode, BlendMode::Multiply); + assert_eq!(InnerShadow::default().mode, BlendMode::Multiply); + assert_eq!(OuterGlow::default().mode, BlendMode::Screen); + let saved = serde_json::to_value(DropShadow { mode: BlendMode::Screen, ..Default::default() }).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap().mode, BlendMode::Screen, "round trip"); + } + #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { diff --git a/crates/doc/src/item/effects.rs b/crates/doc/src/item/effects.rs index fa416643b..46aac2e95 100644 --- a/crates/doc/src/item/effects.rs +++ b/crates/doc/src/item/effects.rs @@ -13,12 +13,22 @@ pub struct DropShadow { pub distance: f64, pub size: f64, pub spread: f64, + /// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before + /// effects had modes reads Normal, as they were drawn then. + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +/// The blend of an effect saved before effects had one. +fn normal_blend() -> BlendMode { + BlendMode::Normal } impl Default for DropShadow { fn default() -> Self { DropShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -45,12 +55,16 @@ pub struct InnerShadow { pub size: f64, /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. pub choke: f64, + /// How the shadow blends with the object (InDesign: Multiply; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for InnerShadow { fn default() -> Self { InnerShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -72,11 +86,15 @@ pub struct OuterGlow { pub size: f64, /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. pub spread: f64, + /// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for OuterGlow { fn default() -> Self { - OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + // InDesign starts a glow screened in white. + OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen } } } diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 44f993a64..638742885 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -276,7 +276,7 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { } // Drag: fit proportionally; click: actual size at the point. let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => { let k = (r.width() / nw).min(r.height() / nh); (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5106fa92e..71eea09c2 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -382,9 +382,10 @@ pub fn specs() -> Vec { "Drop Shadow", ["Object", "Effects"], Some("Cmd+Alt+M"), - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.dropShadow")?; let p2 = p.clone(); set_effects(s, p, "object.dropShadow", move |fx| { let ds = &mut fx.drop_shadow; @@ -398,6 +399,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { ds.color = c.into(); } + if let Some(m) = mode { + ds.mode = m; + } }) } ), @@ -406,9 +410,10 @@ pub fn specs() -> Vec { "Inner Shadow", ["Object", "Effects"], None, - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.innerShadow")?; let p2 = p.clone(); set_effects(s, p, "object.innerShadow", move |fx| { let e = &mut fx.inner_shadow; @@ -422,6 +427,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = mode { + e.mode = m; + } }) } ), @@ -430,9 +438,10 @@ pub fn specs() -> Vec { "Outer Glow", ["Object", "Effects"], None, - "{on?: bool, opacity?, size?, spread? (%), color?, ids?}", + "{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.outerGlow")?; let p2 = p.clone(); set_effects(s, p, "object.outerGlow", move |fx| { let e = &mut fx.outer_glow; @@ -443,6 +452,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = mode { + e.mode = m; + } }) } ), @@ -3245,6 +3257,20 @@ fn align(s: &mut Session, p: &Value) -> Result { }) } +/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved). +/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode. +fn blend_param(p: &Value, cmd: &str) -> Result> { + match p.get("mode") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .and_then(BlendMode::parse) + .or_else(|| serde_json::from_value(v.clone()).ok()) + .map(Some) + .ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))), + } +} + fn distribute(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; if ids.len() < 3 && p.get("spacing").is_none() { @@ -4263,6 +4289,31 @@ mod label_tests { mod light_tests { use super::*; + /// The shadow and glow commands take a blend mode by name. + #[test] + fn shadow_and_glow_commands_take_a_blend_mode() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap(); + s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap(); + s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap(); + let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone(); + assert_eq!(fx.drop_shadow.mode, BlendMode::Screen); + assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay); + assert_eq!(fx.outer_glow.mode, BlendMode::Normal); + let before = s.doc().unwrap().doc.clone(); + for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] { + for mode in [json!("multply"), json!(3)] { + let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err(); + assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}"); + } + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode"); + } + #[test] fn global_light_moves_shadows_that_use_it() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 103a9416f..3590b2814 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -486,6 +486,63 @@ fn place_gun_click_drag_and_into_frame() { assert_eq!(graphic_xf(&s), placed); } +/// A place-cursor drag draws the frame at the graphic's proportions from where it started, as +/// InDesign does, shows its scale, and places exactly that frame, in any direction. +#[test] +fn place_gun_drag_previews_and_places_a_proportional_frame() { + use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; + let mut s = session(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let p = designcraft_geom::Point::new; + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::LoadedGraphic); + // Up and to the left: 80 × 200 holds the 40 × 20 graphic at 200 %, 80 × 40, hanging from the press. + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 220.0, 100.0), v).unwrap(); + assert_eq!(s.cursor(p(220.0, 100.0), Mods::default(), v), Cursor::Crosshair, "the crosshair while drawing"); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0))), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 200.00% +Y: 200.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed"); + // 80 × 1 is past the drag tolerance, but its 2 × 1 proportional frame is too small for + // `place.drop`, which places at natural size: the preview shows that frame and 100 %. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 401.0), v).unwrap(); + let natural = designcraft_geom::Rect::new(100.0, 400.0, 140.0, 420.0); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == natural)), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 100.00% +Y: 100.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), natural, "the frame previewed"); + // A drag under the threshold is a click: no frame drawn, the graphic at actual size where + // the press was. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap(); + assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet"); + assert_eq!(s.cursor(p(102.5, 501.25), Mods::default(), v), Cursor::LoadedGraphic, "the thumbnail stays"); + s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); + // With no graphic loaded there's no thumbnail to show: the crosshair, before any press too. + s.loaded = None; + s.set_tool("placeGun"); + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::Crosshair); +} + #[test] fn preflight_reports_overset_and_missing_fonts() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c982cade7..2badc3a17 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -110,6 +110,7 @@ impl Session { snap: view.snap, unit: view.unit, stroke_in_front: view.stroke_in_front, + loaded: self.loaded.map(|l| l.size), }; Some(f(self, &cx)) } diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index 5ed93879b..371718b65 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -220,6 +220,7 @@ impl<'a> Ex<'a> { .attr("Size", num(ds.size)) .attr("Spread", num(ds.spread)) .attr("Opacity", pct(ds.opacity as f64)) + .attr("BlendMode", names::blend_out(ds.mode)) .attr("EffectColor", self.sw(&ds.color)), ); } diff --git a/crates/idml/src/import/object_style.rs b/crates/idml/src/import/object_style.rs index bcdaedb66..343e67211 100644 --- a/crates/idml/src/import/object_style.rs +++ b/crates/idml/src/import/object_style.rs @@ -111,6 +111,9 @@ impl<'r> Importer<'r> { size: ds.num("Size").unwrap_or(def.size), spread: ds.num("Spread").unwrap_or(def.spread), global_light: false, + // Without the attribute (earlier DesignCraft exports) the shadow stays Normal, as it + // rendered then; a new shadow's default is Multiply. + mode: ds.get("BlendMode").map_or(designcraft_color::BlendMode::Normal, names::blend_in), }; } if let Some(f) = t.find("FeatherSetting") diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index baab1740a..0d529a30e 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3856,6 +3856,7 @@ fn object_style_effects_import_by_category_and_based_on() { let base = fx("Base").unwrap(); assert!(base.drop_shadow.on && base.drop_shadow.size == 9.0 && (base.drop_shadow.distance - 5.0).abs() < 1e-9); assert_eq!(base.feather, 0.0, "EnableFeather=false"); + assert_eq!(base.drop_shadow.mode, designcraft_color::BlendMode::Normal, "no BlendMode: Normal, as earlier exports rendered"); let child = fx("Child").unwrap(); assert_eq!((child.drop_shadow.size, child.feather), (9.0, 6.0), "settings from the BasedOn style"); assert!(fx("Off").is_none()); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 06eb4f4dd..af3c0227c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -102,19 +102,25 @@ impl Renderer { let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; - below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); + below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode)); } if e.drop_shadow.on { let d = &e.drop_shadow; - below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity)); + below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode)); } - for (off, size, pct, color, opacity) in below { + for (off, size, pct, color, opacity, mode) in below { let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; let (sigma, grow) = split(size, pct); let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow); + // The effect's blend sees what's behind the object: a layer around the filtered one. + let mix = crate::blend_mode(mode).mix; + if mix != Mix::Normal { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); @@ -128,6 +134,9 @@ impl Renderer { } c.pop_layer(); }); + if mix != Mix::Normal { + ctx.pop_layer(); + } } // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as // much as the opacity gradient says. @@ -221,7 +230,19 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal, painted); + self.inner_edge( + ctx, + f, + reach, + bp, + xf, + off, + sigma, + choke, + color_of(&c, s.opacity.clamp(0.0, 1.0)), + crate::blend_mode(s.mode).mix, + painted, + ); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on diff --git a/crates/render/tests/golden/inner-shadow.png b/crates/render/tests/golden/inner-shadow.png index c267aa386..5cba47641 100644 Binary files a/crates/render/tests/golden/inner-shadow.png and b/crates/render/tests/golden/inner-shadow.png differ diff --git a/crates/render/tests/it/effects.rs b/crates/render/tests/it/effects.rs index 92d04bc44..967856d24 100644 --- a/crates/render/tests/it/effects.rs +++ b/crates/render/tests/it/effects.rs @@ -141,6 +141,8 @@ fn outer_glow_surrounds_the_object() { g.size = 12.0; g.color = "C=0 M=100 Y=0 K=0".into(); g.opacity = 1.0; + // Normal, so the glow shows on the white page (screened, magenta on white is white). + g.mode = designcraft_color::BlendMode::Normal; let img = render(&d); golden("outer-glow", &img); for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] { @@ -150,6 +152,32 @@ fn outer_glow_surrounds_the_object() { assert_eq!(lum(img.pixel(10, 10)), WHITE); } +/// An effect's blend mode works on what's behind it: a glow screened onto a dark backdrop +/// lightens it in the glow's colour, where Normal paints the glow's colour over it. +#[test] +fn outer_glow_blends_with_its_mode() { + let glow = |mode| { + let mut d = doc(); + rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=100 M=90 Y=10 K=0"); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = mode; + render(&d) + }; + let screen = glow(designcraft_color::BlendMode::Screen); + let normal = glow(designcraft_color::BlendMode::Normal); + let backdrop = screen.pixel(10, 10); + let (s, n) = (screen.pixel(46, 80), normal.pixel(46, 80)); + // Screen only lightens: every channel at least the backdrop's, and the navy shows through + // (blue stays high), where Normal paints yellow (blue drops). + assert!(s.iter().zip(backdrop).take(3).all(|(a, b)| *a + 2 >= b), "screen lightens: {s:?} over {backdrop:?}"); + assert!(s[2] > n[2] + 25, "screen keeps the backdrop's blue: {s:?} vs normal {n:?}"); +} + #[test] fn inner_shadow_darkens_inside_the_lit_edge() { let mut d = doc(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index bb6b9b90c..57872cff9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -16,7 +16,7 @@ mod frame; mod gradient; pub mod layout; mod nav; -pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX, PLACE_DRAG_MIN}; mod pen; mod pencil; pub mod select; @@ -232,6 +232,8 @@ pub struct ToolContext<'a> { pub unit: Unit, /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). pub stroke_in_front: bool, + /// Natural size, in points, of the graphic loaded in the place cursor. + pub loaded: Option<(f64, f64)>, } impl ToolContext<'_> { diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f77c5d0bb..74a8c5074 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -133,7 +133,8 @@ impl Tool for PlaceGun { let Some(a) = self.start.take() else { return vec![] }; let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; let b = cx.layout.to_spread(sr, ev.pos); - if (ev.pos - a).hypot() < cx.tol(3.0) + let click = (ev.pos - a).hypot() < cx.tol(3.0); + if click && let Some((_, id)) = cx.hit(a) && cx .doc @@ -142,7 +143,12 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } - let r = designcraft_geom::Rect::from_points(sa, b); + // Under the drag threshold it's a click, as the overlay shows: actual size there. + if click { + return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))]; + } + // The frame the drag drew: at the graphic's proportions from where it started. + let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( "place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y, "rect": [r.x0, r.y0, r.x1, r.y1]}), @@ -151,11 +157,29 @@ impl Tool for PlaceGun { _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - match self.start { - Some(a) => vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))], - None => vec![], + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(a) = self.start else { return vec![] }; + let Some(size) = cx.loaded else { + return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; + }; + // Under the drag threshold the release is a click (actual size where it was pressed): + // no frame is drawn yet, the loaded cursor shows what lands. + if (self.cur - a).hypot() < cx.tol(3.0) { + return vec![]; } + // As InDesign: the frame the graphic will fill, and its scale beside the pointer. + let (r, k) = drawn_frame(a, self.cur, size); + let pct = format!("{:.2}%", k * 100.0); + vec![ + crate::Overlay::Marquee(r), + crate::Overlay::Measure { + p: self.cur, + text: format!( + "X: {pct} +Y: {pct}" + ), + }, + ] } fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { match key { @@ -164,11 +188,43 @@ impl Tool for PlaceGun { _ => vec![], } } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LoadedGraphic + fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). + // Under the drag threshold no frame is drawn yet (the release is a click), so the + // thumbnail stays, as the overlay does. With no graphic loaded there's no thumbnail to + // show: the crosshair throughout. + let drawing = cx.loaded.is_none() || self.start.is_some_and(|a| (self.cur - a).hypot() >= cx.tol(3.0)); + if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } +/// A dragged frame `place.drop` takes only when it is wider and taller than this, in points; +/// a thinner one places the graphic at its natural size instead. +pub const PLACE_DRAG_MIN: f64 = 2.0; + +/// The frame `place.drop` makes for a place-cursor drag from `a` to `b`: the proportional frame +/// (see [`proportional`]), or the graphic at natural size from `a` when that frame is at most +/// [`PLACE_DRAG_MIN`] in either direction. With its scale. +fn drawn_frame(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (r, k) = proportional(a, b, size); + if r.width() > PLACE_DRAG_MIN && r.height() > PLACE_DRAG_MIN { + (r, k) + } else { + (designcraft_geom::Rect::new(a.x, a.y, a.x + size.0, a.y + size.1), 1.0) + } +} + +/// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the +/// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale. +fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (w, h) = (size.0.max(1e-6), size.1.max(1e-6)); + let (dx, dy) = (b.x - a.x, b.y - a.y); + let k = (dx.abs() / w).min(dy.abs() / h); + let k = if k.is_finite() { k } else { 0.0 }; + let c = Point::new(a.x + (w * k).copysign(dx), a.y + (h * k).copysign(dy)); + (designcraft_geom::Rect::from_points(a, c), k) +} + /// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid /// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its /// proportions; double-click a page for exact sizes. diff --git a/crates/tools/src/snap/tests.rs b/crates/tools/src/snap/tests.rs index 2dfccafae..f97c637c1 100644 --- a/crates/tools/src/snap/tests.rs +++ b/crates/tools/src/snap/tests.rs @@ -64,6 +64,7 @@ fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: & snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 3c25fc4cb..9e2421db5 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -19,6 +19,7 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) snap: SnapView::OFF, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96afd1784..19b507895 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -368,6 +368,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); + if c == Cursor::LoadedGraphic { + draw_loaded_thumbnail(app, &painter, p, &t); + } } // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. @@ -376,6 +379,57 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// Longest side of the loaded place cursor's thumbnail, in points. +const LOADED_THUMB: f32 = 128.0; + +/// The loaded place cursor's thumbnail, as InDesign shows it: the graphic, at most 128 pt on its +/// longer side, hanging from the pointer, with an image badge in its corner. +fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: &Tokens) { + let (Some(l), Ok(st)) = (app.session.loaded.as_ref(), app.session.doc()) else { return }; + let (w, h) = (l.size.0 as f32, l.size.1 as f32); + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return; + } + let k = LOADED_THUMB / w.max(h); + let r = Rect::from_min_size(p + vec2(2.0, 2.0), vec2(w * k, h * k)); + if let Some(tex) = loaded_thumb_texture(painter.ctx(), &st.doc, l.asset, l.size) { + painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(225)); + } else { + // Not drawable here (an unreadable file): its frame only. + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.measure_text), StrokeKind::Inside); + } + let badge = Rect::from_min_size(r.min + vec2(6.0, 6.0), vec2(16.0, 12.0)); + painter.rect_filled(badge, 2.0, t.measure_bg); + let ink = Stroke::new(1.2, t.measure_text); + let inner = badge.shrink2(vec2(3.0, 2.5)); + painter.rect_stroke(inner, 0.5, ink, StrokeKind::Middle); + painter.add(egui::Shape::line( + vec![pos2(inner.min.x, inner.max.y), pos2(inner.min.x + 3.5, inner.min.y + 2.5), pos2(inner.max.x, inner.max.y)], + ink, + )); +} + +/// The loaded graphic's thumbnail texture, cached per image data. +fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_doc::AssetId, size: (f64, f64)) -> Option { + let a = doc.assets.get(&asset)?; + let key = egui::Id::unique(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); + if let Some(t) = ctx.data(|d| d.get_temp::(key)) { + return Some(t); + } + // Twice the thumbnail's size in pixels, for high-density screens. + let scale = (2.0 * f64::from(LOADED_THUMB) / size.0.max(size.1)).min(1.0); + let pm = designcraft_render::images::mip(&a.data, a.page, size.0, scale)?; + let (pw, ph) = (pm.width() as usize, pm.height() as usize); + if pw == 0 || ph == 0 { + return None; + } + let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice()); + let max = ctx.input(|i| i.max_texture_side).min(1024); + let t = ctx.load_texture("loaded_thumb", crate::widgets::fit_texture(ci, max), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(key, t.clone())); + Some(t) +} + /// Presses in a row on the canvas: the last press's count, and where and when it was released /// (`at` is −∞ while the button is down). #[derive(Clone, Copy, Debug)] diff --git a/crates/ui-egui/src/canvas/column_guides.rs b/crates/ui-egui/src/canvas/column_guides.rs index 34d995fda..c15595442 100644 --- a/crates/ui-egui/src/canvas/column_guides.rs +++ b/crates/ui-egui/src/canvas/column_guides.rs @@ -106,6 +106,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, unit: app.ui.units, stroke_in_front: false, + loaded: None, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap( diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index eebd41208..4689b5fb6 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -143,6 +143,7 @@ pub(super) fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option bool { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, + loaded: None, }; cx.hit(xf.to_canvas(p)).is_some() } diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 3e2f32659..4616ca11d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -337,9 +337,12 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui:: events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } + // The place cursor draws a frame from 3 points (its own threshold), before egui starts a + // drag (at `max_click_dist`): follow the pointer from there, so a release between the two + // places the frame that was previewed. + let place_drag = app.session.tool_id() == "placeGun" && origin.zip(latest).is_some_and(|(o, p)| (o - p).length() >= 3.0); if down - && resp.dragged() - && resp.drag_delta() != egui::Vec2::ZERO + && ((resp.dragged() && resp.drag_delta() != egui::Vec2::ZERO) || place_drag) && let Some(p) = latest { events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 7d89a24a8..e137a8a03 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3,6 +3,57 @@ use egui_kittest::{Harness, kittest::Queryable}; use super::*; +/// A place-cursor drag past its 3-point threshold but short of egui's drag distance previews +/// a frame, and the release places that frame: what's shown is what lands. +#[test] +fn a_short_place_cursor_drag_places_the_frame_it_previews() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("place.load", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let (from, slot) = { + let app = h.state(); + let layout = CanvasLayout::new(&app.session.doc().unwrap().doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + (xf.to_screen(layout.slots[0].xf * Point::new(200.0, 200.0)), layout.slots[0].xf) + }; + // About 5.4 points: past the place cursor's 3, short of egui's 6. + let to = from + vec2(4.5, 3.0); + assert!((to - from).length() < h.ctx.options(|o| o.input_options.max_click_dist)); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(2); + let vi = h.state().view_info(); + let preview = h + .state_mut() + .session + .overlays(vi) + .into_iter() + .find_map(|o| match o { + designcraft_tools::Overlay::Marquee(r) => Some(r), + _ => None, + }) + .expect("the frame is previewed"); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); + let shown = slot.inverse().transform_rect_bbox(preview); + for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { + assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); + } +} + #[test] fn a_press_after_a_drag_is_not_a_double_click() { let (delay, dist) = (0.3, 6.0); @@ -1772,6 +1823,7 @@ fn document_grid_is_drawn_where_points_snap_to_it() { snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, unit: Unit::Points, stroke_in_front: false, + loaded: None, }; let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0)); diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 80b957286..0d27513d2 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -409,6 +409,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -450,23 +451,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { - // Inner Glow: its blend mode (Screen, as InDesign starts it). - if key == "innerGlow" { - row(ui, "Mode:"); - let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["mode"].as_str()).unwrap_or("Screen").to_string(); - let label = |m: &designcraft_color::BlendMode| { - serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() - }; - let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label()); - craft_ui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { - for m in designcraft_color::BlendMode::ALL { - if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() { - set(d, &["innerGlow", "mode"], json!(label(&m))); - } - } - }); - ui.end_row(); - } + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -656,6 +641,28 @@ fn summary(lang: &str, d: &Dialog) -> String { format!("{}: {} {:.0}%; {}", crate::i18n::tr(lang, "OBJECT").to_uppercase(), crate::i18n::tr(lang, blend), opacity * 100.0, effects) } +/// The Mode row of a shadow's or glow's Blending: its blend mode, as InDesign's (shadows start +/// Multiply, glows Screen). +fn mode_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, tr: &dyn Fn(&str) -> String) { + crate::rtl::label(ui, tr("Mode:")); + let fallback = if key.ends_with("Glow") { "Screen" } else { "Multiply" }; + let cur = d.fields.get("fx").and_then(|f| f[key]["mode"].as_str()).unwrap_or(fallback).to_string(); + let name = |m: &designcraft_color::BlendMode| serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let shown = designcraft_color::BlendMode::ALL.iter().find(|m| name(m) == cur).map_or(fallback, |m| m.label()); + let mut picked = None; + craft_ui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { + for m in designcraft_color::BlendMode::ALL { + if ui.selectable_label(name(&m) == cur, tr(m.label())).clicked() { + picked = Some(name(&m)); + } + } + }); + if let Some(m) = picked { + set(d, &[key, "mode"], json!(m)); + } + ui.end_row(); +} + /// The dialog's body. pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); @@ -904,6 +911,30 @@ mod tests { assert_eq!(wrap_angle(540.0), 180.0); } + /// Drop Shadow and Outer Glow have InDesign's Mode under Blending (Multiply and Screen to + /// start); a mode picked there is what OK applies. + #[test] + fn shadows_and_glows_have_a_blend_mode() { + let (mut app, id) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open(&mut app, "dropShadow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Mode:") && texts.iter().any(|x| x.trim() == "Multiply"), "{texts:?}"); + crate::dialogs::cancel(&mut app); + open(&mut app, "outerGlow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Screen"), "{texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + set(d, &["outerGlow", "mode"], json!("Normal")); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let fx = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().effects.clone(); + assert!(fx.outer_glow.on); + assert_eq!(fx.outer_glow.mode, designcraft_color::BlendMode::Normal); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index c934d6b19..680391e03 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,6 +11,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); + // Another document (switched to, or moved to this one's place by a close) drops a rename in + // place: a swatch of the same name there isn't the one being renamed. + let uid = st.uid; + if ui.data(|d| d.get_temp::(egui::Id::unique(RENAME_DOC))) != Some(uid) { + ui.data_mut(|d| { + d.remove_temp::<(String, String)>(egui::Id::unique(RENAMING)); + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAME_DOC), uid); + }); + } // With nothing selected, the proxy, Tint field and highlighted row are those of new shapes. let target = super::color_target_or_defaults(app); header(app, ui, &doc, target.as_ref()); @@ -155,6 +165,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// Temp-data key of the swatch last clicked (Delete Swatch removes it). const PICKED: &str = "swatches_picked"; +/// Temp-data key of the swatch renamed in place and its name being typed. +const RENAMING: &str = "swatches_renaming"; +/// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a +/// double-click (Swatch Options), it starts renaming. +const RENAME_CLICK: &str = "swatches_rename_click"; +/// Temp-data key of the document (its uid) the panel last showed: a rename belongs to it. +const RENAME_DOC: &str = "swatches_rename_doc"; /// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), /// Formatting Affects Container / Text, and the tint of the colour in front. `target` is @@ -244,6 +261,47 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { }) } +/// The name field of a swatch being renamed in place, over `rect`; false when `sw` isn't. Enter or +/// a click elsewhere renames it (`swatch.options`), Escape leaves it as it was. +fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color::swatch::Swatch, rect: egui::Rect) -> bool { + let key = egui::Id::unique(RENAMING); + let Some((name, mut text)) = ui.data(|d| d.get_temp::<(String, String)>(key)).filter(|(n, _)| *n == sw.name) else { return false }; + let id = egui::Id::unique(("swatch_rename", &sw.name)); + let edit = egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(12.5)).margin(vec2(2.0, 1.0)); + let resp = ui.put(rect, edit); + if !resp.has_focus() && !resp.lost_focus() { + // The field opens with the name selected. + resp.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); + } + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove_temp::<(String, String)>(key)); + let to = text.trim(); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name { + match app.run("swatch.options", json!({"name": name, "to": to})) { + // Still picked under its new name, unless the click that ended the rename picked + // another swatch. + Ok(_) => ui.data_mut(|d| { + if d.get_temp::(egui::Id::unique(PICKED)).is_none_or(|p| p == name) { + d.insert_temp(egui::Id::unique(PICKED), to.to_string()); + } + }), + Err(e) => { + app.status_error("Swatch Options", e); + } + } + } + } else { + ui.data_mut(|d| d.insert_temp(key, (name, text))); + } + true +} + /// The swatch's chip following the pointer while the row is dragged. fn paint_drag_chip(ui: &egui::Ui, doc: &designcraft_doc::Document, name: &str, p: egui::Pos2) { let t = Tokens::get(ui.ctx()); @@ -436,7 +494,10 @@ fn swatch_row( let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); - ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + let name_rect = egui::Rect::from_min_max(row.min + vec2(26.0 + indent, 1.0), egui::pos2(row.max.x - 72.0, row.max.y - 1.0)); + if !rename_field(app, ui, sw, name_rect) { + ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + } // InDesign's columns on the right: not editable, colour type, colour mode. let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0)); if marks.not_editable { @@ -457,13 +518,37 @@ fn swatch_row( ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(10.5), t.text_dim); } if resp.clicked() && !resp.double_clicked() { + // A click on the name of the swatch picked already renames it in place, as InDesign's + // (a slow double-click), unless it turns into a double-click. + let picked = ui.data(|d| d.get_temp::(egui::Id::unique(PICKED))).is_some_and(|p| p == sw.name); + // A click on another swatch cancels a rename still waiting. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); + if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) { + let now = ui.input(|i| i.time); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME_CLICK), (sw.name.clone(), now))); + } ui.data_mut(|d| d.insert_temp(egui::Id::unique(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } // Double-click: Swatch Options (the first click has applied the swatch). if resp.double_clicked() { + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); } + if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { + let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); + if ui.data(|d| d.get_temp::(egui::Id::unique(PICKED))).is_none_or(|p| p != name) { + // Another swatch picked since: no rename. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); + } else if ui.input(|i| i.time) - at > wait { + ui.data_mut(|d| { + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAMING), (name.clone(), name)); + }); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))).clicked() { let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); @@ -1608,6 +1693,150 @@ mod swatches_header_tests { assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); } + /// A rename in place belongs to its document: with another one active, a swatch of the same + /// name there gets no field, and nothing is renamed. + #[test] + fn a_rename_in_place_ends_with_its_document() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let color = json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}}); + let name = app.run("swatch.create", color.clone()).unwrap()["name"].as_str().unwrap().to_string(); + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("rename_test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + super::show(app, ui); + }, + app, + ); + h.run_steps(3); + let fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count(); + let renaming = egui::Id::unique(super::RENAMING); + h.ctx.data_mut(|d| d.insert_temp(renaming, (name.clone(), "Typed".to_string()))); + h.run_steps(2); + assert!(h.query_all_by_role(egui::accesskit::Role::TextInput).count() > fields, "the name is a field"); + // Another document with a swatch of the same name. + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("swatch.create", color).unwrap(); + h.run_steps(3); + assert!(h.ctx.data(|d| d.get_temp::<(String, String)>(renaming)).is_none(), "the rename is dropped"); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.swatch(&name).is_some() && doc.swatch("Typed").is_none()); + } + + /// The course's chapter 23: a swatch added as "C=14 M=8 Y=95 K=0" is renamed in the panel by + /// clicking it, then clicking its name again: the name becomes a field, and Enter renames the + /// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name, + /// and [Black] can't be renamed. + #[test] + fn a_second_click_on_the_picked_swatch_renames_it_in_place() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + let added = app.run("swatch.create", json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(added, "C=14 M=8 Y=95 K=0"); + // Frames 50 ms apart: the double-click time is six of them. + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + // The panel's own fields (Tint); renaming adds one. + let fields = h.query_all_by_role(Role::TextInput).count(); + let field = |h: &Harness<'_, DesignApp>| h.query_all_by_role(Role::TextInput).count() > fields; + let has = |h: &Harness<'_, DesignApp>, n: &str| h.state().session.doc().unwrap().doc.swatch(n).is_some(); + let name_at = |h: &Harness<'_, DesignApp>, name: &str| { + let row = h.query_all_by(|n| n.label().is_some_and(|l| names_swatch(&l, name))).next().expect("the swatch's row"); + row.rect().left_center() + vec2(40.0, 0.0) + }; + let click = |h: &mut Harness<'_, DesignApp>, at: egui::Pos2| { + h.hover_at(at); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); + }; + // A click on the name: applied, and picked. + let at = name_at(&h, &added); + click(&mut h, at); + h.run_steps(10); + assert_eq!(items(h.state())[0].fill.swatch, added); + assert!(!field(&h)); + // Again, after the double-click time: the name is a field, with the name selected. + click(&mut h, at); + h.run_steps(10); + assert!(field(&h), "the name is a field"); + h.event(egui::Event::Text("Lemon Yellow Sun".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !has(&h, &added)); + assert_eq!(items(h.state())[0].fill.swatch, "Lemon Yellow Sun", "what uses it follows"); + // Escape keeps the name. + let at = name_at(&h, "Lemon Yellow Sun"); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Sour".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !field(&h)); + // A double-click on it opens Swatch Options, and doesn't rename. + h.run_steps(20); + click(&mut h, at); + click(&mut h, at); + h.run_steps(10); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("swatchOptions")); + assert!(!field(&h), "no rename"); + // [Black] keeps its name. + h.state_mut().ui.dialog = None; + h.run_steps(20); + let at = name_at(&h, "[Black]"); + click(&mut h, at); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(!field(&h)); + // A click on another swatch while the rename waits: that one is picked, and nothing + // renames. + let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::unique(super::PICKED))); + let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]")); + h.run_steps(20); + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + click(&mut h, paper); + h.run_steps(10); + assert!(!field(&h), "the waiting rename is cancelled"); + assert_eq!(picked(&h).as_deref(), Some("[Paper]")); + // Renaming, a click on another swatch renames, and picks that one (for Delete Swatch). + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Lemon".into())); + h.step(); + click(&mut h, paper); + h.run_steps(5); + assert!(has(&h, "Lemon") && !field(&h)); + assert_eq!(picked(&h).as_deref(), Some("[Paper]"), "the swatch clicked stays picked"); + } + #[test] fn the_tint_field_keeps_each_objects_own_swatch() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());