Print: adopt craft-print with native settings and job feedback

This commit is contained in:
skylar taylor-barrick
2026-10-12 03:25:47 +02:00
parent 3926d21aa9
commit 587d879f13
15 changed files with 449 additions and 88 deletions
Generated
+18
View File
@@ -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",
+3 -1
View File
@@ -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 |
+1
View File
@@ -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 }
+8 -62
View File
@@ -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<std::path::PathBuf> {
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<egui::IconData> {
#[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);
}
}
+75
View File
@@ -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<Vec<std::thread::JoinHandle<()>>> = 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");
}
}
+28 -1
View File
@@ -175,7 +175,18 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
/// 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<u8> {
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();
+1
View File
@@ -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 }
+9 -3
View File
@@ -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" };
+2
View File
@@ -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",
+61 -17
View File
@@ -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<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// 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<Notice>,
pub services: Services,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
/// Synthetic input from agents, one batch per frame (drags need several frames).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
@@ -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<Json, String> {
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<Result<Json, String>> {
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;
+98
View File
@@ -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<serde_json::Value, String> {
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::<Vec<Value>>(),
"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."),
}
}
+127
View File
@@ -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());
}
}
+12
View File
@@ -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.
+3 -2
View File
@@ -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 |
+3 -2
View File
@@ -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 |