I: Object States — multi-state objects (+ panel)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 04:08:35 -04:00
1 parent dbf1610aa6
commit 3fc38549d4
10 files changed
+181 -6

No files matched your search

+1
View File
@@ -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.
+17
View File
@@ -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<String>,
#[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<Item = &Arc<Item>> {
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<Arc<Item>>> {
match &mut self.content {
Content::Group { items } => Some(items),
+2
View File
@@ -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());
+125
View File
@@ -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<ItemId> {
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<CommandSpec> {
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::<Vec<_>>()}))?["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());
}
}
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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);
}
}
+2
View File
@@ -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),
}
+1
View File
@@ -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\"}",
"<",
+27
View File
@@ -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!({}));
}
}
+3 -3
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 | 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 |