From 0d02222dabe4e6e9031fdce7f0242235c37d89ee Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:48:41 -0400 Subject: [PATCH] W: Scripts panel and script.run (one undo step) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + apps/designcraft/src/main.rs | 2 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/scripts.rs | 59 +++++++++++++++++++++++++++ crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/lib.rs | 6 +++ crates/ui-egui/src/menus.rs | 11 +++++ crates/ui-egui/src/panels/library.rs | 60 ++++++++++++++++++++++++++++ docs/parity.md | 6 +-- 9 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/scripts.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2a0efaa5..bc5c930d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,6 +116,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud. - Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages. - Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text. +- Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript. - 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/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index b89c4ca0..5a577067 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -93,6 +93,8 @@ fn services() -> Services { let d = rfd::FileDialog::new(); let d = if purpose == "swatches" { d.add_filter("Swatch Exchange (ASE)", &["ase"]) + } else if purpose == "script" { + d.add_filter("Script", &["dcscript", "txt", "json"]) } else if purpose == "icc" { d.add_filter("ICC profile", &["icc", "icm"]) } else if purpose == "book" { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b6cd4e04..97a2d1ee 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -42,6 +42,7 @@ pub mod preflight; mod prefs; mod print; mod qr; +mod scripts; mod select; pub mod spelling; mod states; @@ -171,6 +172,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(liquid::specs()); v.extend(xml::specs()); v.extend(strokes::specs()); + v.extend(scripts::specs()); v.extend(states::specs()); v.extend(transitions::specs()); v.extend(path_type::specs()); diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs new file mode 100644 index 00000000..0fa89c59 --- /dev/null +++ b/crates/engine/src/cmd/scripts.rs @@ -0,0 +1,59 @@ +//! Window › Utilities › Scripts: run a command script (see [`crate::script`]) as one undo step. + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::Result; + +fn run(s: &mut crate::Session, p: &Value) -> Result { + let text = str_param(p, "text").ok_or_else(|| bad("script.run", "`text` required"))?; + let steps = crate::script::parse(text).map_err(|e| bad("script.run", e))?; + // The steps' own undo entries collapse into this command's single one. + let uid = s.active().map(|d| d.uid); + let before = s.active().map_or(0, |d| d.history.undo.len()); + let report = s.run_script(&steps); + if let Some(st) = s.active_mut() + && Some(st.uid) == uid + && st.history.undo.len() > before + { + st.history.undo.truncate(before); + } + let out = json!({"results": report.results, "steps": steps.len()}); + match report.failed { + Some((i, c, e)) => Err(bad("script.run", format!("step {i} ({c}): {e}"))), + None => Ok(out), + } +} + +pub fn specs() -> Vec { + vec![cmd!( + "script.run", + "Run Script", + [], + None, + "{text} — command script lines (`command.id {json}`, JSON lines or a JSON array; `$N.path` refers to earlier results) → {results, steps}", + always, + run + )] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn scripts_run_as_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "frame.create {\"rect\": [72, 72, 200, 200]}\nobject.fill {\"swatch\": \"[Black]\", \"ids\": [\"$0.id\"]}\nframe.create {\"rect\": [300, 72, 400, 200]}"; + let r = s.execute("script.run", &json!({"text": text})).unwrap(); + assert_eq!(r["steps"], 3); + let count = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + assert_eq!(count(&s), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count(&s), 0, "one undo removes the whole script's work"); + assert!(s.execute("script.run", &json!({"text": "nope.command"})).is_err()); + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 6f28b1e6..5b43fe2e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -44,6 +44,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("media", "Media", "panel-articles"), ("transitions", "Page Transitions", "panel-pages"), ("trackChanges", "Track Changes", "panel-articles"), + ("scripts", "Scripts", "panel-library"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -193,6 +194,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "media" => panels::interactive::media(app, ui), "transitions" => panels::interactive::transitions(app, ui), "trackChanges" => panels::interactive::track_changes(app, ui), + "scripts" => panels::library::scripts(app, ui), "table" => panels::table::show(app, ui), _ => panels::properties::info_panel(app, ui), } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b9436be6..1f5443d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -103,6 +103,8 @@ pub struct UiState { /// Edit › Menus: hidden menu items (`menu/label`), and Show Full Menus. pub hidden_menu_items: Vec, pub show_full_menus: bool, + /// Scripts panel: saved scripts (name, text). + pub scripts: Vec<(String, String)>, /// View › Structure › Show Tag Markers. pub tag_markers: bool, /// View › Flattener Preview: objects that involve transparency highlighted in red. @@ -191,6 +193,10 @@ impl Default for UiState { hidden_menu_items: Vec::new(), show_full_menus: false, story_editor_size: 14.0, + scripts: vec![ + ("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()), + ("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()), + ], tag_markers: false, flattener_preview: false, hidden_panels: 0, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 34a2823b..bff3624b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -619,6 +619,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Media|{\"panel\": \"media\"}", "ui:window.panel|Page Transitions|{\"panel\": \"transitions\"}", "ui:window.panel|Track Changes|{\"panel\": \"trackChanges\"}", + "ui:window.panel|Scripts|{\"panel\": \"scripts\"}", "ui:window.panel|Liquid Layout|{\"panel\": \"liquid\"}", "ui:window.panel|Tags|{\"panel\": \"tags\"}", "ui:window.panel|Book|{\"panel\": \"book\"}", @@ -2105,6 +2106,16 @@ mod tests { assert!((app.view().unwrap().zoom - z0).abs() < 1e-9, "back at the zoom it started from"); } + #[test] + fn sample_scripts_run() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + for (name, text) in app.ui.scripts.clone() { + let r = app.session.execute("script.run", &json!({"text": text})); + assert!(r.is_ok(), "{name}: {r:?}"); + } + } + #[test] fn panels_float_and_dock() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index 279ea443..c897a59f 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -132,3 +132,63 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } + +/// Scripts panel: saved command scripts — run (one undo step), edit, add, delete, load. +pub fn scripts(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let sel_id = egui::Id::new("scripts_sel"); + let mut sel: usize = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0); + if app.ui.scripts.is_empty() { + ui.label(egui::RichText::new("No scripts. Add one below.").size(11.0).color(t.text_dim)); + } + let mut run: Option = None; + for (i, (name, _)) in app.ui.scripts.iter().enumerate() { + let r = ui.selectable_label(i == sel, name); + if r.clicked() { + sel = i; + } + if r.double_clicked() { + run = Some(i); + } + } + ui.separator(); + ui.horizontal(|ui| { + if ui.add_enabled(!app.ui.scripts.is_empty(), egui::Button::new("Run")).clicked() { + run = Some(sel); + } + if ui.button("New").clicked() { + app.ui.scripts.push((format!("Script {}", app.ui.scripts.len() + 1), "# command.id {json} per line\n".into())); + sel = app.ui.scripts.len() - 1; + } + if ui.button("Load…").clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("script")) + { + #[cfg(not(target_arch = "wasm32"))] + match std::fs::read_to_string(&path) { + Ok(text) => { + let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into()); + app.ui.scripts.push((name, text)); + sel = app.ui.scripts.len() - 1; + } + Err(e) => app.status(format!("Scripts: {e}")), + } + } + if ui.add_enabled(!app.ui.scripts.is_empty(), egui::Button::new("Delete")).clicked() && sel < app.ui.scripts.len() { + app.ui.scripts.remove(sel); + sel = sel.saturating_sub(1); + } + }); + if let Some((name, text)) = app.ui.scripts.get_mut(sel) { + ui.add(egui::TextEdit::singleline(name).desired_width(f32::INFINITY)); + ui.add(egui::TextEdit::multiline(text).code_editor().desired_rows(8).desired_width(f32::INFINITY)); + } + if let Some(i) = run + && let Some((name, text)) = app.ui.scripts.get(i).cloned() + { + match app.run("script.run", json!({"text": text})) { + Ok(r) => app.status(format!("{name}: {} step(s) done", r["steps"])), + Err(e) => app.status(format!("{name}: {e}")), + } + } + ui.data_mut(|d| d.insert_temp(sel_id, sel)); +} diff --git a/docs/parity.md b/docs/parity.md index b404a7a4..87dbf2bd 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 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 63 | 13 | 0 | 91% | +| P2 | 76 | 64 | 12 | 0 | 92% | | Area | Parity | |---|---| @@ -30,7 +30,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Long documents | 100% | | Interactivity & digital | 100% | | Output & production | 92% | -| XML & automation | 89% | +| XML & automation | 94% | | View & navigation | 98% | | Undo, history, saving | 100% | | Accessibility | 100% | @@ -291,7 +291,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | XML & automation | Tags panel, Structure | P2 | D | | XML & automation | Import/Export XML | P2 | P | | XML & automation | Data Merge | P2 | D | -| XML & automation | Scripts panel | P2 | P | +| XML & automation | Scripts panel | P2 | D | | XML & automation | Script Label | P2 | D | | XML & automation | Content Collector/Placer | P2 | D | | XML & automation | Snippets | P1 | D |