From 7d4f00d551e9d45842a7df840da294e8086c704f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:51:21 -0400 Subject: [PATCH] O: Print dialog (file.print via the system spooler); shortcuts open dialogs, UI commands win ties Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/print.rs | 95 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 72 ++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 23 ++++++-- docs/parity.md | 8 +-- 6 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/cmd/print.rs diff --git a/ROADMAP.md b/ROADMAP.md index 53d49c06f..a129f1a05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index cab4c7630..19bedce6f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs new file mode 100644 index 000000000..bbae1b38a --- /dev/null +++ b/crates/engine/src/cmd/print.rs @@ -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 { + 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 = 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 { + 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 = 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()); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index da1b1ba7d..81d09240a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 = 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 { 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", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 778123876..9f1680af2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -919,12 +919,13 @@ pub fn shortcut_of(app: &DesignApp, id: &str) -> Option { } } -/// 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") { diff --git a/docs/parity.md b/docs/parity.md index f8577b824..e89c6af46 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. -**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 |