From 19aa1c6f4cdb437a0f3efb55f38e422694add3e9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 15:46:31 +0200 Subject: [PATCH] feat(swatches): New Swatch names a gradient "New Gradient Swatch"; gradient swatches from colour swatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Swatches panel New Swatch takes what is current: a gradient built in the Gradient panel becomes "New Gradient Swatch" (applied, and listed), an unnamed colour a listed swatch, a selected named swatch a "… copy". DesignCraft's button always added the same orange swatch, and gradient swatches could not be created at all (LYH Studio episode 6, 2:12: "this is our Swatch … create a new swatch for it"). - engine: `swatch.new {swatch?, target?}` does the above for the selection's fill (or stroke) or a named swatch; `swatch.create` also takes `gradient: {kind?, stops: [{location, swatch | color, opacity?, midpoint?}]}`, its stops taking colour swatches by name, as swatches dragged onto the Gradient panel's stops. - Swatches panel: New Swatch runs `swatch.new`, and with nothing selected still adds a colour. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 161 +++++++++++++++++++++++++- crates/ui-egui/src/panels/swatches.rs | 7 +- docs/target-app-parity.md | 2 +- 4 files changed, 167 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..e34e69c17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,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-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..fa2ee78d0 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -135,10 +135,19 @@ pub fn specs() -> Vec { "New Color Swatch…", [], None, - "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}", + "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], 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", @@ -718,7 +727,10 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { } fn create_swatch(s: &mut Session, p: &Value) -> Result { - 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(|| match color { @@ -744,6 +756,82 @@ fn create_swatch(s: &mut Session, p: &Value) -> Result { }) } +/// `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 { + 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 " copy". +fn new_swatch(s: &mut Session, p: &Value) -> Result { + 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 { use designcraft_color::Color; match v { @@ -1751,3 +1839,72 @@ mod sample_color_tests { } } } + +#[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"); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7544347a9..d03297121 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -70,7 +70,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| { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..125e9e62a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,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. +- 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. - 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, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); 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.