W: Scripts panel and script.run (one undo step)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 10:48:58 -04:00
co-authored by Claude Opus 5.5
parent 8eca6305f8
commit 0d02222dab
9 changed files with 146 additions and 3 deletions
+1
View File
@@ -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.
+2
View File
@@ -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" {
+2
View File
@@ -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());
+59
View File
@@ -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<Value> {
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<CommandSpec> {
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());
}
}
+2
View File
@@ -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),
}
+6
View File
@@ -103,6 +103,8 @@ pub struct UiState {
/// Edit › Menus: hidden menu items (`menu/label`), and Show Full Menus.
pub hidden_menu_items: Vec<String>,
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,
+11
View File
@@ -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());
+60
View File
@@ -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<usize> = 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));
}
+3 -3
View File
@@ -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 |