mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
Merge #763: New Swatch names a gradient "New Gradient Swatch"; gradient swatches from colour swatches
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/style.rs # docs/target-app-parity.md
This commit is contained in:
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-13** · New Swatch works as InDesign's: a gradient made in the Gradient panel becomes "New Gradient Swatch" (still applied), an unnamed colour a listed swatch, a named swatch a "copy" (`swatch.new`); `swatch.create {gradient}` makes a gradient swatch from colour swatches. No readiness percentage change.
|
||||
- **2026-10-13** · Radial gradients stretch with the object's box, as InDesign draws them: by default an ellipse reaching the frame's sides (it was a circle reaching only the longer side), and a Gradient Swatch tool drag keeps the box's proportions; on screen and in PDF. No readiness percentage change.
|
||||
- **2026-10-13** · `.indd` and `.indt` documents open through File › Open, drop, the web inbox, `file.open`, `file.openBytes`, `file.openIdml` and the CLI: [storytold/indd-utils](https://github.com/storytold/indd-utils) converts them to IDML in memory and the IDML importer reads the result, on desktop and web. Converter warnings come first in the open result; an unreadable file is an open error (#21, #114). No readiness percentage change until a real-file corpus measures it.
|
||||
- **2026-10-13** · macOS: a command chosen with the mouse in the menu bar runs at once. It used to wait for the next mouse move or key press over the window, so File › Open, Place and Save As seemed to take 10–20 seconds to open their dialogs. No readiness percentage change.
|
||||
|
||||
@@ -170,10 +170,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Color Swatch…",
|
||||
[],
|
||||
None,
|
||||
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1), spot?: bool}",
|
||||
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1), spot?: bool} | {name? (\"New Gradient Swatch\"), gradient: {kind?: linear|radial, stops: [{location: 0–100, swatch?: a colour swatch's name | color?, opacity?: 0–100, midpoint?: 13–87}]}} → {name}",
|
||||
has_doc,
|
||||
create_swatch
|
||||
),
|
||||
cmd!(
|
||||
"swatch.new",
|
||||
"New Swatch",
|
||||
[],
|
||||
None,
|
||||
"{swatch?: name, target?: fill|stroke} → {name} — the Swatches panel's New Swatch: `swatch` (default: the selection's fill or stroke) as a new swatch. An unnamed colour or gradient becomes a named one (a gradient: \"New Gradient Swatch\"), still applied; a named swatch is duplicated (\"… copy\")",
|
||||
has_doc,
|
||||
new_swatch
|
||||
),
|
||||
cmd!(
|
||||
"object.gradient",
|
||||
"Gradient",
|
||||
@@ -876,7 +885,10 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn create_swatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?;
|
||||
if let Some(g) = p.get("gradient") {
|
||||
return create_gradient_swatch(s, p, g);
|
||||
}
|
||||
let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color (or gradient)"))?;
|
||||
let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?;
|
||||
let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false);
|
||||
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| value_name(color));
|
||||
@@ -896,6 +908,82 @@ fn create_swatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `swatch.create {gradient}`: a named gradient swatch. Stops take a colour swatch by name (as a
|
||||
/// swatch dragged onto the Gradient panel's stop) or a colour.
|
||||
fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result<Value> {
|
||||
use designcraft_color::{Gradient, GradientKind, GradientStop};
|
||||
const ID: &str = "swatch.create";
|
||||
let kind = match g.get("kind").and_then(Value::as_str) {
|
||||
None | Some("linear") => GradientKind::Linear,
|
||||
Some("radial") => GradientKind::Radial,
|
||||
Some(k) => return Err(bad(ID, format!("unknown gradient kind {k}"))),
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut stops = Vec::new();
|
||||
for st in g.get("stops").and_then(Value::as_array).ok_or_else(|| bad(ID, "a gradient needs stops"))? {
|
||||
let color = match (st.get("swatch").and_then(Value::as_str), st.get("color")) {
|
||||
(Some(n), _) => match doc.swatch(n).map(|w| &w.value) {
|
||||
Some(SwatchValue::Gradient { .. }) => return Err(bad(ID, format!("`{n}` is a gradient; a stop takes a colour"))),
|
||||
Some(_) => doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no colour")))?,
|
||||
None => return Err(bad(ID, format!("no swatch named `{n}`"))),
|
||||
},
|
||||
(None, Some(c)) => parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?,
|
||||
(None, None) => return Err(bad(ID, "a stop needs a swatch or a color")),
|
||||
};
|
||||
let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0;
|
||||
stops.push(GradientStop {
|
||||
offset: pct("location", 0.0),
|
||||
color,
|
||||
opacity: pct("opacity", 100.0),
|
||||
midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87),
|
||||
});
|
||||
}
|
||||
if stops.len() < 2 {
|
||||
return Err(bad(ID, "a gradient needs at least two stops"));
|
||||
}
|
||||
let mut gradient = Gradient { kind, stops };
|
||||
gradient.sort();
|
||||
let name = str_param(p, "name").unwrap_or("New Gradient Swatch").to_string();
|
||||
s.edit(|d, _| {
|
||||
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name);
|
||||
d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false });
|
||||
Ok(json!({"name": name}))
|
||||
})
|
||||
}
|
||||
|
||||
/// `swatch.new`: InDesign's New Swatch. An unnamed colour or gradient (the selection's, or
|
||||
/// `swatch`) is added to the swatches under a name, and stays applied; a named swatch is
|
||||
/// duplicated as "<name> copy".
|
||||
fn new_swatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "swatch.new";
|
||||
let st = s.doc()?;
|
||||
let name = match str_param(p, "swatch") {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
let id = st.selection.items.first().copied().ok_or_else(|| bad(ID, "select an object or give `swatch`"))?;
|
||||
let it = st.doc.item(id).ok_or_else(|| bad(ID, "no such item"))?;
|
||||
if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }
|
||||
}
|
||||
};
|
||||
let w = st.doc.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch named `{name}`")))?;
|
||||
if matches!(w.value, SwatchValue::None | SwatchValue::Registration) {
|
||||
return Err(bad(ID, format!("`{name}` can't be made a new swatch")));
|
||||
}
|
||||
if w.hidden {
|
||||
// An unnamed gradient takes InDesign's "New Gradient Swatch"; an unnamed colour its values.
|
||||
let new = match w.value {
|
||||
SwatchValue::Gradient { .. } => Styles::unique_name(|n| st.doc.swatch(n).is_some(), "New Gradient Swatch"),
|
||||
_ => name.clone(),
|
||||
};
|
||||
return add_to_swatches(s, &json!({"swatch": name, "name": new}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let copy = Styles::unique_name(|n| d.swatch(n).is_some(), &format!("{name} copy"));
|
||||
d.swatches.push(Swatch { name: copy.clone(), locked: false, named: true, hidden: false, ..w.clone() });
|
||||
Ok(json!({"name": copy}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
|
||||
use designcraft_color::Color;
|
||||
match v {
|
||||
@@ -2680,3 +2768,72 @@ mod radial_gradient_tests {
|
||||
assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradient_swatch_tests {
|
||||
use designcraft_color::SwatchValue;
|
||||
use serde_json::json;
|
||||
|
||||
fn doc() -> crate::Session {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_swatch_from_colour_swatches() {
|
||||
let mut s = doc();
|
||||
// As the Gradient panel's stops take swatches dragged onto them.
|
||||
let r = s
|
||||
.execute(
|
||||
"swatch.create",
|
||||
&json!({"gradient": {"stops": [{"location": 100, "swatch": "C=100 M=0 Y=0 K=0"}, {"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["name"], "New Gradient Swatch");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let w = d.swatch("New Gradient Swatch").unwrap();
|
||||
assert!(w.named && !w.hidden);
|
||||
let SwatchValue::Gradient { gradient } = &w.value else { panic!("{w:?}") };
|
||||
assert_eq!(gradient.stops[0].color, d.resolve_color("C=0 M=100 Y=0 K=0", 1.0).unwrap(), "sorted: magenta first");
|
||||
assert_eq!(gradient.stops[1].color, d.resolve_color("C=100 M=0 Y=0 K=0", 1.0).unwrap());
|
||||
// A second one gets its own name; bad stops are refused.
|
||||
let r = s
|
||||
.execute(
|
||||
"swatch.create",
|
||||
&json!({"gradient": {"kind": "radial", "stops": [{"color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["name"], "New Gradient Swatch 2");
|
||||
for bad in [
|
||||
json!({"stops": [{"swatch": "C=0 M=100 Y=0 K=0"}]}),
|
||||
json!({"stops": [{"swatch": "Nope"}, {"swatch": "[Black]"}]}),
|
||||
json!({"stops": [{"swatch": "New Gradient Swatch"}, {"swatch": "[Black]"}]}),
|
||||
json!({"kind": "conic", "stops": [{"color": "#000000"}, {"color": "#ffffff"}]}),
|
||||
] {
|
||||
assert!(s.execute("swatch.create", &json!({"gradient": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_swatch_names_the_selections_gradient_or_duplicates_a_swatch() {
|
||||
let mut s = doc();
|
||||
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
// A gradient made in the Gradient panel is unnamed (and not listed) …
|
||||
s.execute("object.gradient", &json!({"ids": [id], "stops": [{"location": 0, "color": {"c": 0, "m": 100, "y": 0, "k": 0}}, {"location": 100, "color": {"c": 100, "m": 0, "y": 0, "k": 0}}]})).unwrap();
|
||||
let fill = |s: &crate::Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone();
|
||||
assert!(s.doc().unwrap().doc.swatch(&fill(&s)).unwrap().hidden);
|
||||
// … until New Swatch: "New Gradient Swatch", listed, still the object's fill.
|
||||
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch");
|
||||
assert_eq!(fill(&s), "New Gradient Swatch");
|
||||
let w = s.doc().unwrap().doc.swatch("New Gradient Swatch").unwrap().clone();
|
||||
assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Gradient { .. }));
|
||||
// A named swatch: a copy.
|
||||
assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch copy");
|
||||
assert_eq!(s.execute("swatch.new", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy");
|
||||
assert!(s.execute("swatch.new", &json!({"swatch": "[None]"})).is_err());
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
assert!(s.execute("swatch.new", &json!({})).is_err(), "nothing selected, no swatch given");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Right-click: stroke, groups")).size(10.5).color(t.text_disabled),
|
||||
));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() {
|
||||
// As InDesign's: the selection's colour or gradient becomes a swatch (a gradient made in
|
||||
// the Gradient panel: "New Gradient Swatch"), a named one is duplicated; with nothing
|
||||
// selected, a new colour.
|
||||
if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked()
|
||||
&& app.run("swatch.new", json!({})).is_err()
|
||||
{
|
||||
let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}}));
|
||||
}
|
||||
crate::menus::menu_button(ui, "☰", |ui| {
|
||||
|
||||
@@ -433,7 +433,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
|
||||
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
|
||||
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
|
||||
- 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. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF).
|
||||
- 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 until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient`; the vector round-trips through IDML. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF).
|
||||
- New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it.
|
||||
- Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user