F: Gradient Swatch tool, Gradient panel, unnamed gradients (object.gradient)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 10:15:01 -04:00
co-authored by Claude Opus 5.5
parent 53272183a2
commit 66ab252044
15 changed files with 513 additions and 20 deletions
+1
View File
@@ -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.
+5 -1
View File
@@ -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<f64>,
/// 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() }
+57 -4
View File
@@ -610,7 +610,7 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
} 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<Value> {
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));
}
}
+106
View File
@@ -51,6 +51,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> = 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<Value> {
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<Value> {
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");
+16
View File
@@ -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:?}");
}
}
+6 -1
View File
@@ -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);
+7
View File
@@ -1498,6 +1498,13 @@ impl<'r> Importer<'r> {
{
it.fill.gradient_angle = Some(a);
}
let start: Option<Vec<f64>> = 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();
+14 -5
View File
@@ -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<f64>) -> Option<krilla::paint::Paint> {
fn fill_paint(&self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
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<Stop> = 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,
+11 -5
View File
@@ -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<f64>) -> 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<peniko::ColorStop> = 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())
}
+85
View File
@@ -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<u64>)>,
end: Option<Point>,
}
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<Action> {
match ev.kind {
PointerKind::Down => {
let ids: Vec<u64> = 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<Overlay> {
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()
}
}
+2
View File
@@ -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<dyn Tool> {
"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")),
+25
View File
@@ -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]);
}
+2
View File
@@ -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),
+172
View File
@@ -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<serde_json::Value> {
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}));
}
});
}
}
+4 -4
View File
@@ -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 |