From 3fc38549d4a9c2c7bb5001b9e7aafc39b850dd9f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:08:35 -0400 Subject: [PATCH] =?UTF-8?q?I:=20Object=20States=20=E2=80=94=20multi-state?= =?UTF-8?q?=20objects=20(+=20panel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 17 +++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/states.rs | 125 +++++++++++++++++++++++ crates/pdf/src/export.rs | 2 +- crates/render/src/lib.rs | 4 +- crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/interactive.rs | 27 +++++ docs/parity.md | 6 +- 10 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/states.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9ff0a22..f98051d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,6 +84,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; dragging on the canvas isn't there yet). +- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - 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. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index fb4090e..65352bd 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -450,6 +450,10 @@ fn center_ref() -> u8 { 4 } +fn is_zero_usize(v: &usize) -> bool { + *v == 0 +} + impl Graphic { /// The graphic transform that fits it into `frame` (the frame's inner rect) with `mode`, /// honouring the crop amounts and the reference point. @@ -712,6 +716,11 @@ pub struct Item { /// Alternative text (Object Export Options) for tagged PDF and EPUB. #[serde(default, skip_serializing_if = "String::is_empty")] pub alt_text: String, + /// Object States: a group whose children are states (named here); only `active_state` shows. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub states: Vec, + #[serde(default, skip_serializing_if = "is_zero_usize")] + pub active_state: usize, /// XML tag (Tags panel); its content is the element's content in XML export/import. #[serde(default, skip_serializing_if = "String::is_empty")] pub xml_tag: String, @@ -755,6 +764,8 @@ impl Item { label: String::new(), alt_text: String::new(), xml_tag: String::new(), + states: Vec::new(), + active_state: 0, isolate: false, knockout: false, } @@ -787,6 +798,12 @@ impl Item { _ => &[], } } + /// The children that show: all of them, or just the active state of a multi-state object. + pub fn shown_children(&self) -> impl Iterator> { + let only = (!self.states.is_empty()).then_some(self.active_state); + self.children().iter().enumerate().filter(move |(i, _)| only.is_none_or(|a| a == *i)).map(|(_, c)| c) + } + pub fn children_mut(&mut self) -> Option<&mut Vec>> { match &mut self.content { Content::Group { items } => Some(items), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0208ec0..c5a6ff8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ mod print; mod qr; mod select; pub mod spelling; +mod states; mod strokes; mod style; pub mod table; @@ -157,6 +158,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(linked::specs()); v.extend(xml::specs()); v.extend(strokes::specs()); + v.extend(states::specs()); v.extend(path_type::specs()); v.extend(qr::specs()); v.extend(select::specs()); diff --git a/crates/engine/src/cmd/states.rs b/crates/engine/src/cmd/states.rs new file mode 100644 index 0000000..f9ebb87 --- /dev/null +++ b/crates/engine/src/cmd/states.rs @@ -0,0 +1,125 @@ +//! Window › Interactive › Object States: a group whose objects are alternative states, one shown. + +use designcraft_doc::{ItemId, Shape}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_selection, ids_param}; +use crate::{Result, Session}; + +fn target(s: &Session, p: &Value) -> Result { + p.get("id") + .and_then(Value::as_u64) + .map(ItemId) + .or_else(|| s.active().and_then(|d| d.selection.items.first().copied())) + .ok_or_else(|| bad("states", "select a multi-state object")) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "states.create", + "Convert Selection to Multi-State Object", + ["Window", "Interactive", "Object States"], + None, + "{ids? (default: the selection; one group's objects or several objects become states)} → {id, states}", + has_selection, + |s, p| { + let ids = ids_param(p, "ids").unwrap_or_else(|| s.active().map(|d| d.selection.items.clone()).unwrap_or_default()); + let single_group = ids.len() == 1 && s.doc()?.doc.item(ids[0]).is_some_and(|it| it.shape == Shape::Group && it.children().len() > 1); + let gid = if single_group { + ids[0] + } else if ids.len() > 1 { + ItemId(s.execute("object.group", &json!({"ids": ids.iter().map(|i| i.0).collect::>()}))?["id"].as_u64().unwrap_or(0)) + } else { + return Err(bad("states.create", "select two or more objects (or a group)")); + }; + s.edit(|d, _| { + let g = d.item_mut(gid).ok_or_else(|| bad("states.create", "no such group"))?; + let n = g.children().len(); + g.states = (1..=n).map(|i| format!("State {i}")).collect(); + g.active_state = 0; + Ok(json!({"id": gid.0, "states": g.states})) + }) + } + ), + cmd!(query "states.list", "Object States", [], None, "{id?} → {states, active}", has_selection, |s, p| { + let id = target(s, p)?; + let it = s.doc()?.doc.item(id).ok_or_else(|| bad("states.list", "no such object"))?; + Ok(json!({"states": it.states, "active": it.active_state})) + }), + cmd!("states.show", "Show State", [], None, "{id?, index? | name?}", has_selection, |s, p| { + let id = target(s, p)?; + let idx = p.get("index").and_then(Value::as_u64).map(|v| v as usize); + let name = p.get("name").and_then(Value::as_str).map(str::to_string); + s.edit(|d, _| { + let it = d.item_mut(id).ok_or_else(|| bad("states.show", "no such object"))?; + if it.states.is_empty() { + return Err(bad("states.show", "not a multi-state object")); + } + let i = match (idx, name) { + (Some(i), _) => i, + (None, Some(n)) => it.states.iter().position(|x| *x == n).ok_or_else(|| bad("states.show", format!("no state `{n}`")))?, + _ => return Err(bad("states.show", "`index` or `name` required")), + }; + if i >= it.states.len() { + return Err(bad("states.show", format!("no state {i}"))); + } + it.active_state = i; + Ok(json!({"active": i})) + }) + }), + cmd!("states.rename", "State Options", [], None, "{id?, index, name}", has_selection, |s, p| { + let id = target(s, p)?; + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("states.rename", "`index` required"))? as usize; + let name = p.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + s.edit(|d, _| { + let it = d.item_mut(id).ok_or_else(|| bad("states.rename", "no such object"))?; + let st = it.states.get_mut(i).ok_or_else(|| bad("states.rename", format!("no state {i}")))?; + *st = name; + Ok(Value::Null) + }) + }), + cmd!("states.release", "Release State to Objects", [], None, "{id?} — back to a plain group (every state shows)", has_selection, |s, p| { + let id = target(s, p)?; + s.edit(|d, _| { + let it = d.item_mut(id).ok_or_else(|| bad("states.release", "no such object"))?; + it.states.clear(); + it.active_state = 0; + Ok(Value::Null) + }) + }), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn multi_state_object_shows_one_state() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); + let b = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [b]})).unwrap(); + let r = s.execute("states.create", &json!({"ids": [a, b]})).unwrap(); + let id = r["id"].clone(); + assert_eq!(r["states"].as_array().unwrap().len(), 2); + let px = |s: &Session| { + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(150, 150) + }; + let first = px(&s); + assert!(first[0] < 80 && first[2] < 80, "state 1 (black) shows, not cyan on top: {first:?}"); + s.execute("states.show", &json!({"id": id, "name": "State 2"})).unwrap(); + let second = px(&s); + assert!(second[2] > 150 && second[0] < 100, "state 2 (cyan): {second:?}"); + s.execute("states.release", &json!({"id": id})).unwrap(); + assert!(s.execute("states.list", &json!({"id": id})).unwrap()["states"].as_array().unwrap().is_empty()); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index f3c645a..ff422f3 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -741,7 +741,7 @@ impl Exporter<'_> { } if !it.children().is_empty() { let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it); - for c in it.children() { + for c in it.shown_children() { self.item(s, c, xf, page_name); } for _ in 0..pushes { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 47af008..dc857cc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -423,7 +423,7 @@ impl Renderer { if it.knockout { // Knockout: each object first clears its area of those behind it, so it composites // with the group's backdrop rather than with its siblings. - for c in it.children() { + for c in it.shown_children() { if !c.hidden { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); @@ -435,7 +435,7 @@ impl Renderer { self.draw_item(ctx, f, c, xf, page_name); } } else { - for c in it.children() { + for c in it.shown_children() { self.draw_item(ctx, f, c, xf, page_name); } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 1cbe236..5550c6b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -38,6 +38,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("articles", "Articles", "panel-articles"), ("tags", "Tags", "panel-tags"), ("book", "Book", "panel-library"), + ("states", "Object States", "panel-articles"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -181,6 +182,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "articles" => panels::interactive::articles(app, ui), "tags" => panels::interactive::tags(app, ui), "book" => panels::library::book(app, ui), + "states" => panels::interactive::states(app, ui), "table" => panels::table::show(app, ui), _ => panels::properties::info_panel(app, ui), } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5dcffd9..a4a0bcc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -518,6 +518,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}", "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", "ui:window.panel|Articles|{\"panel\": \"articles\"}", + "ui:window.panel|Object States|{\"panel\": \"states\"}", "ui:window.panel|Tags|{\"panel\": \"tags\"}", "ui:window.panel|Book|{\"panel\": \"book\"}", "<", diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 7b37c32..2f5aef4 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -177,3 +177,30 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } + +/// Object States panel: the selected multi-state object's states (click to show one). +pub fn states(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let n = app.session.active().map_or(0, |d| d.selection.items.len()); + let info = app.session.execute("states.list", &json!({})).ok(); + let list = info.as_ref().and_then(|i| i["states"].as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + ui.label(egui::RichText::new("Select two or more objects (or a group) to make a multi-state object.").size(11.0).color(t.text_dim)); + if ui.add_enabled(n > 0, egui::Button::new("Convert Selection to Multi-State Object")).clicked() + && let Err(e) = app.run("states.create", json!({})) + { + app.status(format!("Object States: {e}")); + } + return; + } + let active = info.as_ref().and_then(|i| i["active"].as_u64()).unwrap_or(0) as usize; + for (i, st) in list.iter().enumerate() { + if ui.selectable_label(i == active, st.as_str().unwrap_or("")).clicked() { + let _ = app.run("states.show", json!({"index": i})); + } + } + ui.separator(); + if ui.small_button("Release to Objects").clicked() { + let _ = app.run("states.release", json!({})); + } +} diff --git a/docs/parity.md b/docs/parity.md index 5c69baa..5fa0c11 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 | 92 | 4 | 1 | 97% | -| P2 | 76 | 34 | 22 | 20 | 59% | +| P2 | 76 | 35 | 22 | 19 | 61% | | Area | Parity | |---|---| @@ -28,7 +28,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Styles | 97% | | Tables | 94% | | Long documents | 96% | -| Interactivity & digital | 75% | +| Interactivity & digital | 85% | | Output & production | 80% | | XML & automation | 83% | | View & navigation | 88% | @@ -271,7 +271,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Interactivity & digital | Hyperlinks panel | P1 | D | | Interactivity & digital | Bookmarks | P1 | D | | Interactivity & digital | Buttons and Forms | P2 | M | -| Interactivity & digital | Object States | P2 | M | +| Interactivity & digital | Object States | P2 | D | | Interactivity & digital | Articles panel | P2 | D | | Interactivity & digital | Object Export Options | P2 | P | | Interactivity & digital | EPUB export | P2 | D |