From 587d879f13f82af162af59569a336ae63c4b34c6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 03:25:47 +0200 Subject: [PATCH] Print: adopt craft-print with native settings and job feedback --- Cargo.lock | 18 ++++ ROADMAP.md | 4 +- apps/gridcraft/Cargo.toml | 1 + apps/gridcraft/src/main.rs | 70 ++------------- apps/gridcraft/src/print.rs | 75 ++++++++++++++++ crates/engine/src/io.rs | 29 +++++- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/backstage.rs | 12 ++- crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/lib.rs | 78 ++++++++++++---- crates/ui-egui/src/printing.rs | 98 +++++++++++++++++++++ crates/ui-egui/tests/native_print.rs | 127 +++++++++++++++++++++++++++ docs/control-protocol.md | 12 +++ docs/gaps.md | 5 +- docs/hardware-parity.md | 5 +- 15 files changed, 449 insertions(+), 88 deletions(-) create mode 100644 apps/gridcraft/src/print.rs create mode 100644 crates/ui-egui/src/printing.rs create mode 100644 crates/ui-egui/tests/native_print.rs diff --git a/Cargo.lock b/Cargo.lock index 2dec069dd..f924b941a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,6 +742,22 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "tempfile", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "egui", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1483,6 +1499,7 @@ name = "gridcraft" version = "0.5.0" dependencies = [ "arboard", + "craft-print", "eframe", "egui", "fmv-macos-events", @@ -1616,6 +1633,7 @@ version = "0.5.0" name = "gridcraft-ui-egui" version = "0.5.0" dependencies = [ + "craft-ui", "egui", "egui_kittest", "epaint_default_fonts", diff --git a/ROADMAP.md b/ROADMAP.md index fbe48d35d..e8e1c06b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -177,6 +178,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 94dc2b083..c867d24a2 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -24,6 +24,7 @@ image = { workspace = true } winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } rfd = { workspace = true } arboard = { workspace = true } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 2f6746644..8093205c4 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -16,6 +16,7 @@ mod graphics; mod logging; #[cfg(target_os = "macos")] mod native_menu; +mod print; use std::cell::RefCell; use std::rc::Rc; @@ -39,6 +40,10 @@ impl eframe::App for App { apple_events::poll(&self.2, &mut self.0, ctx); } self.0.logic(ctx); + self.0.poll_printing(ctx); + if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); @@ -53,6 +58,7 @@ impl eframe::App for App { self.0.ui(ui); } fn on_exit(&mut self) { + print::finish_pending_jobs(); save_prefs(&self.0); // macOS (#117): end the process here, before eframe tears the window down. AccessKit's // macOS adapter subclasses the window's content view and restores its class on teardown; @@ -180,12 +186,8 @@ fn services() -> Services { dialog.save_file().and_then(|p| p.to_str().map(str::to_string)) })), open_url: Some(Box::new(|url: &str| open_with_system(url))), - // File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print - // command reaches the printer dialog (GridCraft has no printer driver of its own). - print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) { - Ok(path) => open_with_system(&path.to_string_lossy()), - Err(e) => log::warn!("print: {e}"), - })), + printers: Some(print::discover()), + print_native: Some(print::submit()), ..Default::default() } } @@ -204,32 +206,6 @@ fn open_with_system(target: &str) { } } -/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the -/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively -/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable -/// only by the user. -fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result { - use std::io::Write; - static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - for _ in 0..64 { - let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1); - let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id())); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - match options.open(&path) { - Ok(mut file) => { - file.write_all(bytes)?; - return Ok(path); - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name")) -} - fn icon() -> Option { #[cfg(target_os = "macos")] let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); @@ -393,33 +369,3 @@ fn main() -> eframe::Result<()> { } result } - -#[cfg(test)] -mod print_tests { - #[test] - fn print_pdfs_get_fresh_names_in_the_given_folder() { - let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap(); - let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap(); - assert_ne!(a, b); - assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf")); - assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b"); - assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err()); - // A file (or symlink) already at the next name is skipped, never overwritten. - let pid = std::process::id(); - let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect(); - for p in &taken { - std::fs::write(p, b"keep").unwrap(); - } - let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap(); - assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep")); - assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it"); - } - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/apps/gridcraft/src/print.rs b/apps/gridcraft/src/print.rs new file mode 100644 index 000000000..09bb801bd --- /dev/null +++ b/apps/gridcraft/src/print.rs @@ -0,0 +1,75 @@ +//! Desktop spooler adapter. Discovery and submission never run on the UI thread. +use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit}; + +pub fn discover() -> Discover { + Box::new(|| { + let (sender, receiver) = std::sync::mpsc::channel(); + let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { + let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect(); + let _ = sender.send(printers); + }); + receiver + }) +} + +// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would +// let process exit kill PowerShell/CUPS before it hands the job to the spooler. +static JOBS: std::sync::Mutex>> = std::sync::Mutex::new(Vec::new()); + +pub fn finish_pending_jobs() { + let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner())); + for worker in workers { + if worker.join().is_err() { + log::error!("the print worker stopped unexpectedly during shutdown"); + } + } +} + +pub fn submit() -> Submit { + Box::new(|bytes, settings: Settings| { + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || { + let job = craft_print::Job { + printer: Some(settings.printer), + copies: settings.copies.clamp(1, 999), + duplex: settings.duplex.map(|value| match value { + Duplex::LongEdge => craft_print::Duplex::LongEdge, + Duplex::ShortEdge => craft_print::Duplex::ShortEdge, + Duplex::Off => craft_print::Duplex::Off, + }), + grayscale: settings.grayscale, + scaling: craft_print::Scaling::ShrinkToFit, + title: "GridCraft document".into(), + ..Default::default() + }; + let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string())); + }); + if let Ok(worker) = worker { + let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner()); + jobs.retain(|job| !job.is_finished()); + jobs.push(worker); + } + receiver + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shutdown_waits_for_pending_submission_workers() { + let (release, wait) = std::sync::mpsc::channel(); + let (completed, completion) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + wait.recv().unwrap(); + completed.send(()).unwrap(); + }); + JOBS.lock().unwrap().push(worker); + let shutdown = std::thread::spawn(finish_pending_jobs); + assert!(completion.try_recv().is_err(), "the print worker is still pending"); + release.send(()).unwrap(); + shutdown.join().unwrap(); + completion.recv().unwrap(); + assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e613611d1..bc7011dec 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -175,7 +175,18 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { - gridcraft_xlsx::write_csv(sh, delimiter, |c| crate::display::value_text(wb, sh, c)) + gridcraft_xlsx::write_csv(sh, delimiter, |c| { + // General's grid-width formatting rounds large identifiers to scientific notation. + // Delimited files have no column width, so preserve the whole numeric value instead. + if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General") + && let gridcraft_core::Value::Number(number) = sh.value(c) + && number.is_finite() + { + number.to_string() + } else { + crate::display::value_text(wb, sh, c) + } + }) } fn esc(s: &str) -> String { @@ -568,6 +579,22 @@ mod tests { assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); } + #[test] + fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { + use gridcraft_core::Value; + use gridcraft_model::{Cell, NumFmt, StyleId}; + let mut workbook = Workbook::new(); + let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); + let sheet = workbook.sheet_mut(0).unwrap(); + sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); + for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { + let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); + assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); + } + } + #[test] fn template_extension_saves_template_content_type() { let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index bfce89e71..85a24dda9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b workspace = true [dependencies] +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } egui = { workspace = true } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 824b4dad8..ae2155419 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -570,9 +570,15 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.set_width(250.0); - if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) { - app.run_or_alert("file.print", params.clone()); - out.close = true; + if let Some(discover) = &app.services.printers { + crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned()); + } + let can_print = app.services.print_native.is_none() || app.printing.ready(); + if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked() + && app.commit_edit(0, 0, false, false) + { + app.print_now(params.clone()); + out.close = app.services.print_native.is_none(); } ui.add_space(16.0); let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdf1f891d..7bf9fd100 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -2,6 +2,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { + "Different First Page" => "Première page différente", + "Different Odd & Even Pages" => "Pages paires et impaires différentes", "Paste" => "Coller", "Paste Values" => "Coller les valeurs", "Paste Formulas" => "Coller les formules", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec29139d3..0b19b2551 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod outline; pub mod panes; pub mod paper_view; pub mod pivot_pane; +pub mod printing; pub mod ribbon; pub mod selection_glide; pub mod tabs; @@ -159,6 +160,10 @@ pub struct Notice { /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { + /// Desktop: discover configured printers on a worker. + pub printers: Option, + /// Desktop: submit the PDF with the chosen settings on a worker. + pub print_native: Option, pub pick_open: Option Option>>, pub pick_save: Option Option>>, /// Native picture picker (PNG/JPEG), separate from workbook opening. @@ -210,6 +215,8 @@ pub struct SheetApp { /// Dismissible info bar under the ribbon (open-time notices). pub notice: Option, pub services: Services, + pub printing: printing::State, + print_feedback_seen: Option, /// Synthetic input from agents, one batch per frame (drags need several frames). pub synthetic: std::collections::VecDeque>, pub control_rx: Option>, @@ -256,6 +263,8 @@ impl SheetApp { toast: None, notice: None, services, + printing: Default::default(), + print_feedback_seen: None, synthetic: Default::default(), control_rx: None, grid: grid::GridState { @@ -404,8 +413,12 @@ impl SheetApp { } pub fn run_or_alert(&mut self, id: &str, params: Json) { - // Printing from the UI also hands the PDF to the host's print flow (web). + // A menu/shortcut opens the settings first; the Print page's button submits explicitly. if id == "file.print" { + if self.services.print_native.is_some() { + self.open_backstage(backstage::Page::Print); + return; + } return self.print_now(params); } if let Err(e) = self.run(id, params) { @@ -422,29 +435,39 @@ impl SheetApp { } } - /// Runs `file.print`, builds the PDF and (where the host provides the hook — the web - /// build) hands the bytes straight to the browser's print flow; errors show the usual - /// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` - /// (which still produces the PDF — just without a UI to act on the result yet). + /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the + /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible + /// on the Print page. The engine command still returns PDF bytes without submitting. pub fn print_now(&mut self, params: Json) { - match self.run("file.print", params) { - Ok(r) => { - let Some(b64) = r.get("base64").and_then(Json::as_str) else { return }; - let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else { - self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into())); - return; - }; - if let Some(print) = &self.services.print { - print(&bytes); - } - } - Err(e) => self.alert(e), + if let Err(error) = self.submit_print(params) { + self.alert(error); } } + fn submit_print(&mut self, params: Json) -> Result { + if self.services.print_native.is_some() && !self.printing.ready() { + return Err("Select an available printer in File > Print before submitting.".into()); + } + let pdf = self.run("file.print", params)?; + let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; + let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; + let len = bytes.len(); + if let Some(submit) = &self.services.print_native { + self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; + self.print_feedback_seen = None; + } else if let Some(print) = &self.services.print { + print(&bytes); + } else { + return Err("Printing is unavailable here; export a PDF instead.".into()); + } + Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) + } + /// UI-only commands (dialogs, view toggles that aren't saved in the file). fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { let r = match id { + "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), + "ui.printSubmit" => self.submit_print(p.clone()), "ui.ribbonTab" => { self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); Ok(Json::Null) @@ -834,6 +857,26 @@ impl SheetApp { self.grid.ensure_visible = true; } + /// Terminal feedback also reaches the normal window when printing starts through control. + pub fn poll_printing(&mut self, ctx: &egui::Context) { + self.printing.poll(ctx); + if self.printing.message == self.print_feedback_seen { + return; + } + self.print_feedback_seen = self.printing.message.clone(); + let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = + self.printing.message.clone() + else { + return; + }; + let text = printing::feedback(&message, |label| self.ui.language.tr(label).to_owned()); + if matches!(message, printing::Feedback::Submitted(_)) { + self.toast = Some((text, now_ms())); + } else { + self.alert(text); + } + } + /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { // A keytip control request lives one frame: the ribbon consumes it during `ui`, and @@ -939,6 +982,7 @@ impl SheetApp { /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + self.poll_printing(&ctx); if !self.fonts_ready || self.fonts_rebind { ctx.request_repaint(); return; diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs new file mode 100644 index 000000000..7c34120bb --- /dev/null +++ b/crates/ui-egui/src/printing.rs @@ -0,0 +1,98 @@ +//! Localized print-page wording around the shared, backend-independent controls. +use craft_ui::printing::Labels; +pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit}; + +pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) { + let labels = Labels { + printer: &translate("Printer"), + select_printer: &translate("Select a printer"), + refresh: &translate("Refresh printers"), + copies: &translate("Copies"), + duplex: &translate("Two-sided printing"), + printer_default: &translate("Printer default"), + one_sided: &translate("One-sided"), + long_edge: &translate("Two-sided: long edge"), + short_edge: &translate("Two-sided: short edge"), + grayscale: &translate("Grayscale"), + }; + state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels); + if let Some(message) = &state.message { + ui.label(feedback(message, translate)); + } +} + +/// Read print settings/status, or validate an explicit update before changing anything. +/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog. +pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result { + use serde_json::{Value, json}; + let object = params.as_object().ok_or("print settings must be an object")?; + if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) { + return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into()); + } + if state.busy() && !object.is_empty() { + return Err("wait for the active print submission before changing settings".into()); + } + let mut next = state.settings.clone(); + if let Some(value) = object.get("printer") { + let printer = value.as_str().ok_or("printer must be a name from the discovered list")?; + if !state.printers.iter().any(|available| available.name == printer) { + return Err("printer must be a name from the discovered list".into()); + } + next.printer = printer.to_owned(); + } + if let Some(value) = object.get("copies") { + let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?; + next.copies = copies as u32; + } + if let Some(value) = object.get("duplex") { + next.duplex = match value.as_str() { + Some("printerDefault") => None, + Some("off") => Some(Duplex::Off), + Some("longEdge") => Some(Duplex::LongEdge), + Some("shortEdge") => Some(Duplex::ShortEdge), + None if value.is_null() => None, + _ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()), + }; + } + if let Some(value) = object.get("grayscale") { + next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?; + } + let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false); + if refresh { + let discover = discover.ok_or("native printer discovery is unavailable here")?; + if !state.refresh(discover) { + return Err("printer discovery or submission is already active".into()); + } + } + state.settings = next; + let (status, detail) = match state.message.as_ref() { + None => ("idle", ""), + Some(Feedback::FindingPrinters) => ("findingPrinters", ""), + Some(Feedback::NoPrinters) => ("noPrinters", ""), + Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""), + Some(Feedback::Submitting) => ("submitting", ""), + Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()), + Some(Feedback::Failed(error)) => ("failed", error.as_str()), + Some(Feedback::WorkerStopped) => ("workerStopped", ""), + }; + Ok(json!({ + "printer": state.settings.printer, "copies": state.settings.copies, + "duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" }, + "grayscale": state.settings.grayscale, + "printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::>(), + "busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail, + })) +} + +pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String { + match message { + Feedback::FindingPrinters => translate("Finding printers…"), + Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."), + Feedback::DiscoveryFailed => translate("Printer discovery failed."), + Feedback::Submitting => translate("Submitting print job…"), + Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."), + Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt), + Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error), + Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."), + } +} diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/native_print.rs new file mode 100644 index 000000000..7875dc069 --- /dev/null +++ b/crates/ui-egui/tests/native_print.rs @@ -0,0 +1,127 @@ +//! Native print onboarding preserves engine PDF generation and submits the selected settings. +use gridcraft_engine::Session; +use gridcraft_ui_egui::printing::{Duplex, Feedback, Printer}; +use gridcraft_ui_egui::{Services, SheetApp}; +use serde_json::json; +use std::{cell::RefCell, rc::Rc}; + +#[test] +fn native_print_uses_selected_settings_and_reports_spool_errors() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap(); + let jobs = Rc::new(RefCell::new(Vec::new())); + let observed = jobs.clone(); + app.services.print_native = Some(Box::new(move |bytes, settings| { + observed.borrow_mut().push((bytes, settings)); + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Err("printer is offline".into())).unwrap(); + receiver + })); + app.run_or_alert("file.print", json!({})); + assert!(app.backstage.is_some(), "a shortcut opens settings without submitting"); + let pdf = app.run("file.print", json!({"sheets":"active"})).unwrap(); + assert!(pdf["base64"].as_str().is_some(), "the engine still returns PDF bytes without submitting"); + assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool"); + app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false }); + let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap(); + assert_eq!(configured["ready"], true); + assert_eq!(configured["printers"][0]["name"], "Chosen queue"); + app.run("ui.printSubmit", json!({"sheets":"active"})).unwrap(); + assert!(app.printing.busy()); + let jobs = jobs.borrow(); + assert_eq!(jobs.len(), 1); + assert!(jobs[0].0.starts_with(b"%PDF")); + assert_eq!(jobs[0].1.printer, "Chosen queue"); + assert_eq!(jobs[0].1.copies, 3); + assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge)); + assert!(jobs[0].1.grayscale); + app.poll_printing(&egui::Context::default()); + assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into()))); + assert!(app.message.as_ref().unwrap().1.contains("printer is offline")); +} + +#[test] +fn print_settings_are_visible_in_a_narrow_window() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap(); + app.services.printers = Some(Box::new(|| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap(); + receiver + })); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("queued".into())).unwrap(); + receiver + })); + app.run("ui.backstage", json!({"page":"print"})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(5); + assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled()); + assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled()); + harness.get_by_label("Grayscale").click(); + harness.run_steps(2); + assert!(harness.state().printing.settings.grayscale); + assert!(harness.state().printing.ready()); +} + +#[test] +fn invalid_print_settings_are_rejected_without_partial_changes() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + let before = app.run("ui.printSettings", json!({})).unwrap(); + for params in [ + json!({"printer":"Missing"}), + json!({"copies":0}), + json!({"copies":1000}), + json!({"copies":1.5}), + json!({"copies":-1}), + json!({"copies":"3"}), + json!({"duplex":"maybe"}), + json!({"grayscale":"yes"}), + json!({"refresh":"yes"}), + json!({"copies":3,"duplex":"invalid"}), + json!([]), + ] { + assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}"); + assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}"); + } +} + +#[test] +fn repeated_fast_jobs_each_surface_an_identical_receipt() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Services::default()); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("same receipt".into())).unwrap(); + receiver + })); + for _ in 0..2 { + app.toast = None; + app.run("ui.printSubmit", json!({})).unwrap(); + // Completion may be consumed by the shared widget before the app's logic pass. + app.printing.poll(&egui::Context::default()); + app.poll_printing(&egui::Context::default()); + assert!(app.toast.as_ref().unwrap().0.contains("same receipt")); + app.toast = None; + app.poll_printing(&egui::Context::default()); + assert!(app.toast.is_none()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a16208a38..347508e34 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -65,3 +65,15 @@ the password, and never replaces the encrypted original. For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; see [`mcp.md`](mcp.md) and [`cli.md`](cli.md). + +### Native printing + +`engine.execute` can call `ui.printSettings` with `{}` to inspect the printer list, selected +printer, copies, duplex, grayscale, discovery/submission status and backend receipt/error. +`{"refresh":true}` starts background discovery; poll the same command until `discovering` is +false. Set `printer` to a discovered name, integer `copies` from 1 to 999, `duplex` to +`printerDefault`, `off`, `longEdge` or `shortEdge`, and boolean `grayscale`. Invalid updates are +rejected without changing earlier settings. `ui.printSubmit` explicitly submits the current +print-ready PDF without opening a dialog; inspect `ui.printSettings` until `busy` becomes false +and check `status`/`detail`. The browser keeps its own print hook. PDF export remains separate. +GridCraft accepts the existing `file.print` sheet/pagination params on `ui.printSubmit`; the engine `file.print` command returns the PDF without spooling. diff --git a/docs/gaps.md b/docs/gaps.md index 09870ba27..17a56b899 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§6: iterative calculation landed) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -111,7 +111,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 14. Page Layout view and print polish (U, F) - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging - margins on the ruler; web print exports only (#141, PR #143). **Estimate:** 5–10 h. + margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h. ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. @@ -160,6 +160,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | | 2026-10-11 | minor | §4: trendlines (six types, forecast, equation and R²) read, written and drawn | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 9d2c5e7f5..3f9135c49 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Windows DirectX 12 shaders with FXC, #339) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -14,12 +14,13 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | | High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | -| Printers | System print with driver options | Desktop prints through the system dialog via PDF; web exports only | Counted under print | +| Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | | 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | | 2026-10-10 | major | Created: single-threaded calc, wgpu rendering, no pen pressure, single window |