From 66ab252044b023643ff2de79ed62277f664cbbfe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:15:01 -0400 Subject: [PATCH] F: Gradient Swatch tool, Gradient panel, unnamed gradients (object.gradient) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 6 +- crates/engine/src/cmd/object.rs | 61 ++++++++- crates/engine/src/cmd/style.rs | 106 ++++++++++++++++ crates/engine/src/tests_idml.rs | 16 +++ crates/idml/src/export.rs | 7 +- crates/idml/src/import.rs | 7 ++ crates/pdf/src/export.rs | 19 ++- crates/render/src/lib.rs | 16 ++- crates/tools/src/gradient.rs | 85 +++++++++++++ crates/tools/src/lib.rs | 2 + crates/tools/src/tests.rs | 25 ++++ crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/panels/swatches.rs | 172 ++++++++++++++++++++++++++ docs/parity.md | 8 +- 15 files changed, 513 insertions(+), 20 deletions(-) create mode 100644 crates/tools/src/gradient.rs diff --git a/ROADMAP.md b/ROADMAP.md index 766dec894..d7b87c3c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index ce2092ccd..4507c4a79 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -18,6 +18,10 @@ pub struct Fill { /// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds). #[serde(skip_serializing_if = "Option::is_none")] pub gradient_angle: Option, + /// Gradient Swatch tool vector in the item's own space: start and end of a linear gradient, + /// centre and radius point of a radial one (`None` = fit to bounds at `gradient_angle`). + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, pub overprint: bool, } @@ -29,7 +33,7 @@ impl Default for Fill { impl Fill { pub fn none() -> Self { - Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, overprint: false } + Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } } pub fn swatch(name: &str) -> Self { Fill { swatch: name.into(), ..Fill::none() } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index baa99fe94..c5ad4fb71 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -610,7 +610,7 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { } else { // Resize the frame's path (in inner space); content keeps its size (InDesign's default). let inner = it.xf.inverse() * m * it.xf; - it.path.transform(inner); + bake(it, inner); } } Ok(json!({"resized": ids.len()})) @@ -621,6 +621,15 @@ fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect { ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } +/// Transform an item's geometry in its own space: the path and the gradient vector with it. +fn bake(it: &mut Item, inner: Affine) { + it.path.transform(inner); + if let Some([x0, y0, x1, y1]) = it.fill.gradient_vector { + let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1)); + it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + } +} + /// Multiply the stroke weights of an item and everything nested in it. fn scale_weights(it: &mut Item, k: f64) { it.stroke.weight *= k; @@ -640,14 +649,14 @@ pub(crate) fn scale_item(it: &mut Item, m: Affine, k: f64, strokes: bool) { let inner = it.xf.inverse() * m * it.xf; match &mut it.content { Content::Unassigned => { - it.path.transform(inner); + bake(it, inner); if strokes { it.stroke.weight *= k; } } Content::Graphic(g) => { g.xf = inner * g.xf; - it.path.transform(inner); + bake(it, inner); if strokes { it.stroke.weight *= k; } @@ -737,7 +746,7 @@ fn transform_set(s: &mut Session, p: &Value) -> Result { it.xf = m * it.xf; } else { let inner = it.xf.inverse() * m * it.xf; - it.path.transform(inner); + bake(it, inner); } } } @@ -1258,3 +1267,47 @@ mod scale_tests { assert!((bounds(&s).width() - 120.0).abs() < 1e-9); } } + +#[cfg(test)] +mod gradient_tests { + use super::*; + + #[test] + fn gradient_tool_vector_and_unnamed_gradients() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let swatches = |s: &Session| s.doc().unwrap().doc.swatches.len(); + let n = swatches(&s); + // The default White→Black gradient, dragged from the left edge to the middle. + let r = s.execute("object.gradient", &json!({"from": [100, 150], "to": [200, 150]})).unwrap(); + assert_eq!(r["kind"], "linear"); + assert_eq!(r["stops"][1]["color"], "#000000"); + assert_eq!(swatches(&s), n + 1, "an unnamed gradient"); + let fill = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().fill.clone(); + assert_eq!(fill(&s).gradient_vector, Some([100.0, 150.0, 200.0, 150.0])); + // Same gradient again: no new swatch. Reverse / radial: a new unnamed gradient. + s.execute("object.gradient", &json!({})).unwrap(); + assert_eq!(swatches(&s), n + 1); + let r = s.execute("object.gradient", &json!({"kind": "radial", "reverse": true})).unwrap(); + assert_eq!(r["kind"], "radial"); + assert_eq!(r["stops"][0]["color"], "#000000"); + let r = s + .execute( + "object.gradient", + &json!({"stops": [{"location": 0, "color": "#ff0000"}, {"location": 60, "color": "#0000ff", "midpoint": 30}]}), + ) + .unwrap(); + assert_eq!(r["stops"][1]["location"], 60.0); + assert_eq!(r["stops"][1]["midpoint"], 30.0); + assert!(s.execute("object.gradient", &json!({"stops": [{"location": 0, "color": "#ff0000"}]})).is_err()); + // Scaling moves the vector with the shape. + s.execute("transform.scale", &json!({"sx": 2.0})).unwrap(); + assert_eq!(fill(&s).gradient_vector, Some([0.0, 150.0, 200.0, 150.0])); + // An angle replaces the vector. + s.execute("object.gradient", &json!({"angle": 90})).unwrap(); + assert_eq!(fill(&s).gradient_vector, None); + assert_eq!(fill(&s).gradient_angle, Some(90.0)); + } +} diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 7d0582eb7..f1cb901f4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -51,6 +51,15 @@ pub fn specs() -> Vec { has_doc, create_swatch ), + cmd!( + "object.gradient", + "Gradient", + [], + None, + "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", + super::has_selection, + apply_gradient + ), cmd!( "object.color", "Apply Color", @@ -380,6 +389,103 @@ fn value_name(color: designcraft_color::Color) -> String { } } +fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value { + let stops: Vec = g + .stops + .iter() + .map(|s| json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()})) + .collect(); + json!({"swatch": name, "kind": if g.kind == designcraft_color::GradientKind::Radial { "radial" } else { "linear" }, "stops": stops}) +} + +fn apply_gradient(s: &mut Session, p: &Value) -> Result { + use designcraft_color::{GradientKind, GradientStop}; + let kind = match str_param(p, "kind") { + Some("radial") => Some(GradientKind::Radial), + Some("linear") => Some(GradientKind::Linear), + Some(k) => return Err(bad("object.gradient", format!("unknown kind {k}"))), + None => None, + }; + let stops = match p.get("stops").and_then(Value::as_array) { + Some(a) => { + let mut v = Vec::new(); + for st in a { + let color = st.get("color").and_then(parse_color).ok_or_else(|| bad("object.gradient", "a stop needs a color"))?; + let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; + v.push(GradientStop { + offset: pct("location", 0.0), + color, + opacity: pct("opacity", 100.0), + midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), + }); + } + if v.len() < 2 { + return Err(bad("object.gradient", "a gradient needs at least two stops")); + } + Some(v) + } + None => None, + }; + let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let angle = p.get("angle").and_then(Value::as_f64); + let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to")); + let ids = super::targets(s, p)?; + s.edit(|d, _| { + let mut out = Value::Null; + for id in &ids { + let Some(it) = d.item(*id) else { continue }; + let cur_name = it.fill.swatch.clone(); + let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); + let mut g = cur.clone().unwrap_or_default(); + if let Some(k) = kind { + g.kind = k; + } + if let Some(st) = &stops { + g.stops = st.clone(); + g.sort(); + } + if reverse { + g.reverse(); + } + // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch + // or add an unnamed one. + let name = if cur.as_ref() == Some(&g) { + cur_name + } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) { + w.name.clone() + } else { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient"); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Gradient { gradient: g.clone() }, + locked: false, + named: false, + hidden: true, + }); + name + }; + let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); + let Some(it) = d.item_mut(*id) else { continue }; + it.fill.swatch = name.clone(); + it.fill.tint = 1.0; + if let Some(a) = angle { + it.fill.gradient_angle = Some(a); + it.fill.gradient_vector = None; + } + if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { + let (a, b) = (inv * a, inv * b); + if (b - a).hypot() > 1e-6 { + it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + } + } + if out.is_null() { + out = gradient_json(&name, &g); + } + } + Ok(out) + }) +} + fn apply_color(s: &mut Session, p: &Value) -> Result { let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; let stroke = str_param(p, "target") == Some("stroke"); diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 976314424..cbd06d265 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -115,3 +115,19 @@ fn idml_commands() { assert_eq!(s.doc().unwrap().doc.title, "mag"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn gradient_vector_round_trips_through_idml() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.gradient", &json!({"from": [120, 180], "to": [280, 120]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let v = d.item(ItemId(id)).unwrap().fill.gradient_vector.unwrap(); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap(); + let it = back.spreads.iter().flat_map(|sp| sp.items.iter()).find(|i| i.fill.gradient_vector.is_some()).expect("a gradient item"); + let w = it.fill.gradient_vector.unwrap(); + for k in 0..4 { + assert!((v[k] - w[k]).abs() < 1e-3, "{v:?} vs {w:?}"); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 00b175a6e..1b5f1e3e3 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1141,7 +1141,12 @@ impl<'a> Ex<'a> { el.set("FillColor", self.sw(&it.fill.swatch)); el.set("FillTint", pct(it.fill.tint as f64)); el.set("OverprintFill", bool_s(it.fill.overprint)); - if let Some(a) = it.fill.gradient_angle { + if let Some([x0, y0, x1, y1]) = it.fill.gradient_vector { + // Gradient Swatch tool vector: start, length and angle in the item's space. + el.set("GradientFillStart", format!("{} {}", num(x0), num(y0))); + el.set("GradientFillLength", num((x1 - x0).hypot(y1 - y0))); + el.set("GradientFillAngle", num((-(y1 - y0)).atan2(x1 - x0).to_degrees())); + } else if let Some(a) = it.fill.gradient_angle { el.set("GradientFillAngle", num(a)); } self.stroke_attrs(&mut el, &it.stroke); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 07beb2fd8..9ce4d7926 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1498,6 +1498,13 @@ impl<'r> Importer<'r> { { it.fill.gradient_angle = Some(a); } + let start: Option> = e.prop("GradientFillStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength")) + && len > 0.0 + { + let a = e.num("GradientFillAngle").unwrap_or(0.0).to_radians(); + it.fill.gradient_vector = Some([x, y, x + len * a.cos(), y - len * a.sin()]); + } it.stroke = self.stroke_from(e, Some(e)); if it.stroke.weight <= 0.0 { it.stroke.swatch = swatch::NONE.into(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 5229bb806..06eebd75d 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -425,7 +425,7 @@ impl Exporter<'_> { s.push_transform(&tf(xf)); if !it.fill.is_none() && let Some(p) = &path - && let Some(paint) = self.fill_paint(&it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) + && let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box()) { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); @@ -491,7 +491,7 @@ impl Exporter<'_> { if !it.fill.is_none() && it.path.is_closed() && let Some(p) = &path - && let Some(paint) = self.fill_paint(&it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) + && let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box()) { if it.fill.overprint { self.warn("overprint is not exported yet"); @@ -545,7 +545,8 @@ impl Exporter<'_> { } /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. - fn fill_paint(&self, swatch: &str, tint: f32, bounds: Rect, angle: Option) -> Option { + fn fill_paint(&self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let mut stops: Vec = Vec::new(); let mut last = 0.0f32; @@ -558,9 +559,14 @@ impl Exporter<'_> { return None; } let c = bounds.center(); + let v = fill.gradient_vector; return Some(match g.kind { GradientKind::Radial => { - let r = (bounds.width().max(bounds.height()) / 2.0).max(1e-3) as f32; + let (c, r) = match v { + Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()), + None => (c, bounds.width().max(bounds.height()) / 2.0), + }; + let r = r.max(1e-3) as f32; let (cx, cy) = (c.x as f32, c.y as f32); RadialGradient { fx: cx, @@ -580,7 +586,10 @@ impl Exporter<'_> { let a = angle.unwrap_or(0.0).to_radians(); let half = ((bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0).max(1e-3); let d = Vec2::new(a.cos(), -a.sin()) * half; - let (p0, p1) = (c - d, c + d); + let (p0, p1) = match v { + Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), designcraft_geom::Point::new(x1, y1)), + None => (c - d, c + d), + }; LinearGradient { x1: p0.x as f32, y1: p0.y as f32, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9c2f8bca6..16b17a192 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -351,7 +351,7 @@ impl Renderer { } if !it.fill.is_none() { ctx.set_transform(f.view * xf); - if set_fill_paint(ctx, f.doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) { + if set_fill_paint(ctx, f.doc, &it.fill, bp.bounding_box()) { ctx.fill_path(&bp); } ctx.reset_paint_transform(); @@ -430,7 +430,7 @@ impl Renderer { // Fill. if !it.fill.is_none() { ctx.set_transform(f.view * xf); - if set_fill_paint(ctx, doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) && it.path.is_closed() { + if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) && it.path.is_closed() { ctx.fill_path(bp); } ctx.reset_paint_transform(); @@ -563,15 +563,21 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { } /// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown. -fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, swatch: &str, tint: f32, bounds: Rect, angle: Option) -> bool { +fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool { + let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { let stops: Vec = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { return false; } let c = bounds.center(); - let grad = match g.kind { - designcraft_color::GradientKind::Radial => { + let grad = match (g.kind, fill.gradient_vector) { + (designcraft_color::GradientKind::Radial, Some([x0, y0, x1, y1])) => { + let r = Vec2::new(x1 - x0, y1 - y0).hypot().max(1e-3); + peniko::Gradient::new_radial((x0, y0), r as f32).with_stops(stops.as_slice()) + } + (_, Some([x0, y0, x1, y1])) => peniko::Gradient::new_linear((x0, y0), (x1, y1)).with_stops(stops.as_slice()), + (designcraft_color::GradientKind::Radial, None) => { let r = bounds.width().max(bounds.height()) / 2.0; peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice()) } diff --git a/crates/tools/src/gradient.rs b/crates/tools/src/gradient.rs new file mode 100644 index 000000000..bf0e2a880 --- /dev/null +++ b/crates/tools/src/gradient.rs @@ -0,0 +1,85 @@ +//! Gradient Swatch tool (G): drag across the selected objects (or the object under the pointer) +//! to set where their gradient fill starts and ends; Shift constrains the angle to 45° steps. + +use designcraft_geom::{Point, Vec2}; +use serde_json::json; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; + +#[derive(Default)] +pub struct GradientTool { + /// Canvas start point, spread-space start point and the target ids. + drag: Option<(Point, Point, Vec)>, + end: Option, +} + +fn constrain(a: Point, b: Point, m: Mods) -> Point { + if !m.shift { + return b; + } + let v = b - a; + let step = std::f64::consts::FRAC_PI_4; + let ang = (v.y.atan2(v.x) / step).round() * step; + a + Vec2::new(ang.cos(), ang.sin()) * v.hypot() +} + +impl Tool for GradientTool { + fn id(&self) -> &'static str { + "gradientSwatch" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + let ids: Vec = if cx.selection.items.is_empty() { + cx.hit(ev.pos).map(|(_, id)| vec![cx.doc.top_level_of(id).unwrap_or(id).0]).unwrap_or_default() + } else { + cx.selection.items.iter().map(|i| i.0).collect() + }; + let Some((_, sp)) = cx.layout.spread_at(ev.pos) else { return vec![] }; + if ids.is_empty() { + return vec![]; + } + self.drag = Some((ev.pos, sp, ids)); + self.end = None; + vec![Action::Begin("Gradient".into())] + } + PointerKind::Drag => { + let Some((start, sp, ids)) = &self.drag else { return vec![] }; + let end = constrain(*start, ev.pos, ev.mods); + self.end = Some(end); + if (end - *start).hypot() < cx.tol(2.0) { + return vec![]; + } + let to = *sp + (end - *start); + vec![Action::Preview("object.gradient".into(), json!({"ids": ids, "from": [sp.x, sp.y], "to": [to.x, to.y]}))] + } + PointerKind::Up => { + let moved = self.end.zip(self.drag.as_ref()).is_some_and(|(e, (s, _, _))| (e - *s).hypot() >= cx.tol(2.0)); + let was = self.drag.take().is_some(); + self.end = None; + match (was, moved) { + (true, true) => vec![Action::Commit], + (true, false) => vec![Action::Cancel], + _ => vec![], + } + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + match (&self.drag, self.end) { + (Some((a, _, _)), Some(b)) => vec![Overlay::Line { a: *a, b, color: [0, 0, 0], dashed: false }], + _ => vec![], + } + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + + fn busy(&self) -> bool { + self.drag.is_some() + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 10822695b..2fcb5f583 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -10,6 +10,7 @@ pub mod catalog; mod frame; +mod gradient; pub mod layout; mod nav; mod pen; @@ -257,6 +258,7 @@ pub fn create(id: &str) -> Box { "rectangleFrame" | "ellipseFrame" | "polygonFrame" | "rectangle" | "ellipse" | "polygon" | "line" => Box::new(frame::FrameTool::new(id)), "type" => Box::new(text::TypeTool::default()), "pen" => Box::new(pen::PenTool::default()), + "gradientSwatch" => Box::new(gradient::GradientTool::default()), "rotate" => Box::new(xform::XformTool::new("rotate")), "scale" => Box::new(xform::XformTool::new("scale")), "shear" => Box::new(xform::XformTool::new("shear")), diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index e6b20005b..c944f4da0 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -79,3 +79,28 @@ fn cmd_shift_click_overrides_parent_item() { let a = t.pointer(&cx, &ev); assert_eq!(a, vec![Action::Exec("layout.overrideParentItems".into(), serde_json::json!({"page": 0, "ids": [pid.0]}))]); } + +#[test] +fn gradient_tool_drag_sets_the_vector() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "", ParaFormat::default()).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.offset(SpreadRef::Doc(0)); + let mut t = create("gradientSwatch"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 150.0 + off.y)), vec![Action::Begin("Gradient".into())]); + // Shift snaps a slightly tilted drag to horizontal. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 220.0 + off.x, 153.0 + off.y).with_mods(Mods { shift: true, ..Default::default() })); + match &a[0] { + Action::Preview(cmd, p) => { + assert_eq!(cmd, "object.gradient"); + assert_eq!(p["ids"], serde_json::json!([fid.0])); + assert_eq!(p["from"], serde_json::json!([120.0, 150.0])); + let to = p["to"].as_array().unwrap(); + assert!((to[1].as_f64().unwrap() - 150.0).abs() < 1e-9, "{to:?}"); + } + other => panic!("{other:?}"), + } + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 220.0 + off.x, 153.0 + off.y)), vec![Action::Commit]); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d615f418..6dd86634e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -15,6 +15,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("stroke", "Stroke", "panel-stroke"), ("swatches", "Swatches", "panel-swatches"), ("color", "Color", "panel-color"), + ("gradient", "Gradient", "tool-gradient"), ("effects", "Effects", "panel-effects"), ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), @@ -126,6 +127,7 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { "textWrap" => panels::properties::wrap_panel(app, ui), "align" => panels::properties::align_panel(app, ui), "color" => panels::swatches::color_panel(app, ui), + "gradient" => panels::swatches::gradient_panel(app, ui), "effects" => panels::properties::effects_panel(app, ui), "links" => panels::properties::links_panel(app, ui), "preflight" => panels::properties::preflight_panel(app, ui), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index dbcfd22fe..4e5465944 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -264,3 +264,175 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { let _ = app.run("object.color", json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target})); } } + +/// Stops in `object.gradient` form. +fn stops_json(g: &designcraft_color::Gradient) -> Vec { + g.stops + .iter() + .map(|s| json!({"location": s.offset as f64 * 100.0, "color": s.color.to_hex(), "opacity": s.opacity as f64 * 100.0, "midpoint": s.midpoint as f64 * 100.0})) + .collect() +} + +/// Gradient panel: type, angle, reverse, and the ramp with its stops (drag to move, click below +/// the ramp to add, drag off to remove; the selected stop's location and colour are editable). +pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let Some(it) = st.selection.items.first().and_then(|i| st.doc.item(*i)) else { + ui.label(egui::RichText::new("Select an object to give its fill a gradient.").color(t.text_dim)); + return; + }; + let fill = it.fill.clone(); + let Some(g) = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned() else { + ui.label(egui::RichText::new("The fill isn't a gradient.").color(t.text_dim)); + if ui.button("Apply Gradient").clicked() { + let _ = app.run("object.gradient", json!({})); + } + return; + }; + let radial = g.kind == designcraft_color::GradientKind::Radial; + ui.horizontal(|ui| { + crate::widgets::caption(ui, "Type:"); + egui::ComboBox::from_id_salt("grad_type").selected_text(g.kind.label()).width(100.0).show_ui(ui, |ui| { + for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { + if ui.selectable_label(g.kind.label() == k, k).clicked() { + let _ = app.run("object.gradient", json!({"kind": v})); + } + } + }); + if ui.button("Reverse").on_hover_text("Reverse the gradient").clicked() { + let _ = app.run("object.gradient", json!({"reverse": true})); + } + }); + let sel_id = egui::Id::new("grad_sel"); + let drag_id = egui::Id::new("grad_drag"); + let mut sel: usize = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1)); + ui.horizontal(|ui| { + crate::widgets::caption(ui, "Location:"); + let loc = g.stops.get(sel).map(|s| s.offset as f64 * 100.0); + if let Some(v) = crate::widgets::number(ui, "grad_loc", loc, " %", 54.0, 1) { + let mut stops = stops_json(&g); + stops[sel]["location"] = json!(v.clamp(0.0, 100.0)); + let _ = app.run("object.gradient", json!({"stops": stops})); + } + if !radial { + crate::widgets::caption(ui, "Angle:"); + let angle = fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0); + if let Some(v) = crate::widgets::number(ui, "grad_angle", Some(angle), "°", 54.0, 1) { + let _ = app.run("object.gradient", json!({"angle": v})); + } + } + }); + ui.add_space(4.0); + // The ramp, with stop markers below it. + let w = ui.available_width().min(240.0); + let (area, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click_and_drag()); + let ramp = egui::Rect::from_min_size(area.min + vec2(6.0, 0.0), vec2(w - 12.0, 18.0)); + let painter = ui.painter(); + let n = 64; + for i in 0..n { + let (a, b) = (i as f32 / n as f32, (i + 1) as f32 / n as f32); + let (c, alpha) = g.sample((a + b) / 2.0); + let [r, gg, bb] = c.to_rgb(); + let col = egui::Color32::from_rgba_unmultiplied((r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8, (alpha * 255.0) as u8); + let x0 = ramp.left() + ramp.width() * a; + let x1 = ramp.left() + ramp.width() * b + 0.5; + painter.rect_filled(egui::Rect::from_x_y_ranges(x0..=x1, ramp.y_range()), 0.0, col); + } + painter.rect_stroke(ramp, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); + // A stop being dragged: (index, location 0–1, off the ramp). + let mut dragging: Option<(usize, f32, bool)> = ui.data(|d| d.get_temp(drag_id)); + let x_of = |o: f32| ramp.left() + ramp.width() * o; + let o_of = |x: f32| ((x - ramp.left()) / ramp.width()).clamp(0.0, 1.0); + let near = |x: f32| { + g.stops.iter().enumerate().map(|(i, s)| (i, (x_of(s.offset) - x).abs())).filter(|(_, d)| *d < 7.0).min_by(|a, b| a.1.total_cmp(&b.1)) + }; + if resp.drag_started() + && let Some(p) = resp.interact_pointer_pos() + && let Some((i, _)) = near(p.x) + { + sel = i; + dragging = Some((i, g.stops[i].offset, false)); + } + if resp.dragged() + && let (Some((i, _, _)), Some(p)) = (dragging, resp.interact_pointer_pos()) + { + dragging = Some((i, o_of(p.x), p.y > area.bottom() + 16.0 && g.stops.len() > 2)); + } + if resp.drag_stopped() + && let Some((i, o, off)) = dragging.take() + { + let mut stops = stops_json(&g); + if off { + stops.remove(i); + sel = 0; + } else { + stops[i]["location"] = json!(o as f64 * 100.0); + } + let _ = app.run("object.gradient", json!({"stops": stops})); + } + if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + match near(p.x) { + Some((i, _)) => sel = i, + // Below the ramp, away from the stops: a new stop with the colour there. + None if p.y > ramp.bottom() => { + let o = o_of(p.x); + let (c, a) = g.sample(o); + let mut stops = stops_json(&g); + stops.push(json!({"location": o as f64 * 100.0, "color": c.to_hex(), "opacity": a as f64 * 100.0, "midpoint": 50.0})); + let _ = app.run("object.gradient", json!({"stops": stops})); + sel = g.stops.iter().filter(|s| s.offset <= o).count(); + } + None => {} + } + } + for (i, s) in g.stops.iter().enumerate() { + let (o, off) = match dragging { + Some((j, o, off)) if j == i => (o, off), + _ => (s.offset, false), + }; + if off { + continue; + } + let x = x_of(o); + let top = ramp.bottom() + 2.0; + let [r, gg, bb] = s.color.to_rgb(); + let col = egui::Color32::from_rgb((r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8); + let edge = if i == sel { t.accent } else { t.text_dim }; + painter.add(egui::Shape::convex_polygon( + vec![ + egui::pos2(x, top), + egui::pos2(x + 6.0, top + 7.0), + egui::pos2(x + 6.0, top + 13.0), + egui::pos2(x - 6.0, top + 13.0), + egui::pos2(x - 6.0, top + 7.0), + ], + col, + egui::Stroke::new(if i == sel { 2.0 } else { 1.0 }, edge), + )); + } + ui.data_mut(|d| { + d.insert_temp(sel_id, sel); + match dragging { + Some(v) => { + d.insert_temp(drag_id, v); + } + None => d.remove::<(usize, f32, bool)>(drag_id), + } + }); + // The selected stop's colour. + if let Some(s) = g.stops.get(sel) { + ui.horizontal(|ui| { + crate::widgets::caption(ui, "Stop Color:"); + let [r, gg, bb] = s.color.to_rgb(); + let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8]; + if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { + let mut stops = stops_json(&g); + stops[sel]["color"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])); + let _ = app.run("object.gradient", json!({"stops": stops})); + } + }); + } +} diff --git a/docs/parity.md b/docs/parity.md index 1c553d305..4df52e656 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -9,7 +9,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| -| P0 | 102 | 92 | 10 | 0 | 95% | +| P0 | 102 | 93 | 9 | 0 | 96% | | P1 | 97 | 33 | 23 | 41 | 46% | | P2 | 76 | 8 | 7 | 61 | 15% | @@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Layers | 74% | | Frames, shapes & paths | 74% | | Transform | 78% | -| Fill, stroke, colour | 65% | +| Fill, stroke, colour | 69% | | Effects & transparency | 50% | | Placing & links | 57% | | Type & text frames | 77% | @@ -35,7 +35,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Undo, history, saving | 100% | | Accessibility | 20% | -Open P0 items: Dockable panels, Preferences, Pen, add/delete anchor, convert direction, Gradient panel & tool, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance. +Open P0 items: Dockable panels, Preferences, Pen, add/delete anchor, convert direction, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance. ## Rows @@ -142,7 +142,7 @@ Open P0 items: Dockable panels, Preferences, Pen, add/delete anchor, convert dir | Fill, stroke, colour | Swatches panel | P0 | D | | Fill, stroke, colour | Color panel | P0 | D | | Fill, stroke, colour | Color Picker dialog | P0 | D | -| Fill, stroke, colour | Gradient panel & tool | P0 | P | +| Fill, stroke, colour | Gradient panel & tool | P0 | D | | Fill, stroke, colour | Gradient Feather tool | P1 | M | | Fill, stroke, colour | Tints | P0 | D | | Fill, stroke, colour | Special swatches | P0 | D |