O: Print dialog (file.print via the system spooler); shortcuts open dialogs, UI commands win ties

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 16:51:21 -04:00
co-authored by Claude Opus 5.5
parent fcef655ba8
commit 7d4f00d551
6 changed files with 192 additions and 9 deletions
+1
View File
@@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document).
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles and their IDML mapping are still to come.
- Table and cell styles: cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. IDML mapping is still to come.
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
+2
View File
@@ -23,6 +23,7 @@ mod paths;
mod place_text;
pub mod preflight;
mod prefs;
mod print;
pub mod spelling;
mod style;
pub mod table;
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(anchors::specs());
v.extend(paths::specs());
v.extend(package::specs());
v.extend(print::specs());
v.extend(text::specs());
v.extend(style::specs());
v.extend(table::specs());
+95
View File
@@ -0,0 +1,95 @@
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "file.printers", "Printers", [], None, "{} → {printers: [names], default}", super::always, |_, _| Ok(printers())),
cmd!(
noundo "file.print",
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
has_doc,
print
),
]
}
#[cfg(not(target_arch = "wasm32"))]
fn printers() -> Value {
let run = |args: &[&str]| {
std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()
};
let names: Vec<String> = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
// "system default destination: NAME"
let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string);
json!({"printers": names, "default": default})
}
#[cfg(target_arch = "wasm32")]
fn printers() -> Value {
json!({"printers": [], "default": null})
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let opts = super::export::options(p, d.page_count())?;
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
let printer = str_param(p, "printer").map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
let mut cmd: Vec<String> = vec!["lpr".into()];
if let Some(pr) = &printer {
cmd.extend(["-P".into(), pr.clone()]);
}
if copies > 1 {
cmd.extend(["-#".into(), copies.to_string()]);
}
cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]);
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
if dry {
return Ok(out);
}
#[cfg(target_arch = "wasm32")]
{
let _ = pdf;
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
}
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?;
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
if !st.status.success() {
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn print_builds_the_spool_command() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
assert_eq!(r["pages"], 2);
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
assert!(cmd.last().unwrap().ends_with(".pdf"));
assert!(r["bytes"].as_u64().unwrap() > 500);
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
}
}
+72
View File
@@ -176,6 +176,65 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// File › Print, with the printers the system knows.
pub fn open_print(app: &mut DesignApp) {
let p = app.session.execute("file.printers", &json!({})).unwrap_or_default();
let printer = p["default"].as_str().map(str::to_string).or_else(|| p["printers"][0].as_str().map(str::to_string)).unwrap_or_default();
app.ui.dialog = Some(Dialog::new(
"print",
json!({"printers": p["printers"], "printer": printer, "copies": 1, "range": "all", "pages": "1", "spreads": false, "marks": false, "bleed": false}),
));
}
fn print_dialog(ui: &mut egui::Ui, d: &mut Dialog) {
let printers: Vec<String> = d
.fields
.get("printers")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
egui::Grid::new("print").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Printer:");
let cur = d.s("printer");
if printers.is_empty() {
ui.label(egui::RichText::new("System default").italics());
} else {
egui::ComboBox::from_id_salt("printer").selected_text(&cur).width(220.0).show_ui(ui, |ui| {
for p in &printers {
if ui.selectable_label(*p == cur, p).clicked() {
d.fields.insert("printer".into(), json!(p));
}
}
});
}
ui.end_row();
ui.label("Copies:");
text_field(ui, d, "copies", 50.0);
ui.end_row();
ui.label("Pages:");
ui.vertical(|ui| {
let range = d.s("range");
if ui.radio(range == "all", "All").clicked() {
d.fields.insert("range".into(), json!("all"));
}
ui.horizontal(|ui| {
if ui.radio(range == "range", "Range:").clicked() {
d.fields.insert("range".into(), json!("range"));
}
text_field(ui, d, "pages", 120.0);
});
});
ui.end_row();
ui.label("");
ui.vertical(|ui| {
check(ui, d, "spreads", "Spreads");
check(ui, d, "marks", "Printer's Marks");
check(ui, d, "bleed", "Include Bleed");
});
ui.end_row();
});
}
/// File › Document Setup, filled from the document.
pub fn open_document_setup(app: &mut DesignApp) {
let Ok(cur) = app.session.execute("layout.documentSetup", &json!({})) else { return };
@@ -480,6 +539,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"findFont" => "Find/Replace Font",
"polygonSettings" => "Polygon Settings",
"preferences" => "Preferences",
"print" => "Print",
"keyboardShortcuts" => "Keyboard Shortcuts",
"colorPicker" => "Color Picker",
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
@@ -618,6 +678,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"findFont" => find_font(app, ui, &mut d),
"colorPicker" => color_picker(ui, &mut d),
"preferences" => preferences(ui, &mut d),
"print" => print_dialog(ui, &mut d),
"keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d),
"polygonSettings" => {
egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
@@ -779,6 +840,17 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
let hex = d.s("hex");
app.run("object.color", json!({"color": hex, "target": d.s("target")}))
}
"print" => {
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
let printer = d.s("printer");
let r = app.run(
"file.print",
json!({"printer": if printer.is_empty() { Value::Null } else { json!(printer) }, "copies": d.n("copies").unwrap_or(1.0).max(1.0) as u64,
"pages": pages, "spreads": d.b("spreads"), "marks": d.b("marks"), "bleed": d.b("bleed")}),
)?;
app.status(format!("Sent {} page(s) to {}", r["pages"], r["printer"].as_str().unwrap_or("the default printer")));
Ok(r)
}
"preferences" => {
app.run(
"prefs.set",
+18 -5
View File
@@ -919,12 +919,13 @@ pub fn shortcut_of(app: &DesignApp, id: &str) -> Option<String> {
}
}
/// Every command with a shortcut in effect: (id, shortcut).
/// Every command with a shortcut in effect: (id, shortcut). UI commands come first: where one
/// shares keys with an engine command (⌘N, ⌘O…) it's the one with the dialog.
pub fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> {
designcraft_engine::command_specs()
UI_COMMANDS
.iter()
.map(|c| c.id)
.chain(UI_COMMANDS.iter().map(|c| c.0))
.map(|c| c.0)
.chain(designcraft_engine::command_specs().iter().map(|c| c.id))
.filter_map(|id| shortcut_of(app, id).map(|s| (id.to_string(), s)))
.collect()
}
@@ -1088,6 +1089,14 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
crate::dialogs::open_document_setup(app);
return;
}
if params.is_null() && id == "file.print" {
crate::dialogs::open_print(app);
return;
}
if params.is_null() && id == "object.textFrameOptions" {
app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
return;
}
if params.is_null()
&& ui_label(id).is_none()
&& let Some(c) = designcraft_engine::find_command(id)
@@ -1204,7 +1213,8 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
if app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
continue; // The native menu handles it.
}
let _ = app.run(&id, json!({}));
// Like choosing the menu item: "…" commands open their dialog.
activate(app, &id, &Value::Null);
continue;
}
// Tool shortcuts (single keys, Shift+key).
@@ -1335,6 +1345,9 @@ mod tests {
assert_eq!(shortcut_of(&app, "app.palette"), None);
assert!(run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Nope"})).unwrap().is_err());
run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap();
// ⌘N is the New Document dialog, not a bare file.new.
let first_cmd_n = effective_shortcuts(&app).into_iter().find(|(_, s)| s == "Cmd+N").map(|(i, _)| i);
assert_eq!(first_cmd_n.as_deref(), Some("app.newDocumentDialog"));
assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return"));
assert_eq!(shortcut_string(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K), "Cmd+Shift+K");
if cfg!(target_os = "macos") {
+4 -4
View File
@@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 78%** over 275 features.
**Weighted breadth parity: 79%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 57 | 25 | 15 | 72% |
| P1 | 97 | 58 | 25 | 14 | 73% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| Area | Parity |
@@ -29,7 +29,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Tables | 81% |
| Long documents | 58% |
| Interactivity & digital | 30% |
| Output & production | 58% |
| Output & production | 68% |
| XML & automation | 39% |
| View & navigation | 75% |
| Undo, history, saving | 100% |
@@ -278,7 +278,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Interactivity & digital | HTML export | P2 | M |
| Output & production | Export to PDF (print) | P0 | P |
| Output & production | Interactive PDF | P2 | P |
| Output & production | Print dialog | P1 | M |
| Output & production | Print dialog | P1 | D |
| Output & production | Print booklet | P2 | M |
| Output & production | Package | P1 | D |
| Output & production | Preflight | P1 | D |