C: Swatches panel colour groups (+ IDML ColorGroup)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:45:59 -04:00
parent 9ab1f9f330
commit 902fd214c2
8 changed files with 281 additions and 40 deletions

View File

@ -43,6 +43,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB).
- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup).
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.

View File

@ -126,6 +126,7 @@ impl Document {
stories: BTreeMap::new(),
styles: Arc::new(Styles::default()),
swatches: designcraft_color::default_swatches(),
color_groups: vec![],
sections: vec![Section {
start: 0,
start_number: Some(nd.start_page.max(1)),

View File

@ -131,6 +131,14 @@ impl Default for NumberedList {
}
}
/// A Swatches panel colour group: a named folder of swatches (by name).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ColorGroup {
pub name: String,
pub swatches: Vec<String>,
}
/// Preferences › Advanced Type (stored with the document, like InDesign).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@ -293,6 +301,10 @@ pub struct Document {
pub stories: BTreeMap<StoryId, Arc<Story>>,
pub styles: Arc<Styles>,
pub swatches: Vec<Swatch>,
/// Swatches panel colour groups (folders), in panel order; swatches not in a group sit at
/// the top level.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub color_groups: Vec<ColorGroup>,
pub sections: Vec<Section>,
#[serde(default)]
pub assets: BTreeMap<AssetId, Arc<Asset>>,

View File

@ -126,6 +126,91 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("swatch.delete", "special swatches can't be deleted"));
}
d.swatches.retain(|w| w.name != name);
for g in &mut d.color_groups {
g.swatches.retain(|w| *w != name);
}
ok()
})
}),
cmd!(
"swatch.newColorGroup",
"New Color Group",
[],
None,
"{name?, swatches?: [names]} — a Swatches panel folder (the swatches move into it) → {name}",
has_doc,
|s, p| {
let names: Vec<String> = p
.get("swatches")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let want = str_param(p, "name").map(str::to_string);
s.edit(|d, _| {
for n in &names {
if d.swatch(n).is_none_or(|w| w.locked) {
return Err(bad("swatch.newColorGroup", format!("`{n}` can't go in a group")));
}
}
let taken = |n: &str| d.color_groups.iter().any(|g| g.name == n);
let name = match want {
Some(n) if taken(&n) => return Err(bad("swatch.newColorGroup", format!("a group named `{n}` exists"))),
Some(n) => n,
None => (1..).map(|i| format!("Color Group {i}")).find(|n| !taken(n)).expect("free name"),
};
for g in &mut d.color_groups {
g.swatches.retain(|w| !names.contains(w));
}
d.color_groups.push(designcraft_doc::ColorGroup { name: name.clone(), swatches: names });
Ok(json!({"name": name}))
})
}
),
cmd!("swatch.moveToGroup", "Move to Color Group", [], None, "{swatches: [names], group: name | null (top level)}", has_doc, |s, p| {
let names: Vec<String> = p
.get("swatches")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let group = str_param(p, "group").map(str::to_string);
s.edit(|d, _| {
if let Some(g) = &group
&& !d.color_groups.iter().any(|x| x.name == *g)
{
return Err(bad("swatch.moveToGroup", format!("no color group `{g}`")));
}
for g in &mut d.color_groups {
g.swatches.retain(|w| !names.contains(w));
}
if let Some(g) = group.and_then(|g| d.color_groups.iter_mut().find(|x| x.name == g)) {
g.swatches.extend(names.iter().filter(|n| d.swatches.iter().any(|w| w.name == **n && !w.locked)).cloned());
}
ok()
})
}),
cmd!("swatch.ungroupColorGroup", "Ungroup Color Group", [], None, "{name} — its swatches go back to the top level", has_doc, |s, p| {
let name = str_param(p, "name").unwrap_or("").to_string();
s.edit(|d, _| {
let n = d.color_groups.len();
d.color_groups.retain(|g| g.name != name);
if d.color_groups.len() == n {
return Err(bad("swatch.ungroupColorGroup", format!("no color group `{name}`")));
}
ok()
})
}),
cmd!("swatch.renameColorGroup", "Color Group Options", [], None, "{name, to}", has_doc, |s, p| {
let (name, to) = (str_param(p, "name").unwrap_or("").to_string(), str_param(p, "to").unwrap_or("").trim().to_string());
s.edit(|d, _| {
if to.is_empty() || d.color_groups.iter().any(|g| g.name == to) {
return Err(bad("swatch.renameColorGroup", format!("can't rename to `{to}`")));
}
let g = d
.color_groups
.iter_mut()
.find(|g| g.name == name)
.ok_or_else(|| bad("swatch.renameColorGroup", format!("no color group `{name}`")))?;
g.name = to;
ok()
})
}),
@ -696,6 +781,13 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let w = d.swatches.iter_mut().find(|w| w.name == name).expect("found above");
w.name = new.clone();
w.named = true;
for g in &mut d.color_groups {
for w in &mut g.swatches {
if *w == name {
*w = new.clone();
}
}
}
// Everything using the colour follows the rename.
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
let sp = std::sync::Arc::make_mut(sp);
@ -808,3 +900,39 @@ mod group_sample_tests {
assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}");
}
}
#[cfg(test)]
mod color_group_tests {
use serde_json::json;
use crate::Session;
#[test]
fn color_groups_hold_swatches_and_round_trip() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("swatch.create", &json!({"name": "Brand Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap()["name"]
.as_str()
.unwrap()
.to_string();
let b = s.execute("swatch.create", &json!({"name": "Brand Blue", "color": {"c": 100, "m": 60, "y": 0, "k": 0}})).unwrap()["name"]
.as_str()
.unwrap()
.to_string();
let g = s.execute("swatch.newColorGroup", &json!({"name": "Brand", "swatches": [a]})).unwrap();
assert_eq!(g["name"], "Brand");
assert!(s.execute("swatch.newColorGroup", &json!({"swatches": ["[Black]"]})).is_err(), "special swatches stay at the top");
s.execute("swatch.moveToGroup", &json!({"swatches": [b], "group": "Brand"})).unwrap();
let groups = |s: &Session| s.doc().unwrap().doc.color_groups.clone();
assert_eq!(groups(&s)[0].swatches, [a.clone(), b.clone()]);
let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc);
let back = designcraft_idml::import_idml(&bytes).unwrap();
assert_eq!(back.color_groups, groups(&s), "IDML ColorGroup");
s.execute("swatch.delete", &json!({"name": b})).unwrap();
assert_eq!(groups(&s)[0].swatches, [a.as_str()]);
s.execute("swatch.renameColorGroup", &json!({"name": "Brand", "to": "Identity"})).unwrap();
s.execute("swatch.ungroupColorGroup", &json!({"name": "Identity"})).unwrap();
assert!(groups(&s).is_empty());
assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches");
}
}

View File

@ -560,6 +560,18 @@ impl<'a> Ex<'a> {
for g in gradients {
root.push(g);
}
for (gi, g) in d.color_groups.iter().enumerate() {
let mut el = El::new("ColorGroup")
.attr("Self", format!("ColorGroup/{}", escape_id(&g.name)))
.attr("Name", &g.name)
.attr("IsRootColorGroup", "false");
for (k, n) in g.swatches.iter().enumerate() {
if let Some(id) = self.swatch_ids.get(n) {
el.push(El::new("ColorGroupSwatch").attr("Self", format!("u{gi}ColorGroupSwatch{k}")).attr("SwatchItemRef", id));
}
}
root.push(el);
}
for s in STROKE_STYLES {
root.push(El::new("StrokeStyle").attr("Self", format!("StrokeStyle/$ID/{s}")).attr("Name", format!("$ID/{s}")));
}

View File

@ -97,6 +97,7 @@ struct Importer<'r> {
hidden_colors: HashMap<String, Color>,
/// Unnamed swatches (gradients) added on first use.
hidden_swatches: HashMap<String, Swatch>,
color_groups: Vec<designcraft_doc::ColorGroup>,
styles: Styles,
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
@ -175,6 +176,7 @@ impl<'r> Importer<'r> {
swatch_names,
hidden_colors: HashMap::new(),
hidden_swatches: HashMap::new(),
color_groups: Vec::new(),
styles,
para_names: HashMap::new(),
char_names: HashMap::new(),
@ -545,6 +547,12 @@ impl<'r> Importer<'r> {
names.insert(s.name.clone());
self.swatches.push(s);
}
// Colour groups other than the root one become Swatches panel folders.
for g in top.iter().filter(|e| e.local() == "ColorGroup" && e.get("IsRootColorGroup") != Some("true")) {
let swatches: Vec<String> =
g.find_all("ColorGroupSwatch").filter_map(|s| s.get("SwatchItemRef")).filter_map(|r| self.swatch_names.get(r).cloned()).collect();
self.color_groups.push(designcraft_doc::ColorGroup { name: g.get("Name").unwrap_or("Color Group").to_string(), swatches });
}
}
fn color_for_ref(&self, r: &str, pending: &[(String, Swatch)]) -> Color {
@ -1947,6 +1955,7 @@ impl<'r> Importer<'r> {
stories: std::mem::take(&mut self.stories).into_iter().map(|(k, v)| (k, Arc::new(v))).collect(),
styles: Arc::new(std::mem::take(&mut self.styles)),
swatches: std::mem::take(&mut self.swatches),
color_groups: std::mem::take(&mut self.color_groups),
sections: std::mem::take(&mut self.sections),
assets: std::mem::take(&mut self.assets),
hyperlinks: vec![],

View File

@ -16,57 +16,60 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(if text { "Applies to text" } else { "Applies to fill" }).size(11.0).color(t.text_dim));
});
// Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches.
for sw in doc.swatches.iter().filter(|w| !w.hidden) {
// Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in
// colour groups are listed under their folder.
let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect();
for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref(), text);
}
for g in &doc.color_groups {
let open_id = egui::Id::new(("color_group_open", &g.name));
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if fill_cur.as_deref() == Some(sw.name.as_str()) {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if resp.hovered() {
if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
let (c, g) = crate::widgets::swatch_colors(&doc, &sw.name, 1.0);
let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(ui.painter(), chip, c, g);
ui.painter().text(row.min + vec2(28.0, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text);
// Type indicators: spot dot / process square, colour mode.
let kind = match &sw.value {
SwatchValue::Color { color, color_type } => {
let m = match color {
designcraft_color::Color::Cmyk { .. } => "CMYK",
designcraft_color::Color::Rgb { .. } => "RGB",
designcraft_color::Color::Gray { .. } => "Gray",
};
format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m)
}
SwatchValue::Gradient { .. } => "Gradient".into(),
SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0),
_ => String::new(),
};
if sw.locked {
icons::paint(
ui.painter(),
egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)),
"lock",
t.text_dim,
);
}
ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim);
icons::paint(
ui.painter(),
egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
if open { "chevron-down" } else { "chevron-right" },
t.text_dim,
);
// A folder.
let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
if resp.clicked() {
let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) };
let _ = r;
open = !open;
ui.data_mut(|d| d.insert_temp(open_id, open));
}
if resp.secondary_clicked() {
let _ = app.run("object.stroke", json!({"swatch": sw.name}));
resp.context_menu(|ui| {
if ui.button("Ungroup Color Group").clicked() {
let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
ui.close();
}
});
if open {
for n in &g.swatches {
if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref(), text);
}
}
}
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Right-click applies to stroke").size(10.5).color(t.text_disabled));
ui.label(egui::RichText::new("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, "New Swatch").clicked() {
let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}}));
}
ui.menu_button("☰", |ui| {
if ui.button("New Color Group").clicked() {
let _ = app.run("swatch.newColorGroup", json!({}));
ui.close();
}
if ui.button("Load Swatches…").clicked() {
let _ = app.run("app.loadSwatches", json!({}));
ui.close();
@ -80,6 +83,81 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// One Swatches panel row: chip, name, type; click applies to fill (or text), the context menu
/// to stroke, colour groups and delete.
fn swatch_row(
app: &mut DesignApp,
ui: &mut egui::Ui,
doc: &designcraft_doc::Document,
sw: &designcraft_color::swatch::Swatch,
indent: f32,
fill_cur: Option<&str>,
text: bool,
) {
let t = Tokens::get(ui.ctx());
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if fill_cur == Some(sw.name.as_str()) {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0);
let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(ui.painter(), chip, c, g);
ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text);
// Type indicators: spot dot / process square, colour mode.
let kind = match &sw.value {
SwatchValue::Color { color, color_type } => {
let m = match color {
designcraft_color::Color::Cmyk { .. } => "CMYK",
designcraft_color::Color::Rgb { .. } => "RGB",
designcraft_color::Color::Gray { .. } => "Gray",
};
format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m)
}
SwatchValue::Gradient { .. } => "Gradient".into(),
SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0),
_ => String::new(),
};
if sw.locked {
icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim);
}
ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim);
if resp.clicked() {
let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) };
let _ = r;
}
resp.context_menu(|ui| {
if ui.button("Apply to Stroke").clicked() {
let _ = app.run("object.stroke", json!({"swatch": sw.name}));
ui.close();
}
if !sw.locked {
ui.menu_button("Move to Color Group", |ui| {
if ui.button("(Top Level)").clicked() {
let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": null}));
ui.close();
}
for g in &doc.color_groups {
if ui.button(&g.name).clicked() {
let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": g.name}));
ui.close();
}
}
});
if ui.button("New Color Group with Swatch").clicked() {
let _ = app.run("swatch.newColorGroup", json!({"swatches": [sw.name]}));
ui.close();
}
ui.separator();
if ui.button("Delete Swatch").clicked() {
let _ = app.run("swatch.delete", json!({"name": sw.name}));
ui.close();
}
}
});
}
/// Colour models of the Color panel (its panel menu: Lab, CMYK, RGB).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ColorMode {

View File

@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 74 | 21 | 2 | 87% |
| P2 | 76 | 13 | 13 | 50 | 26% |
| P2 | 76 | 14 | 13 | 49 | 27% |
| Area | Parity |
|---|---|
@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Layers | 95% |
| Frames, shapes & paths | 91% |
| Transform | 100% |
| Fill, stroke, colour | 86% |
| Fill, stroke, colour | 88% |
| Effects & transparency | 62% |
| Placing & links | 82% |
| Type & text frames | 88% |
@ -149,7 +149,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Fill, stroke, colour | Spot colours | P1 | D |
| Fill, stroke, colour | Ink Manager | P2 | M |
| Fill, stroke, colour | Load/Save Swatches (.ase) | P1 | D |
| Fill, stroke, colour | Color groups | P2 | M |
| Fill, stroke, colour | Color groups | P2 | D |
| Fill, stroke, colour | Unnamed colour to swatch | P1 | D |
| Fill, stroke, colour | Stroke panel | P0 | D |
| Fill, stroke, colour | Stroke styles | P1 | P |